diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ea0e3c2..57090b51 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,610 +1,25 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/configuration-reference version: 2.1 -aliases: - ui-deps-cache-key: &uiDepsCacheKey - key: ui-deps-{{ checksum "yarn.lock" }} - dev-filter: &devFilter - filters: - branches: - only: - - main - - /^build\/.*/ - build-filter: &buildFilter - filters: - branches: - only: - - /^build.*/ - stage-filter: &stageFilter - filters: - branches: - only: - - /^release.*/ - prod-filter: &prodFilter - filters: - branches: - only: - - latest - manual-build-conditions: &manual-build-conditions - or: - - << pipeline.parameters.linux >> - - << pipeline.parameters.mac >> - - << pipeline.parameters.windows >> - ignore-for-manual-build: &ignore-for-manual-build - when: - not: *manual-build-conditions - -orbs: - node: circleci/node@5.3.0 - win: circleci/windows@5.0.0 - aws: circleci/aws-cli@4.1.3 - executors: - linux-executor: - machine: - image: ubuntu-2004:2023.04.2 - linux-executor-dlc: - machine: - image: ubuntu-2004:2023.04.2 - docker_layer_caching: true docker-node: docker: - image: cimg/node:20.15 - docker: - docker: - - image: cibuilds/docker:19.03.5 - macos: - macos: - xcode: 14.2.0 - -parameters: - linux: - type: string - default: &ignore "" - mac: - type: string - default: *ignore - windows: - type: string - default: *ignore jobs: - unit-tests-ui: - executor: docker-node - steps: - - checkout - - restore_cache: - <<: *uiDepsCacheKey - - run: - name: Install dependencies - command: | - yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - run: - name: Run Unit tests UI - command: | - yarn test:cov - - save_cache: - <<: *uiDepsCacheKey - paths: - - ~/.cache/yarn - - e2e-linux: - executor: linux-executor-dlc - parameters: - report: - description: Send report for test run to slack - type: boolean - default: false - parallelism: - description: Number of threads to run tests - type: integer - default: 1 - parallelism: << parameters.parallelism >> - steps: - - checkout - - node/install: - install-yarn: true - node-version: '20.15' - - attach_workspace: - at: . - - run: sudo apt-get install net-tools - - run: - name: Start Xvfb - command: | - sudo apt-get install -y xvfb - Xvfb :99 -screen 0 1920x1080x24 & - - run: - name: Install dependencies - command: cd tests/e2e && yarn install - - run: - name: Compile TypeScript - command: cd tests/e2e && yarn compile - - run: - name: Verify Compiled Files - command: ls -R tests/e2e/dist/tests/ - - run: - name: Run e2e tests - command: | - cd tests/e2e/dist - export TEST_FILES=$(circleci tests glob "tests/**/*.e2e.js" | circleci tests split --split-by=timings) - echo "Running tests: $TEST_FILES" - cd ../../.. - .circleci/e2e/test.app.sh - - when: - condition: - equal: [ true, << parameters.report >> ] - steps: - - run: - name: Send report - when: always - command: | - APP_BUILD_TYPE="VSCode (Linux)" node ./.circleci/e2e-results.js - # curl -H "Content-type: application/json" --data @e2e.report.json -H "Authorization: Bearer $SLACK_TEST_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage - - store_test_results: - path: tests/e2e/mochawesome-report - - store_artifacts: - path: tests/e2e/mochawesome-report - destination: tests/e2e/mochawesome-report - - store_artifacts: - path: /tmp/test-resources/screenshots - destination: test-resources/screenshots - - # Build jobs - manual-build-validate: + # Placeholder job + placeholder-job: executor: docker-node - parameters: - os: - type: string - default: "" - target: - type: string - default: "" - steps: - - checkout - - run: - command: | - node .circleci/build/manual-build-validate.js << parameters.os >> << parameters.target >> - - linux: - executor: docker-node - resource_class: large - parameters: - env: - description: Build environment (stage || prod) - type: enum - default: stage - enum: ['stage', 'prod', 'dev'] - target: - description: Build target - type: string - default: "" - steps: - - checkout - - restore_cache: - <<: *uiDepsCacheKey - - attach_workspace: - at: . - - run: - name: Install dependencies - command: | - yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - run: - name: Build .vsix package - command: | - envFile=".env" - packagePath="./release/redis-for-vscode-extension-linux-x64.vsix" - yarn download:backend - - if [ << parameters.env >> == 'prod' ]; then - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile - yarn package:prod --target linux-x64 --out ${packagePath} - exit 0; - fi - - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile - sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile - yarn package:stage --target linux-x64 --out ${packagePath} - - persist_to_workspace: - root: . - paths: - - release/redis-for-*.vsix - - save_cache: - <<: *uiDepsCacheKey - paths: - - ~/.cache/yarn - - macosx: - executor: macos - resource_class: macos.m1.medium.gen1 - parameters: - env: - description: Build environment (stage || prod) - type: enum - default: stage - enum: ['stage', 'prod', 'dev'] - target: - description: Build target - type: string - default: "" - steps: - - checkout - - node/install: - node-version: '20.15' - - attach_workspace: - at: . - - run: - name: Install dependencies - command: | - yarn install - no_output_timeout: 15m - - run: - name: Build .vsix package - command: | - envFile=".env" - packagePath=./release/redis-for-vscode-extension-mac - - if [ << parameters.target >> ]; then - yarn download:backend << parameters.target >> - - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile - sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile - yarn package:stage --target darwin-<< parameters.target >> --out ${packagePath}-<< parameters.target >>.vsix - exit 0; - fi - - if [ << parameters.env >> == 'prod' ]; then - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile - yarn download:backend arm64 - yarn package:prod --target darwin-arm64 --out ${packagePath}-arm64.vsix - - yarn download:backend x64 - yarn package:prod --target darwin-x64 --out ${packagePath}-x64.vsix - exit 0; - fi - - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile - sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile - yarn download:backend arm64 - yarn package:stage --target darwin-arm64 --out ${packagePath}-arm64.vsix - - yarn download:backend x64 - yarn package:stage --target darwin-x64 --out ${packagePath}-x64.vsix - - persist_to_workspace: - root: . - paths: - - release/redis-for-*.vsix - - windows: - executor: - name: win/default - parameters: - env: - description: Build environment (stage || prod) - type: enum - default: stage - enum: ['stage', 'prod', 'dev'] - target: - description: Build target - type: string - default: "" - steps: - - checkout - - attach_workspace: - at: . - - run: - name: Install dependencies - command: | - nvm install 20.15 - nvm use 20.15 - npm install --global yarn - - yarn install - shell: bash.exe - no_output_timeout: 15m - - run: - name: Build .vsix package - command: | - envFile=".env" - packagePath=./release/redis-for-vscode-extension-win-x64.vsix - yarn download:backend - - if [ << parameters.env >> == 'prod' ]; then - echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile - yarn package:prod --target win32-x64 --out ${packagePath} - exit 0; - fi - - sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile - yarn package:stage --target win32-x64 --out ${packagePath} - shell: bash.exe - no_output_timeout: 20m - - persist_to_workspace: - root: . - paths: - - release/redis-for-*.vsix - - # Release jobs - store-build-artifacts: - executor: linux-executor - steps: - - attach_workspace: - at: . - - store_artifacts: - path: release - destination: release - - release-aws-private: - executor: linux-executor - steps: - - checkout - - attach_workspace: - at: . - - store_artifacts: - path: release - destination: release - - run: - name: publish - command: | - chmod +x .circleci/build/sum_sha256.sh - .circleci/build/sum_sha256.sh - applicationVersion=$(jq -r '.version' package.json) - - aws s3 cp release/ s3://${AWS_BUCKET_NAME}/private/vscode/${applicationVersion} --recursive - - licenses-check: - executor: linux-executor steps: - checkout - - node/install: - install-yarn: true - node-version: '20.15' - - restore_cache: - <<: *uiDepsCacheKey - - run: - name: Run install all dependencies - command: | - yarn install - yarn --cwd tests/e2e install - - run: - name: Generate licenses csv files and send csv data to google sheet - command: | - npm i -g license-checker - echo "$GOOGLE_ACCOUNT_SERVICE_KEY_BASE64" | base64 -id > gasKey.json - SPREADSHEET_ID=$GOOGLE_SPREADSHEET_DEPENDENCIES_ID node .circleci/deps-licenses-report.js - - store_artifacts: - path: licenses - destination: licenses -# Orchestrate jobs using workflows -# See: https://circleci.com/docs/configuration-reference/#workflows workflows: - license-checker: + # Placeholder workflow + placeholder: jobs: - - licenses-check: - name: Run License checker + - placeholder-job: + name: Placeholder filters: branches: only: - - /^license.*/ - - frontend-tests: - <<: *ignore-for-manual-build - jobs: - - unit-tests-ui: - name: Run Unit Tests - filters: - branches: - only: - - /^feature.*/ - - e2e-tests: - jobs: - - approve: - name: Start E2E Tests - type: approval - filters: - branches: - only: - - /^e2e/feature.*/ - - /^e2e/bugfix.*/ - - - linux: - name: Build extension - Linux (stage) - env: stage - filters: - branches: - only: - - /^e2e/feature.*/ - - /^e2e/bugfix.*/ - requires: - - Start E2E Tests - - - e2e-linux: - name: E2ETest (linux) - parallelism: 2 - requires: - - Build extension - Linux (stage) - - # Manual builds using web UI - manual-build-linux: - when: << pipeline.parameters.linux >> - jobs: - - manual-build-validate: - name: Validating build parameters - os: linux - target: << pipeline.parameters.linux >> - - linux: - name: Build extension - Linux (stage) - env: stage - target: << pipeline.parameters.linux >> - requires: - - Validating build parameters - - store-build-artifacts: - name: Store build artifacts (stage) - requires: - - Build extension - Linux (stage) - - manual-build-mac: - when: << pipeline.parameters.mac >> - jobs: - - manual-build-validate: - name: Validating build parameters - os: mac - target: << pipeline.parameters.mac >> - - macosx: - name: Build extension - MacOS (stage) - env: stage - target: << pipeline.parameters.mac >> - requires: - - Validating build parameters - - store-build-artifacts: - name: Store build artifacts (stage) - requires: - - Build extension - MacOS (stage) - - manual-build-windows: - when: << pipeline.parameters.windows >> - jobs: - - manual-build-validate: - name: Validating build parameters - os: windows - target: << pipeline.parameters.windows >> - - windows: - name: Build extension - Windows (stage) - env: stage - target: << pipeline.parameters.windows >> - requires: - - Validating build parameters - - store-build-artifacts: - name: Store build artifacts (stage) - requires: - - Build extension - Windows (stage) - - # build vscode extension (dev) from "build" branches - build: - <<: *ignore-for-manual-build - jobs: - - linux: - name: Build extension - Linux (dev) - env: dev - <<: *buildFilter - - macosx: - name: Build extension - MacOS (dev) - env: dev - <<: *buildFilter - - windows: - name: Build extension - Windows (dev) - env: dev - <<: *buildFilter - - store-build-artifacts: - name: Store build artifacts (dev) - requires: - - Build extension - Linux (dev) - - Build extension - MacOS (dev) - - Build extension - Windows (dev) - - # Main workflow for release/* and latest branches only - release: - <<: *ignore-for-manual-build - jobs: - # unit tests (on any commit) - - unit-tests-ui: - name: Run Unit Tests - filters: &releaseAndLatestFilter - branches: - only: - - /^release.*/ - - latest - - # ================== STAGE ================== - # build extensions (stage) - - linux: - name: Build extension - Linux (stage) - <<: *stageFilter - - macosx: - name: Build extension - MacOS (stage) - <<: *stageFilter - - windows: - name: Build extension - Windows (stage) - <<: *stageFilter - # e2e tests on linux build - - e2e-linux: - name: E2ETest (linux) - parallelism: 2 - requires: - - Build extension - Linux (stage) - - - store-build-artifacts: - name: Store build artifacts (stage) - requires: - - Build extension - Linux (stage) - - Build extension - MacOS (stage) - - Build extension - Windows (stage) - - # Needs approval from QA team that build was tested before merging to latest - - qa-approve: - name: Approved by QA team - type: approval - requires: - - Build extension - Linux (stage) - - Build extension - MacOS (stage) - - Build extension - Windows (stage) - - # ================== PROD ================== - # build and release vscode extension (prod) - - linux: - name: Build extension - Linux (prod) - env: prod - <<: *prodFilter - - macosx: - name: Build extension - MacOS (prod) - env: prod - <<: *prodFilter - - windows: - name: Build extension - Windows (prod) - env: prod - <<: *prodFilter - - # e2e tests on linux build - - e2e-linux: - name: E2ETest (Linux) - parallelism: 2 - requires: - - Build extension - Linux (prod) - - # upload release to prerelease AWS folder - - release-aws-private: - name: Release AWS S3 Private (prod) - requires: - - Build extension - Linux (prod) - - Build extension - MacOS (prod) - - Build extension - Windows (prod) - - # Manual approve for publish release - - approve-publish: - name: Approve Publish Release (prod) - type: approval - requires: - - Release AWS S3 Private (prod) - # # Publish release - # - publish-prod-aws: - # name: Publish AWS S3 - # requires: - # - Approve Publish Release (prod) - # <<: *prodFilter # double check for "latest" - - weekly: - triggers: - - schedule: - cron: '0 0 * * 1' - filters: - branches: - only: - - main - jobs: - # Process all licenses - - licenses-check: - name: Process licenses of packages - + - placeholder diff --git a/.circleci/config.yml.backup b/.circleci/config.yml.backup new file mode 100644 index 00000000..4ea0e3c2 --- /dev/null +++ b/.circleci/config.yml.backup @@ -0,0 +1,610 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference +version: 2.1 + +aliases: + ui-deps-cache-key: &uiDepsCacheKey + key: ui-deps-{{ checksum "yarn.lock" }} + dev-filter: &devFilter + filters: + branches: + only: + - main + - /^build\/.*/ + build-filter: &buildFilter + filters: + branches: + only: + - /^build.*/ + stage-filter: &stageFilter + filters: + branches: + only: + - /^release.*/ + prod-filter: &prodFilter + filters: + branches: + only: + - latest + manual-build-conditions: &manual-build-conditions + or: + - << pipeline.parameters.linux >> + - << pipeline.parameters.mac >> + - << pipeline.parameters.windows >> + ignore-for-manual-build: &ignore-for-manual-build + when: + not: *manual-build-conditions + +orbs: + node: circleci/node@5.3.0 + win: circleci/windows@5.0.0 + aws: circleci/aws-cli@4.1.3 + +executors: + linux-executor: + machine: + image: ubuntu-2004:2023.04.2 + linux-executor-dlc: + machine: + image: ubuntu-2004:2023.04.2 + docker_layer_caching: true + docker-node: + docker: + - image: cimg/node:20.15 + docker: + docker: + - image: cibuilds/docker:19.03.5 + macos: + macos: + xcode: 14.2.0 + +parameters: + linux: + type: string + default: &ignore "" + mac: + type: string + default: *ignore + windows: + type: string + default: *ignore + +jobs: + unit-tests-ui: + executor: docker-node + steps: + - checkout + - restore_cache: + <<: *uiDepsCacheKey + - run: + name: Install dependencies + command: | + yarn install --frozen-lockfile --cache-folder ~/.cache/yarn + - run: + name: Run Unit tests UI + command: | + yarn test:cov + - save_cache: + <<: *uiDepsCacheKey + paths: + - ~/.cache/yarn + + e2e-linux: + executor: linux-executor-dlc + parameters: + report: + description: Send report for test run to slack + type: boolean + default: false + parallelism: + description: Number of threads to run tests + type: integer + default: 1 + parallelism: << parameters.parallelism >> + steps: + - checkout + - node/install: + install-yarn: true + node-version: '20.15' + - attach_workspace: + at: . + - run: sudo apt-get install net-tools + - run: + name: Start Xvfb + command: | + sudo apt-get install -y xvfb + Xvfb :99 -screen 0 1920x1080x24 & + - run: + name: Install dependencies + command: cd tests/e2e && yarn install + - run: + name: Compile TypeScript + command: cd tests/e2e && yarn compile + - run: + name: Verify Compiled Files + command: ls -R tests/e2e/dist/tests/ + - run: + name: Run e2e tests + command: | + cd tests/e2e/dist + export TEST_FILES=$(circleci tests glob "tests/**/*.e2e.js" | circleci tests split --split-by=timings) + echo "Running tests: $TEST_FILES" + cd ../../.. + .circleci/e2e/test.app.sh + - when: + condition: + equal: [ true, << parameters.report >> ] + steps: + - run: + name: Send report + when: always + command: | + APP_BUILD_TYPE="VSCode (Linux)" node ./.circleci/e2e-results.js + # curl -H "Content-type: application/json" --data @e2e.report.json -H "Authorization: Bearer $SLACK_TEST_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage + - store_test_results: + path: tests/e2e/mochawesome-report + - store_artifacts: + path: tests/e2e/mochawesome-report + destination: tests/e2e/mochawesome-report + - store_artifacts: + path: /tmp/test-resources/screenshots + destination: test-resources/screenshots + + # Build jobs + manual-build-validate: + executor: docker-node + parameters: + os: + type: string + default: "" + target: + type: string + default: "" + steps: + - checkout + - run: + command: | + node .circleci/build/manual-build-validate.js << parameters.os >> << parameters.target >> + + linux: + executor: docker-node + resource_class: large + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod', 'dev'] + target: + description: Build target + type: string + default: "" + steps: + - checkout + - restore_cache: + <<: *uiDepsCacheKey + - attach_workspace: + at: . + - run: + name: Install dependencies + command: | + yarn install --frozen-lockfile --cache-folder ~/.cache/yarn + - run: + name: Build .vsix package + command: | + envFile=".env" + packagePath="./release/redis-for-vscode-extension-linux-x64.vsix" + yarn download:backend + + if [ << parameters.env >> == 'prod' ]; then + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + yarn package:prod --target linux-x64 --out ${packagePath} + exit 0; + fi + + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + yarn package:stage --target linux-x64 --out ${packagePath} + - persist_to_workspace: + root: . + paths: + - release/redis-for-*.vsix + - save_cache: + <<: *uiDepsCacheKey + paths: + - ~/.cache/yarn + + macosx: + executor: macos + resource_class: macos.m1.medium.gen1 + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod', 'dev'] + target: + description: Build target + type: string + default: "" + steps: + - checkout + - node/install: + node-version: '20.15' + - attach_workspace: + at: . + - run: + name: Install dependencies + command: | + yarn install + no_output_timeout: 15m + - run: + name: Build .vsix package + command: | + envFile=".env" + packagePath=./release/redis-for-vscode-extension-mac + + if [ << parameters.target >> ]; then + yarn download:backend << parameters.target >> + + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + yarn package:stage --target darwin-<< parameters.target >> --out ${packagePath}-<< parameters.target >>.vsix + exit 0; + fi + + if [ << parameters.env >> == 'prod' ]; then + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + yarn download:backend arm64 + yarn package:prod --target darwin-arm64 --out ${packagePath}-arm64.vsix + + yarn download:backend x64 + yarn package:prod --target darwin-x64 --out ${packagePath}-x64.vsix + exit 0; + fi + + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY_STAGE'" >> $envFile + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + yarn download:backend arm64 + yarn package:stage --target darwin-arm64 --out ${packagePath}-arm64.vsix + + yarn download:backend x64 + yarn package:stage --target darwin-x64 --out ${packagePath}-x64.vsix + - persist_to_workspace: + root: . + paths: + - release/redis-for-*.vsix + + windows: + executor: + name: win/default + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod', 'dev'] + target: + description: Build target + type: string + default: "" + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Install dependencies + command: | + nvm install 20.15 + nvm use 20.15 + npm install --global yarn + + yarn install + shell: bash.exe + no_output_timeout: 15m + - run: + name: Build .vsix package + command: | + envFile=".env" + packagePath=./release/redis-for-vscode-extension-win-x64.vsix + yarn download:backend + + if [ << parameters.env >> == 'prod' ]; then + echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile + yarn package:prod --target win32-x64 --out ${packagePath} + exit 0; + fi + + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile + yarn package:stage --target win32-x64 --out ${packagePath} + shell: bash.exe + no_output_timeout: 20m + - persist_to_workspace: + root: . + paths: + - release/redis-for-*.vsix + + # Release jobs + store-build-artifacts: + executor: linux-executor + steps: + - attach_workspace: + at: . + - store_artifacts: + path: release + destination: release + + release-aws-private: + executor: linux-executor + steps: + - checkout + - attach_workspace: + at: . + - store_artifacts: + path: release + destination: release + - run: + name: publish + command: | + chmod +x .circleci/build/sum_sha256.sh + .circleci/build/sum_sha256.sh + applicationVersion=$(jq -r '.version' package.json) + + aws s3 cp release/ s3://${AWS_BUCKET_NAME}/private/vscode/${applicationVersion} --recursive + + licenses-check: + executor: linux-executor + steps: + - checkout + - node/install: + install-yarn: true + node-version: '20.15' + - restore_cache: + <<: *uiDepsCacheKey + - run: + name: Run install all dependencies + command: | + yarn install + yarn --cwd tests/e2e install + - run: + name: Generate licenses csv files and send csv data to google sheet + command: | + npm i -g license-checker + + echo "$GOOGLE_ACCOUNT_SERVICE_KEY_BASE64" | base64 -id > gasKey.json + SPREADSHEET_ID=$GOOGLE_SPREADSHEET_DEPENDENCIES_ID node .circleci/deps-licenses-report.js + - store_artifacts: + path: licenses + destination: licenses + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/configuration-reference/#workflows +workflows: + license-checker: + jobs: + - licenses-check: + name: Run License checker + filters: + branches: + only: + - /^license.*/ + + frontend-tests: + <<: *ignore-for-manual-build + jobs: + - unit-tests-ui: + name: Run Unit Tests + filters: + branches: + only: + - /^feature.*/ + + e2e-tests: + jobs: + - approve: + name: Start E2E Tests + type: approval + filters: + branches: + only: + - /^e2e/feature.*/ + - /^e2e/bugfix.*/ + + - linux: + name: Build extension - Linux (stage) + env: stage + filters: + branches: + only: + - /^e2e/feature.*/ + - /^e2e/bugfix.*/ + requires: + - Start E2E Tests + + - e2e-linux: + name: E2ETest (linux) + parallelism: 2 + requires: + - Build extension - Linux (stage) + + # Manual builds using web UI + manual-build-linux: + when: << pipeline.parameters.linux >> + jobs: + - manual-build-validate: + name: Validating build parameters + os: linux + target: << pipeline.parameters.linux >> + - linux: + name: Build extension - Linux (stage) + env: stage + target: << pipeline.parameters.linux >> + requires: + - Validating build parameters + - store-build-artifacts: + name: Store build artifacts (stage) + requires: + - Build extension - Linux (stage) + + manual-build-mac: + when: << pipeline.parameters.mac >> + jobs: + - manual-build-validate: + name: Validating build parameters + os: mac + target: << pipeline.parameters.mac >> + - macosx: + name: Build extension - MacOS (stage) + env: stage + target: << pipeline.parameters.mac >> + requires: + - Validating build parameters + - store-build-artifacts: + name: Store build artifacts (stage) + requires: + - Build extension - MacOS (stage) + + manual-build-windows: + when: << pipeline.parameters.windows >> + jobs: + - manual-build-validate: + name: Validating build parameters + os: windows + target: << pipeline.parameters.windows >> + - windows: + name: Build extension - Windows (stage) + env: stage + target: << pipeline.parameters.windows >> + requires: + - Validating build parameters + - store-build-artifacts: + name: Store build artifacts (stage) + requires: + - Build extension - Windows (stage) + + # build vscode extension (dev) from "build" branches + build: + <<: *ignore-for-manual-build + jobs: + - linux: + name: Build extension - Linux (dev) + env: dev + <<: *buildFilter + - macosx: + name: Build extension - MacOS (dev) + env: dev + <<: *buildFilter + - windows: + name: Build extension - Windows (dev) + env: dev + <<: *buildFilter + - store-build-artifacts: + name: Store build artifacts (dev) + requires: + - Build extension - Linux (dev) + - Build extension - MacOS (dev) + - Build extension - Windows (dev) + + # Main workflow for release/* and latest branches only + release: + <<: *ignore-for-manual-build + jobs: + # unit tests (on any commit) + - unit-tests-ui: + name: Run Unit Tests + filters: &releaseAndLatestFilter + branches: + only: + - /^release.*/ + - latest + + # ================== STAGE ================== + # build extensions (stage) + - linux: + name: Build extension - Linux (stage) + <<: *stageFilter + - macosx: + name: Build extension - MacOS (stage) + <<: *stageFilter + - windows: + name: Build extension - Windows (stage) + <<: *stageFilter + # e2e tests on linux build + - e2e-linux: + name: E2ETest (linux) + parallelism: 2 + requires: + - Build extension - Linux (stage) + + - store-build-artifacts: + name: Store build artifacts (stage) + requires: + - Build extension - Linux (stage) + - Build extension - MacOS (stage) + - Build extension - Windows (stage) + + # Needs approval from QA team that build was tested before merging to latest + - qa-approve: + name: Approved by QA team + type: approval + requires: + - Build extension - Linux (stage) + - Build extension - MacOS (stage) + - Build extension - Windows (stage) + + # ================== PROD ================== + # build and release vscode extension (prod) + - linux: + name: Build extension - Linux (prod) + env: prod + <<: *prodFilter + - macosx: + name: Build extension - MacOS (prod) + env: prod + <<: *prodFilter + - windows: + name: Build extension - Windows (prod) + env: prod + <<: *prodFilter + + # e2e tests on linux build + - e2e-linux: + name: E2ETest (Linux) + parallelism: 2 + requires: + - Build extension - Linux (prod) + + # upload release to prerelease AWS folder + - release-aws-private: + name: Release AWS S3 Private (prod) + requires: + - Build extension - Linux (prod) + - Build extension - MacOS (prod) + - Build extension - Windows (prod) + + # Manual approve for publish release + - approve-publish: + name: Approve Publish Release (prod) + type: approval + requires: + - Release AWS S3 Private (prod) + # # Publish release + # - publish-prod-aws: + # name: Publish AWS S3 + # requires: + # - Approve Publish Release (prod) + # <<: *prodFilter # double check for "latest" + + weekly: + triggers: + - schedule: + cron: '0 0 * * 1' + filters: + branches: + only: + - main + jobs: + # Process all licenses + - licenses-check: + name: Process licenses of packages + diff --git a/.env b/.env index 25ddeaf5..9dae0d2c 100644 --- a/.env +++ b/.env @@ -5,17 +5,17 @@ NODE_ENV='production' RI_BASE_APP_URL='http://localhost' RI_APP_PORT=5541 -RI_APP_VERSION='1.0.0' +RI_APP_VERSION='1.2.0' RI_APP_PREFIX='api' RI_APP_FOLDER_NAME='.redis-for-vscode' -RI_CDN_PATH='https://s3.amazonaws.com/redisinsight.download/public/releases/2.54.1/web-mini' +RI_CDN_PATH='https://s3.amazonaws.com/redisinsight.download/public/releases/2.64.0/web-mini' RI_WITHOUT_BACKEND=false # RI_WITHOUT_BACKEND=true RI_STDOUT_LOGGER=false RI_AUTO_BOOTSTRAP=false RI_MIGRATE_OLD_FOLDERS=false RI_BUILD_TYPE='VS_CODE' -RI_ENCRYPTION_KEYTAR=false RI_ANALYTICS_START_EVENTS=true RI_AGREEMENTS_PATH='../../webviews/resources/agreements-spec.json' +RI_ENCRYPTION_KEYTAR_SERVICE="redis-for-vscode" # RI_SEGMENT_WRITE_KEY='SEGMENT_WRITE_KEY' diff --git a/.eslintrc.js b/.eslintrc.js index 849d7afa..0ba652ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,6 +86,8 @@ module.exports = { 'function-paren-newline': 'off', 'prefer-regex-literals': 'off', 'react/display-name': 'off', + 'react/jsx-indent-props': [2, 2], + 'react/jsx-indent': [2, 2], 'no-promise-executor-return': 'off', 'import/order': [ 1, diff --git a/.github/actions/download-backend/action.yml b/.github/actions/download-backend/action.yml new file mode 100644 index 00000000..d70b30ff --- /dev/null +++ b/.github/actions/download-backend/action.yml @@ -0,0 +1,14 @@ +name: Download backend + +inputs: + arch: + description: Architecture arm64 or x64 + required: false + default: 'x64' + +runs: + using: 'composite' + steps: + - name: Download backend + shell: bash + run: yarn download:backend ${{ inputs.arch }} diff --git a/.github/actions/install-all-build-libs/action.yml b/.github/actions/install-all-build-libs/action.yml new file mode 100644 index 00000000..3ea1fa54 --- /dev/null +++ b/.github/actions/install-all-build-libs/action.yml @@ -0,0 +1,15 @@ +name: Install all libraries action +description: Install all libraries and dependencies + +runs: + using: 'composite' + steps: + # OS libraries + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.18.0' + + - name: Install dependencies for root package.js + shell: bash + run: yarn install --frozen-lockfile --network-timeout 1000000 diff --git a/.github/build/sum_sha256.sh b/.github/build/sum_sha256.sh new file mode 100755 index 00000000..4af88b1e --- /dev/null +++ b/.github/build/sum_sha256.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +find ./release -type f -name '*.tar.gz' -execdir sh -c 'sha256sum "$1" > "$1.sha256"' _ {} \; diff --git a/.github/deps-licenses-report.js b/.github/deps-licenses-report.js new file mode 100644 index 00000000..844e2ce0 --- /dev/null +++ b/.github/deps-licenses-report.js @@ -0,0 +1,250 @@ +const fs = require('fs'); +const { join } = require('path'); +const { last, set } = require('lodash'); +const { google } = require('googleapis'); +const { exec } = require('child_process'); +const csvParser = require('csv-parser'); +const { stringify } = require('csv-stringify'); + +const licenseFolderName = 'licenses'; +const spreadsheetId = process.env.SPREADSHEET_ID; +const summaryFilePath = `./${licenseFolderName}/summary.csv`; +const allData = []; +let csvFiles = []; + +// Main function +async function main() { + const folderPath = './'; + const packageJsons = findPackageJsonFiles(folderPath); // Find all package.json files in the given folder + + console.log('All package.jsons was found:', packageJsons); + + // Create the folder if it doesn't exist + if (!fs.existsSync(licenseFolderName)) { + fs.mkdirSync(licenseFolderName); + } + + try { + await Promise.all(packageJsons.map(runLicenseCheck)); + console.log('All csv files was generated'); + await generateSummary() + await sendLicensesToGoogleSheet() + } catch (error) { + console.error('An error occurred:', error); + process.exit(1); + } +} + +main(); + +// Function to find all package.json files in a given folder +function findPackageJsonFiles(folderPath) { + const packageJsonPaths = []; + const packageJsonName = 'package.json'; + const excludeFolders = ['dist', 'node_modules', 'test-extensions']; + + // Recursive function to search for package.json files + function searchForPackageJson(currentPath) { + const files = fs.readdirSync(currentPath); + + for (const file of files) { + const filePath = join(currentPath, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory() && !excludeFolders.includes(file)) { + searchForPackageJson(filePath); + } else if (file === packageJsonName) { + packageJsonPaths.push(`./${filePath.slice(0, -packageJsonName.length - 1)}`); + } + } + } + + searchForPackageJson(folderPath); + return packageJsonPaths; +} + +// Function to run license check for a given package.json file +async function runLicenseCheck(path) { + const name = last(path.split('/')) || 'vscode'; + + const COMMANDS = [ + `license-checker --start ${path} --csv --out ./${licenseFolderName}/${name}_prod.csv --production`, + `license-checker --start ${path} --csv --out ./${licenseFolderName}/${name}_dev.csv --development`, + ] + + return await Promise.all(COMMANDS.map((command) => + new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + console.error(`Failed command: ${command}, error:`, stderr); + reject(error); + } + resolve(); + }); + }) + )); +} + +async function sendLicensesToGoogleSheet() { + try { + const serviceAccountKey = JSON.parse(fs.readFileSync('./gasKey.json', 'utf-8')); + + // Set up JWT client + const jwtClient = new google.auth.JWT( + serviceAccountKey.client_email, + null, + serviceAccountKey.private_key, + ['https://www.googleapis.com/auth/spreadsheets'] + ); + + const sheets = google.sheets('v4'); + + // Read all .csv files in the 'licenses' folder + csvFiles.forEach((csvFile) => { + // Extract sheet name from file name + const sheetName = csvFile.replace('.csv', '').replaceAll('_', ' '); + + const data = []; + fs.createReadStream(`./${licenseFolderName}/${csvFile}`) + .pipe(csvParser({ headers: false })) + .on('data', (row) => { + data.push(Object.values(row)); + }) + .on('end', async () => { + const resource = { values: data }; + + try { + const response = await sheets.spreadsheets.get({ + auth: jwtClient, + spreadsheetId, + }); + + const sheet = response.data.sheets.find((sheet) => sheet.properties.title === sheetName); + if (sheet) { + // Clear contents of the sheet starting from cell A2 + await sheets.spreadsheets.values.clear({ + auth: jwtClient, + spreadsheetId, + range: `${sheetName}!A1:Z`, // Assuming Z is the last column + }); + } else { + // Create the sheet if it doesn't exist + await sheets.spreadsheets.batchUpdate({ + auth: jwtClient, + spreadsheetId, + resource: set({}, 'requests[0].addSheet.properties.title', sheetName), + }); + } + } catch (error) { + console.error(`Error checking/creating sheet for ${sheetName}:`, error); + } + + try { + await sheets.spreadsheets.values.batchUpdate({ + auth: jwtClient, + spreadsheetId, + resource: { + valueInputOption: 'RAW', + data: [ + { + range: `${sheetName}!A1`, // Use the sheet name as the range and start from A2 + majorDimension: 'ROWS', + values: data, + }, + ], + }, + }); + + console.log(`CSV data has been inserted into ${sheetName} sheet.`); + } catch (err) { + console.error(`Error inserting data for ${sheetName}:`, err); + } + }); + }); + } catch (error) { + console.error('Error loading service account key:', error); + } +} + +// Function to read and process each CSV file +const processCSVFile = (file) => { + return new Promise((resolve, reject) => { + const parser = csvParser({ columns: true, trim: true }); + const input = fs.createReadStream(`./${licenseFolderName}/${file}`); + + parser.on('data', (record) => { + allData.push(record); + }); + + parser.on('end', () => { + resolve(); + }); + + parser.on('error', (err) => { + reject(err); + }); + + input.pipe(parser); + }); +}; + +// Process and aggregate license data +const processLicenseData = () => { + const licenseCountMap = {}; + for (const record of allData) { + const license = record.license; + licenseCountMap[license] = (licenseCountMap[license] || 0) + 1; + } + return licenseCountMap; +}; + +// Create summary CSV data +const createSummaryData = (licenseCountMap) => { + const summaryData = [['License', 'Count']]; + for (const license in licenseCountMap) { + summaryData.push([license, licenseCountMap[license]]); + } + return summaryData; +}; + +// Write summary CSV file +const writeSummaryCSV = async (summaryData) => { + try { + const summaryCsvString = await stringifyPromise(summaryData); + fs.writeFileSync(summaryFilePath, summaryCsvString); + csvFiles.push(last(summaryFilePath.split('/'))); + console.log(`Summary CSV saved as ${summaryFilePath}`); + } catch (err) { + console.error(`Error: ${err}`); + } +}; + +// Stringify as a promise +const stringifyPromise = (data) => { + return new Promise((resolve, reject) => { + stringify(data, (err, csvString) => { + if (err) { + reject(err); + } else { + resolve(csvString); + } + }); + }); +}; + +async function generateSummary() { + csvFiles = fs.readdirSync(licenseFolderName).filter(file => file.endsWith('.csv')).sort(); + + for (const file of csvFiles) { + try { + await processCSVFile(file); + } catch (err) { + console.error(`Error processing ${file}: ${err}`); + } + } + + const licenseCountMap = processLicenseData(); + const summaryData = createSummaryData(licenseCountMap); + + await writeSummaryCSV(summaryData); +} diff --git a/.github/e2e/e2e-results.js b/.github/e2e/e2e-results.js new file mode 100644 index 00000000..6a4d5118 --- /dev/null +++ b/.github/e2e/e2e-results.js @@ -0,0 +1,57 @@ +const fs = require('fs'); + +let parallelNodeInfo = ''; +const totalNodes = 4; +if (totalNodes > 1) { + parallelNodeInfo = ` (node: ${parseInt(process.env.NODE_INDEX, 10) + 1}/${totalNodes})` +} + +const file = fs.readdirSync('tests/e2e/mochawesome-report').find(file => file.endsWith('-setup-report.json')) +const appBuildType = process.env.APP_BUILD_TYPE || 'VSCode (Linux)' +const results = { + message: { + text: `*E2ETest - ${appBuildType}${parallelNodeInfo}* (Branch: *${process.env.GITHUB_REF_NAME}*)` + + `\n`, + attachments: [], + }, +}; + +const result = JSON.parse(fs.readFileSync(file, 'utf-8')) +const testRunResult = { + color: '#36a64f', + title: `Started at: *${result.stats.start}`, + text: `Executed ${result.stats.tests} in ${(new Date(result.stats.end) - new Date(result.stats.start)) / 1000}s`, + fields: [ + { + title: 'Passed', + value: result.stats.passes, + short: true, + }, + { + title: 'Skipped', + value: result.stats.skipped, + short: true, + }, + ], +}; +const failed = result.stats.failures; +if (failed) { + results.passed = false; + testRunResult.color = '#cc0000'; + testRunResult.fields.push({ + title: 'Failed', + value: failed, + short: true, + }); +} + +results.message.attachments.push(testRunResult); + +if (results.passed === false) { + results.message.text = ' ' + results.message.text; +} + +fs.writeFileSync('e2e.report.json', JSON.stringify({ + channel: process.env.SLACK_TEST_REPORT_CHANNEL, + ...results.message, +})); diff --git a/.github/e2e/test.app.sh b/.github/e2e/test.app.sh new file mode 100755 index 00000000..1f15126d --- /dev/null +++ b/.github/e2e/test.app.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# Create folder before tests run to prevent permissions issues +mkdir -p tests/e2e/remote + +# Run RTE (Redis Test Environment) +docker compose -f tests/e2e/rte.docker-compose.yml build +docker compose -f tests/e2e/rte.docker-compose.yml up --force-recreate -d -V +./tests/e2e/wait-for-redis.sh localhost 12000 + +# Run tests +RI_SOCKETS_CORS=true \ +xvfb-run --auto-servernum \ +yarn --cwd tests/e2e dotenv -e .ci.env yarn --cwd tests/e2e test:ci diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml new file mode 100644 index 00000000..d353c6c5 --- /dev/null +++ b/.github/workflows/aws.yml @@ -0,0 +1,33 @@ +name: AWS + +on: + workflow_call: + +env: + AWS_BUCKET_NAME: ${{ secrets.AWS_BUCKET_NAME }} + AWS_DISTRIBUTION_ID: ${{ secrets.AWS_DISTRIBUTION_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} + +jobs: + release-private: + name: Release s3 private + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: ./release + + - run: ls -R ./release + + - name: Publish private + run: | + chmod +x .github/build/sum_sha256.sh + .github/build/sum_sha256.sh + applicationVersion=$(jq -r '.version' package.json) + + aws s3 cp release/ s3://${AWS_BUCKET_NAME}/private/vscode/${applicationVersion} --recursive diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..44fc7e66 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,60 @@ +name: πŸš€ Build + +on: + # Manual trigger build + workflow_dispatch: + inputs: + target: + description: Build target + required: false + default: 'all' + type: choice + options: + - all + - macos:x64 + - macos:arm64 + - linux:x64 + - windows:x64 + + environment: + description: Environment to run build + type: environment + default: 'staging' + required: false + + # Called for Release workflows + workflow_call: + inputs: + environment: + description: Environment to run build + type: string + default: 'staging' + required: false + target: + description: Build target + type: string + default: 'all' + required: false + +jobs: + build-linux: + if: startsWith(inputs.target, 'linux') || endsWith(inputs.target, 'all') + uses: ./.github/workflows/pipeline-build-linux.yml + secrets: inherit + with: + environment: ${{ inputs.environment }} + + build-macos: + if: startsWith(inputs.target, 'macos') || endsWith(inputs.target, 'all') + uses: ./.github/workflows/pipeline-build-macos.yml + secrets: inherit + with: + environment: ${{ inputs.environment }} + target: ${{ inputs.target }} + + build-windows: + if: startsWith(inputs.target, 'windows') || endsWith(inputs.target, 'all') + uses: ./.github/workflows/pipeline-build-windows.yml + secrets: inherit + with: + environment: ${{ inputs.environment }} diff --git a/.github/workflows/licenses-check.yml b/.github/workflows/licenses-check.yml new file mode 100644 index 00000000..6f14d21f --- /dev/null +++ b/.github/workflows/licenses-check.yml @@ -0,0 +1,32 @@ +name: Licenses check pipeline +on: + workflow_call: + workflow_dispatch: + +jobs: + licenses-check: + name: Licenses check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install all libs and dependencies + uses: ./.github/actions/install-all-build-libs + + - name: Install e2e dependencies + run: yarn --cwd tests/e2e install + + - name: Generate licenses csv files and send csv data to google sheet + env: + GOOGLE_ACCOUNT_SERVICE_KEY_BASE64: ${{ secrets.GOOGLE_ACCOUNT_SERVICE_KEY_BASE64 }} + GOOGLE_SPREADSHEET_DEPENDENCIES_ID: ${{ secrets.GOOGLE_SPREADSHEET_DEPENDENCIES_ID }} + run: | + npm i -g license-checker + echo "$GOOGLE_ACCOUNT_SERVICE_KEY_BASE64" | base64 -id > gasKey.json + SPREADSHEET_ID=$GOOGLE_SPREADSHEET_DEPENDENCIES_ID node .github/deps-licenses-report.js + + - uses: actions/upload-artifact@v4 + with: + name: licenses + path: licenses + if-no-files-found: error diff --git a/.github/workflows/pipeline-build-linux.yml b/.github/workflows/pipeline-build-linux.yml new file mode 100644 index 00000000..f9519361 --- /dev/null +++ b/.github/workflows/pipeline-build-linux.yml @@ -0,0 +1,61 @@ +name: Build linux pipeline + +on: + workflow_call: + inputs: + environment: + description: Environment for build + required: false + default: 'staging' + type: string + +jobs: + build: + name: Build linux + runs-on: ubuntu-24.04 + environment: ${{ inputs.environment }} + + steps: + - uses: actions/checkout@v4 + # SSH + # - name: Setup tmate session + # uses: mxschmitt/action-tmate@v3 + # with: + # detached: true + + - name: Setup Node + uses: actions/setup-node@v4.0.4 + with: + node-version: '20.15' + + - name: Install dependencies for root package.js + run: yarn install --frozen-lockfile + + - name: Download backend + uses: ./.github/actions/download-backend + + - name: Set RI_SEGMENT_WRITE_KEY to .env file + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + + - name: Build linux package (production) + if: inputs.environment == 'production' + run: | + yarn package:prod --target linux-x64 --out ${packagePath} + + - name: Build linux package (staging) + if: inputs.environment == 'staging' + run: | + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} + yarn package:stage --target linux-x64 --out ${packagePath} + + - uses: actions/upload-artifact@v4 + name: Upload extension artifact + with: + name: linux-build + path: | + release/redis-for-*.vsix + + env: + envFile: '.env' + packagePath: './release/redis-for-vscode-extension-linux-x64.vsix' + RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} diff --git a/.github/workflows/pipeline-build-macos.yml b/.github/workflows/pipeline-build-macos.yml new file mode 100644 index 00000000..c12a9b3e --- /dev/null +++ b/.github/workflows/pipeline-build-macos.yml @@ -0,0 +1,78 @@ +name: Build macos pipeline + +on: + workflow_call: + inputs: + environment: + description: Environment for build + required: false + default: 'staging' + type: string + + target: + description: Build target + required: false + default: 'all' + type: string + +jobs: + build: + name: Build macos + runs-on: macos-14 + environment: ${{ inputs.environment }} + + steps: + - uses: actions/checkout@v4 + + - name: Install all libs and dependencies + uses: ./.github/actions/install-all-build-libs + + - name: Set RI_SEGMENT_WRITE_KEY to .env file + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + + - name: Download backend x64 + uses: ./.github/actions/download-backend + with: + arch: x64 + + - name: Build macos x64 package (staging) + if: inputs.environment != 'production' + run: | + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} + + yarn package:stage --target darwin-x64 --out ${packagePath}-x64.vsix + + - name: Build macos x64 package (production) + if: inputs.environment == 'production' + run: | + yarn package:prod --target darwin-x64 --out ${packagePath}-x64.vsix + + - name: Download backend arm64 + uses: ./.github/actions/download-backend + with: + arch: arm64 + + - name: Build macos arm64 package (staging) + if: inputs.environment != 'production' + run: | + sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} + + yarn package:stage --target darwin-arm64 --out ${packagePath}-arm64.vsix + + + - name: Build macos arm64 package (production) + if: inputs.environment == 'production' + run: | + yarn package:prod --target darwin-arm64 --out ${packagePath}-arm64.vsix + + - uses: actions/upload-artifact@v4 + name: Upload extension artifact + with: + name: macos-builds + path: | + release/redis-for-*.vsix + + env: + envFile: '.env' + packagePath: './release/redis-for-vscode-extension-mac' + RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} diff --git a/.github/workflows/pipeline-build-windows.yml b/.github/workflows/pipeline-build-windows.yml new file mode 100644 index 00000000..dc3600e1 --- /dev/null +++ b/.github/workflows/pipeline-build-windows.yml @@ -0,0 +1,51 @@ +name: Build windows pipeline + +on: + workflow_call: + inputs: + environment: + description: Environment for build + required: false + default: 'staging' + type: string + +jobs: + build: + name: Build windows + runs-on: windows-2022 + environment: ${{ inputs.environment }} + + steps: + - uses: actions/checkout@v4 + + - name: Install all libs and dependencies + uses: ./.github/actions/install-all-build-libs + + - name: Download backend + uses: ./.github/actions/download-backend + + - name: Set RI_SEGMENT_WRITE_KEY to .env file + run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }} + + - name: Build windows package (production) + if: inputs.environment == 'production' + run: | + yarn package:prod --target win32-x64 --out ${{ env.packagePath }} + + - name: Build windows package (staging) + if: inputs.environment == 'staging' + run: | + sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }} + yarn package:stage --target win32-x64 --out ${{ env.packagePath }} + + - uses: actions/upload-artifact@v4 + name: Upload extension artifact + with: + name: windows-build + path: | + release/redis-for-*.vsix + + env: + envFile: '.env' + packagePath: './release/redis-for-vscode-extension-win-x64.vsix' + RI_SEGMENT_WRITE_KEY: ${{ secrets.RI_SEGMENT_WRITE_KEY }} diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml new file mode 100644 index 00000000..b37b98e4 --- /dev/null +++ b/.github/workflows/release-prod.yml @@ -0,0 +1,36 @@ +name: ❗ Release (prod) + +on: + push: + branches: + - 'latest' + +jobs: + tests-prod: + name: Run all tests + uses: ./.github/workflows/tests.yml + secrets: inherit + with: + group_tests: 'without_e2e' + pre_release: true + + builds-prod: + name: Create all builds for release + uses: ./.github/workflows/build.yml + needs: tests-prod + secrets: inherit + with: + environment: 'production' + target: 'all' + + e2e-linux-tests: + name: E2E Docker tests + needs: builds-prod + uses: ./.github/workflows/tests-e2e-linux.yml + secrets: inherit + + aws-prod: + name: Realse to AWS S3 + uses: ./.github/workflows/aws.yml + needs: builds-prod + secrets: inherit diff --git a/.github/workflows/release-stage.yml b/.github/workflows/release-stage.yml new file mode 100644 index 00000000..0ee72b80 --- /dev/null +++ b/.github/workflows/release-stage.yml @@ -0,0 +1,33 @@ +name: πŸ“– Release (stage) + +on: + push: + branches: + - 'release/**' + +jobs: + tests: + name: Release stage tests + uses: ./.github/workflows/tests.yml + secrets: inherit + with: + group_tests: 'without_e2e' + pre_release: true + + builds: + name: Release stage builds + uses: ./.github/workflows/build.yml + needs: tests + secrets: inherit + with: + environment: 'staging' + target: 'all' + + e2e-linux-tests: + needs: builds + uses: ./.github/workflows/tests-e2e-linux.yml + secrets: inherit + + + + diff --git a/.github/workflows/tests-e2e-linux.yml b/.github/workflows/tests-e2e-linux.yml new file mode 100644 index 00000000..a9850a1d --- /dev/null +++ b/.github/workflows/tests-e2e-linux.yml @@ -0,0 +1,98 @@ +name: Tests E2E Linux +on: + workflow_call: + inputs: + report: + description: Send report to Slack + required: false + default: false + type: boolean + +env: + SLACK_TEST_REPORT_KEY: ${{ secrets.SLACK_TEST_REPORT_KEY }} + TEST_BIG_DB_DUMP: ${{ secrets.TEST_BIG_DB_DUMP }} + +jobs: + e2e-linux-tests: + runs-on: ubuntu-latest + name: E2E tests + strategy: + fail-fast: false + matrix: + # Number of threads to run tests + parallel: [0, 1, 2, 3] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.18.0' + + - name: Download linux artifact + uses: actions/download-artifact@v4 + with: + name: linux-build + path: ./release + + - name: Setup e2e tests + working-directory: ./tests/e2e + run: | + npm install -g @vscode/vsce + yarn install + yarn compile + ls -R dist/tests/ + + - name: Generate short list of the test files + working-directory: ./tests/e2e + run: | + testFiles=$(find dist -type f -name '*.e2e.js' | sort | awk "NR % 4 == ${{ matrix.parallel }}") + echo $testFiles + + # Multi-Line value + echo "TEST_FILES<> $GITHUB_ENV + echo "$testFiles" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Run tests + run: | + .github/e2e/test.app.sh + + - name: Upload Test Mocha Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: mocha-report-linux-node-${{ matrix.parallel }} + path: tests/e2e/mochawesome-report + + - name: Send report to Slack + if: inputs.report && always() + run: | + APP_BUILD_TYPE="VSCode (Linux)" node ./.github/e2e/e2e-results.js + # curl -H "Content-type: application/json" --data @e2e.report.json -H "Authorization: Bearer $SLACK_TEST_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage + + - name: Generate test results for ${{ matrix.parallel }}th node + uses: dorny/test-reporter@v1 + if: always() + with: + name: 'Test results: E2E (linux) ${{ matrix.parallel }}th node' + path: tests/e2e/mochawesome-report/junit-report.xml + reporter: java-junit + list-tests: 'failed' + list-suites: 'failed' + fail-on-error: 'false' + + merge-artifacts: + runs-on: ubuntu-latest + needs: e2e-linux-tests + steps: + - name: Merge report artifacts + id: merge-artifacts + uses: actions/upload-artifact/merge@v4 + with: + name: mocha-report-linux + pattern: mocha-report-linux-node-* + separate-directories: true + delete-merged: true + retention-days: 5 diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml new file mode 100644 index 00000000..c13eabe2 --- /dev/null +++ b/.github/workflows/tests-frontend.yml @@ -0,0 +1,55 @@ +name: Tests UI +on: + workflow_call: + +env: + SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }} + SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }} + AWS_BUCKET_NAME_TEST: ${{ vars.AWS_BUCKET_NAME_TEST }} + AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} + AWS_DISTRIBUTION_ID: ${{ secrets.AWS_DISTRIBUTION_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + REPORT_NAME: "report-vscode-fe" + S3_PATH: "report-vscode-fe" + +jobs: + unit-tests: + runs-on: ubuntu-latest + name: Frontend tests + steps: + - uses: actions/checkout@v4 + + - name: Install all libs and dependencies + uses: ./.github/actions/install-all-build-libs + + - name: Unit tests UI + run: yarn test:cov + + - name: Get current date + id: date + if: always() + uses: RedisInsight/RedisInsight/.github/actions/get-current-date@873a0ebf55c85d3127bb4efb4d0636d9ab838226 + + + - name: Deploy πŸš€ + if: always() + run: | + + GZIP_FILE=html.meta.json.gz + S3_SUB_PATH="test-reports/${{ steps.date.outputs.date }}/${{ github.run_id }}/${{ env.REPORT_NAME }}" + + aws s3 cp report/ s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH} --recursive --exclude "*.gz" + + # s3 modified "gzip" content-type + # https://github.com/aws/aws-cli/issues/1131 + aws s3 cp report/${GZIP_FILE} s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH}/${GZIP_FILE} --content-type "application/x-gzip" --metadata-directive REPLACE + + echo "S3_SUB_PATH=${S3_SUB_PATH}" >> $GITHUB_ENV + + + - name: Add link to report in the workflow summary + if: always() + run: | + link="${{ vars.DEFAULT_TEST_REPORTS_URL }}/${S3_SUB_PATH}/index.html" + echo "[${link}](${link})" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..91a42183 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,55 @@ +name: βœ… Tests + +on: + push: + branches: + - 'feature/**' + - 'e2e/**' + + workflow_dispatch: + inputs: + group_tests: + description: Run group of tests + default: 'all' + type: choice + options: + - all + - without_e2e + + workflow_call: + inputs: + group_tests: + description: Run group of tests + type: string + default: 'all' + pre_release: + description: Is pre-release + default: false + type: boolean + +jobs: + frontend-tests: + if: inputs.group_tests == 'all' || inputs.group_tests == 'without_e2e' || startsWith(github.ref_name, 'feature/') + uses: ./.github/workflows/tests-frontend.yml + secrets: inherit + + # E2E Approve + e2e-approve: + runs-on: ubuntu-latest + timeout-minutes: 60 + if: inputs.group_tests == 'all' || startsWith(github.ref_name, 'e2e/') + environment: ${{ startsWith(github.ref_name, 'e2e/') && 'e2e-approve' || 'staging' }} + name: Approve E2E tests + steps: + - uses: actions/checkout@v4 + + # E2E Docker + build-linux: + uses: ./.github/workflows/pipeline-build-linux.yml + needs: e2e-approve + secrets: inherit + + e2e-linux-test: + needs: build-linux + uses: ./.github/workflows/tests-e2e-linux.yml + secrets: inherit diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml new file mode 100644 index 00000000..c8c6f28f --- /dev/null +++ b/.github/workflows/weekly.yml @@ -0,0 +1,9 @@ +name: Weekly jobs +on: + schedule: + - cron: 0 0 * * 1 + +jobs: + licenses-check: + uses: ./.github/workflows/licenses-check.yml + secrets: inherit diff --git a/.gitignore b/.gitignore index 7b829b1b..97518b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ lerna-debug.log* coverage /.nyc_output **/coverage +reports +report # IDEs and editors /.idea @@ -59,3 +61,9 @@ scripts/*.js # security *.local + +#CI +.actrc +my.secrets +my.inputs +event.json diff --git a/.vscode/settings.json b/.vscode/settings.json index bb1001f5..9653f327 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,9 @@ "source.fixAll.eslint": "explicit" }, "eslint.validate": ["javascript", "typescript"], + "eslint.workingDirectories": [ + { "directory": "./", "changeProcessCWD": true } + ], "files.associations": { "*.css": "postcss", "*.scss": "postcss" @@ -35,6 +38,7 @@ ".eslintcache": true, "bower_components": true, "release": true, + "src/webviews/public": true, "npm-debug.log.*": true, "tests/**/__snapshots__": true, "yarn.lock": true, @@ -44,7 +48,13 @@ "**/pnpm-lock.yaml": true, "**/test-extensions": true }, - "cSpell.words": ["githubocto", "tailwindcss", "webviews", "zustand"], + "cSpell.words": [ + "githubocto", + "keyspace", + "tailwindcss", + "webviews", + "zustand" + ], "testing.automaticallyOpenPeekView": "never", "[typescriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" diff --git a/.vscodeignore b/.vscodeignore index 033ae5f3..0fe0f708 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -28,6 +28,8 @@ vite.config.mjs # test coverage test +report +reports tests test-workspace .vscode-test diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 2d2ba56d..2d260197 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -41,7 +41,7 @@ "Key Name": "Key Name", " will be deleted.": " will be deleted.", "Delete": "Delete", - "will be deleted from Redis for VS Code.": "will be deleted from Redis for VS Code.", + "will be removed from Redis for VS Code.": "will be removed from Redis for VS Code.", "Key Size": "Key Size", "Key Size: ": "Key Size: ", "Length": "Length", @@ -295,9 +295,6 @@ "Host:": "Host:", "Database Index:": "Database Index:", "Modules:": "Modules:", - "Select Logical Database": "Select Logical Database", - "Database Index": "Database Index", - "Enter Database Index": "Enter Database Index", "No decompression": "No decompression", "Enable automatic data decompression": "Enable automatic data decompression", "Decompression format": "Decompression format", diff --git a/package.json b/package.json index 8de3bb8a..f8a47b39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redis-for-vscode", - "version": "1.0.0", + "version": "1.2.0", "displayName": "Redis for VS Code", "description": "Visually interact with data and build queries in Redis", "license": "SEE LICENSE IN LICENSE", @@ -143,6 +143,7 @@ "download:backend": "tsc ./scripts/downloadBackend.ts && node ./scripts/downloadBackend.js", "dev": "vite dev", "dev:key": "cross-env RI_DATA_ROUTE=main/key vite dev", + "dev:database": "cross-env RI_DATA_ROUTE=main/add_database vite dev", "dev:sidebar": "cross-env RI_DATA_ROUTE=sidebar vite dev", "l10n:collect": "npx @vscode/l10n-dev export -o ./l10n ./src", "watch": "tsc -watch -p ./", @@ -194,9 +195,9 @@ "@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/coverage-v8": "^1.3.1", - "@vitest/ui": "^1.3.1", + "@vitest/ui": "^1.6.0", "@vscode/l10n-dev": "^0.0.35", - "@vscode/vsce": "^3.0.0", + "@vscode/vsce": "^3.2.1", "cross-env": "^7.0.3", "csv-parser": "^3.0.0", "csv-stringify": "^6.5.1", @@ -236,6 +237,7 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.4.5", + "upath": "^2.0.1", "uuid": "^9.0.1", "vite": "^5.2.10", "vite-plugin-react-click-to-component": "^3.0.0", @@ -248,7 +250,7 @@ "@stablelib/snappy": "^1.0.3", "@vscode/l10n": "^0.0.18", "@vscode/webview-ui-toolkit": "^1.4.0", - "axios": "^1.5.1", + "axios": "^1.7.4", "brotli-unicode": "^1.0.2", "buffer": "^6.0.3", "classnames": "^2.3.2", @@ -286,7 +288,7 @@ "react-inlinesvg": "^4.1.1", "react-monaco-editor": "^0.55.0", "react-router-dom": "^6.17.0", - "react-select": "^5.8.0", + "react-select": "^5.8.3", "react-spinners": "^0.13.8", "react-virtualized": "^9.22.5", "react-virtualized-auto-sizer": "^1.0.20", diff --git a/scripts/downloadBackend.ts b/scripts/downloadBackend.ts index 83e173d6..0982f2ea 100755 --- a/scripts/downloadBackend.ts +++ b/scripts/downloadBackend.ts @@ -3,8 +3,10 @@ import * as fs from 'fs' import * as path from 'path' import * as cp from 'child_process' import * as dotenv from 'dotenv' +import * as upath from 'upath' import { parse as parseUrl } from 'url' + dotenv.config({ path: [ path.join(__dirname, '..', '.env'), @@ -56,12 +58,14 @@ async function downloadRedisBackendArchive( destDir: string, ): Promise { ensureFolderExists(destDir) - const downloadUrl = getDownloadUrl() + let downloadUrl = getDownloadUrl() return new Promise((resolve, reject) => { const requestOptions: https.RequestOptions = parseUrl(downloadUrl) https.get(requestOptions, (res) => { - if (res.statusCode !== 200) { + if (res.statusCode === 302 && res.headers.location) { + downloadUrl = res.headers.location + } else if (res.statusCode !== 200) { reject(new Error('Failed to get Redis Insight backend archive location')) } @@ -78,13 +82,28 @@ async function downloadRedisBackendArchive( }) } +function getNormalizedCIString(string: string) { + return string?.startsWith('D:') && process.env.CI + ? upath.normalize(string).replace('D:', '/d') + : string +} + function unzipRedisServer(redisInsideArchivePath: string, extractDir: string) { // tar does not create extractDir by default if (!fs.existsSync(extractDir)) { fs.mkdirSync(extractDir) } - cp.spawnSync('tar', ['-xf', redisInsideArchivePath, '-C', extractDir, '--strip-components', '1', 'api']) + cp.spawnSync('tar', [ + '-xf', + getNormalizedCIString(redisInsideArchivePath), + '-C', + getNormalizedCIString(extractDir), + '--strip-components', + '1', + 'api', + ]) + // remove tutorials fs.rmSync(tutorialsPath, { recursive: true, force: true }); diff --git a/src/extension.ts b/src/extension.ts index 02f0d3de..6bd9af33 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -164,7 +164,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('RedisForVSCode.editDatabaseClose', (args) => { WebviewPanel.getInstance({ viewId: ViewId.EditDatabase }).dispose() - sidebarProvider.view?.webview.postMessage({ action: 'RefreshTree', data: args }) + sidebarProvider.view?.webview.postMessage({ action: 'UpdateDatabaseInList', data: args }) const keyDetailsWebview = WebviewPanel.instances[ViewId.Key] if (keyDetailsWebview) { diff --git a/src/resources/agreements-spec.json b/src/resources/agreements-spec.json index 71d31fdc..890abb4f 100644 --- a/src/resources/agreements-spec.json +++ b/src/resources/agreements-spec.json @@ -1,5 +1,5 @@ { - "version": "1.0.1", + "version": "1.0.2", "agreements": { "analytics": { "defaultValue": false, @@ -35,6 +35,37 @@ "title": "Server Side Public License", "label": "I have read and understood the Terms", "requiredText": "Accept the Server Side Public License" + }, + "encryption": { + "conditional": true, + "checker": "KEYTAR", + "defaultOption": "false", + "options": { + "true": { + "defaultValue": true, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": false, + "category": "privacy", + "since": "1.0.2", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "Select to encrypt sensitive information using system keychain. Otherwise, this information is stored locally in plain text, which may incur security risk." + }, + "false": { + "defaultValue": false, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": true, + "category": "privacy", + "since": "1.0.2", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "Install or enable the system keychain to encrypt and securely store your sensitive information added before using the application. Otherwise, this information will be stored locally in plain text and may lead to security risks." + } + } } } } diff --git a/src/webviews/src/actions/processCliAction.ts b/src/webviews/src/actions/processCliAction.ts index 305a3a69..4ebdf0ba 100644 --- a/src/webviews/src/actions/processCliAction.ts +++ b/src/webviews/src/actions/processCliAction.ts @@ -4,9 +4,11 @@ import { useDatabasesStore } from 'uiSrc/store' export const processCliAction = (message: CliAction) => { const prevDatabaseId = useDatabasesStore.getState().connectedDatabase?.id + const prevDatabaseIndex = useDatabasesStore.getState().connectedDatabase?.db const database = message?.data?.database + const dbIndex = database?.db ?? 0 - if (prevDatabaseId === database?.id) { + if (prevDatabaseId! + prevDatabaseIndex === database?.id + dbIndex) { return } window.ri.database = database diff --git a/src/webviews/src/actions/tests/processCliAction.spec.ts b/src/webviews/src/actions/tests/processCliAction.spec.ts new file mode 100644 index 00000000..1a3adcd2 --- /dev/null +++ b/src/webviews/src/actions/tests/processCliAction.spec.ts @@ -0,0 +1,16 @@ +import * as useCliSettingsThunks from 'uiSrc/modules/cli/hooks/cli-settings/useCliSettingsThunks' +import { constants } from 'testSrc/helpers' +import { processCliAction } from 'uiSrc/actions' + +vi.spyOn(useCliSettingsThunks, 'addCli') + +beforeEach(() => { + vi.stubGlobal('ri', { }) +}) + +describe('processCliAction', () => { + it('should call addCli', () => { + processCliAction(constants.VSCODE_CLI_ACTION) + expect(useCliSettingsThunks.addCli).toBeCalled() + }) +}) diff --git a/src/webviews/src/actions/tests/selectKeyAction.spec.ts b/src/webviews/src/actions/tests/selectKeyAction.spec.ts new file mode 100644 index 00000000..28779db0 --- /dev/null +++ b/src/webviews/src/actions/tests/selectKeyAction.spec.ts @@ -0,0 +1,23 @@ +import * as useSelectedKey from 'uiSrc/store/hooks/use-selected-key-store/useSelectedKeyStore' +import { selectKeyAction } from 'uiSrc/actions' +import { constants } from 'testSrc/helpers' + + +vi.spyOn(useSelectedKey, 'fetchKeyInfo') + + +beforeEach(() => { + vi.stubGlobal('ri', { }) + + useSelectedKey.useSelectedKeyStore.setState((state) => ({ + ...state, + data: constants.KEY_INFO, + })) +}) + +describe('selectKeyAction', () => { + it('should call fetchKeyInfo', () => { + selectKeyAction(constants.VSCODE_SELECT_KEY_ACTION) + expect(useSelectedKey.fetchKeyInfo).toBeCalled() + }) +}) diff --git a/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx b/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx new file mode 100644 index 00000000..62870f3c --- /dev/null +++ b/src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, screen, render, act } from 'testSrc/helpers' +import { AutoRefresh, Props } from './AutoRefresh' +import { DEFAULT_REFRESH_RATE } from './utils' + +const mockedProps = mock() + +const INLINE_ITEM_EDITOR = 'inline-item-editor' + +describe('AutoRefresh', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('prop "displayText = true" should show Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).toBeInTheDocument() + }) + + it('prop "displayText = false" should hide Refresh text', () => { + const { queryByTestId } = render() + + expect(queryByTestId('refresh-message-label')).not.toBeInTheDocument() + }) + + it('should call onRefresh', () => { + const onRefresh = vi.fn() + render() + + fireEvent.click(screen.getByTestId('refresh-btn')) + expect(onRefresh).toBeCalled() + }) + + it('refresh text should contain "Last refresh" time with disabled auto-refresh', async () => { + render() + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Last refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent('now') + }) + + it('refresh text should contain "Auto-refresh" time with enabled auto-refresh', async () => { + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + + expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Auto refresh:/i) + expect(screen.getByTestId('refresh-message')).toHaveTextContent(DEFAULT_REFRESH_RATE) + }) + + it('should locate refresh message label when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-message-label')).toBeInTheDocument() + }) + + it('should locate refresh message when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-message')).toBeInTheDocument() + }) + + it('should locate refresh button when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-refresh-btn')).toBeInTheDocument() + }) + + it('should locate auto-refresh config button when testid is provided', () => { + render() + + expect(screen.getByTestId('testid-auto-refresh-config-btn')).toBeInTheDocument() + }) + + it('should locate auto-refresh switch when testid is provided', () => { + render() + + fireEvent.click(screen.getByTestId('testid-auto-refresh-config-btn')) + expect(screen.getByTestId('testid-auto-refresh-switch')).toBeInTheDocument() + }) + + describe('AutoRefresh Config', () => { + it('Auto refresh config should render', () => { + const { queryByTestId } = render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + expect(queryByTestId('auto-refresh-switch')).toBeInTheDocument() + }) + + it('should call onRefresh after enable auto-refresh and set 1 sec', async () => { + const onRefresh = vi.fn() + render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR)) + + fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } }) + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1') + + screen.getByTestId(/apply-btn/).click() + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(1) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(2) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(3) + }) + }) + + it('should NOT call onRefresh with disabled state', async () => { + const onRefresh = vi.fn() + const { rerender } = render() + + fireEvent.click(screen.getByTestId('auto-refresh-config-btn')) + fireEvent.click(screen.getByTestId('auto-refresh-switch')) + fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR)) + fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } }) + + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1') + + screen.getByTestId(/apply-btn/).click() + + await act(() => { + rerender() + }) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(0) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(0) + + await act(() => { + rerender() + }) + + await act(async () => { + await new Promise((r) => setTimeout(r, 1300)) + }) + expect(onRefresh).toBeCalledTimes(1) + }) +}) diff --git a/src/webviews/src/components/auto-refresh/AutoRefresh.tsx b/src/webviews/src/components/auto-refresh/AutoRefresh.tsx new file mode 100644 index 00000000..89e1f89b --- /dev/null +++ b/src/webviews/src/components/auto-refresh/AutoRefresh.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useState } from 'react' +import * as l10n from '@vscode/l10n' +import Popover from 'reactjs-popup' +import cx from 'classnames' +import { VscChevronDown, VscRefresh } from 'react-icons/vsc' + +import { + MIN_REFRESH_RATE, + StorageItem, +} from 'uiSrc/constants' +import { + errorValidateRefreshRateNumber, + validateRefreshRateNumber, +} from 'uiSrc/utils' +import { InlineEditor } from 'uiSrc/components' +import { localStorageService } from 'uiSrc/services' +import { Nullable } from 'uiSrc/interfaces' +import { Checkbox, RiButton, Tooltip } from 'uiSrc/ui' +import { + getTextByRefreshTime, + DEFAULT_REFRESH_RATE, + DURATION_FIRST_REFRESH_TIME, + MINUTE, + NOW, +} from './utils' + +import styles from './styles.module.scss' + +export interface Props { + postfix: string + loading: boolean + displayText?: boolean + lastRefreshTime: Nullable + testid?: string + containerClassName?: string + turnOffAutoRefresh?: boolean + onRefresh: (enableAutoRefresh: boolean) => void + onRefreshClicked?: () => void + onEnableAutoRefresh?: (enableAutoRefresh: boolean, refreshRate: string) => void + onChangeAutoRefreshRate?: (enableAutoRefresh: boolean, refreshRate: string) => void + disabled?: boolean + enableAutoRefreshDefault?: boolean +} + +const TIMEOUT_TO_UPDATE_REFRESH_TIME = 1_000 * MINUTE // once a minute + +const AutoRefresh = React.memo(({ + postfix, + loading, + displayText = true, + lastRefreshTime, + containerClassName = '', + testid = '', + turnOffAutoRefresh, + onRefresh, + onRefreshClicked, + onEnableAutoRefresh, + onChangeAutoRefreshRate, + disabled, + enableAutoRefreshDefault = false, +}: Props) => { + let intervalText: NodeJS.Timeout + let intervalRefresh: NodeJS.Timeout + + const [refreshMessage, setRefreshMessage] = useState(NOW) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [refreshRate, setRefreshRate] = useState('') + const [refreshRateMessage, setRefreshRateMessage] = useState('') + const [enableAutoRefresh, setEnableAutoRefresh] = useState(enableAutoRefreshDefault) + + const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen) + const closePopover = () => { + setEnableAutoRefresh(enableAutoRefresh) + setIsPopoverOpen(false) + } + + useEffect(() => { + const refreshRateStorage = localStorageService.get(StorageItem.autoRefreshRate + postfix) + || DEFAULT_REFRESH_RATE + + setRefreshRate(refreshRateStorage) + }, [postfix]) + + useEffect(() => { + if (turnOffAutoRefresh && enableAutoRefresh) { + setEnableAutoRefresh(false) + clearInterval(intervalRefresh) + } + }, [turnOffAutoRefresh]) + + // update refresh label text + useEffect(() => { + const delta = getLastRefreshDelta(lastRefreshTime) + updateLastRefresh() + + intervalText = setInterval(() => { + if (document.hidden) return + + updateLastRefresh() + }, delta < DURATION_FIRST_REFRESH_TIME ? DURATION_FIRST_REFRESH_TIME : TIMEOUT_TO_UPDATE_REFRESH_TIME) + return () => clearInterval(intervalText) + }, [lastRefreshTime]) + + // refresh interval + useEffect(() => { + updateLastRefresh() + + if (enableAutoRefresh && !loading && !disabled) { + intervalRefresh = setInterval(() => { + if (document.hidden) return + + handleRefresh() + }, +refreshRate * 1_000) + } else { + clearInterval(intervalRefresh) + } + + if (enableAutoRefresh) { + updateAutoRefreshText(refreshRate) + } + + return () => clearInterval(intervalRefresh) + }, [enableAutoRefresh, refreshRate, loading, disabled, lastRefreshTime]) + + const getLastRefreshDelta = (time:Nullable) => (Date.now() - (time || 0)) / 1_000 + + const getDataTestid = (suffix: string) => (testid ? `${testid}-${suffix}` : suffix) + + const updateLastRefresh = () => { + const delta = getLastRefreshDelta(lastRefreshTime) + const text = getTextByRefreshTime(delta, lastRefreshTime ?? 0) + + lastRefreshTime && setRefreshMessage(text) + } + + const updateAutoRefreshText = (refreshRate: string) => { + enableAutoRefresh && setRefreshRateMessage( + // more than 1 minute + +refreshRate > MINUTE ? `${Math.floor(+refreshRate / MINUTE)} min` : `${refreshRate} s`, + ) + } + + const handleApplyAutoRefreshRate = (initValue: string) => { + const value = +initValue >= MIN_REFRESH_RATE ? initValue : `${MIN_REFRESH_RATE}` + setRefreshRate(value) + localStorageService.set(StorageItem.autoRefreshRate + postfix, value) + onChangeAutoRefreshRate?.(enableAutoRefresh, value) + } + + const handleRefresh = () => { + onRefresh(enableAutoRefresh) + } + + const handleRefreshClick = () => { + handleRefresh() + onRefreshClicked?.() + } + + const onChangeEnableAutoRefresh = (value: boolean) => { + setEnableAutoRefresh(value) + + onEnableAutoRefresh?.(value, refreshRate) + } + + return ( +
+ + {displayText && ( + {enableAutoRefresh ? l10n.t('Auto refresh:') : l10n.t('Last refresh:')} + )} + {displayText && ( + {` ${enableAutoRefresh ? refreshRateMessage : refreshMessage}`} + )} + + + + + + + + + + + + )} + > +
+ onChangeEnableAutoRefresh(e.target.checked)} + data-testid={getDataTestid('auto-refresh-switch')} + className={styles.switchOption} + labelText={l10n.t('Auto Refresh')} + /> +
+
+
Refresh rate:
+
+ handleApplyAutoRefreshRate(value)} + /> +
+
+
+
+ ) +}) + +export { AutoRefresh } diff --git a/src/webviews/src/components/auto-refresh/styles.module.scss b/src/webviews/src/components/auto-refresh/styles.module.scss new file mode 100644 index 00000000..3ca6e84d --- /dev/null +++ b/src/webviews/src/components/auto-refresh/styles.module.scss @@ -0,0 +1,60 @@ +.container { + @apply flex justify-center items-center relative whitespace-nowrap; +} + +.btn { + transition: transform 0.3s ease; + + &.rolling svg { + color: var(--vscode-button-background) !important; + } +} + +.time { + @apply pr-1.5; + + &.disabled { + @apply opacity-50; + } +} + + +:global(.popover-auto-refresh-content) { + @apply pr-[26px] #{!important}; +} + +.switch { + @apply pb-4; +} + +.input { + @apply h-[30px] inline-block w-[90px]; + + input { + @apply h-[30px]; + } +} +.inputContainer { + @apply h-[30px]; +} + +.inputLabel { + @apply inline-block w-20; +} + +.anchorBtn { + @apply min-h-[14px] min-w-[14px] -ml-[3px]; + + svg { + @apply w-[10px] h-[10px]; + } +} + +.enable { + svg { + fill: var(--vscode-button-background) !important; + } + .time { + color: var(--vscode-button-background) !important; + } +} diff --git a/src/webviews/src/components/auto-refresh/utils.ts b/src/webviews/src/components/auto-refresh/utils.ts new file mode 100644 index 00000000..d2f8261b --- /dev/null +++ b/src/webviews/src/components/auto-refresh/utils.ts @@ -0,0 +1,23 @@ +import * as l10n from '@vscode/l10n' +import { truncateNumberToFirstUnit } from 'uiSrc/utils' + +export const NOW = l10n.t('now') +export const MINUTE = 60 +export const DURATION_FIRST_REFRESH_TIME = 5 +export const DEFAULT_REFRESH_RATE = '5.0' + +export const getTextByRefreshTime = (delta: number, lastRefreshTime: number) => { + let text = '' + + if (delta > MINUTE) { + text = truncateNumberToFirstUnit((Date.now() - (lastRefreshTime || 0)) / 1_000) + } + if (delta < MINUTE) { + text = '< 1 min' + } + if (delta < DURATION_FIRST_REFRESH_TIME) { + text = NOW + } + + return text +} diff --git a/src/webviews/src/components/database-form/DbIndex.tsx b/src/webviews/src/components/database-form/DbIndex.tsx deleted file mode 100644 index c9a87cbd..00000000 --- a/src/webviews/src/components/database-form/DbIndex.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { ChangeEvent, useId } from 'react' -import cx from 'classnames' -import { FormikProps } from 'formik' -import * as l10n from '@vscode/l10n' -import { VSCodeDivider } from '@vscode/webview-ui-toolkit/react' - -import { validateNumber } from 'uiSrc/utils' -import { DbConnectionInfo } from 'uiSrc/interfaces' -import { Checkbox, InputText, CheckboxChangeEvent } from 'uiSrc/ui' - -export interface Props { - - formik: FormikProps -} - -const DbIndex = (props: Props) => { - const { formik } = props - - const id = useId() - - const handleChangeDbIndexCheckbox = (e: CheckboxChangeEvent): void => { - const isChecked = e.target.checked - if (!isChecked) { - // Reset db field to initial value - formik.setFieldValue('db', null) - } - formik.setFieldValue('showDb', isChecked) - } - - return ( - <> -
- -
- - {formik.values.showDb && ( -
- ) => { - formik.setFieldValue( - e.target.name, - validateNumber(e.target.value.trim()), - ) - }} - type="text" - min={0} - /> -
- )} - - ) -} - -export { DbIndex } diff --git a/src/webviews/src/components/database-form/TlsDetails.tsx b/src/webviews/src/components/database-form/TlsDetails.tsx index ddd70cd6..d4398d89 100644 --- a/src/webviews/src/components/database-form/TlsDetails.tsx +++ b/src/webviews/src/components/database-form/TlsDetails.tsx @@ -2,40 +2,61 @@ import React, { ChangeEvent, useId } from 'react' import cx from 'classnames' import { FormikProps } from 'formik' import * as l10n from '@vscode/l10n' -import { VSCodeDivider } from '@vscode/webview-ui-toolkit/react' import { CheckboxChangeEvent } from 'rc-checkbox' +import { find } from 'lodash' +import { MenuListProps } from 'react-select' -import { validateCertName, validateField } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent, validateCertName, validateField } from 'uiSrc/utils' import { ADD_NEW, ADD_NEW_CA_CERT, - ADD_NEW_CA_CERT_LABEL, - ADD_NEW_LABEL, + ApiEndpoints, NO_CA_CERT, - NO_CA_CERT_LABEL, } from 'uiSrc/constants' -import { DbConnectionInfo } from 'uiSrc/interfaces' -import { Checkbox, InputText, Select, TextArea } from 'uiSrc/ui' +import { DbConnectionInfo, RedisString } from 'uiSrc/interfaces' +import { Checkbox, InputText, TextArea } from 'uiSrc/ui' +import { removeCertAction } from 'uiSrc/store' +import { SuperSelectRemovableOption, SuperSelect, SuperSelectOption } from 'uiSrc/components' import styles from './styles.module.scss' +const suffix = '_tls_details' + export interface Props { formik: FormikProps caCertificates?: { id: string, name: string }[] certificates?: { id: string, name: string }[] } + const TlsDetails = (props: Props) => { const { formik, caCertificates, certificates } = props const id = useId() - const optionsCertsCA = [ - { - value: NO_CA_CERT, - label: NO_CA_CERT_LABEL, - }, - { - value: ADD_NEW_CA_CERT, - label: ADD_NEW_CA_CERT_LABEL, - }, + const handleDeleteCaCert = (id: RedisString, onSuccess?: () => void) => { + removeCertAction(id, ApiEndpoints.CA_CERTIFICATES, () => { + onSuccess?.() + handleClickDeleteCert('CA') + }) + } + + const handleDeleteClientCert = (id: RedisString, onSuccess?: () => void) => { + removeCertAction(id, ApiEndpoints.CLIENT_CERTIFICATES, () => { + onSuccess?.() + handleClickDeleteCert('Client') + }) + } + + const handleClickDeleteCert = (certificateType: 'Client' | 'CA') => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_CERTIFICATE_REMOVED, + eventData: { + certificateType, + }, + }) + } + + const optionsCertsCA: SuperSelectOption[] = [ + NO_CA_CERT, + ADD_NEW_CA_CERT, ] caCertificates?.forEach((cert) => { @@ -45,12 +66,7 @@ const TlsDetails = (props: Props) => { }) }) - const optionsCertsClient = [ - { - value: ADD_NEW, - label: ADD_NEW_LABEL, - }, - ] + const optionsCertsClient: SuperSelectOption[] = [ADD_NEW] certificates?.forEach((cert) => { optionsCertsClient.push({ @@ -130,17 +146,22 @@ const TlsDetails = (props: Props) => {
{`${l10n.t('CA Certificate')}${formik.values.verifyServerTlsCert ? '*' : ''}`}
-