diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 52510678b0dc..8a91c7406682 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,8 @@ tsconfig.json # Licenses /distribution/engine/ +/distribution/launcher/ +/distribution/project-manager/ /tools/legal-review/ # The default project template is owned by the libraries team diff --git a/.github/workflows/bench-upload.yml b/.github/workflows/bench-upload.yml index 69d8d44f504f..ddd1792a1acf 100644 --- a/.github/workflows/bench-upload.yml +++ b/.github/workflows/bench-upload.yml @@ -7,6 +7,9 @@ on: workflows: ["Benchmark Engine", "Benchmark Standard Libraries"] types: - completed + # Allow to trigger this workflow manually + workflow_dispatch: + jobs: upload-benchmarks: name: Upload benchmarks diff --git a/.github/workflows/engine-changed-files.yml b/.github/workflows/engine-changed-files.yml new file mode 100644 index 000000000000..e35c43956d66 --- /dev/null +++ b/.github/workflows/engine-changed-files.yml @@ -0,0 +1,55 @@ +# This file is not auto-generated. Feel free to edit it. + +name: Engine Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.engine-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.engine-changed-files.outputs.any_changed }} + +jobs: + engine-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.engine-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.engine-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: engine-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + distribution/** + engine/** + lib/** + project/** + std-bits/** + test/** + build.sbt + .cargo/** + Cargo.lock + Cargo.toml + rust-toolchain.toml + .github/workflows/engine-changed-files.yml + .github/workflows/engine-checks-optional.yml + .github/workflows/engine-checks.yml + .github/workflows/engine-pull-request.yml + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.engine-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.engine-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/engine-nightly.yml b/.github/workflows/engine-checks-nightly.yml similarity index 100% rename from .github/workflows/engine-nightly.yml rename to .github/workflows/engine-checks-nightly.yml diff --git a/.github/workflows/engine-checks-optional.yml b/.github/workflows/engine-checks-optional.yml new file mode 100644 index 000000000000..ad1d1eb8ea7a --- /dev/null +++ b/.github/workflows/engine-checks-optional.yml @@ -0,0 +1,179 @@ +# This file is auto-generated. Do not edit it manually! +# Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`. + +name: Engine Checks (Optional) +on: + workflow_dispatch: + inputs: + clean_build_required: + description: Clean before and after the run. + required: false + type: boolean + default: false + workflow_call: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-engine-checks-optional + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} +jobs: + enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64: + name: Engine (GraalVM CE) (macos, amd64) + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run backend ci-check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + env: + GRAAL_EDITION: GraalVM CE + enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64: + name: JVM Tests (GraalVM CE) (macos, amd64) + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run backend test jvm + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: (success() || failure()) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + name: Engine Test Reporter + uses: dorny/test-reporter@v1 + with: + max-annotations: 50 + name: Engine Tests Report (GraalVM CE, macos, amd64) + path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml + path-replace-backslashes: true + reporter: java-junit + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + env: + GRAAL_EDITION: GraalVM CE + permissions: + checks: write + enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64: + name: Standard Library Tests (GraalVM CE) (macos, amd64) + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run backend test standard-library + env: + ENSO_LIB_S3_AWS_ACCESS_KEY_ID: ${{ secrets.ENSO_LIB_S3_AWS_ACCESS_KEY_ID }} + ENSO_LIB_S3_AWS_REGION: ${{ secrets.ENSO_LIB_S3_AWS_REGION }} + ENSO_LIB_S3_AWS_SECRET_ACCESS_KEY: ${{ secrets.ENSO_LIB_S3_AWS_SECRET_ACCESS_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: (success() || failure()) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + name: Standard Library Test Reporter + uses: dorny/test-reporter@v1 + with: + max-annotations: 50 + name: Standard Library Tests Report (GraalVM CE, macos, amd64) + path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml + path-replace-backslashes: true + reporter: java-junit + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + env: + GRAAL_EDITION: GraalVM CE + permissions: + checks: write +env: + ENSO_BUILD_SKIP_VERSION_CHECK: "true" diff --git a/.github/workflows/scala-new.yml b/.github/workflows/engine-checks.yml similarity index 69% rename from .github/workflows/scala-new.yml rename to .github/workflows/engine-checks.yml index 0615bdee35f0..80b81ed41535 100644 --- a/.github/workflows/scala-new.yml +++ b/.github/workflows/engine-checks.yml @@ -1,12 +1,8 @@ # This file is auto-generated. Do not edit it manually! # Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`. -name: Engine CI +name: Engine Checks on: - push: - branches: - - develop - pull_request: {} workflow_dispatch: inputs: clean_build_required: @@ -14,8 +10,9 @@ on: required: false type: boolean default: false + workflow_call: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }}-engine-checks cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} jobs: enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-amd64: @@ -63,50 +60,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} env: GRAAL_EDITION: GraalVM CE - enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64: - name: Engine (GraalVM CE) (macos, amd64) - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run backend ci-check - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - env: - GRAAL_EDITION: GraalVM CE enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-amd64: name: Engine (GraalVM CE) (windows, amd64) runs-on: @@ -208,61 +161,6 @@ jobs: GRAAL_EDITION: GraalVM CE permissions: checks: write - enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64: - name: JVM Tests (GraalVM CE) (macos, amd64) - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run backend test jvm - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: (success() || failure()) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - name: Engine Test Reporter - uses: dorny/test-reporter@v1 - with: - max-annotations: 50 - name: Engine Tests Report (GraalVM CE, macos, amd64) - path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml - path-replace-backslashes: true - reporter: java-junit - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - env: - GRAAL_EDITION: GraalVM CE - permissions: - checks: write enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-amd64: name: JVM Tests (GraalVM CE) (windows, amd64) runs-on: @@ -378,64 +276,6 @@ jobs: GRAAL_EDITION: GraalVM CE permissions: checks: write - enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64: - name: Standard Library Tests (GraalVM CE) (macos, amd64) - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run backend test standard-library - env: - ENSO_LIB_S3_AWS_ACCESS_KEY_ID: ${{ secrets.ENSO_LIB_S3_AWS_ACCESS_KEY_ID }} - ENSO_LIB_S3_AWS_REGION: ${{ secrets.ENSO_LIB_S3_AWS_REGION }} - ENSO_LIB_S3_AWS_SECRET_ACCESS_KEY: ${{ secrets.ENSO_LIB_S3_AWS_SECRET_ACCESS_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: (success() || failure()) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - name: Standard Library Test Reporter - uses: dorny/test-reporter@v1 - with: - max-annotations: 50 - name: Standard Library Tests Report (GraalVM CE, macos, amd64) - path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml - path-replace-backslashes: true - reporter: java-junit - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - env: - GRAAL_EDITION: GraalVM CE - permissions: - checks: write enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-amd64: name: Standard Library Tests (GraalVM CE) (windows, amd64) runs-on: diff --git a/.github/workflows/engine-pull-request.yml b/.github/workflows/engine-pull-request.yml new file mode 100644 index 000000000000..7231da64327d --- /dev/null +++ b/.github/workflows/engine-pull-request.yml @@ -0,0 +1,52 @@ +# This file is not auto-generated. Feel free to edit it. + +name: ✨ Engine + +on: + push: + branches: + - develop + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-engine-pull-request + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} + +permissions: + checks: write + +jobs: + engine-changed-files: + name: πŸ” Files Changed + uses: ./.github/workflows/engine-changed-files.yml + secrets: inherit + + engine-checks: + name: βš™οΈ Checks + uses: ./.github/workflows/engine-checks.yml + needs: [engine-changed-files] + if: needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + secrets: inherit + + engine-checks-optional: + name: βš™οΈ Checks (Optional) + uses: ./.github/workflows/engine-checks-optional.yml + needs: [engine-changed-files] + if: needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + secrets: inherit + + required-checks: + name: Engine Required Checks + runs-on: ubuntu-latest + needs: [engine-checks] + if: always() + steps: + - name: Checks Summary + run: | + echo "Engine Checks: ${{ needs.engine-checks.result }}" + + if [[ "${{ needs.engine-checks.result }}" == "failure" ]]; then + exit 1 + fi + + echo "Success!" diff --git a/.github/workflows/gui-changed-files.yml b/.github/workflows/gui-changed-files.yml new file mode 100644 index 000000000000..cc6d950115cb --- /dev/null +++ b/.github/workflows/gui-changed-files.yml @@ -0,0 +1,55 @@ +# This file is not auto-generated. Feel free to edit it. + +name: GUI Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.gui-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.gui-changed-files.outputs.any_changed }} + +jobs: + gui-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.gui-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.gui-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: gui-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + app/** + package.json + pnpm-lock.yaml + pnpm-workspace.yaml + eslint.config.js + .prettierrc.js + .prettierignore + vitest.workspace.ts + .github/workflows/gui* + .github/workflows/storybook.yml + files_ignore: | + app/ide-desktop/** + app/gui/scripts/** + app/gui/.gitignore + .git-* + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.gui-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.gui-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/gui-checks.yml b/.github/workflows/gui-checks.yml index 5d1091df8457..5ac10409c741 100644 --- a/.github/workflows/gui-checks.yml +++ b/.github/workflows/gui-checks.yml @@ -1,10 +1,12 @@ +# This file is not auto-generated. Feel free to edit it. + name: GUI Checks on: workflow_call # Cancel in-progress workflows if a new one is started concurrency: group: ${{ github.workflow }}-${{ github.ref }}-gui-checks - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} permissions: contents: read # Read-only access to repository contents @@ -31,17 +33,6 @@ jobs: node-version-file: .node-version cache: "pnpm" - - uses: actions/cache/restore@v4 - name: Download cache - id: cache - with: - path: | - **/.eslintcache - node_modules/.cache/prettier - key: ${{ runner.os }}-gui-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-gui - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 @@ -51,10 +42,15 @@ jobs: - name: πŸ“¦ Install dependencies run: pnpm install --frozen-lockfile - - name: πŸ“ Prettier - id: prettier - continue-on-error: true - run: pnpm run ci:prettier + - uses: actions/cache/restore@v4 + name: Download cache + id: cache + with: + path: | + **/.eslintcache + key: ${{ runner.os }}-gui-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-gui # Next Tasks are depend on Typecheck, because we build libraries at this stage - name: 🧠 Typecheck @@ -88,7 +84,6 @@ jobs: - name: ❌ Fail if any check failed if: always() && (steps.prettier.outcome == 'failure' || steps.lint.outcome == 'failure' || steps.typecheck.outcome == 'failure' || steps.unit-tests.outcome == 'failure') run: | - echo "Prettier outcome: ${{ steps.prettier.outcome }}" echo "Lint outcome: ${{ steps.lint.outcome }}" echo "Typecheck outcome: ${{ steps.typecheck.outcome }}" echo "Unit tests outcome: ${{ steps.unit-tests.outcome }}" @@ -102,7 +97,6 @@ jobs: key: ${{ steps.cache.outputs.cache-primary-key }} path: | **/.eslintcache - node_modules/.cache/prettier playwright: name: 🎭 Playwright Tests diff --git a/.github/workflows/gui-pull-request.yml b/.github/workflows/gui-pull-request.yml index 650f001d2279..56040e07291f 100644 --- a/.github/workflows/gui-pull-request.yml +++ b/.github/workflows/gui-pull-request.yml @@ -1,6 +1,6 @@ # This file is not auto-generated. Feel free to edit it. -name: ✨ GUI Pull Request +name: ✨ GUI on: push: @@ -9,8 +9,8 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }}-gui-pull-request + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} permissions: contents: read # Read-only access to repository contents @@ -20,73 +20,85 @@ permissions: checks: write jobs: - changed-files: + gui-changed-files: + name: πŸ” Files Changed + uses: ./.github/workflows/gui-changed-files.yml + secrets: inherit + + prettier: + name: 🧹 Prettier runs-on: ubuntu-latest - name: πŸ” GUI files changed - outputs: - all_changed_files: ${{ steps.changed-files.outputs.all_changed_files }} - any_changed: ${{ steps.changed-files.outputs.any_changed }} steps: - uses: actions/checkout@v4 + - name: πŸ“¦ Setup pnpm + uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + name: βŽ” Setup Node with: - fetch-depth: 2 - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v44 + node-version-file: .node-version + cache: "pnpm" + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 with: - files: | - app/** - package.json - pnpm-lock.yaml - pnpm-workspace.yaml - eslint.config.js - .prettierrc.js - .prettierignore - vitest.workspace.ts - .github/workflows/gui* - files_ignore: | - app/ide-desktop/** - app/gui/scripts/** - app/gui/.gitignore - .git-* - - - name: List all changed files - env: - ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - run: | - for file in ${ALL_CHANGED_FILES}; do - echo "$file was changed" - done + version: v0.12.1 + - name: πŸ“¦ Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + - uses: actions/cache/restore@v4 + name: Download cache + id: cache + with: + path: | + node_modules/.cache/prettier + key: ${{ runner.os }}-gui-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-gui + - name: Run prettier + run: pnpm run ci:prettier + - name: πŸ’Ύ Save cache + uses: actions/cache/save@v4 + if: always() && steps.cache.outputs.cache-hit != 'true' + id: save-cache + with: + key: ${{ steps.cache.outputs.cache-primary-key }} + path: | + node_modules/.cache/prettier - checks: + gui-checks: name: 🧰 Checks uses: ./.github/workflows/gui-checks.yml - needs: [changed-files] - if: ${{ needs.changed-files.outputs.any_changed == 'true' }} + needs: [gui-changed-files] + if: needs.gui-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit storybook: name: πŸ“š Deploy Storybook uses: ./.github/workflows/storybook.yml - needs: [changed-files] - if: ${{ needs.changed-files.outputs.any_changed == 'true' }} + needs: [gui-changed-files] + if: needs.gui-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit - # This job is used to report success if the needed jobs were successful. - # This is a workaround to make optional jobs required if they run - report-success: - name: GUI Checks Success or Skipped + required-checks: + name: GUI Required Checks runs-on: ubuntu-latest - needs: [checks, storybook] + needs: [prettier, gui-checks, storybook] if: always() steps: - - name: Report success + - name: Checks Summary run: | - echo "Checks: ${{ needs.checks.result }}" + echo "Prettier: ${{ needs.prettier.result }}" + echo "GUI Checks: ${{ needs.gui-checks.result }}" echo "Storybook: ${{ needs.storybook.result }}" - if [[ "${{ needs.checks.result }}" == "failure" || "${{ needs.storybook.result }}" == "failure" ]]; then - exit 1 - fi + declare -a checks + checks+=("${{ needs.prettier.result }}") + checks+=("${{ needs.gui-checks.result }}") + checks+=("${{ needs.storybook.result }}") + + for result in "${checks[@]}"; do + if [[ "$result" == "failure" ]]; then + exit 1 + fi + done echo "Success!" diff --git a/.github/workflows/ide-changed-files.yml b/.github/workflows/ide-changed-files.yml new file mode 100644 index 000000000000..c366fedf06ff --- /dev/null +++ b/.github/workflows/ide-changed-files.yml @@ -0,0 +1,44 @@ +# This file is not auto-generated. Feel free to edit it. + +name: IDE Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.ide-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.ide-changed-files.outputs.any_changed }} + +jobs: + ide-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.ide-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.ide-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: ide-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + app/ide-desktop/** + package.json + pnpm-lock.yaml + .github/workflows/ide* + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.ide-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.ide-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/ide-packaging-optional.yml b/.github/workflows/ide-packaging-optional.yml new file mode 100644 index 000000000000..300955745908 --- /dev/null +++ b/.github/workflows/ide-packaging-optional.yml @@ -0,0 +1,197 @@ +# This file is auto-generated. Do not edit it manually! +# Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`. + +name: IDE Packaging (Optional) +on: + workflow_dispatch: + inputs: + clean_build_required: + description: Clean before and after the run. + required: false + type: boolean + default: false + workflow_call: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-ide-packaging-optional + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} +jobs: + enso-build-ci-gen-job-build-backend-macos-amd64: + name: Build Backend (macos, amd64) + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run backend get + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + enso-build-ci-gen-job-gui-build-macos-amd64: + name: GUI build (macos, amd64) + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run gui build + env: + ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} + ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} + ENSO_CLOUD_COGNITO_DOMAIN: ${{ vars.ENSO_CLOUD_COGNITO_DOMAIN }} + ENSO_CLOUD_COGNITO_REGION: ${{ vars.ENSO_CLOUD_COGNITO_REGION }} + ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }} + ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }} + ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} + ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} + ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} + ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} + ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + enso-build-ci-gen-job-package-ide-macos-amd64: + name: Package New IDE (macos, amd64) + needs: + - enso-build-ci-gen-job-build-backend-macos-amd64 + runs-on: + - macos-13 + continue-on-error: true + steps: + - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') + name: Installing wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.12.1 + - name: Expose Artifact API and context information. + uses: actions/github-script@v7 + with: + script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " + - name: Checking out the repository + uses: actions/checkout@v4 + with: + clean: false + submodules: recursive + - name: Build Script Setup + run: ./run --help || (git clean -ffdx && ./run --help) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean before + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false + env: + APPLEID: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} + APPLEIDPASS: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + APPLETEAMID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} + CSC_FOR_PULL_REQUEST: "true" + CSC_IDENTITY_AUTO_DISCOVERY: "true" + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODE_SIGNING_CERT_PASSWORD }} + CSC_LINK: ${{ secrets.APPLE_CODE_SIGNING_CERT }} + ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} + ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} + ENSO_CLOUD_COGNITO_DOMAIN: ${{ vars.ENSO_CLOUD_COGNITO_DOMAIN }} + ENSO_CLOUD_COGNITO_REGION: ${{ vars.ENSO_CLOUD_COGNITO_REGION }} + ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }} + ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }} + ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} + ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} + ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} + ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} + ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: corepack pnpm -r --filter enso exec playwright test --timeout 300000 + env: + DEBUG: "pw:browser log:" + ENSO_TEST_USER: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_USERNAME }} + ENSO_TEST_USER_PASSWORD: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_PASSWORD }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() + name: Upload Test Traces + uses: actions/upload-artifact@v4 + with: + compression-level: 0 + name: test-traces-macos-amd64 + path: app/ide-desktop/client/test-traces + - run: rm $HOME/.enso/credentials + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() && runner.os == 'Windows' + name: List files if failed (Windows) + run: Get-ChildItem -Force -Recurse + - if: failure() && runner.os != 'Windows' + name: List files if failed (non-Windows) + run: ls -lAR + - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" + name: Clean after + run: ./run git-clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +env: + ENSO_BUILD_SKIP_VERSION_CHECK: "true" diff --git a/.github/workflows/gui.yml b/.github/workflows/ide-packaging.yml similarity index 66% rename from .github/workflows/gui.yml rename to .github/workflows/ide-packaging.yml index d31f76450119..b6a94a5150d8 100644 --- a/.github/workflows/gui.yml +++ b/.github/workflows/ide-packaging.yml @@ -1,12 +1,8 @@ # This file is auto-generated. Do not edit it manually! # Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`. -name: GUI Packaging +name: IDE Packaging on: - push: - branches: - - develop - pull_request: {} workflow_dispatch: inputs: clean_build_required: @@ -14,8 +10,9 @@ on: required: false type: boolean default: false + workflow_call: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }}-ide-packaging cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} jobs: enso-build-ci-gen-job-build-backend-linux-amd64: @@ -61,48 +58,6 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - enso-build-ci-gen-job-build-backend-macos-amd64: - name: Build Backend (macos, amd64) - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run backend get - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} enso-build-ci-gen-job-build-backend-windows-amd64: name: Build Backend (windows, amd64) runs-on: @@ -201,60 +156,6 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - enso-build-ci-gen-job-gui-build-macos-amd64: - name: GUI build (macos, amd64) - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run gui build - env: - ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} - ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} - ENSO_CLOUD_COGNITO_DOMAIN: ${{ vars.ENSO_CLOUD_COGNITO_DOMAIN }} - ENSO_CLOUD_COGNITO_REGION: ${{ vars.ENSO_CLOUD_COGNITO_REGION }} - ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }} - ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }} - ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} - ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} - ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} - ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} - ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} - ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} enso-build-ci-gen-job-gui-build-windows-amd64: name: GUI build (windows, amd64) runs-on: @@ -384,85 +285,6 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - enso-build-ci-gen-job-package-ide-macos-amd64: - name: Package New IDE (macos, amd64) - needs: - - enso-build-ci-gen-job-build-backend-macos-amd64 - runs-on: - - macos-13 - steps: - - if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent') - name: Installing wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: v0.12.1 - - name: Expose Artifact API and context information. - uses: actions/github-script@v7 - with: - script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n " - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - name: Build Script Setup - run: ./run --help || (git clean -ffdx && ./run --help) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: "(contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean before - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false - env: - APPLEID: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} - APPLEIDPASS: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} - APPLETEAMID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - CSC_FOR_PULL_REQUEST: "true" - CSC_IDENTITY_AUTO_DISCOVERY: "true" - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODE_SIGNING_CERT_PASSWORD }} - CSC_LINK: ${{ secrets.APPLE_CODE_SIGNING_CERT }} - ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} - ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} - ENSO_CLOUD_COGNITO_DOMAIN: ${{ vars.ENSO_CLOUD_COGNITO_DOMAIN }} - ENSO_CLOUD_COGNITO_REGION: ${{ vars.ENSO_CLOUD_COGNITO_REGION }} - ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }} - ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }} - ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} - ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} - ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} - ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} - ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} - ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: corepack pnpm -r --filter enso exec playwright test - env: - DEBUG: "pw:browser log:" - ENSO_TEST_USER: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_USERNAME }} - ENSO_TEST_USER_PASSWORD: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_PASSWORD }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: failure() - name: Upload Test Traces - uses: actions/upload-artifact@v4 - with: - compression-level: 0 - name: test-traces-macos-amd64 - path: app/ide-desktop/client/test-traces - - run: rm $HOME/.enso/credentials - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - if: failure() && runner.os == 'Windows' - name: List files if failed (Windows) - run: Get-ChildItem -Force -Recurse - - if: failure() && runner.os != 'Windows' - name: List files if failed (non-Windows) - run: ls -lAR - - if: "(always()) && (contains(github.event.pull_request.labels.*.name, 'CI: Clean build required') || inputs.clean_build_required)" - name: Clean after - run: ./run git-clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} enso-build-ci-gen-job-package-ide-windows-amd64: name: Package New IDE (windows, amd64) needs: diff --git a/.github/workflows/ide-pull-request.yml b/.github/workflows/ide-pull-request.yml new file mode 100644 index 000000000000..50338cadce5a --- /dev/null +++ b/.github/workflows/ide-pull-request.yml @@ -0,0 +1,59 @@ +# This file is not auto-generated. Feel free to edit it. + +name: ✨ IDE + +on: + push: + branches: + - develop + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-ide-pull-request + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} + +jobs: + gui-changed-files: + name: πŸ” GUI Files Changed + uses: ./.github/workflows/gui-changed-files.yml + secrets: inherit + + ide-changed-files: + name: πŸ” IDE Files Changed + uses: ./.github/workflows/ide-changed-files.yml + secrets: inherit + + engine-changed-files: + name: πŸ” Engine Files Changed + uses: ./.github/workflows/engine-changed-files.yml + secrets: inherit + + ide-packaging: + name: πŸ“¦ Package + uses: ./.github/workflows/ide-packaging.yml + needs: [gui-changed-files, ide-changed-files, engine-changed-files] + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + secrets: inherit + + ide-packaging-optional: + name: πŸ“¦ Package (Optional) + uses: ./.github/workflows/ide-packaging-optional.yml + needs: [gui-changed-files, ide-changed-files, engine-changed-files] + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + secrets: inherit + + required-checks: + name: IDE Required Checks + runs-on: ubuntu-latest + needs: [ide-packaging] + if: always() + steps: + - name: Checks Summary + run: | + echo "IDE: ${{ needs.ide-packaging.result }}" + + if [[ "${{ needs.ide-packaging.result }}" == "failure" ]]; then + exit 1 + fi + + echo "Success!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db824a4ad201..e6939dd88a6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -504,10 +504,13 @@ jobs: ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_SENTRY_ORGANIZATION: ${{ vars.ENSO_CLOUD_SENTRY_ORGANIZATION }} + ENSO_CLOUD_SENTRY_PROJECT: ${{ vars.ENSO_CLOUD_SENTRY_PROJECT }} ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - if: failure() && runner.os == 'Windows' name: List files if failed (Windows) run: Get-ChildItem -Force -Recurse @@ -573,10 +576,13 @@ jobs: ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_SENTRY_ORGANIZATION: ${{ vars.ENSO_CLOUD_SENTRY_ORGANIZATION }} + ENSO_CLOUD_SENTRY_PROJECT: ${{ vars.ENSO_CLOUD_SENTRY_PROJECT }} ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - if: failure() && runner.os == 'Windows' name: List files if failed (Windows) run: Get-ChildItem -Force -Recurse @@ -640,10 +646,13 @@ jobs: ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_SENTRY_ORGANIZATION: ${{ vars.ENSO_CLOUD_SENTRY_ORGANIZATION }} + ENSO_CLOUD_SENTRY_PROJECT: ${{ vars.ENSO_CLOUD_SENTRY_PROJECT }} ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - if: failure() && runner.os == 'Windows' name: List files if failed (Windows) run: Get-ChildItem -Force -Recurse @@ -701,10 +710,13 @@ jobs: ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }} ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }} ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }} + ENSO_CLOUD_SENTRY_ORGANIZATION: ${{ vars.ENSO_CLOUD_SENTRY_ORGANIZATION }} + ENSO_CLOUD_SENTRY_PROJECT: ${{ vars.ENSO_CLOUD_SENTRY_PROJECT }} ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_MAPBOX_API_TOKEN: ${{ vars.ENSO_MAPBOX_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} WIN_CSC_KEY_PASSWORD: ${{ secrets.MICROSOFT_CODE_SIGNING_CERT_PASSWORD }} WIN_CSC_LINK: ${{ secrets.MICROSOFT_CODE_SIGNING_CERT }} - if: failure() && runner.os == 'Windows' diff --git a/.github/workflows/shader-tools.yml b/.github/workflows/shader-tools.yml deleted file mode 100644 index 4f9250871d5b..000000000000 --- a/.github/workflows/shader-tools.yml +++ /dev/null @@ -1,105 +0,0 @@ -# This file is auto-generated. Do not edit it manually! -# Edit the build/shader-tools/src/ci.rs module instead and run `cargo run --package enso-build-ci-gen`. - -name: Package Tools -on: - workflow_dispatch: {} -jobs: - run-create-linux-latest: - name: Run create (LinuxLatest) - runs-on: - - ubuntu-latest - steps: - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - id: step_0 - run: cargo run --package enso-build-shader-tools --bin create - env: - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_TOKEN }} - outputs: - ENSO_RELEASE_ID: ${{ steps.step_0.outputs.ENSO_RELEASE_ID }} - timeout-minutes: 360 - run-package-linux-latest: - name: Run package (LinuxLatest) - needs: - - run-create-linux-latest - runs-on: - - ubuntu-latest - steps: - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - id: step_1 - run: cargo run --package enso-build-shader-tools --bin package - env: - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_TOKEN }} - env: - ENSO_RELEASE_ID: ${{ needs.run-create-linux-latest.outputs.ENSO_RELEASE_ID }} - timeout-minutes: 360 - run-package-mac-os-latest: - name: Run package (MacOSLatest) - needs: - - run-create-linux-latest - runs-on: - - macos-latest - steps: - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - id: step_3 - run: cargo run --package enso-build-shader-tools --bin package - env: - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_TOKEN }} - env: - ENSO_RELEASE_ID: ${{ needs.run-create-linux-latest.outputs.ENSO_RELEASE_ID }} - timeout-minutes: 360 - run-package-windows-latest: - name: Run package (WindowsLatest) - needs: - - run-create-linux-latest - runs-on: - - windows-latest - steps: - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - id: step_2 - run: cargo run --package enso-build-shader-tools --bin package - env: - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_TOKEN }} - env: - ENSO_RELEASE_ID: ${{ needs.run-create-linux-latest.outputs.ENSO_RELEASE_ID }} - timeout-minutes: 360 - run-publish-linux-latest: - name: Run publish (LinuxLatest) - needs: - - run-create-linux-latest - - run-package-linux-latest - - run-package-mac-os-latest - - run-package-windows-latest - runs-on: - - ubuntu-latest - steps: - - name: Checking out the repository - uses: actions/checkout@v4 - with: - clean: false - submodules: recursive - - id: step_4 - run: cargo run --package enso-build-shader-tools --bin publish - env: - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_TOKEN }} - env: - ENSO_RELEASE_ID: ${{ needs.run-create-linux-latest.outputs.ENSO_RELEASE_ID }} - timeout-minutes: 360 -env: - ENSO_BUILD_SKIP_VERSION_CHECK: "true" diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 79b9ae96c629..f2abd5d3563b 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -7,7 +7,7 @@ on: workflow_call # Cancel in-progress workflows if a new one is started concurrency: group: ${{ github.workflow }}-${{ github.ref }}-chromatic - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} permissions: contents: read # Read-only access to repository contents diff --git a/.github/workflows/wasm-changed-files.yml b/.github/workflows/wasm-changed-files.yml new file mode 100644 index 000000000000..fdbf1aa2b345 --- /dev/null +++ b/.github/workflows/wasm-changed-files.yml @@ -0,0 +1,51 @@ +# This file is not auto-generated. Feel free to edit it. + +name: WASM Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.wasm-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.wasm-changed-files.outputs.any_changed }} + +jobs: + wasm-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.wasm-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.wasm-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: wasm-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + .cargo/** + app/rust-ffi/** + build/** + lib/rust/** + tools/language-server/logstat/** + tools/language-server/wstest/** + Cargo.lock + Cargo.toml + rust-toolchain.toml + rustfmt.toml + .github/workflows/wasm* + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.wasm-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.wasm-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/gui-tests.yml b/.github/workflows/wasm-checks.yml similarity index 93% rename from .github/workflows/gui-tests.yml rename to .github/workflows/wasm-checks.yml index 4abe8f1d23f3..246f8bb97305 100644 --- a/.github/workflows/gui-tests.yml +++ b/.github/workflows/wasm-checks.yml @@ -1,12 +1,8 @@ # This file is auto-generated. Do not edit it manually! # Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`. -name: GUI Check +name: WASM Checks on: - push: - branches: - - develop - pull_request: {} workflow_dispatch: inputs: clean_build_required: @@ -14,19 +10,11 @@ on: required: false type: boolean default: false + workflow_call: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-wasm-checks + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} jobs: - enso-build-ci-gen-job-cancel-workflow-linux-amd64: - name: Cancel Previous Runs - if: github.ref != 'refs/heads/develop' - runs-on: - - ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - with: - access_token: ${{ github.token }} - permissions: - actions: write enso-build-ci-gen-job-lint-linux-amd64: name: Lint (linux, amd64) runs-on: diff --git a/.github/workflows/wasm-pull-request.yml b/.github/workflows/wasm-pull-request.yml new file mode 100644 index 000000000000..2f0a22b8443b --- /dev/null +++ b/.github/workflows/wasm-pull-request.yml @@ -0,0 +1,42 @@ +# This file is not auto-generated. Feel free to edit it. + +name: ✨ WASM + +on: + push: + branches: + - develop + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-wasm-pull-request + cancel-in-progress: ${{ github.ref != 'refs/heads/develop' }} + +jobs: + wasm-changed-files: + name: πŸ” Files Changed + uses: ./.github/workflows/wasm-changed-files.yml + secrets: inherit + + wasm-checks: + name: πŸ¦€ Checks + uses: ./.github/workflows/wasm-checks.yml + needs: [wasm-changed-files] + if: needs.wasm-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + secrets: inherit + + required-checks: + name: WASM Required Checks + runs-on: ubuntu-latest + needs: [wasm-checks] + if: always() + steps: + - name: Checks Summary + run: | + echo "WASM Checks: ${{ needs.wasm-checks.result }}" + + if [[ "${{ needs.wasm-checks.result }}" == "failure" ]]; then + exit 1 + fi + + echo "Success!" diff --git a/.gitignore b/.gitignore index 3e609c90af90..444645571d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ node_modules/ eslint_report.json .eslintcache +############ +## Python ## +############ +**/__pycache__/ + ############ ## System ## ############ @@ -105,6 +110,7 @@ bench-report*.xml /enso.lib *.dll +*.dylib *.exe *.pdb *.so diff --git a/.prettierignore b/.prettierignore index f84e714076b5..41d9adc6f8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,12 +11,11 @@ build/build/release-body.md distribution/launcher/THIRD-PARTY distribution/engine/THIRD-PARTY distribution/project-manager/THIRD-PARTY -tools/legal-review +tools distribution/lib/Standard/*/*/manifest.yaml distribution/lib/Standard/*/*/polyglot distribution/lib/Standard/*/*/THIRD-PARTY distribution/docs-js -docs/**/*.md built-distribution/ THIRD-PARTY diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a60d9b109e4..3c054bb201f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ #### Enso IDE +- [ENSO_IDE_MAPBOX_API_TOKEN environment variable should be provided to enable + GeoMap visualization][11889]. +- [Round β€˜Add component’ button under the component menu replaced by a small + button protruding from the output port.][11836]. +- [Fixed nodes being selected after deleting other nodes or connections.][11902] +- [Redo stack is no longer lost when interacting with text literals][11908]. + +[11889]: https://github.com/enso-org/enso/pull/11889 +[11836]: https://github.com/enso-org/enso/pull/11836 +[11902]: https://github.com/enso-org/enso/pull/11902 +[11908]: https://github.com/enso-org/enso/pull/11908 + +#### Enso Language & Runtime + +- [Promote broken values instead of ignoring them][11777]. +- [Intersection types & type checks][11600] +- A constructor or type definition with a single inline argument definition was + previously allowed to use spaces in the argument definition without + parentheses. [This is now a syntax error.][11856] +- [Native libraries of projects can be added to `polyglot/lib` directory][11874] +- [Redo stack is no longer lost when interacting with text literals][11908]. +- Symetric, transitive and reflexive [equality for intersection types][11897] + +[11777]: https://github.com/enso-org/enso/pull/11777 +[11600]: https://github.com/enso-org/enso/pull/11600 +[11856]: https://github.com/enso-org/enso/pull/11856 +[11874]: https://github.com/enso-org/enso/pull/11874 +[11908]: https://github.com/enso-org/enso/pull/11908 +[11897]: https://github.com/enso-org/enso/pull/11897 + +# Enso 2024.5 + +#### Enso IDE + - [Rows and Columns may be now removed in Table Input Widget][11151]. The option is available in right-click context menu. - [Rows and Columns may be now reordered by dragging in Table Input @@ -108,6 +142,9 @@ - [Added `Table.input` allowing creation of typed tables from vectors of data, including auto parsing text columns.][11562] - [Enhance Managed_Resource to allow implementation of in-memory caches][11577] +- [Added `add_group_number` to the in-memory database.[11818] +- [The reload button clears the HTTP cache.][11673] +- [SQL Server Support for Aggregate][11811] [11235]: https://github.com/enso-org/enso/pull/11235 [11255]: https://github.com/enso-org/enso/pull/11255 @@ -116,6 +153,9 @@ [11490]: https://github.com/enso-org/enso/pull/11490 [11562]: https://github.com/enso-org/enso/pull/11562 [11577]: https://github.com/enso-org/enso/pull/11577 +[11818]: https://github.com/enso-org/enso/pull/11818 +[11673]: https://github.com/enso-org/enso/pull/11673 +[11811]: https://github.com/enso-org/enso/pull/11811 #### Enso Language & Runtime diff --git a/app/common/package.json b/app/common/package.json index a3bda304841a..1d90e3d03a1a 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -15,6 +15,7 @@ "./src/backendQuery": "./src/backendQuery.ts", "./src/queryClient": "./src/queryClient.ts", "./src/utilities/data/array": "./src/utilities/data/array.ts", + "./src/utilities/errors": "./src/utilities/errors.ts", "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", "./src/utilities/data/object": "./src/utilities/data/object.ts", @@ -32,12 +33,12 @@ "lint": "eslint ./src --cache --max-warnings=0" }, "peerDependencies": { - "@tanstack/query-core": "5.54.1", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0" + "@tanstack/query-core": "5.59.20", + "@tanstack/vue-query": "5.59.20" }, "dependencies": { - "@tanstack/query-persist-client-core": "^5.54.0", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0", + "@tanstack/query-persist-client-core": "5.59.20", + "@tanstack/vue-query": "5.59.20", "lib0": "^0.2.85", "react": "^18.3.1", "vitest": "^1.3.1", diff --git a/app/common/src/detect.ts b/app/common/src/detect.ts index 7dbd00aa2a5d..d2a4af76b34f 100644 --- a/app/common/src/detect.ts +++ b/app/common/src/detect.ts @@ -166,17 +166,21 @@ export function isOnUnknownBrowser() { let detectedArchitecture: string | null = null // Only implemented by Chromium. -// @ts-expect-error This API exists, but no typings exist for it yet. -navigator.userAgentData?.getHighEntropyValues(['architecture']).then((values: unknown) => { - if ( - typeof values === 'object' && - values != null && - 'architecture' in values && - typeof values.architecture === 'string' - ) { - detectedArchitecture = String(values.architecture) - } -}) +// navigator is undefined in Node.js, e.g. in integration tests(mock server). +// So we need to check if it is defined before using it. +if (typeof navigator !== 'undefined' && 'userAgentData' in navigator) { + // @ts-expect-error This API exists, but no typings exist for it yet. + navigator.userAgentData.getHighEntropyValues(['architecture']).then((values: unknown) => { + if ( + typeof values === 'object' && + values != null && + 'architecture' in values && + typeof values.architecture === 'string' + ) { + detectedArchitecture = String(values.architecture) + } + }) +} /** Possible processor architectures. */ export enum Architecture { diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index ab69795436d0..140caf24287e 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -59,7 +59,7 @@ export type QueryClient = vueQuery.QueryClient const DEFAULT_QUERY_STALE_TIME_MS = Infinity const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days -const DEFAULT_BUSTER = 'v1.1' +const DEFAULT_BUSTER = 'v1.2' export interface QueryClientOptions { readonly persisterStorage?: AsyncStorage & { @@ -83,7 +83,7 @@ export function createQueryClient( storage: persisterStorage, // Prefer online first and don't rely on the local cache if user is online // fallback to the local cache only if the user is offline - maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS, + maxAge: DEFAULT_QUERY_PERSIST_TIME_MS, buster: DEFAULT_BUSTER, filters: { predicate: query => query.meta?.persist !== false }, prefix: 'enso:query-persist:', @@ -130,8 +130,18 @@ export function createQueryClient( defaultOptions: { queries: { ...(persister != null ? { persister } : {}), + // Default set to 'always' to don't pause ongoing queries + // and make them fail. + networkMode: 'always', refetchOnReconnect: 'always', staleTime: DEFAULT_QUERY_STALE_TIME_MS, + // This allows to prefetch queries in the render phase. Enables returning + // a promise from the `useQuery` hook, which is useful for the `Await` component, + // which needs to prefetch the query in the render phase to be able to display + // the error boundary/suspense fallback. + // @see [experimental_prefetchInRender](https://tanstack.com/query/latest/docs/framework/react/guides/suspense#using-usequerypromise-and-reactuse-experimental) + // eslint-disable-next-line camelcase + experimental_prefetchInRender: true, retry: (failureCount, error: unknown) => { const statusesToIgnore = [403, 404] const errorStatus = diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index ae80d71aa390..452d08ef633e 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -14,20 +14,36 @@ export const S3_CHUNK_SIZE_BYTES = 10_000_000 // ================ /** Unique identifier for an organization. */ -export type OrganizationId = newtype.Newtype +export type OrganizationId = newtype.Newtype<`organization-${string}`, 'OrganizationId'> export const OrganizationId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link OrganizationId}. */ +export function isOrganizationId(id: string): id is OrganizationId { + return id.startsWith('organization-') +} /** Unique identifier for a user in an organization. */ export type UserId = newtype.Newtype export const UserId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link UserId}. */ +export function isUserId(id: string): id is UserId { + return id.startsWith('user-') +} /** Unique identifier for a user group. */ -export type UserGroupId = newtype.Newtype +export type UserGroupId = newtype.Newtype<`usergroup-${string}`, 'UserGroupId'> export const UserGroupId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link UserGroupId}. */ +export function isUserGroupId(id: string): id is UserGroupId { + return id.startsWith('usergroup-') +} /** Unique identifier for a directory. */ -export type DirectoryId = newtype.Newtype +export type DirectoryId = newtype.Newtype<`directory-${string}`, 'DirectoryId'> export const DirectoryId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link DirectoryId}. */ +export function isDirectoryId(id: string): id is DirectoryId { + return id.startsWith('directory-') +} /** * Unique identifier for an asset representing the items inside a directory for which the @@ -73,6 +89,7 @@ export const S3ObjectVersionId = newtype.newtypeConstructor() /** Unique identifier for an arbitrary asset. */ export type AssetId = IdType[keyof IdType] +export const AssetId = newtype.newtypeConstructor() /** Unique identifier for a payment checkout session. */ export type CheckoutSessionId = newtype.Newtype @@ -117,16 +134,6 @@ export type UserPermissionIdentifier = UserGroupId | UserId export type Path = newtype.Newtype export const Path = newtype.newtypeConstructor() -/** Whether a given {@link string} is an {@link UserId}. */ -export function isUserId(id: string): id is UserId { - return id.startsWith('user-') -} - -/** Whether a given {@link string} is an {@link UserGroupId}. */ -export function isUserGroupId(id: string): id is UserGroupId { - return id.startsWith('usergroup-') -} - const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-' /** @@ -142,7 +149,7 @@ export function isPlaceholderUserGroupId(id: string) { * being created on the backend. */ export function newPlaceholderUserGroupId() { - return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}`) + return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}` as const) } // ============= @@ -386,18 +393,6 @@ export interface Label { readonly color: LChColor } -/** - * Type of application that a {@link Version} applies to. - * - * We keep track of both backend and IDE versions, so that we can update the two independently. - * However the format of the version numbers is the same for both, so we can use the same type for - * both. We just need this enum to disambiguate. - */ -export enum VersionType { - backend = 'Backend', - ide = 'Ide', -} - /** Stability of an IDE or backend version. */ export enum VersionLifecycle { stable = 'Stable', @@ -412,14 +407,6 @@ export interface VersionNumber { readonly lifecycle: VersionLifecycle } -/** A version describing a release of the backend or IDE. */ -export interface Version { - readonly number: VersionNumber - readonly ami: Ami | null - readonly created: dateTime.Rfc3339DateTime - readonly version_type: VersionType -} - /** Credentials that need to be passed to libraries to give them access to the Cloud API. */ export interface CognitoCredentials { readonly accessToken: string @@ -849,7 +836,7 @@ export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAss title: '(root)', id: directoryId, modifiedAt: dateTime.toRfc3339(new Date()), - parentId: DirectoryId(''), + parentId: DirectoryId('directory-'), permissions: [], projectState: null, extension: null, @@ -924,7 +911,7 @@ export function createPlaceholderDirectoryAsset( ): DirectoryAsset { return { type: AssetType.directory, - id: DirectoryId(createPlaceholderId()), + id: DirectoryId(`directory-${createPlaceholderId()}` as const), title, parentId, permissions: assetPermissions, @@ -990,9 +977,7 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad return { type: AssetType.specialLoading, title: '', - id: LoadingAssetId( - createPlaceholderId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`), - ), + id: LoadingAssetId(createPlaceholderId(`${AssetType.specialLoading}-${directoryId}`)), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -1018,7 +1003,7 @@ export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyA return { type: AssetType.specialEmpty, title: '', - id: EmptyAssetId(`${AssetType.specialEmpty}-${uniqueString.uniqueString()}`), + id: EmptyAssetId(`${AssetType.specialEmpty}-${directoryId}`), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -1044,7 +1029,7 @@ export function createSpecialErrorAsset(directoryId: DirectoryId): SpecialErrorA return { type: AssetType.specialError, title: '', - id: ErrorAssetId(`${AssetType.specialError}-${uniqueString.uniqueString()}`), + id: ErrorAssetId(`${AssetType.specialError}-${directoryId}`), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -1096,7 +1081,7 @@ export function createPlaceholderAssetId( let result: AssetId switch (assetType) { case AssetType.directory: { - result = DirectoryId(id) + result = DirectoryId(`directory-${id}` as const) break } case AssetType.project: { @@ -1305,7 +1290,8 @@ export interface CreateSecretRequestBody { /** HTTP request body for the "update secret" endpoint. */ export interface UpdateSecretRequestBody { - readonly value: string + readonly title: string | null + readonly value: string | null } /** HTTP request body for the "create datalink" endpoint. */ @@ -1400,12 +1386,6 @@ export interface UploadPictureRequestParams { readonly fileName: string | null } -/** URL query string parameters for the "list versions" endpoint. */ -export interface ListVersionsRequestParams { - readonly versionType: VersionType - readonly default: boolean -} - // ============================== // === detectVersionLifecycle === // ============================== @@ -1540,18 +1520,28 @@ export function isNewTitleValid( item: AnyAsset, newTitle: string, siblings?: readonly AnyAsset[] | null, +) { + return newTitle !== '' && newTitle !== item.title && isNewTitleUnique(item, newTitle, siblings) +} + +/** + * Check whether a new title is unique among the siblings. + */ +export function isNewTitleUnique( + item: AnyAsset, + newTitle: string, + siblings?: readonly AnyAsset[] | null, ) { siblings ??= [] - return ( - newTitle !== '' && - newTitle !== item.title && - siblings.every(sibling => { - const isSelf = sibling.id === item.id - const hasSameType = sibling.type === item.type - const hasSameTitle = sibling.title === newTitle - return !(!isSelf && hasSameType && hasSameTitle) - }) - ) + + return siblings.every(sibling => { + if (sibling.id === item.id) { + return true + } + + const hasSameTitle = sibling.title.toLowerCase() === newTitle.toLowerCase() + return !hasSameTitle + }) } /** Network error class. */ @@ -1687,6 +1677,7 @@ export default abstract class Backend { abstract getProjectDetails( projectId: ProjectId, directoryId: DirectoryId | null, + getPresignedUrl?: boolean, ): Promise /** Return Language Server logs for a project session. */ abstract getProjectSessionLogs( @@ -1723,7 +1714,11 @@ export default abstract class Backend { /** Change the name of a file. */ abstract updateFile(fileId: FileId, body: UpdateFileRequestBody, title: string): Promise /** Return file details. */ - abstract getFileDetails(fileId: FileId, title: string): Promise + abstract getFileDetails( + fileId: FileId, + title: string, + getPresignedUrl?: boolean, + ): Promise /** Create a Datalink. */ abstract createDatalink(body: CreateDatalinkRequestBody): Promise /** Return a Datalink. */ @@ -1760,8 +1755,6 @@ export default abstract class Backend { abstract deleteUserGroup(userGroupId: UserGroupId, name: string): Promise /** Return all user groups in the organization. */ abstract listUserGroups(): Promise - /** Return a list of backend or IDE versions. */ - abstract listVersions(params: ListVersionsRequestParams): Promise /** Create a payment checkout session. */ abstract createCheckoutSession(body: CreateCheckoutSessionRequestBody): Promise /** Get the status of a payment checkout session. */ diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index a89c0135bc24..2a8daff68ada 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -1,6 +1,10 @@ { "submit": "Submit", "retry": "Retry", + + "arbitraryFetchError": "An error occurred while fetching data", + "arbitraryFetchImageError": "An error occurred while fetching an image", + "createFolderError": "Could not create new folder", "createProjectError": "Could not create new project", "createDatalinkError": "Could not create new Datalink", @@ -17,6 +21,9 @@ "deleteAssetError": "Could not delete '$0'", "restoreAssetError": "Could not restore '$0'", + "refetchQueriesPending": "Getting latest updates...", + "refetchQueriesError": "Could not get latest updates. Some information may be outdated", + "localBackendDatalinkError": "Cannot create Datalinks on the local drive", "localBackendSecretError": "Cannot create secrets on the local drive", "offlineUploadFilesError": "Cannot upload files when offline", @@ -52,6 +59,9 @@ "otherUserIsUsingProjectError": "Someone else is using this project", "localBackendNotDetectedError": "Could not detect the local backend", + "invalidInput": "Invalid input", + "nameShouldBeUnique": "Name must be unique", + "nameShouldNotContainInvalidCharacters": "Name should not contain invalid characters", "invalidEmailValidationError": "Please enter a valid email address", "projectHasNoSourceFilesPhrase": "project has no source files", @@ -174,6 +184,7 @@ "getCustomerPortalUrlBackendError": "Could not get customer portal URL", "duplicateLabelError": "This label already exists.", "emptyStringError": "This value must not be empty.", + "resolveProjectAssetPathBackendError": "Could not get asset", "directoryAssetType": "folder", "directoryDoesNotExistError": "Unable to find directory. Does it exist?", @@ -400,6 +411,7 @@ "thisFolderFailedToFetch": "This folder failed to fetch.", "yourTrashIsEmpty": "Your trash is empty.", "deleteTheAssetTypeTitle": "delete the $0 '$1'", + "deleteTheAssetTypeTitleForever": "permanently delete the $0 '$1'", "trashTheAssetTypeTitle": "move the $0 '$1' to Trash", "notImplemetedYet": "Not implemented yet.", "newLabelButtonLabel": "New label", @@ -445,7 +457,8 @@ "youHaveNoRecentProjects": "You have no recent projects. Switch to another category to create a project.", "youHaveNoFiles": "This folder is empty. You can create a project using the buttons above.", "placeholderChatPrompt": "Login or register to access live chat with our support team.", - "confirmPrompt": "Are you sure you want to $0?", + "confirmPrompt": "Do you really want to $0?", + "thisOperationCannotBeUndone": "This operation is final and cannot be undone.", "couldNotInviteUser": "Could not invite user $0", "inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more", "inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0", @@ -477,20 +490,20 @@ "hidePassword": "Hide password", "showPassword": "Show password", "copiedToClipboard": "Copied to clipboard", - "noResultsFound": "No results found.", - "youAreOffline": "You are offline.", - "cannotCreateAssetsHere": "You do not have the permissions to create assets here.", + "noResultsFound": "No results found", + "youAreOffline": "You are offline", + "cannotCreateAssetsHere": "You do not have the permissions to create assets here", "enableVersionChecker": "Enable Version Checker", "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "disableAnimations": "Disable animations", "disableAnimationsDescription": "Disable all animations in the app.", - "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites", + "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your sidebar", "changeLocalRootDirectoryInSettings": "Change the root folder", "localStorage": "Local Storage", "addLocalDirectory": "Add Folder", "browseForNewLocalRootDirectory": "Browse for new Root Folder", "resetLocalRootDirectory": "Reset Root Folder", - "removeDirectoryFromFavorites": "Remove folder from favorites", + "removeDirectoryFromFavorites": "Remove from Sidebar", "organizationInviteTitle": "You have been invited!", "organizationInvitePrefix": "The organization '", "organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.", @@ -681,6 +694,7 @@ "accessedDataColumnName": "Accessed data", "docsColumnName": "Docs", "rootFolderColumnName": "Root folder", + "pathColumnName": "Location", "settingsShortcut": "Settings", "closeTabShortcut": "Close Tab", @@ -750,6 +764,10 @@ "accessedDataColumnHide": "Accessed Data", "docsColumnShow": "Docs", "docsColumnHide": "Docs", + "pathColumnShow": "Location", + "pathColumnHide": "Location", + + "hideColumn": "Hide column", "activityLog": "Activity Log", "startDate": "Start Date", diff --git a/app/common/src/text/index.ts b/app/common/src/text/index.ts index 8c532137d50e..55ebb239a7e3 100644 --- a/app/common/src/text/index.ts +++ b/app/common/src/text/index.ts @@ -1,11 +1,8 @@ /** @file Functions related to displaying text. */ +import { unsafeKeys } from '../utilities/data/object' import ENGLISH from './english.json' with { type: 'json' } -// ============= -// === Types === -// ============= - /** Possible languages in which to display text. */ export enum Language { english = 'english', @@ -45,6 +42,7 @@ interface PlaceholderOverrides { readonly confirmPrompt: [action: string] readonly trashTheAssetTypeTitle: [assetType: string, assetName: string] readonly deleteTheAssetTypeTitle: [assetType: string, assetName: string] + readonly deleteTheAssetTypeTitleForever: [assetType: string, assetName: string] readonly couldNotInviteUser: [userEmail: string] readonly filesWithoutConflicts: [fileCount: number] readonly projectsWithoutConflicts: [projectCount: number] @@ -159,3 +157,54 @@ export interface Replacements export const TEXTS: Readonly> = { [Language.english]: ENGLISH, } +/** + * A function that gets localized text for a given key, with optional replacements. + * @param key - The key of the text to get. + * @param replacements - The replacements to insert into the text. + * If the text contains placeholders like `$0`, `$1`, etc., + * they will be replaced with the corresponding replacement. + */ +export type GetText = ( + dictionary: Texts, + key: K, + ...replacements: Replacements[K] +) => string + +/** + * Resolves the language texts based on the user's preferred language. + */ +export function resolveUserLanguage() { + const locale = navigator.language + const language = + unsafeKeys(LANGUAGE_TO_LOCALE).find(language => locale === LANGUAGE_TO_LOCALE[language]) ?? + Language.english + + return language +} + +/** + * Gets the dictionary for a given language. + * @param language - The language to get the dictionary for. + * @returns The dictionary for the given language. + */ +export function getDictionary(language: Language) { + return TEXTS[language] +} + +/** + * Gets the text for a given key, with optional replacements. + * @param dictionary - The dictionary to get the text from. + * @param key - The key of the text to get. + * @param replacements - The replacements to insert into the text. + * If the text contains placeholders like `$0`, `$1`, etc., + * they will be replaced with the corresponding replacement. + */ +export const getText: GetText = (dictionary, key, ...replacements) => { + const template = dictionary[key] + + return replacements.length === 0 ? + template + : template.replace(/[$]([$]|\d+)/g, (_match, placeholder: string) => + placeholder === '$' ? '$' : String(replacements[Number(placeholder)] ?? `$${placeholder}`), + ) +} diff --git a/app/common/src/utilities/errors.ts b/app/common/src/utilities/errors.ts new file mode 100644 index 000000000000..a359fd3e1a7a --- /dev/null +++ b/app/common/src/utilities/errors.ts @@ -0,0 +1,52 @@ +/** + * An error that occurs when a network request fails. + * + * This error is used to indicate that a network request failed due to a network error, + * such as a timeout or a connection error. + */ +export class NetworkError extends Error { + /** + * Create a new {@link NetworkError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'NetworkError' + } +} + +/** + * An error that occurs when the user is offline. + * + * This error is used to indicate that the user is offline, such as when they are + * not connected to the internet or when they are on an airplane. + */ +export class OfflineError extends Error { + /** + * Create a new {@link OfflineError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string = 'User is offline', options?: ErrorOptions) { + super(message, options) + this.name = 'OfflineError' + } +} + +/** + * An error with a display message. + * + * This message can be shown to a user. + */ +export class ErrorWithDisplayMessage extends Error { + readonly displayMessage: string + /** + * Create a new {@link ErrorWithDisplayMessage} with the specified message and display message. + * @param message - The message to display when the error is thrown. + * @param options - The options to pass to the error. + */ + constructor(message: string, options: ErrorOptions & { displayMessage: string }) { + super(message, options) + this.name = 'ErrorWithDisplayMessage' + this.displayMessage = options.displayMessage + } +} diff --git a/app/gui/.gitignore b/app/gui/.gitignore index 869cec171367..a05ebb778efa 100644 --- a/app/gui/.gitignore +++ b/app/gui/.gitignore @@ -33,3 +33,6 @@ src/project-view/util/iconName.ts *storybook.log storybook-static + +# Sentry Config File +.env.sentry-build-plugin diff --git a/app/gui/env.d.ts b/app/gui/env.d.ts index f7095e04d189..01bee4b4999f 100644 --- a/app/gui/env.d.ts +++ b/app/gui/env.d.ts @@ -175,6 +175,7 @@ declare global { readonly projectManagementApi?: ProjectManagementApi readonly fileBrowserApi?: FileBrowserApi readonly versionInfo?: VersionInfo + readonly mapBoxApiToken?: () => string toggleDevtools: () => void /** * If set to `true`, animations will be disabled. @@ -183,6 +184,13 @@ declare global { * ATM only affects the framer-motion animations. */ readonly DISABLE_ANIMATIONS?: boolean + readonly featureFlags: FeatureFlags + readonly setFeatureFlags: (flags: Partial) => void + /** + * Feature flags that override the default or stored feature flags. + * This is used by integration tests to set feature flags. + */ + readonly overrideFeatureFlags: Partial } namespace NodeJS { diff --git a/app/gui/integration-test/dashboard/README.md b/app/gui/integration-test/dashboard/README.md index 204492c82da6..04feed3cd434 100644 --- a/app/gui/integration-test/dashboard/README.md +++ b/app/gui/integration-test/dashboard/README.md @@ -3,51 +3,47 @@ ## Running tests Execute all commands from the parent directory. +Note that all options can be used in any combination. ```sh # Run tests normally -pnpm run test:integration +pnpm playwright test # Open UI to run tests -pnpm run test:integration:debug +pnpm playwright test --ui # Run tests in a specific file only -pnpm run test:integration -- integration-test/file-name-here.spec.ts -pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts +pnpm playwright test integration-test/dashboard/file-name-here.spec.ts # Compile the entire app before running the tests. # DOES NOT hot reload the tests. # Prefer not using this when you are trying to fix a test; # prefer using this when you just want to know which tests are failing (if any). -PROD=1 pnpm run test:integration -PROD=1 pnpm run test:integration:debug -PROD=1 pnpm run test:integration -- integration-test/file-name-here.spec.ts -PROD=1 pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts +PROD=true pnpm playwright test ``` ## Getting started ```ts -test.test('test name here', ({ page }) => - actions.mockAllAndLogin({ page }).then( - // ONLY chain methods from `pageActions`. - // Using methods not in `pageActions` is UNDEFINED BEHAVIOR. - // If it is absolutely necessary though, please remember to `await` the method chain. - // Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s, - // not `Promise`s, which causes Playwright to output a type error. - async ({ pageActions }) => await pageActions.goTo.drive(), - ), -) +// ONLY chain methods from `pageActions`. +// Using methods not in `pageActions` is UNDEFINED BEHAVIOR. +// If it is absolutely necessary though, please remember to `await` the method chain. +test('test name here', ({ page }) => mockAllAndLogin({ page }).goToPage.drive()) ``` ### Perform arbitrary actions (e.g. actions on the API) ```ts -test.test('test name here', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions, api }) => - await pageActions.do(() => { - api.foo() - api.bar() - test.expect(api.baz()?.quux).toEqual('bar') - }), - ), -) +test('test name here', ({ page }) => + mockAllAndLogin({ page }).do((_page, { api }) => { + api.foo() + api.bar() + expect(api.baz()?.quux).toEqual('bar') + })) ``` + +### Writing new classes extending `BaseActions` + +- Make sure that every method returns either the class itself (`this`) or `.into(AnotherActionsClass)`. +- Avoid constructing `new AnotherActionsClass()` - instead prefer `.into(AnotherActionsClass)` and optionally `.into(ThisClass)` if required. +- Never construct an `ActionsClass` + - In some rare exceptions, it is fine as long as you `await` the `PageActions` class - for example in `index.ts` there is `await new StartModalActions().close()`. +- Methods for locators are fine, but it is not recommended to expose them as it makes it easy to accidentally - i.e. it is fine as long as they are `private`. + - In general, avoid exposing any method that returns a `Promise` rather than a `PageActions`. diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 1e22ca9a1817..77c71501dc22 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -1,31 +1,31 @@ /** @file The base class from which all `Actions` classes are derived. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import type * as inputBindings from '#/utilities/inputBindings' +import type { AutocompleteKeybind, ModifierKey } from '#/utilities/inputBindings' -import { modModifier } from '.' - -// ==================== -// === PageCallback === -// ==================== - -/** A callback that performs actions on a {@link test.Page}. */ -export interface PageCallback { - (input: test.Page): Promise | void +/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ +export async function modModifier(page: Page) { + let userAgent = '' + await test.step('Detect browser OS', async () => { + userAgent = await page.evaluate(() => navigator.userAgent) + }) + return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' } -// ======================= -// === LocatorCallback === -// ======================= +/** A callback that performs actions on a {@link Page}. */ +export interface PageCallback { + (input: Page, context: Context): Promise | void +} -/** A callback that performs actions on a {@link test.Locator}. */ -export interface LocatorCallback { - (input: test.Locator): Promise | void +/** A callback that performs actions on a {@link Locator}. */ +export interface LocatorCallback { + (input: Locator, context: Context): Promise | void } -// =================== -// === BaseActions === -// =================== +export interface BaseActionsClass { + // The return type should be `InstanceType`, but that results in a circular reference error. + new (page: Page, context: Context, promise: Promise, ...args: Args): any +} /** * The base class from which all `Actions` classes are derived. @@ -34,10 +34,11 @@ export interface LocatorCallback { * * [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables */ -export default class BaseActions implements Promise { +export default class BaseActions implements Promise { /** Create a {@link BaseActions}. */ constructor( - protected readonly page: test.Page, + protected readonly page: Page, + protected readonly context: Context, private readonly promise = Promise.resolve(), ) {} @@ -50,27 +51,43 @@ export default class BaseActions implements Promise { } /** - * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` - * on all other platforms. + * Return the appropriate key for a shortcut, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, + * and `Control` on all other platforms. Similarly, replace the text `Delete` with `Backspace` + * on `macOS`, and `Delete` on all other platforms. */ - static press(page: test.Page, keyOrShortcut: string): Promise { - return test.test.step(`Press '${keyOrShortcut}'`, async () => { + static async withNormalizedKey( + page: Page, + keyOrShortcut: string, + callback: (shortcut: string) => Promise, + description = 'Normalize', + ): Promise { + return test.step(`${description} '${keyOrShortcut}'`, async () => { if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { let userAgent = '' - await test.test.step('Detect browser OS', async () => { + await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) }) const isMacOS = /\bMac OS\b/i.test(userAgent) const ctrlKey = isMacOS ? 'Meta' : 'Control' const deleteKey = isMacOS ? 'Backspace' : 'Delete' const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) - await page.keyboard.press(shortcut) + return await callback(shortcut) } else { - await page.keyboard.press(keyOrShortcut) + return callback(keyOrShortcut) } }) } + /** Press a key or shortcut. */ + static async press(page: Page, keyOrShortcut: string) { + await BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ) + } + /** Proxies the `then` method of the internal {@link Promise}. */ async then( onfulfilled?: (() => PromiseLike | T) | null | undefined, @@ -99,43 +116,86 @@ export default class BaseActions implements Promise { /** Return a {@link BaseActions} with the same {@link Promise} but a different type. */ into< - T extends new (page: test.Page, promise: Promise, ...args: Args) => InstanceType, + T extends new ( + page: Page, + context: Context, + promise: Promise, + ...args: Args + ) => InstanceType, Args extends readonly unknown[], >(clazz: T, ...args: Args): InstanceType { - return new clazz(this.page, this.promise, ...args) + return new clazz(this.page, this.context, this.promise, ...args) } /** - * Perform an action on the current page. This should generally be avoided in favor of using + * Perform an action. This should generally be avoided in favor of using * specific methods; this is more or less an escape hatch used ONLY when the methods do not * support desired functionality. */ - do(callback: PageCallback): this { + do(callback: PageCallback): this { // @ts-expect-error This is SAFE, but only when the constructor of this class has the exact // same parameters as `BaseActions`. return new this.constructor( this.page, - this.then(() => callback(this.page)), + this.context, + this.then(() => callback(this.page, this.context)), + ) + } + + /** Perform an action. */ + step(name: string, callback: PageCallback) { + return this.do(() => test.step(name, () => callback(this.page, this.context))) + } + + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + press(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ), ) } - /** Perform an action on the current page. */ - step(name: string, callback: PageCallback) { - return this.do(() => test.test.step(name, () => callback(this.page))) + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + down(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.down(shortcut), + 'Press', + ), + ) } /** * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - press(keyOrShortcut: inputBindings.AutocompleteKeybind) { - return this.do((page) => BaseActions.press(page, keyOrShortcut)) + up(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.up(shortcut), + 'Release', + ), + ) } /** Perform actions until a predicate passes. */ retry( callback: (actions: this) => this, - predicate: (page: test.Page) => Promise, + predicate: (page: Page) => Promise, options: { retries?: number; delay?: number } = {}, ) { const { retries = 3, delay = 1_000 } = options @@ -152,7 +212,7 @@ export default class BaseActions implements Promise { } /** Perform actions with the "Mod" modifier key pressed. */ - withModPressed(callback: (actions: this) => R) { + withModPressed>(callback: (actions: this) => R) { return callback( this.step('Press "Mod"', async (page) => { await page.keyboard.down(await modModifier(page)) @@ -171,11 +231,11 @@ export default class BaseActions implements Promise { return this } else if (expected != null) { return this.step(`Expect ${description} error to be '${expected}'`, async (page) => { - await test.expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected) + await expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected) }) } else { return this.step(`Expect no ${description} error`, async (page) => { - await test.expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible() + await expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible() }) } } diff --git a/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts new file mode 100644 index 000000000000..696959dfe633 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts @@ -0,0 +1,11 @@ +/** @file Actions for the "user" tab of the "settings" page. */ +import { goToPageActions, type GoToPageActions } from './goToPageActions' +import PageActions from './PageActions' + +/** Actions common to all settings pages. */ +export default class BaseSettingsTabActions extends PageActions { + /** Actions for navigating to another page. */ + get goToPage(): Omit, 'settings'> { + return goToPageActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 462cabcd271f..b31d0a05be85 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -1,60 +1,94 @@ /** @file Actions for the "drive" page. */ -import * as test from 'playwright/test' - -import { - locateAssetPanel, - locateAssetsTable, - locateContextMenus, - locateCreateButton, - locateDriveView, - locateNewSecretIcon, - locateNonAssetRows, - locateSecretNameInput, - locateSecretValueInput, - TEXT, -} from '.' -import type * as baseActions from './BaseActions' -import * as contextMenuActions from './contextMenuActions' -import * as goToPageActions from './goToPageActions' +import { expect, type Locator, type Page } from '@playwright/test' + +import { TEXT } from '.' +import type { LocatorCallback } from './BaseActions' +import { contextMenuActions } from './contextMenuActions' +import EditorPageActions from './EditorPageActions' +import { goToPageActions, type GoToPageActions } from './goToPageActions' import NewDataLinkModalActions from './NewDataLinkModalActions' import PageActions from './PageActions' import StartModalActions from './StartModalActions' -// ================= -// === Constants === -// ================= - const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } -// ======================= -// === locateAssetRows === -// ======================= +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} + +/** Find a "create" button. */ +function locateCreateButton(page: Page) { + return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create) +} + +/** Find an assets table. */ +function locateAssetsTable(page: Page) { + return page.getByTestId('drive-view').getByRole('table') +} -/** Find all assets table rows (if any). */ -function locateAssetRows(page: test.Page) { +/** Find all assets table rows. */ +function locateAssetRows(page: Page) { return locateAssetsTable(page).getByTestId('asset-row') } -// ======================== -// === DrivePageActions === -// ======================== +/** Find assets table placeholder rows. */ +function locateNonAssetRows(page: Page) { + return locateAssetsTable(page).locator( + 'tbody tr:not([data-testid="asset-row"]):not([data-testid="dummy-row"])', + ) +} + +/** Find a "new secret" icon. */ +function locateNewSecretIcon(page: Page) { + return page.getByRole('button', { name: 'New Secret' }) +} + +/** Find an "upsert secret" modal. */ +function locateUpsertSecretModal(page: Page) { + // This has no identifying features. + return page.getByTestId('upsert-secret-modal') +} + +/** Find a "name" input for an "upsert secret" modal. */ +function locateSecretNameInput(page: Page) { + return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder) +} + +/** Find a "value" input for an "upsert secret" modal. */ +function locateSecretValueInput(page: Page) { + return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) +} + +/** Find an asset panel. */ +function locateAssetPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-panel').locator('visible=true') +} /** Actions for the "drive" page. */ -export default class DrivePageActions extends PageActions { +export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { - return goToPageActions.goToPageActions(this.step.bind(this)) + get goToPage(): Omit, 'drive'> { + return goToPageActions(this.step.bind(this)) } /** Actions related to context menus. */ get contextMenu() { - return contextMenuActions.contextMenuActions(this.step.bind(this)) + return contextMenuActions(this.step.bind(this)) } /** Switch to a different category. */ get goToCategory() { // eslint-disable-next-line @typescript-eslint/no-this-alias - const self: DrivePageActions = this + const self: DrivePageActions = this return { /** Switch to the "cloud" category. */ cloud() { @@ -92,24 +126,86 @@ export default class DrivePageActions extends PageActions { } } + /** Interact with the assets search bar. */ + withSearchBar(callback: LocatorCallback) { + return this.step('Interact with search bar', (page, context) => + callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/), context), + ) + } + + /** + * Expect the category to be selected. + */ + expectCategory(category: string) { + return this.step(`Expect category '${category}'`, (page) => + expect(page.getByRole('button', { name: category })).toHaveAttribute('data-selected', 'true'), + ) + } + + /** + * Expect the category to be not selected. + */ + expectCategoryNotSelected(category: string) { + return this.step(`Expect category '${category}' not selected`, (page) => + expect(page.getByRole('button', { name: category })).toHaveAttribute( + 'data-selected', + 'false', + ), + ) + } + /** Actions specific to the Drive table. */ get driveTable() { // eslint-disable-next-line @typescript-eslint/no-this-alias - const self: DrivePageActions = this + const self: DrivePageActions = this + const locateNameColumnHeading = (page: Page) => + page + .getByLabel(TEXT.sortByName) + .or(page.getByLabel(TEXT.sortByNameDescending)) + .or(page.getByLabel(TEXT.stopSortingByName)) + const locateModifiedColumnHeading = (page: Page) => + page + .getByLabel(TEXT.sortByModificationDate) + .or(page.getByLabel(TEXT.sortByModificationDateDescending)) + .or(page.getByLabel(TEXT.stopSortingByModificationDate)) + + const locatePathColumnHeading = (page: Page) => page.getByTestId('path-column-heading') + const locatePathColumnCell = (page: Page, title: string) => + page.getByTestId(`path-column-cell-${title.toLowerCase().replace(/\s+/g, '-')}`) + return { /** Click the column heading for the "name" column to change its sort order. */ clickNameColumnHeading() { return self.step('Click "name" column heading', (page) => - page.getByLabel(TEXT.sortByName).or(page.getByLabel(TEXT.stopSortingByName)).click(), + locateNameColumnHeading(page).click(), + ) + }, + /** Interact with the column heading for the "name" column. */ + withNameColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "name" column heading', (page, context) => + callback(locateNameColumnHeading(page), context), + ) + }, + withPathColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "path" column heading', (page, context) => + callback(locatePathColumnHeading(page), context), + ) + }, + withPathColumnCell(title: string, callback: LocatorCallback) { + return self.step(`Interact with "path" column cell '${title}'`, (page, context) => + callback(locatePathColumnCell(page, title), context), ) }, /** Click the column heading for the "modified" column to change its sort order. */ clickModifiedColumnHeading() { return self.step('Click "modified" column heading', (page) => - page - .getByLabel(TEXT.sortByModificationDate) - .or(page.getByLabel(TEXT.stopSortingByModificationDate)) - .click(), + locateModifiedColumnHeading(page).click(), + ) + }, + /** Interact with the column heading for the "modified" column. */ + withModifiedColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "modified" column heading', (page, context) => + callback(locateModifiedColumnHeading(page), context), ) }, /** Click to select a specific row. */ @@ -137,16 +233,27 @@ export default class DrivePageActions extends PageActions { }, /** Interact with the set of all rows in the Drive table. */ withRows( - callback: (assetRows: test.Locator, nonAssetRows: test.Locator) => Promise | void, + callback: ( + assetRows: Locator, + nonAssetRows: Locator, + context: Context, + page: Page, + ) => Promise | void, ) { return self.step('Interact with drive table rows', async (page) => { - await callback(locateAssetRows(page), locateNonAssetRows(page)) + await callback(locateAssetRows(page), locateNonAssetRows(page), self.context, page) + }) + }, + withSelectedRows(callback: LocatorCallback) { + return self.step('Interact with selected drive table rows', async (page, context) => { + await callback(locateAssetRows(page).and(page.locator('[data-selected="true"]')), context) }) }, /** Drag a row onto another row. */ dragRowToRow(from: number, to: number) { return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => { const rows = locateAssetRows(page) + rows.nth(from).click() await rows.nth(from).dragTo(rows.nth(to), { sourcePosition: ASSET_ROW_SAFE_POSITION, targetPosition: ASSET_ROW_SAFE_POSITION, @@ -154,7 +261,7 @@ export default class DrivePageActions extends PageActions { }) }, /** Drag a row onto another row. */ - dragRow(from: number, to: test.Locator, force?: boolean) { + dragRow(from: number, to: Locator, force?: boolean) { return self.step(`Drag drive table row #${from} to custom locator`, (page) => locateAssetRows(page) .nth(from) @@ -164,16 +271,38 @@ export default class DrivePageActions extends PageActions { }), ) }, + expandDirectory(index: number) { + return self.step(`Expand drive table row #${index}`, async (page) => { + const expandButton = locateAssetRows(page) + .nth(index) + .getByTestId('directory-row-expand-button') + + await expect(expandButton).toHaveAttribute('aria-label', TEXT.expand) + + await expandButton.click() + }) + }, + collapseDirectory(index: number) { + return self.step(`Collapse drive table row #${index}`, async (page) => { + const collapseButton = locateAssetRows(page) + .nth(index) + .getByTestId('directory-row-expand-button') + + await expect(collapseButton).toHaveAttribute('aria-label', TEXT.collapse) + + return collapseButton.click() + }) + }, /** * A test assertion to confirm that there is only one row visible, and that row is the * placeholder row displayed when there are no assets to show. */ expectPlaceholderRow() { return self.step('Expect placeholder row', async (page) => { - await test.expect(locateAssetRows(page)).toHaveCount(0) + await expect(locateAssetRows(page)).toHaveCount(0) const nonAssetRows = locateNonAssetRows(page) - await test.expect(nonAssetRows).toHaveCount(1) - await test.expect(nonAssetRows).toHaveText(/This folder is empty/) + await expect(nonAssetRows).toHaveCount(1) + await expect(nonAssetRows).toHaveText(/This folder is empty/) }) }, /** @@ -182,10 +311,10 @@ export default class DrivePageActions extends PageActions { */ expectTrashPlaceholderRow() { return self.step('Expect trash placeholder row', async (page) => { - await test.expect(locateAssetRows(page)).toHaveCount(0) + await expect(locateAssetRows(page)).toHaveCount(0) const nonAssetRows = locateNonAssetRows(page) - await test.expect(nonAssetRows).toHaveCount(1) - await test.expect(nonAssetRows).toHaveText(/Your trash is empty/) + await expect(nonAssetRows).toHaveCount(1) + await expect(nonAssetRows).toHaveText(/Your trash is empty/) }) }, /** Toggle a column's visibility. */ @@ -236,7 +365,22 @@ export default class DrivePageActions extends PageActions { openStartModal() { return this.step('Open "start" modal', (page) => page.getByText(TEXT.startWithATemplate).click(), - ).into(StartModalActions) + ).into(StartModalActions) + } + + /** Expect the "start" modal to be visible. */ + expectStartModal() { + return this.into(StartModalActions).withStartModal(async (startModal) => { + await expect(startModal).toBeVisible() + }) + } + + /** Clear trash. */ + clearTrash() { + return this.step('Clear trash', async (page) => { + await page.getByText(TEXT.clearTrash).click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + }) } /** Create a new empty project. */ @@ -246,19 +390,30 @@ export default class DrivePageActions extends PageActions { (page) => page.getByText(TEXT.newEmptyProject, { exact: true }).click(), // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. - ) /* .into(EditorPageActions) */ + ) /* .into(EditorPageActions) */ + } + + // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 + // Delete once cloud execution in the browser is re-enabled. + /** Create a new empty project. */ + newEmptyProjectTest() { + return this.step('Create empty project', (page) => + page.getByText(TEXT.newEmptyProject, { exact: true }).click(), + ).into(EditorPageActions) } /** Interact with the drive view (the main container of this page). */ - withDriveView(callback: baseActions.LocatorCallback) { - return this.step('Interact with drive view', (page) => callback(locateDriveView(page))) + withDriveView(callback: LocatorCallback) { + return this.step('Interact with drive view', (page, context) => + callback(locateDriveView(page), context), + ) } /** Create a new folder using the icon in the Drive Bar. */ createFolder() { return this.step('Create folder', async (page) => { await page.getByRole('button', { name: TEXT.newFolder, exact: true }).click() - await test.expect(page.locator('input:focus')).toBeVisible() + await expect(page.locator('input:focus')).toBeVisible() await page.keyboard.press('Escape') }) } @@ -320,7 +475,7 @@ export default class DrivePageActions extends PageActions { /** * Check if the Asset Panel is shown. */ - async isAssetPanelShown(page: test.Page) { + async isAssetPanelShown(page: Page) { return await page .getByTestId('asset-panel') .isVisible({ timeout: 0 }) @@ -333,7 +488,7 @@ export default class DrivePageActions extends PageActions { /** * Wait for the Asset Panel to be shown and visually stable */ - async waitForAssetPanelShown(page: test.Page) { + async waitForAssetPanelShown(page: Page) { await page.getByTestId('asset-panel').waitFor({ state: 'visible' }) } @@ -354,16 +509,18 @@ export default class DrivePageActions extends PageActions { } /** Interact with the container element of the assets table. */ - withAssetsTable(callback: baseActions.LocatorCallback) { + withAssetsTable( + callback: (input: Locator, context: Context, page: Page) => Promise | void, + ) { return this.step('Interact with drive table', async (page) => { - await callback(locateAssetsTable(page)) + await callback(locateAssetsTable(page), this.context, page) }) } /** Interact with the Asset Panel. */ - withAssetPanel(callback: baseActions.LocatorCallback) { - return this.step('Interact with asset panel', async (page) => { - await callback(locateAssetPanel(page)) + withAssetPanel(callback: LocatorCallback) { + return this.step('Interact with asset panel', async (page, context) => { + await callback(locateAssetPanel(page), context) }) } @@ -371,27 +528,13 @@ export default class DrivePageActions extends PageActions { openDataLinkModal() { return this.step('Open "new data link" modal', (page) => page.getByRole('button', { name: TEXT.newDatalink }).click(), - ).into(NewDataLinkModalActions) + ).into(NewDataLinkModalActions) } /** Interact with the context menus (the context menus MUST be visible). */ - withContextMenus(callback: baseActions.LocatorCallback) { - return this.step('Interact with context menus', async (page) => { - await callback(locateContextMenus(page)) - }) - } - - /** Close the "get started" modal. */ - closeGetStartedModal() { - return this.step('Close "get started" modal', async (page) => { - await new StartModalActions(page).close() - }) - } - - /** Interact with the "start" modal. */ - withStartModal(callback: baseActions.LocatorCallback) { - return this.step('Interact with start modal', async (page) => { - await callback(new StartModalActions(page).locateStartModal()) + withContextMenus(callback: LocatorCallback) { + return this.step('Interact with context menus', async (page, context) => { + await callback(locateContextMenu(page), context) }) } } diff --git a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts index 4df9a30fb59e..3bba639a5819 100644 --- a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts @@ -1,19 +1,15 @@ /** @file Actions for the "editor" page. */ -import * as goToPageActions from './goToPageActions' +import { goToPageActions, type GoToPageActions } from './goToPageActions' import PageActions from './PageActions' -// ========================= -// === EditorPageActions === -// ========================= - /** Actions for the "editor" page. */ -export default class EditorPageActions extends PageActions { +export default class EditorPageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { - return goToPageActions.goToPageActions(this.step.bind(this)) + get goToPage(): Omit, 'editor'> { + return goToPageActions(this.step.bind(this)) } /** Waits for the editor to load. */ - waitForEditorToLoad(): EditorPageActions { + waitForEditorToLoad(): EditorPageActions { return this.step('wait for the editor to load', async () => { await this.page.waitForSelector('[data-testid=editor]', { state: 'visible' }) }) diff --git a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts index 738975c79fe0..4e8d4199737d 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -1,30 +1,26 @@ /** @file Available actions for the login page. */ -import * as test from '@playwright/test' +import { expect } from '@playwright/test' import { TEXT, VALID_EMAIL } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' -// ================================= -// === ForgotPasswordPageActions === -// ================================= - /** Available actions for the login page. */ -export default class ForgotPasswordPageActions extends BaseActions { +export default class ForgotPasswordPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - login: (): LoginPageActions => + login: (): LoginPageActions => this.step("Go to 'login' page", async (page) => page.getByRole('link', { name: TEXT.goBackToLogin, exact: true }).click(), - ).into(LoginPageActions), + ).into(LoginPageActions), } } /** Perform a successful login. */ forgotPassword(email = VALID_EMAIL) { return this.step('Forgot password', () => this.forgotPasswordInternal(email)).into( - LoginPageActions, + LoginPageActions, ) } @@ -36,9 +32,9 @@ export default class ForgotPasswordPageActions extends BaseActions { } /** Interact with the email input. */ - withEmailInput(callback: LocatorCallback) { - return this.step('Interact with email input', async (page) => { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', async (page, context) => { + await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context) }) } @@ -49,6 +45,6 @@ export default class ForgotPasswordPageActions extends BaseActions { .getByRole('button', { name: TEXT.login, exact: true }) .getByText(TEXT.login) .click() - await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } } diff --git a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts index 9d2ca08fa6b8..e8462d5ada0c 100644 --- a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts @@ -1,5 +1,5 @@ /** @file Available actions for the login page. */ -import * as test from '@playwright/test' +import { expect } from '@playwright/test' import { TEXT, VALID_EMAIL, VALID_PASSWORD, passAgreementsDialog } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' @@ -8,23 +8,19 @@ import ForgotPasswordPageActions from './ForgotPasswordPageActions' import RegisterPageActions from './RegisterPageActions' import SetupUsernamePageActions from './SetupUsernamePageActions' -// ======================== -// === LoginPageActions === -// ======================== - /** Available actions for the login page. */ -export default class LoginPageActions extends BaseActions { +export default class LoginPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - register: (): RegisterPageActions => + register: (): RegisterPageActions => this.step("Go to 'register' page", async (page) => page.getByRole('link', { name: TEXT.dontHaveAnAccount, exact: true }).click(), - ).into(RegisterPageActions), - forgotPassword: (): ForgotPasswordPageActions => + ).into(RegisterPageActions), + forgotPassword: (): ForgotPasswordPageActions => this.step("Go to 'forgot password' page", async (page) => page.getByRole('link', { name: TEXT.forgotYourPassword, exact: true }).click(), - ).into(ForgotPasswordPageActions), + ).into(ForgotPasswordPageActions), } } @@ -33,7 +29,7 @@ export default class LoginPageActions extends BaseActions { return this.step('Login', async (page) => { await this.loginInternal(email, password) await passAgreementsDialog({ page }) - }).into(DrivePageActions) + }).into(DrivePageActions) } /** Perform a login as a new user (a user that does not yet have a username). */ @@ -41,7 +37,7 @@ export default class LoginPageActions extends BaseActions { return this.step('Login (as new user)', async (page) => { await this.loginInternal(email, password) await passAgreementsDialog({ page }) - }).into(SetupUsernamePageActions) + }).into(SetupUsernamePageActions) } /** Perform a failing login. */ @@ -66,11 +62,11 @@ export default class LoginPageActions extends BaseActions { return next } else if (formError != null) { return next.step(`Expect form error to be '${formError}'`, async (page) => { - await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError) + await expect(page.getByTestId('form-submit-error')).toHaveText(formError) }) } else { return next.step('Expect no form error', async (page) => { - await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible() + await expect(page.getByTestId('form-submit-error')).not.toBeVisible() }) } } @@ -83,10 +79,10 @@ export default class LoginPageActions extends BaseActions { } /** Interact with the email input. */ - withEmailInput(callback: LocatorCallback) { - return this.step('Interact with email input', async (page) => { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) - }) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', (page, context) => + callback(page.getByPlaceholder(TEXT.emailPlaceholder), context), + ) } /** Internal login logic shared between all public methods. */ @@ -97,6 +93,6 @@ export default class LoginPageActions extends BaseActions { .getByRole('button', { name: TEXT.login, exact: true }) .getByText(TEXT.login) .click() - await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } } diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 9a5835743345..b4e8607ecbda 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -1,38 +1,29 @@ /** @file Actions for a "new Data Link" modal. */ -import type * as test from 'playwright/test' +import type { Page } from '@playwright/test' import { TEXT } from '.' -import type * as baseActions from './BaseActions' -import BaseActions from './BaseActions' +import BaseActions, { type LocatorCallback } from './BaseActions' import DrivePageActions from './DrivePageActions' -// ============================== -// === locateNewDataLinkModal === -// ============================== - /** Locate the "new data link" modal. */ -function locateNewDataLinkModal(page: test.Page) { +function locateNewDataLinkModal(page: Page) { return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') }) } -// =============================== -// === NewDataLinkModalActions === -// =============================== - /** Actions for a "new Data Link" modal. */ -export default class NewDataLinkModalActions extends BaseActions { +export default class NewDataLinkModalActions extends BaseActions { /** Cancel creating the new Data Link (don't submit the form). */ - cancel() { + cancel(): DrivePageActions { return this.step('Cancel out of "new data link" modal', async () => { await this.press('Escape') - }).into(DrivePageActions) + }).into(DrivePageActions) } /** Interact with the "name" input - for example, to set the name using `.fill("")`. */ - withNameInput(callback: baseActions.LocatorCallback) { - return this.step('Interact with "name" input', async (page) => { + withNameInput(callback: LocatorCallback) { + return this.step('Interact with "name" input', async (page, context) => { const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder) - await callback(locator) + await callback(locator, context) }) } } diff --git a/app/gui/integration-test/dashboard/actions/PageActions.ts b/app/gui/integration-test/dashboard/actions/PageActions.ts index 614c15eeec5e..a7e55a31f440 100644 --- a/app/gui/integration-test/dashboard/actions/PageActions.ts +++ b/app/gui/integration-test/dashboard/actions/PageActions.ts @@ -1,21 +1,17 @@ /** @file Actions common to all pages. */ import BaseActions from './BaseActions' -import * as openUserMenuAction from './openUserMenuAction' -import * as userMenuActions from './userMenuActions' - -// =================== -// === PageActions === -// =================== +import { openUserMenuAction } from './openUserMenuAction' +import { userMenuActions } from './userMenuActions' /** Actions common to all pages. */ -export default class PageActions extends BaseActions { +export default class PageActions extends BaseActions { /** Actions related to the User Menu. */ get userMenu() { - return userMenuActions.userMenuActions(this.step.bind(this)) + return userMenuActions(this.step.bind(this)) } /** Open the User Menu. */ openUserMenu() { - return openUserMenuAction.openUserMenuAction(this.step.bind(this)) + return openUserMenuAction(this.step.bind(this)) } } diff --git a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts index dcdd3d8fc46e..00322177bc68 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -1,23 +1,19 @@ /** @file Available actions for the login page. */ -import * as test from '@playwright/test' +import { expect } from '@playwright/test' import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' -// ======================== -// === LoginPageActions === -// ======================== - /** Available actions for the login page. */ -export default class RegisterPageActions extends BaseActions { +export default class RegisterPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - login: (): LoginPageActions => + login: (): LoginPageActions => this.step("Go to 'login' page", async (page) => page.getByRole('link', { name: TEXT.alreadyHaveAnAccount, exact: true }).click(), - ).into(LoginPageActions), + ).into(LoginPageActions), } } @@ -25,7 +21,7 @@ export default class RegisterPageActions extends BaseActions { register(email = VALID_EMAIL, password = VALID_PASSWORD, confirmPassword = password) { return this.step('Reegister', () => this.registerInternal(email, password, confirmPassword), - ).into(LoginPageActions) + ).into(LoginPageActions) } /** Perform a failing login. */ @@ -55,11 +51,11 @@ export default class RegisterPageActions extends BaseActions { return next } else if (formError != null) { return next.step(`Expect form error to be '${formError}'`, async (page) => { - await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError) + await expect(page.getByTestId('form-submit-error')).toHaveText(formError) }) } else { return next.step('Expect no form error', async (page) => { - await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible() + await expect(page.getByTestId('form-submit-error')).not.toBeVisible() }) } } @@ -72,9 +68,9 @@ export default class RegisterPageActions extends BaseActions { } /** Interact with the email input. */ - withEmailInput(callback: LocatorCallback) { - return this.step('Interact with email input', async (page) => { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', async (page, context) => { + await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context) }) } @@ -95,6 +91,6 @@ export default class RegisterPageActions extends BaseActions { .getByRole('button', { name: TEXT.register, exact: true }) .getByText(TEXT.register) .click() - await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } } diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts new file mode 100644 index 000000000000..18bcc3118a74 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts @@ -0,0 +1,42 @@ +/** @file Actions for the "account" form in settings. */ +import { TEXT } from '.' +import type { LocatorCallback } from './BaseActions' +import type PageActions from './PageActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsFormActions from './SettingsFormActions' + +/** Actions for the "account" form in settings. */ +export default class SettingsAccountFormActions extends SettingsFormActions< + Context, + typeof SettingsAccountTabActions +> { + /** Create a {@link SettingsAccountFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsAccountTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.userAccountSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "name" input of this form. */ + fillName(name: string) { + return this.step("Fill 'name' input of 'account' form", (page) => + this.locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox').fill(name), + ) + } + + /** Interact with the "name" input of this form. */ + withName(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'), + context, + ), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts new file mode 100644 index 000000000000..ce8237a97d73 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -0,0 +1,37 @@ +/** @file Actions for the "account" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import SettingsAccountFormActions from './SettingsAccountFormActions' +import SettingsChangePasswordFormActions from './SettingsChangePasswordFormActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "account" tab of the "settings" page. */ +export default class SettingsAccountTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'account'> { + return goToSettingsTabActions(this.step.bind(this)) + } + + /** Manipulate the "account" form. */ + accountForm() { + return this.into(SettingsAccountFormActions) + } + + /** Manipulate the "change password" form. */ + changePasswordForm() { + return this.into(SettingsChangePasswordFormActions) + } + + /** Upload a profile picture. */ + uploadProfilePicture( + name: string, + content: WithImplicitCoercion, + mimeType: string, + ) { + return this.step('Upload account profile picture', async (page) => { + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByTestId('user-profile-picture-input').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) + }) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts new file mode 100644 index 000000000000..63665ffff627 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts @@ -0,0 +1,13 @@ +/** @file Actions for the "activity log" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "activity log" tab of the "settings" page. */ +export default class SettingsActivityLogShortcutsTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'activityLog'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts new file mode 100644 index 000000000000..7a5e1b68d0ed --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts @@ -0,0 +1,13 @@ +/** @file Actions for the "billing and plans" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "billing and plans" tab of the "settings" page. */ +export default class SettingsBillingAndPlansTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'billingAndPlans'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts new file mode 100644 index 000000000000..1799f210b609 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts @@ -0,0 +1,54 @@ +/** @file Actions for the "change password" form in settings. */ +import { TEXT } from '.' +import type PageActions from './PageActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsFormActions from './SettingsFormActions' + +/** Actions for the "change password" form in settings. */ +export default class SettingsChangePasswordFormActions extends SettingsFormActions< + Context, + typeof SettingsAccountTabActions +> { + /** Create a {@link SettingsChangePasswordFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsAccountTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.changePasswordSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "current password" input of this form. */ + fillCurrentPassword(name: string) { + return this.step("Fill 'current password' input of 'change password' form", (page) => + this.locate(page) + .getByLabel(TEXT.userCurrentPasswordSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "new password" input of this form. */ + fillNewPassword(name: string) { + return this.step("Fill 'new password' input of 'change password' form", (page) => + this.locate(page) + .getByLabel(new RegExp('^' + TEXT.userNewPasswordSettingsInput)) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "confirm new password" input of this form. */ + fillConfirmNewPassword(name: string) { + return this.step("Fill 'confirm new password' input of 'change password' form", (page) => + this.locate(page) + .getByLabel(TEXT.userConfirmNewPasswordSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts new file mode 100644 index 000000000000..c4c84a6fdfeb --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts @@ -0,0 +1,34 @@ +/** @file Actions for the "account" form in settings. */ +import type { Locator, Page } from '@playwright/test' +import { TEXT } from '.' +import type { BaseActionsClass } from './BaseActions' +import PageActions from './PageActions' + +/** Actions for the "account" form in settings. */ +export default class SettingsFormActions< + Context, + ParentClass extends BaseActionsClass, +> extends PageActions { + /** Construct a {@link SettingsFormActions}. */ + constructor( + private parentClass: ParentClass, + protected locate: (page: Page) => Locator, + ...args: ConstructorParameters> + ) { + super(...args) + } + + /** Save and submit this settings section. */ + save(): InstanceType { + return this.step('Save settings form', (page) => + this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.save).click(), + ).into(this.parentClass) + } + + /** Cancel editing this settings section. */ + cancel(): InstanceType { + return this.step('Cancel editing settings form', (page) => + this.locate(page).getByRole('button', { name: TEXT.cancel }).getByText(TEXT.cancel).click(), + ).into(this.parentClass) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts new file mode 100644 index 000000000000..efcf2c6d7b01 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts @@ -0,0 +1,13 @@ +/** @file Actions for the "keyboard shortcuts" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "keyboard shortcuts" tab of the "settings" page. */ +export default class SettingsKeyboardShortcutsTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'keyboardShortcuts'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts new file mode 100644 index 000000000000..c62afd835ccc --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts @@ -0,0 +1,11 @@ +/** @file Actions for the "local" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "local" tab of the "settings" page. */ +export default class SettingsLocalTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'local'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts new file mode 100644 index 000000000000..4145174927e8 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts @@ -0,0 +1,11 @@ +/** @file Actions for the "members" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "members" tab of the "settings" page. */ +export default class SettingsMembersTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'members'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts new file mode 100644 index 000000000000..a191e178da6d --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts @@ -0,0 +1,105 @@ +/** @file Actions for the "organization" form in settings. */ +import { TEXT } from '.' +import type { LocatorCallback } from './BaseActions' +import type PageActions from './PageActions' +import SettingsFormActions from './SettingsFormActions' +import SettingsOrganizationTabActions from './SettingsOrganizationTabActions' + +/** Actions for the "organization" form in settings. */ +export default class SettingsOrganizationFormActions extends SettingsFormActions< + Context, + typeof SettingsOrganizationTabActions +> { + /** Create a {@link SettingsOrganizationFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsOrganizationTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.organizationSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "name" input of this form. */ + fillName(name: string) { + return this.step("Fill 'name' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationNameSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Interact with the "name" input of this form. */ + withName(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'), + context, + ), + ) + } + + /** Fill the "email" input of this form. */ + fillEmail(name: string) { + return this.step("Fill 'email' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationEmailSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Interact with the "email" input of this form. */ + withEmail(callback: LocatorCallback) { + return this.step("Interact with 'email' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox'), + context, + ), + ) + } + + /** Fill the "website" input of this form. */ + fillWebsite(name: string) { + return this.step("Fill 'website' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationWebsiteSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Interact with the "website" input of this form. */ + withWebsite(callback: LocatorCallback) { + return this.step("Interact with 'website' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox'), + context, + ), + ) + } + + /** Fill the "location" input of this form. */ + fillLocation(name: string) { + return this.step("Fill 'location' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationLocationSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Interact with the "location" input of this form. */ + withLocation(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox'), + context, + ), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts new file mode 100644 index 000000000000..4fa95e58e5ec --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -0,0 +1,33 @@ +/** @file Actions for the "organization" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import SettingsOrganizationFormActions from './SettingsOrganizationFormActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "organization" tab of the "settings" page. */ +export default class SettingsOrganizationTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'organization'> { + return goToSettingsTabActions(this.step.bind(this)) + } + + /** Manipulate the "organization" form. */ + organizationForm() { + return this.into(SettingsOrganizationFormActions) + } + + /** Upload a profile picture. */ + uploadProfilePicture( + name: string, + content: WithImplicitCoercion, + mimeType: string, + ) { + return this.step('Upload organization profile picture', async (page) => { + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByTestId('organization-profile-picture-input').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) + }) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index 25a250fc4a57..fc426eb4c7a9 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -1,16 +1,10 @@ -/** @file Actions for the "settings" page. */ -import * as goToPageActions from './goToPageActions' -import PageActions from './PageActions' +/** @file Actions for the default tab of the "settings" page. */ +import SettingsAccountTabActions from './SettingsAccountTabActions' -// =========================== -// === SettingsPageActions === -// =========================== +/** Actions for the default tab of the "settings" page. */ +type SettingsPageActions = SettingsAccountTabActions -// TODO: split settings page actions into different classes for each settings tab. -/** Actions for the "settings" page. */ -export default class SettingsPageActions extends PageActions { - /** Actions for navigating to another page. */ - get goToPage(): Omit { - return goToPageActions.goToPageActions(this.step.bind(this)) - } -} +/** Actions for the default tab of the "settings" page. */ +const SettingsPageActions = SettingsAccountTabActions + +export default SettingsPageActions diff --git a/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts new file mode 100644 index 000000000000..afa4ba2970c0 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts @@ -0,0 +1,11 @@ +/** @file Actions for the "user groups" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' + +/** Actions for the "user groups" tab of the "settings" page. */ +export default class SettingsUserGroupsTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'userGroups'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts index ca417a883af4..22c8949bb2a5 100644 --- a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts @@ -3,19 +3,15 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' -// ============================ -// === SetupDonePageActions === -// ============================ - /** Actions for the fourth step of the "setup" page. */ -export default class SetupDonePageActions extends BaseActions { +export default class SetupDonePageActions extends BaseActions { /** Go to the drive page. */ get goToPage() { return { drive: () => this.step("Finish setup and go to 'drive' page", async (page) => { await page.getByText(TEXT.goToDashboard).click() - }).into(DrivePageActions), + }).into(DrivePageActions), } } } diff --git a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts index 062dce8c5fff..f62361aae329 100644 --- a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts @@ -3,24 +3,20 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupTeamPageActions from './SetupTeamPageActions' -// ============================== -// === SetupInvitePageActions === -// ============================== - /** Actions for the "invite users" step of the "setup" page. */ -export default class SetupInvitePageActions extends BaseActions { +export default class SetupInvitePageActions extends BaseActions { /** Invite users by email. */ inviteUsers(emails: string) { return this.step(`Invite users '${emails.split(/[ ;,]+/).join("', '")}'`, async (page) => { await page.getByLabel(TEXT.inviteEmailFieldLabel).getByRole('textbox').fill(emails) await page.getByText(TEXT.inviteSubmit).click() - }).into(SetupTeamPageActions) + }).into(SetupTeamPageActions) } /** Continue to the next step without inviting users. */ skipInvitingUsers() { return this.step('Skip inviting users in setup', async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupTeamPageActions) + }).into(SetupTeamPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts index 6f1a5eca6864..b3ab2bd6380e 100644 --- a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts @@ -3,12 +3,8 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupInvitePageActions from './SetupInvitePageActions' -// ==================================== -// === SetupOrganizationPageActions === -// ==================================== - /** Actions for the third step of the "setup" page. */ -export default class SetupOrganizationPageActions extends BaseActions { +export default class SetupOrganizationPageActions extends BaseActions { /** Set the organization name for this organization. */ setOrganizationName(organizationName: string) { return this.step(`Set organization name to '${organizationName}'`, async (page) => { @@ -17,6 +13,6 @@ export default class SetupOrganizationPageActions extends BaseActions { .and(page.getByRole('textbox')) .fill(organizationName) await page.getByText(TEXT.next).click() - }).into(SetupInvitePageActions) + }).into(SetupInvitePageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts index ecd0208a8b64..cb6d9f9e6325 100644 --- a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts @@ -6,12 +6,8 @@ import BaseActions from './BaseActions' import SetupDonePageActions from './SetupDonePageActions' import SetupOrganizationPageActions from './SetupOrganizationPageActions' -// ============================ -// === SetupPlanPageActions === -// ============================ - /** Actions for the "select plan" step of the "setup" page. */ -export default class SetupPlanPageActions extends BaseActions { +export default class SetupPlanPageActions extends BaseActions { /** Select a plan. */ selectSoloPlan() { return this.step(`Select 'solo' plan`, async (page) => { @@ -21,7 +17,7 @@ export default class SetupPlanPageActions extends BaseActions { .getByText(TEXT.licenseAgreementCheckbox) .click() await page.getByText(TEXT.startTrial).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } /** Select a plan that has teams. */ @@ -38,20 +34,20 @@ export default class SetupPlanPageActions extends BaseActions { .getByText(duration === 12 ? TEXT.billingPeriodOneYear : TEXT.billingPeriodThreeYears) .click() await page.getByText(TEXT.startTrial).click() - }).into(SetupOrganizationPageActions) + }).into(SetupOrganizationPageActions) } /** Stay on the current (free) plan. */ stayOnFreePlan() { return this.step(`Stay on current plan`, async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } /** Stay on the current (paid) plan. */ stayOnPaidPlan() { return this.step(`Stay on current plan`, async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupOrganizationPageActions) + }).into(SetupOrganizationPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts index fe2010d9b100..e51c60a74f1b 100644 --- a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts @@ -3,12 +3,8 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupDonePageActions from './SetupDonePageActions' -// ================================ -// === SetupTeamNamePageActions === -// ================================ - /** Actions for the "setup team name" page. */ -export default class SetupTeamNamePagePageActions extends BaseActions { +export default class SetupTeamNamePagePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ setTeamName(teamName: string) { return this.step(`Set team name to '${teamName}'`, async (page) => { @@ -17,6 +13,6 @@ export default class SetupTeamNamePagePageActions extends BaseActions { .and(page.getByRole('textbox')) .fill(teamName) await page.getByText(TEXT.next).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts index 0a91f27837b7..bdf608370e6f 100644 --- a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts @@ -3,17 +3,13 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupPlanPageActions from './SetupPlanPageActions' -// ================================ -// === SetupUsernamePageActions === -// ================================ - /** Actions for the "setup" page. */ -export default class SetupUsernamePageActions extends BaseActions { +export default class SetupUsernamePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ setUsername(username: string) { return this.step(`Set username to '${username}'`, async (page) => { await page.getByPlaceholder(TEXT.usernamePlaceholder).fill(username) await page.getByText(TEXT.next).click() - }).into(SetupPlanPageActions) + }).into(SetupPlanPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index 7d51704046d6..2322d6733fa5 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -1,36 +1,41 @@ /** @file Actions for the "home" page. */ -import * as test from '@playwright/test' -import * as actions from '.' -import BaseActions from './BaseActions' +import type { Page } from '@playwright/test' +import BaseActions, { type LocatorCallback } from './BaseActions' +import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' -// ========================= -// === StartModalActions === -// ========================= +/** Find a samples list. */ +function locateSamplesList(page: Page) { + // This has no identifying features. + return page.getByTestId('samples') +} + +/** Find all samples list. */ +function locateSamples(page: Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} /** Actions for the "start" modal. */ -export default class StartModalActions extends BaseActions { +export default class StartModalActions extends BaseActions { /** Close this modal and go back to the Drive page. */ - async close() { - const isOnScreen = await this.isStartModalShown() - - if (isOnScreen) { - return test.test.step('Close start modal', async () => { - await this.locateStartModal().getByTestId('close-button').click() - }) - } + close() { + return this.step('Close start modal', async (page) => { + const isOnScreen = await this.isStartModalShown(page) + if (isOnScreen) { + await this.locateStartModal(page).getByTestId('close-button').click() + } + }).into(DrivePageActions) } /** Locate the "start" modal. */ - locateStartModal() { - return this.page.getByTestId('start-modal') + private locateStartModal(page: Page) { + return page.getByTestId('start-modal') } - /** - * Check if the Asset Panel is shown. - */ - isStartModalShown() { - return this.locateStartModal() + /** Check if the Asset Panel is shown. */ + private isStartModalShown(page: Page) { + return this.locateStartModal(page) .isHidden() .then( (result) => !result, @@ -41,10 +46,16 @@ export default class StartModalActions extends BaseActions { /** Create a project from the template at the given index. */ createProjectFromTemplate(index: number) { return this.step(`Create project from template #${index}`, (page) => - actions - .locateSamples(page) + locateSamples(page) .nth(index + 1) .click(), - ).into(EditorPageActions) + ).into(EditorPageActions) + } + + /** Interact with the "start" modal. */ + withStartModal(callback: LocatorCallback) { + return this.step('Interact with start modal', async (page, context) => { + await callback(this.locateStartModal(page), context) + }) } } diff --git a/app/gui/integration-test/dashboard/api.ts b/app/gui/integration-test/dashboard/actions/api.ts similarity index 64% rename from app/gui/integration-test/dashboard/api.ts rename to app/gui/integration-test/dashboard/actions/api.ts index 496b3eebcbdf..8f12be78c4a3 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -10,12 +10,14 @@ import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' import * as uniqueString from 'enso-common/src/utilities/uniqueString' -import * as actions from './actions' +import * as actions from '.' +import type { FeatureFlags } from '#/providers/FeatureFlagsProvider' +import { organizationIdToDirectoryId } from '#/services/RemoteBackend' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } +import invariant from 'tiny-invariant' // ================= // === Constants === @@ -46,9 +48,9 @@ const HTTP_STATUS_NOT_FOUND = 404 /** A user id that is a path glob. */ const GLOB_USER_ID = backend.UserId('*') /** An asset ID that is a path glob. */ -const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*') +const GLOB_ASSET_ID: backend.AssetId = '*' as backend.DirectoryId /** A directory ID that is a path glob. */ -const GLOB_DIRECTORY_ID = backend.DirectoryId('*') +const GLOB_DIRECTORY_ID = '*' as backend.DirectoryId /** A project ID that is a path glob. */ const GLOB_PROJECT_ID = backend.ProjectId('*') /** A tag ID that is a path glob. */ @@ -58,9 +60,75 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*') const BASE_URL = 'https://mock/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' -// =============== -// === mockApi === -// =============== +function array(): Readonly[] { + return [] +} + +const INITIAL_CALLS_OBJECT = { + changePassword: array<{ oldPassword: string; newPassword: string }>(), + listDirectory: array<{ + parent_id?: string + filter_by?: backend.FilterBy + labels?: backend.LabelName[] + recent_projects?: boolean + }>(), + listFiles: array(), + listProjects: array(), + listSecrets: array(), + listTags: array(), + listUsers: array(), + listUserGroups: array(), + getProjectDetails: array<{ projectId: backend.ProjectId }>(), + copyAsset: array<{ assetId: backend.AssetId; parentId: backend.DirectoryId }>(), + listInvitations: array(), + inviteUser: array(), + createPermission: array(), + closeProject: array<{ projectId: backend.ProjectId }>(), + openProject: array<{ projectId: backend.ProjectId }>(), + deleteTag: array<{ tagId: backend.TagId }>(), + postLogEvent: array(), + uploadUserPicture: array<{ content: string }>(), + uploadOrganizationPicture: array<{ content: string }>(), + s3Put: array(), + uploadFileStart: array<{ uploadId: backend.FileId }>(), + uploadFileEnd: array(), + createSecret: array(), + createCheckoutSession: array(), + getCheckoutSession: array<{ + body: backend.CreateCheckoutSessionRequestBody + status: backend.CheckoutSessionStatus + }>(), + updateAsset: array<{ assetId: backend.AssetId } & backend.UpdateAssetRequestBody>(), + associateTag: array<{ assetId: backend.AssetId; labels: readonly backend.LabelName[] }>(), + updateDirectory: array< + { directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody + >(), + deleteAsset: array<{ assetId: backend.AssetId; force: boolean }>(), + undoDeleteAsset: array<{ assetId: backend.AssetId }>(), + createUser: array(), + createUserGroup: array(), + changeUserGroup: array<{ userId: backend.UserId } & backend.ChangeUserGroupRequestBody>(), + updateCurrentUser: array(), + usersMe: array(), + updateOrganization: array(), + getOrganization: array(), + createTag: array(), + createProject: array(), + createDirectory: array(), + getProjectContent: array<{ projectId: backend.ProjectId }>(), + getProjectAsset: array<{ projectId: backend.ProjectId }>(), + updateProject: array(), +} + +const READONLY_INITIAL_CALLS_OBJECT: TrackedCallsInternal = INITIAL_CALLS_OBJECT + +export { READONLY_INITIAL_CALLS_OBJECT as INITIAL_CALLS_OBJECT } + +type TrackedCallsInternal = { + [K in keyof typeof INITIAL_CALLS_OBJECT]: Readonly<(typeof INITIAL_CALLS_OBJECT)[K]> +} + +export interface TrackedCalls extends TrackedCallsInternal {} /** Parameters for {@link mockApi}. */ export interface MockParams { @@ -77,24 +145,10 @@ export interface SetupAPI { } /** The return type of {@link mockApi}. */ -export type MockApi = Awaited> +export interface MockApi extends Awaited> {} export const mockApi: (params: MockParams) => Promise = mockApiInternal -export const EULA_JSON = { - path: '/eula.md', - size: 9472, - modified: '2024-05-21T10:47:27.000Z', - hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', -} - -export const PRIVACY_JSON = { - path: '/privacy.md', - size: 1234, - modified: '2024-05-21T10:47:27.000Z', - hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', -} - /** Add route handlers for the mock API to a page. */ async function mockApiInternal({ page, setupAPI }: MockParams) { const defaultEmail = 'email@example.com' as backend.EmailAddress @@ -124,6 +178,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { website: null, subscription: {}, } + const callsObjects = new Set() let totalSeats = 1 // eslint-disable-next-line @typescript-eslint/no-unused-vars let subscriptionDuration = 0 @@ -137,7 +192,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const assetMap = new Map() const deletedAssets = new Set() - const assets: backend.AnyAsset[] = [] + let assets: backend.AnyAsset[] = [] const labels: backend.Label[] = [] const labelsByValue = new Map() const labelMap = new Map() @@ -158,11 +213,65 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly status: backend.CheckoutSessionStatus } >() + usersMap.set(defaultUser.userId, defaultUser) + function getParentPath(parentId: backend.DirectoryId, acc: string[] = []) { + const parent = assetMap.get(parentId) + + if (parent == null) { + return [parentId, ...acc].join('/') + } + + // this should never happen, but we need to check it for a case + invariant(parent.type === backend.AssetType.directory, 'Parent is not a directory') + + return getParentPath(parent.parentId, [parent.id, ...acc]) + } + + function getVirtualParentPath( + parentId: backend.DirectoryId, + _parentTitle: string, + acc: string[] = [], + ) { + const parent = assetMap.get(parentId) + + if (parent == null) { + return acc.join('/') + } + + // this should never happen, but we need to check it for a case + invariant(parent.type === backend.AssetType.directory, 'Parent is not a directory') + + return getVirtualParentPath(parent.parentId, parent.title, [parent.title, ...acc]) + } + + function trackCalls() { + const calls = structuredClone(INITIAL_CALLS_OBJECT) + callsObjects.add(calls) + return calls + } + + function pushToKey, Key extends keyof Object>( + object: Object, + key: Key, + item: Object[Key][number], + ) { + object[key].push(item) + } + + function called( + key: Key, + args: (typeof INITIAL_CALLS_OBJECT)[Key][number], + ) { + for (const callsObject of callsObjects) { + pushToKey(callsObject, key, args) + } + } + const addAsset = (asset: T) => { - assets.push(asset) assetMap.set(asset.id, asset) + assets = Array.from(assetMap.values()) return asset } @@ -170,16 +279,70 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const deleteAsset = (assetId: backend.AssetId) => { const alreadyDeleted = deletedAssets.has(assetId) deletedAssets.add(assetId) + return !alreadyDeleted } + const forceDeleteAsset = (assetId: backend.AssetId) => { + const hasAsset = assetMap.has(assetId) + deletedAssets.delete(assetId) + assetMap.delete(assetId) + assets.splice( + assets.findIndex((asset) => asset.id === assetId), + 1, + ) + return hasAsset + } + const undeleteAsset = (assetId: backend.AssetId) => { const wasDeleted = deletedAssets.has(assetId) deletedAssets.delete(assetId) return wasDeleted } + const editAsset = (assetId: backend.AssetId, rest: Partial) => { + const asset = assetMap.get(assetId) + + if (asset == null) { + throw new Error(`Asset ${assetId} not found`) + } + + const updated = object.merge(asset, rest) + + addAsset(updated) + + return updated + } + + const createUserPermission = ( + user: backend.User, + permission: permissions.PermissionAction = permissions.PermissionAction.own, + rest: Partial = {}, + ): backend.UserPermission => + object.merge( + { + user, + permission, + }, + rest, + ) + + const createUserGroupPermission = ( + userGroup: backend.UserGroupInfo, + permission: permissions.PermissionAction = permissions.PermissionAction.own, + rest: Partial = {}, + ): backend.UserGroupPermission => + object.merge( + { + userGroup, + permission, + }, + rest, + ) + const createDirectory = (rest: Partial = {}): backend.DirectoryAsset => { + const parentId = rest.parentId ?? defaultDirectoryId + const directoryTitles = new Set( assets .filter((asset) => asset.type === backend.AssetType.directory) @@ -188,33 +351,45 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const title = rest.title ?? `New Folder ${directoryTitles.size + 1}` - return object.merge( + const directory = object.merge( { type: backend.AssetType.directory, - id: backend.DirectoryId('directory-' + uniqueString.uniqueString()), + id: backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const), projectState: null, extension: null, title, modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], - parentId: defaultDirectoryId, - permissions: [ - { - user: { - organizationId: defaultOrganizationId, - userId: defaultUserId, - name: defaultUsername, - email: defaultEmail, - }, - permission: permissions.PermissionAction.own, - }, - ], + parentId, + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) + + Object.defineProperty(directory, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(directory, 'parentsPath', { + get: () => getParentPath(directory.parentId), + }) + + Object.defineProperty(directory, 'virtualParentsPath', { + get: () => getVirtualParentPath(directory.id, directory.title), + }) + + return directory } const createProject = (rest: Partial = {}): backend.ProjectAsset => { @@ -226,7 +401,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const title = rest.title ?? `New Project ${projectNames.size + 1}` - return object.merge( + const project = object.merge( { type: backend.AssetType.project, id: backend.ProjectId('project-' + uniqueString.uniqueString()), @@ -240,16 +415,37 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) + Object.defineProperty(project, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(project, 'parentsPath', { + get: () => getParentPath(project.parentId), + }) + + Object.defineProperty(project, 'virtualParentsPath', { + get: () => getVirtualParentPath(project.parentId, project.title), + }) + + return project } - const createFile = (rest: Partial = {}): backend.FileAsset => - object.merge( + const createFile = (rest: Partial = {}): backend.FileAsset => { + const file = object.merge( { type: backend.AssetType.file, id: backend.FileId('file-' + uniqueString.uniqueString()), @@ -260,15 +456,38 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) - const createSecret = (rest: Partial): backend.SecretAsset => - object.merge( + Object.defineProperty(file, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(file, 'parentsPath', { + get: () => getParentPath(file.parentId), + }) + + Object.defineProperty(file, 'virtualParentsPath', { + get: () => getVirtualParentPath(file.parentId, file.title), + }) + + return file + } + + const createSecret = (rest: Partial): backend.SecretAsset => { + const secret = object.merge( { type: backend.AssetType.secret, id: backend.SecretId('secret-' + uniqueString.uniqueString()), @@ -279,35 +498,104 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], + parentsPath: '', + virtualParentsPath: '', + }, + rest, + ) + + Object.defineProperty(secret, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(secret, 'parentsPath', { + get: () => getParentPath(secret.parentId), + }) + + Object.defineProperty(secret, 'virtualParentsPath', { + get: () => getVirtualParentPath(secret.parentId, secret.title), + }) + + return secret + } + + const createDatalink = (rest: Partial): backend.DatalinkAsset => { + const datalink = object.merge( + { + type: backend.AssetType.datalink, + id: backend.DatalinkId('datalink-' + uniqueString.uniqueString()), + projectState: null, + extension: null, + title: rest.title ?? '', + modifiedAt: dateTime.toRfc3339(new Date()), + description: rest.description ?? '', + labels: [], + parentId: defaultDirectoryId, + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) + Object.defineProperty(datalink, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(datalink, 'parentsPath', { + get: () => getParentPath(datalink.parentId), + }) + + Object.defineProperty(datalink, 'virtualParentsPath', { + get: () => getVirtualParentPath(datalink.parentId, datalink.title), + }) + + return datalink + } + const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ id: backend.TagId('tag-' + uniqueString.uniqueString()), value: backend.LabelName(value), color, }) - const addDirectory = (rest: Partial) => { + const addDirectory = (rest: Partial = {}) => { return addAsset(createDirectory(rest)) } - const addProject = (rest: Partial) => { + const addProject = (rest: Partial = {}) => { return addAsset(createProject(rest)) } - const addFile = (rest: Partial) => { + const addFile = (rest: Partial = {}) => { return addAsset(createFile(rest)) } - const addSecret = (rest: Partial) => { + const addSecret = (rest: Partial = {}) => { return addAsset(createSecret(rest)) } + const addDatalink = (rest: Partial = {}) => { + return addAsset(createDatalink(rest)) + } + const addLabel = (value: string, color: backend.LChColor) => { const label = createLabel(value, color) labels.push(label) @@ -316,7 +604,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return label } - const setLabels = (id: backend.AssetId, newLabels: backend.LabelName[]) => { + const setLabels = (id: backend.AssetId, newLabels: readonly backend.LabelName[]) => { const ids = new Set([id]) for (const [innerId, asset] of assetMap) { if (ids.has(asset.parentId)) { @@ -357,7 +645,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { name, email: backend.EmailAddress(`${name}@example.org`), organizationId, - rootDirectoryId: backend.DirectoryId(organizationId.replace(/^organization-/, 'directory-')), + rootDirectoryId: organizationIdToDirectoryId(organizationId), isEnabled: true, userGroups: null, plan: backend.Plan.enterprise, @@ -382,7 +670,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const addUserGroup = (name: string, rest?: Partial) => { const userGroup: backend.UserGroupInfo = { - id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}`), + id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}` as const), groupName: name, organizationId: currentOrganization?.id ?? defaultOrganizationId, ...rest, @@ -451,56 +739,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const patch = method('PATCH') const delete_ = method('DELETE') - await page.route('https://cdn.enso.org/**', (route) => route.fulfill()) - await page.route('https://www.google-analytics.com/**', (route) => route.fulfill()) - await page.route('https://www.googletagmanager.com/gtag/js*', (route) => - route.fulfill({ contentType: 'text/javascript', body: 'export {};' }), - ) - - if (process.env.MOCK_ALL_URLS === 'true') { - await page.route( - 'https://api.github.com/repos/enso-org/enso/releases/latest', - async (route) => { - await route.fulfill({ json: LATEST_GITHUB_RELEASES }) - }, - ) - await page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { - await route.fulfill({ - status: 302, - headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, - }) - }) - - await page.route('https://objects.githubusercontent.com/**', async (route) => { - await route.fulfill({ - status: 200, - headers: { - 'content-type': 'application/octet-stream', - 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', - etag: '"0x8DCAC053D058EA5"', - server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', - 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', - 'x-ms-version': '2020-10-02', - 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', - 'x-ms-lease-status': 'unlocked', - 'x-ms-lease-state': 'available', - 'x-ms-blob-type': 'BlockBlob', - 'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', - 'x-ms-server-encrypted': 'true', - via: '1.1 varnish, 1.1 varnish', - 'accept-ranges': 'bytes', - age: '1217', - date: 'Mon, 29 Jul 2024 09:40:09 GMT', - 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', - 'x-cache': 'HIT, HIT', - 'x-cache-hits': '48, 0', - 'x-timer': 'S1722246008.269342,VS0,VE895', - 'content-length': '1030383958', - }, - }) - }) - } - await page.route(BASE_URL + '**', (_route, request) => { throw new Error( `Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`, @@ -519,6 +757,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly newPassword: string } const body: Body = await request.postDataJSON() + called('changePassword', body) if (body.oldPassword === currentPassword) { currentPassword = body.newPassword await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) @@ -530,7 +769,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // === Endpoints returning arrays === - await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (_route, request) => { + await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (route, request) => { /** The type for the search query for this endpoint. */ interface Query { readonly parent_id?: string @@ -538,20 +777,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly labels?: backend.LabelName[] readonly recent_projects?: boolean } - const body = Object.fromEntries( + const query = Object.fromEntries( new URL(request.url()).searchParams.entries(), ) as unknown as Query - const parentId = body.parent_id ?? defaultDirectoryId + called('listDirectory', query) + const parentId = query.parent_id ?? defaultDirectoryId let filteredAssets = assets.filter((asset) => asset.parentId === parentId) // This lint rule is broken; there is clearly a case for `undefined` below. - switch (body.filter_by) { + switch (query.filter_by) { case backend.FilterBy.active: { filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id)) break } case backend.FilterBy.trashed: { - filteredAssets = filteredAssets.filter((asset) => deletedAssets.has(asset.id)) + filteredAssets = assets.filter((asset) => deletedAssets.has(asset.id)) break } case backend.FilterBy.recent: { @@ -573,21 +813,26 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { ) const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets } - return json + route.fulfill({ json }) }) await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { + called('listFiles', {}) return { files: [] } satisfies remoteBackend.ListFilesResponseBody }) await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => { + called('listProjects', {}) return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody }) await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => { + called('listSecrets', {}) return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody }) await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => { + called('listTags', {}) return { tags: labels } satisfies remoteBackend.ListTagsResponseBody }) await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { + called('listUsers', {}) if (currentUser != null) { return { users } satisfies remoteBackend.ListUsersResponseBody } else { @@ -596,28 +841,16 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => { + called('listUserGroups', {}) await route.fulfill({ json: userGroups }) }) - await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({ - versions: [ - { - ami: null, - created: dateTime.toRfc3339(new Date()), - number: { - lifecycle: - 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, - value: '2023.2.1-dev', - }, - // eslint-disable-next-line camelcase - version_type: (new URL(request.url()).searchParams.get('version_type') ?? - '') as backend.VersionType, - } satisfies backend.Version, - ], - })) // === Endpoints with dummy implementations === await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('getProjectDetails', { projectId }) const project = assetMap.get(projectId) if (!project) { @@ -661,11 +894,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly parentDirectoryId: backend.DirectoryId } - const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] + const maybeId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] + if (!maybeId) return + const assetId = maybeId != null ? (decodeURIComponent(maybeId) as backend.DirectoryId) : null // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const asset = - assetId != null ? assetMap.get(backend.DirectoryId(decodeURIComponent(assetId))) : null + const asset = assetId != null ? assetMap.get(assetId) : null if (asset == null) { if (assetId == null) { await route.fulfill({ @@ -681,8 +915,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } else { const body: Body = request.postDataJSON() const parentId = body.parentDirectoryId + called('copyAsset', { assetId: assetId!, parentId }) // Can be any asset ID. - const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`) + const id = `${assetId?.split('-')[0]}-${uniqueString.uniqueString()}` as backend.DirectoryId + const json: backend.CopyAssetResponse = { asset: { id, @@ -701,22 +937,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => { + called('listInvitations', {}) return { invitations: [], availableLicenses: totalSeats - usersMap.size, } }) await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => { + called('inviteUser', {}) await route.fulfill() }) await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => { - await route.fulfill() - }) - await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route) => { + called('createPermission', {}) await route.fulfill() }) await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('closeProject', { projectId }) const project = assetMap.get(projectId) if (project?.projectState) { object.unsafeMutable(project.projectState).type = backend.ProjectState.closed @@ -724,7 +963,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill() }) await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('openProject', { projectId }) const project = assetMap.get(projectId) @@ -740,10 +982,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { route.fulfill() }) - await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => { + await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const tagId = backend.TagId(maybeId) + called('deleteTag', { tagId }) await route.fulfill() }) await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => { + called('postLogEvent', {}) await route.fulfill() }) @@ -752,6 +999,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { + called('uploadUserPicture', { content }) currentProfilePicture = content return null } else { @@ -762,6 +1010,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { + called('uploadOrganizationPicture', { content }) currentOrganizationProfilePicture = content return null } else { @@ -771,6 +1020,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => { if (request.method() !== 'PUT') { + called('s3Put', {}) await route.fallback() } else { await route.fulfill({ @@ -782,9 +1032,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => { + const uploadId = backend.FileId('file-' + uniqueString.uniqueString()) + called('uploadFileStart', { uploadId }) return { sourcePath: backend.S3FilePath(''), - uploadId: 'file-' + uniqueString.uniqueString(), + uploadId, presignedUrls: Array.from({ length: 10 }, () => backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`), ), @@ -792,6 +1044,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => { const body: backend.UploadFileEndRequestBody = request.postDataJSON() + called('uploadFileEnd', body) const file = addFile({ id: backend.FileId(body.uploadId), @@ -804,9 +1057,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { const body: backend.CreateSecretRequestBody = await request.postDataJSON() - const secret = addSecret({ - title: body.name, - }) + called('createSecret', body) + const secret = addSecret({ title: body.name }) return secret.id }) @@ -814,6 +1066,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => { const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON() + called('createCheckoutSession', body) return createCheckoutSession(body) }) await get( @@ -825,6 +1078,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } else { const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId)) if (result) { + called('getCheckoutSession', result) if (currentUser) { object.unsafeMutable(currentUser).plan = result.body.plan } @@ -837,12 +1091,20 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }, ) - await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => { - const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' - const body: backend.UpdateAssetRequestBody = request.postDataJSON() + + await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (route, request) => { + const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] + + if (!maybeId) throw new Error('updateAssetPath: Missing asset ID in path') // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const asset = assetMap.get(backend.DirectoryId(assetId)) + const assetId = maybeId as backend.DirectoryId + const body: backend.UpdateAssetRequestBody = request.postDataJSON() + + called('updateAsset', { ...body, assetId }) + + const asset = assetMap.get(assetId) + if (asset != null) { if (body.description != null) { object.unsafeMutable(asset).description = body.description @@ -852,21 +1114,27 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { object.unsafeMutable(asset).parentId = body.parentDirectoryId } } + + return route.fulfill({ json: asset }) }) + await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { - const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? '' + const maybeId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] + if (!maybeId) return + // This could be an id for an arbitrary asset, but pretend it's a + // `DirectoryId` to make TypeScript happy. + const assetId = maybeId as backend.DirectoryId /** The type for the JSON request payload for this endpoint. */ interface Body { - readonly labels: backend.LabelName[] + readonly labels: readonly backend.LabelName[] } /** The type for the JSON response payload for this endpoint. */ interface Response { - readonly tags: backend.Label[] + readonly tags: readonly backend.Label[] } const body: Body = await request.postDataJSON() - // This could be an id for an arbitrary asset, but pretend it's a - // `DirectoryId` to make TypeScript happy. - setLabels(backend.DirectoryId(assetId), body.labels) + called('associateTag', { ...body, assetId }) + setLabels(assetId, body.labels) const json: Response = { tags: body.labels.flatMap((value) => { const label = labelsByValue.get(value) @@ -875,45 +1143,89 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } return json }) + await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { - const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? '' + const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] + if (!maybeId) return + const directoryId = maybeId as backend.DirectoryId const body: backend.UpdateDirectoryRequestBody = request.postDataJSON() - const asset = assetMap.get(backend.DirectoryId(directoryId)) + called('updateDirectory', { ...body, directoryId }) + const asset = assetMap.get(directoryId) if (asset == null) { await route.abort() } else { object.unsafeMutable(asset).title = body.title await route.fulfill({ json: { - id: backend.DirectoryId(directoryId), + id: directoryId, parentId: asset.parentId, title: body.title, } satisfies backend.UpdatedDirectory, }) } }) + await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { - const assetId = decodeURIComponent(request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '') + const force = new URL(request.url()).searchParams.get('force') === 'true' + const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] + + if (!maybeId) return + // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - deleteAsset(backend.DirectoryId(assetId)) + const assetId = decodeURIComponent(maybeId) as backend.DirectoryId + + called('deleteAsset', { assetId, force }) + + if (force) { + forceDeleteAsset(assetId) + } else { + deleteAsset(assetId) + } + await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) + await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { /** The type for the JSON request payload for this endpoint. */ interface Body { readonly assetId: backend.AssetId } const body: Body = await request.postDataJSON() + called('undoDeleteAsset', body) undeleteAsset(body.assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) + + await put(remoteBackendPaths.projectUpdatePath(GLOB_PROJECT_ID), async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + + if (!maybeId) return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) + + const projectId = backend.ProjectId(maybeId) + + const body: backend.UpdateProjectRequestBody = await request.postDataJSON() + + called('updateProject', body) + + const newTitle = body.projectName + + if (newTitle == null) { + return route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) + } + + return route.fulfill({ + json: editAsset(projectId, { title: newTitle }), + }) + }) + await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (_route, request) => { const body: backend.CreateUserRequestBody = await request.postDataJSON() + const organizationId = body.organizationId ?? defaultUser.organizationId - const rootDirectoryId = backend.DirectoryId( - organizationId.replace(/^organization-/, 'directory-'), - ) + const rootDirectoryId = organizationIdToDirectoryId(organizationId) + called('createUser', body) + currentUser = { email: body.userEmail, name: body.userName, @@ -926,19 +1238,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } return currentUser }) + await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => { const body: backend.CreateUserGroupRequestBody = await request.postDataJSON() + called('createUserGroup', body) const userGroup = addUserGroup(body.name) return userGroup }) + await put( remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*', async (route, request) => { - const userId = backend.UserId( - decodeURIComponent(request.url().match(/[/]users[/]([^?/]+)/)?.[1] ?? ''), - ) + const maybeId = request.url().match(/[/]users[/]([^?/]+)/)?.[1] + if (!maybeId) return + const userId = backend.UserId(decodeURIComponent(maybeId)) // The type of the body sent by this app is statically known. const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON() + called('changeUserGroup', { userId, ...body }) const user = usersMap.get(userId) if (!user) { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) @@ -950,11 +1266,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { ) await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => { const body: backend.UpdateUserRequestBody = await request.postDataJSON() + called('updateCurrentUser', body) if (currentUser && body.username != null) { currentUser = { ...currentUser, name: body.username } } }) await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => { + called('usersMe', {}) if (currentUser == null) { return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) } else { @@ -963,6 +1281,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON() + called('updateOrganization', body) if (body.name === '') { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, @@ -978,6 +1297,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => { + called('getOrganization', {}) await route.fulfill({ json: currentOrganization, status: currentOrganization == null ? 404 : 200, @@ -985,13 +1305,16 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => { const body: backend.CreateTagRequestBody = route.request().postDataJSON() + called('createTag', body) return addLabel(body.value, body.color) }) await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => { const body: backend.CreateProjectRequestBody = request.postDataJSON() + called('createProject', body) const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const parentId = - body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + body.parentDirectoryId ?? + backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const) const state = { type: backend.ProjectState.closed, volumeId: '' } @@ -1028,26 +1351,17 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => { const body: backend.CreateDirectoryRequestBody = request.postDataJSON() - const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + + called('createDirectory', body) + + const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const) const parentId = body.parentId ?? defaultDirectoryId const directory = addDirectory({ description: null, id, labels: [], - modifiedAt: dateTime.toRfc3339(new Date()), parentId, - permissions: [ - { - user: { - organizationId: defaultOrganizationId, - userId: defaultUserId, - name: defaultUsername, - email: defaultEmail, - }, - permission: permissions.PermissionAction.own, - }, - ], projectState: null, }) @@ -1058,8 +1372,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) - await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route) => { - const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8') + await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('getProjectContent', { projectId }) + const content = readFileSync(join(__dirname, '../mock/enso-demo.main'), 'utf8') return route.fulfill({ body: content, @@ -1067,13 +1385,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) }) - await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route) => { - return route.fulfill({ - // This is a mock SVG image. Just a square with a black background. - body: '/mock/svg.svg', - contentType: 'text/plain', - }) - }) + await get( + remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), + async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + + invariant(maybeId, 'Unable to parse the ID provided') + + const projectId = backend.ProjectId(maybeId) + + called('getProjectAsset', { projectId }) + + return route.fulfill({ + // This is a mock SVG image. Just a square with a black background. + path: join(__dirname, '../mock/example.png'), + }) + }, + ) await page.route('mock/svg.svg', (route) => { return route.fulfill({ body: MOCK_SVG, contentType: 'image/svg+xml' }) @@ -1099,6 +1427,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { defaultUser, defaultUserId, rootDirectoryId: defaultDirectoryId, + get assetCount() { + return assetMap.size + }, goOffline: () => { isOnline = false }, @@ -1123,15 +1454,18 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { currentOrganizationProfilePicture: () => currentOrganizationProfilePicture, addAsset, deleteAsset, + editAsset, undeleteAsset, createDirectory, createProject, createFile, createSecret, + createDatalink, addDirectory, addProject, addFile, addSecret, + addDatalink, createLabel, addLabel, setLabels, @@ -1140,11 +1474,27 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { deleteUser, addUserGroup, deleteUserGroup, + createUserPermission, + createUserGroupPermission, + setFeatureFlags: (flags: Partial) => { + return page.addInitScript((flags: Partial) => { + const currentOverrideFeatureFlags = + 'overrideFeatureFlags' in window && typeof window.overrideFeatureFlags === 'object' ? + window.overrideFeatureFlags + : {} + + Object.defineProperty(window, 'overrideFeatureFlags', { + value: { ...currentOverrideFeatureFlags, ...flags }, + writable: false, + }) + }, flags) + }, // TODO: // addPermission, // deletePermission, addUserGroupToUser, removeUserGroupFromUser, + trackCalls, } as const if (setupAPI) { diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index a9e443b36ead..a7fa28156008 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -1,28 +1,24 @@ /** @file Actions for the context menu. */ import { TEXT } from '.' -import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import EditorPageActions from './EditorPageActions' -// ========================== -// === ContextMenuActions === -// ========================== - /** Actions for the context menu. */ -export interface ContextMenuActions { +export interface ContextMenuActions, Context> { readonly open: () => T readonly uploadToCloud: () => T readonly rename: () => T readonly snapshot: () => T readonly moveNonFolderToTrash: () => T readonly moveFolderToTrash: () => T - readonly moveAllToTrash: () => T + readonly moveAllToTrash: (confirm?: boolean) => T readonly restoreFromTrash: () => T readonly restoreAllFromTrash: () => T readonly share: () => T readonly label: () => T readonly duplicate: () => T - readonly duplicateProject: () => EditorPageActions + readonly duplicateProject: () => EditorPageActions readonly copy: () => T readonly cut: () => T readonly paste: () => T @@ -34,14 +30,10 @@ export interface ContextMenuActions { readonly newDataLink: () => T } -// ========================== -// === contextMenuActions === -// ========================== - /** Generate actions for the context menu. */ -export function contextMenuActions( - step: (name: string, callback: baseActions.PageCallback) => T, -): ContextMenuActions { +export function contextMenuActions, Context>( + step: (name: string, callback: PageCallback) => T, +): ContextMenuActions { return { open: () => step('Open (context menu)', (page) => @@ -69,27 +61,32 @@ export function contextMenuActions( .click(), ), moveNonFolderToTrash: () => - step('Move to trash (context menu)', (page) => - page + step('Move to trash (context menu)', async (page) => { + await page .getByRole('button', { name: TEXT.moveToTrashShortcut }) .getByText(TEXT.moveToTrashShortcut) - .click(), - ), + .click() + }), moveFolderToTrash: () => step('Move folder to trash (context menu)', async (page) => { await page .getByRole('button', { name: TEXT.moveToTrashShortcut }) .getByText(TEXT.moveToTrashShortcut) .click() + + // Confirm the deletion in the dialog await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() }), - moveAllToTrash: () => - step('Move all to trash (context menu)', (page) => - page + moveAllToTrash: (hasFolder = false) => + step('Move all to trash (context menu)', async (page) => { + await page .getByRole('button', { name: TEXT.moveAllToTrashShortcut }) .getByText(TEXT.moveAllToTrashShortcut) - .click(), - ), + .click() + if (hasFolder) { + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + } + }), restoreFromTrash: () => step('Restore from trash (context menu)', (page) => page @@ -131,7 +128,7 @@ export function contextMenuActions( .getByRole('button', { name: TEXT.duplicateShortcut }) .getByText(TEXT.duplicateShortcut) .click(), - ).into(EditorPageActions), + ).into(EditorPageActions), copy: () => step('Copy (context menu)', (page) => page diff --git a/app/gui/integration-test/dashboard/actions/goToPageActions.ts b/app/gui/integration-test/dashboard/actions/goToPageActions.ts index ff054a1a4b20..2101676935f7 100644 --- a/app/gui/integration-test/dashboard/actions/goToPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/goToPageActions.ts @@ -1,29 +1,21 @@ /** @file Actions for going to a different page. */ -import type * as baseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' import SettingsPageActions from './SettingsPageActions' -// ======================= -// === GoToPageActions === -// ======================= - /** Actions for going to a different page. */ -export interface GoToPageActions { - readonly drive: () => DrivePageActions - readonly editor: () => EditorPageActions - readonly settings: () => SettingsPageActions +export interface GoToPageActions { + readonly drive: () => DrivePageActions + readonly editor: () => EditorPageActions + readonly settings: () => SettingsPageActions } -// ======================= -// === goToPageActions === -// ======================= - /** Generate actions for going to a different page. */ -export function goToPageActions( - step: (name: string, callback: baseActions.PageCallback) => BaseActions, -): GoToPageActions { +export function goToPageActions( + step: (name: string, callback: PageCallback) => BaseActions, +): GoToPageActions { return { drive: () => step('Go to "Data Catalog" page', (page) => @@ -31,14 +23,14 @@ export function goToPageActions( .getByRole('tab') .filter({ has: page.getByText('Data Catalog') }) .click(), - ).into(DrivePageActions), + ).into(DrivePageActions), editor: () => step('Go to "Spatial Analysis" page', (page) => page.getByTestId('editor-tab-button').click(), - ).into(EditorPageActions), + ).into(EditorPageActions), settings: () => step('Go to "settings" page', (page) => BaseActions.press(page, 'Mod+,')).into( - SettingsPageActions, + SettingsPageActions, ), } } diff --git a/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts new file mode 100644 index 000000000000..ca92c3adf93f --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts @@ -0,0 +1,88 @@ +/** @file Actions for going to a different page. */ +import { TEXT } from '.' +import type { PageCallback } from './BaseActions' +import BaseActions from './BaseActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsActivityLogShortcutsTabActions from './SettingsActivityLogTabActions' +import SettingsBillingAndPlansTabActions from './SettingsBillingAndPlansTabActions' +import SettingsKeyboardShortcutsTabActions from './SettingsKeyboardShortcutsTabActions' +import SettingsLocalTabActions from './SettingsLocalTabActions' +import SettingsMembersTabActions from './SettingsMembersTabActions' +import SettingsOrganizationTabActions from './SettingsOrganizationTabActions' +import SettingsUserGroupsTabActions from './SettingsUserGroupsTabActions' + +/** Actions for going to a different settings tab. */ +export interface GoToSettingsTabActions { + readonly account: () => SettingsAccountTabActions + readonly organization: () => SettingsOrganizationTabActions + readonly local: () => SettingsLocalTabActions + readonly billingAndPlans: () => SettingsBillingAndPlansTabActions + readonly members: () => SettingsMembersTabActions + readonly userGroups: () => SettingsUserGroupsTabActions + readonly keyboardShortcuts: () => SettingsKeyboardShortcutsTabActions + readonly activityLog: () => SettingsActivityLogShortcutsTabActions +} + +/** Generate actions for going to a different page. */ +export function goToSettingsTabActions( + step: (name: string, callback: PageCallback) => BaseActions, +): GoToSettingsTabActions { + return { + account: () => + step('Go to "account" settings tab', (page) => + page + .getByRole('button', { name: TEXT.accountSettingsTab }) + .getByText(TEXT.accountSettingsTab) + .click(), + ).into(SettingsAccountTabActions), + organization: () => + step('Go to "organization" settings tab', (page) => + page + .getByRole('button', { name: TEXT.organizationSettingsTab }) + .getByText(TEXT.organizationSettingsTab) + .click(), + ).into(SettingsOrganizationTabActions), + local: () => + step('Go to "local" settings tab', (page) => + page + .getByRole('button', { name: TEXT.localSettingsTab }) + .getByText(TEXT.localSettingsTab) + .click(), + ).into(SettingsLocalTabActions), + billingAndPlans: () => + step('Go to "billing and plans" settings tab', (page) => + page + .getByRole('button', { name: TEXT.billingAndPlansSettingsTab }) + .getByText(TEXT.billingAndPlansSettingsTab) + .click(), + ).into(SettingsBillingAndPlansTabActions), + members: () => + step('Go to "members" settings tab', (page) => + page + .getByRole('button', { name: TEXT.membersSettingsTab }) + .getByText(TEXT.membersSettingsTab) + .click(), + ).into(SettingsMembersTabActions), + userGroups: () => + step('Go to "user groups" settings tab', (page) => + page + .getByRole('button', { name: TEXT.userGroupsSettingsTab }) + .getByText(TEXT.userGroupsSettingsTab) + .click(), + ).into(SettingsUserGroupsTabActions), + keyboardShortcuts: () => + step('Go to "keyboard shortcuts" settings tab', (page) => + page + .getByRole('button', { name: TEXT.keyboardShortcutsSettingsTab }) + .getByText(TEXT.keyboardShortcutsSettingsTab) + .click(), + ).into(SettingsKeyboardShortcutsTabActions), + activityLog: () => + step('Go to "activity log" settings tab', (page) => + page + .getByRole('button', { name: TEXT.activityLogSettingsTab }) + .getByText(TEXT.activityLogSettingsTab) + .click(), + ).into(SettingsActivityLogShortcutsTabActions), + } +} diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 3c14d8c4f585..a83e3e37fad2 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -1,18 +1,23 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ -import * as test from '@playwright/test' -import { TEXTS } from 'enso-common/src/text' +import { TEXTS, getText as baseGetText, type Replacements, type TextId } from 'enso-common/src/text' import path from 'node:path' -import * as apiModule from '../api' + +import { expect, test, type Page } from '@playwright/test' + +import { + INITIAL_CALLS_OBJECT, + mockApi, + type MockApi, + type SetupAPI, + type TrackedCalls, +} from './api' import DrivePageActions from './DrivePageActions' +import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } import LoginPageActions from './LoginPageActions' import StartModalActions from './StartModalActions' -// ================= -// === Constants === -// ================= - /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' /** An example password that meets validation requirements. */ @@ -21,662 +26,10 @@ export const VALID_PASSWORD = 'Password0!' export const VALID_EMAIL = 'email@example.com' export const TEXT = TEXTS.english -// ================ -// === Locators === -// ================ - -// === Input locators === - -/** Find an email input (if any) on the current page. */ -export function locateEmailInput(page: test.Locator | test.Page) { - return page.getByPlaceholder('Enter your email') -} - -/** Find a password input (if any) on the current page. */ -export function locatePasswordInput(page: test.Locator | test.Page) { - return page.getByPlaceholder('Enter your password') -} - -/** Find a "confirm password" input (if any) on the current page. */ -export function locateConfirmPasswordInput(page: test.Locator | test.Page) { - return page.getByPlaceholder('Confirm your password') -} - -/** Find a "name" input for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalNameInput(page: test.Page) { - return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) -} - -/** Find all color radio button inputs for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalColorButtons(page: test.Page) { - return ( - locateNewLabelModal(page) - .filter({ has: page.getByText('Color') }) - // The `radio` inputs are invisible, so they cannot be used in the locator. - .locator('label[data-rac]') - ) -} - -/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretNameInput(page: test.Page) { - return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder) -} - -/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretValueInput(page: test.Page) { - return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) -} - -/** Find a search bar input (if any) on the current page. */ -export function locateSearchBarInput(page: test.Page) { - return locateSearchBar(page).getByPlaceholder(/(?:)/) -} - -/** Find the name column of the given assets table row. */ -export function locateAssetRowName(locator: test.Locator) { - return locator.getByTestId('asset-row-name') -} - -// === Button locators === - -/** Find a "login" button (if any) on the current locator. */ -export function locateLoginButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') -} - -/** Find a "register" button (if any) on the current locator. */ -export function locateRegisterButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Register' }).getByText('Register') -} - -/** Find a "create" button (if any) on the current page. */ -export function locateCreateButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Create' }).getByText('Create') -} - -/** Find a button to open the editor (if any) on the current page. */ -export function locatePlayOrOpenProjectButton(page: test.Locator | test.Page) { - return page.getByLabel('Open in editor') -} - -/** Find a button to close the project (if any) on the current page. */ -export function locateStopProjectButton(page: test.Locator | test.Page) { - return page.getByLabel('Stop execution') -} - -/** Close a modal. */ -export function closeModal(page: test.Page) { - return test.test.step('Close modal', async () => { - await page.getByLabel('Close').click() - }) -} - -/** Find all labels in the labels panel (if any) on the current page. */ -export function locateLabelsPanelLabels(page: test.Page, name?: string) { - return ( - locateLabelsPanel(page) - .getByRole('button') - .filter(name != null ? { has: page.getByText(name) } : {}) - // The delete button is also a `button`. - .and(page.locator(':nth-child(1)')) - ) -} - -/** Find a tick button (if any) on the current page. */ -export function locateEditingTick(page: test.Locator | test.Page) { - return page.getByLabel('Confirm Edit') -} - -/** Find a cross button (if any) on the current page. */ -export function locateEditingCross(page: test.Locator | test.Page) { - return page.getByLabel('Cancel Edit') -} - -/** Find labels in the "Labels" column of the assets table (if any) on the current page. */ -export function locateAssetLabels(page: test.Locator | test.Page) { - return page.getByTestId('asset-label') -} - -/** Find a toggle for the "Name" column (if any) on the current page. */ -export function locateNameColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Name') -} - -/** Find a toggle for the "Modified" column (if any) on the current page. */ -export function locateModifiedColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Modified') -} - -/** Find a toggle for the "Shared with" column (if any) on the current page. */ -export function locateSharedWithColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Shared With') -} - -/** Find a toggle for the "Labels" column (if any) on the current page. */ -export function locateLabelsColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Labels') -} - -/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ -export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Accessed By Projects') -} - -/** Find a toggle for the "Accessed data" column (if any) on the current page. */ -export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Accessed Data') -} - -/** Find a toggle for the "Docs" column (if any) on the current page. */ -export function locateDocsColumnToggle(page: test.Locator | test.Page) { - return page.getByLabel('Docs') -} - -/** Find a button for the "Recent" category (if any) on the current page. */ -export function locateRecentCategory(page: test.Locator | test.Page) { - return page.getByLabel('Recent').locator('visible=true') -} - -/** Find a button for the "Home" category (if any) on the current page. */ -export function locateHomeCategory(page: test.Locator | test.Page) { - return page.getByLabel('Home').locator('visible=true') -} - -/** Find a button for the "Trash" category (if any) on the current page. */ -export function locateTrashCategory(page: test.Locator | test.Page) { - return page.getByLabel('Trash').locator('visible=true') -} - -// === Other buttons === - -/** Find a "new label" button (if any) on the current page. */ -export function locateNewLabelButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'new label' }).getByText('new label') -} - -/** Find an "upgrade" button (if any) on the current page. */ -export function locateUpgradeButton(page: test.Locator | test.Page) { - return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade').first() -} - -/** Find a not enabled stub view (if any) on the current page. */ -export function locateNotEnabledStub(page: test.Locator | test.Page) { - return page.getByTestId('not-enabled-stub') -} - -/** Find a "new folder" icon (if any) on the current page. */ -export function locateNewFolderIcon(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'New Folder', exact: true }) -} - -/** Find a "new secret" icon (if any) on the current page. */ -export function locateNewSecretIcon(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'New Secret' }) -} - -/** Find a "download files" icon (if any) on the current page. */ -export function locateDownloadFilesIcon(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Export' }) -} - -/** Find a list of tags in the search bar (if any) on the current page. */ -export function locateSearchBarTags(page: test.Page) { - return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button') -} - -/** Find a list of labels in the search bar (if any) on the current page. */ -export function locateSearchBarLabels(page: test.Page) { - return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button') -} - -/** Find a list of labels in the search bar (if any) on the current page. */ -export function locateSearchBarSuggestions(page: test.Page) { - return locateSearchBar(page).getByTestId('asset-search-suggestion') -} - -// === Icon locators === - -// These are specifically icons that are not also buttons. -// Icons that *are* buttons belong in the "Button locators" section. - -/** Find a "sort ascending" icon (if any) on the current page. */ -export function locateSortAscendingIcon(page: test.Locator | test.Page) { - return page.getByAltText('Sort Ascending') -} - -/** Find a "sort descending" icon (if any) on the current page. */ -export function locateSortDescendingIcon(page: test.Locator | test.Page) { - return page.getByAltText('Sort Descending') -} - -// === Heading locators === - -/** Find a "name" column heading (if any) on the current page. */ -export function locateNameColumnHeading(page: test.Locator | test.Page) { - return page - .getByLabel('Sort by name') - .or(page.getByLabel('Stop sorting by name')) - .or(page.getByLabel('Sort by name descending')) -} - -/** Find a "modified" column heading (if any) on the current page. */ -export function locateModifiedColumnHeading(page: test.Locator | test.Page) { - return page - .getByLabel('Sort by modification date') - .or(page.getByLabel('Stop sorting by modification date')) - .or(page.getByLabel('Sort by modification date descending')) -} - -// === Container locators === - -/** Find a drive view (if any) on the current page. */ -export function locateDriveView(page: test.Locator | test.Page) { - // This has no identifying features. - return page.getByTestId('drive-view') -} - -/** Find a samples list (if any) on the current page. */ -export function locateSamplesList(page: test.Locator | test.Page) { - // This has no identifying features. - return page.getByTestId('samples') -} - -/** Find all samples list (if any) on the current page. */ -export function locateSamples(page: test.Locator | test.Page) { - // This has no identifying features. - return locateSamplesList(page).getByRole('button') -} - -/** Find an editor container (if any) on the current page. */ -export function locateEditor(page: test.Page) { - // Test ID of a placeholder editor component used during testing. - return page.locator('.App') -} - -/** Find an assets table (if any) on the current page. */ -export function locateAssetsTable(page: test.Page) { - return locateDriveView(page).getByRole('table') -} - -/** Find assets table rows (if any) on the current page. */ -export function locateAssetRows(page: test.Page) { - return locateAssetsTable(page).getByTestId('asset-row') -} - -/** Find assets table placeholder rows (if any) on the current page. */ -export function locateNonAssetRows(page: test.Page) { - return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])') -} - -/** Find the name column of the given asset row. */ -export function locateAssetName(locator: test.Locator) { - return locator.locator('> :nth-child(1)') -} - -/** - * Find assets table rows that represent directories that can be expanded (if any) - * on the current page. - */ -export function locateExpandableDirectories(page: test.Page) { - // The icon is hidden when not hovered so `getByLabel` will not work. - return locateAssetRows(page).filter({ has: page.locator('[aria-label=Expand]') }) -} - -/** - * Find assets table rows that represent directories that can be collapsed (if any) - * on the current page. - */ -export function locateCollapsibleDirectories(page: test.Page) { - // The icon is hidden when not hovered so `getByLabel` will not work. - return locateAssetRows(page).filter({ has: page.locator('[aria-label=Collapse]') }) -} - -/** Find a "new label" modal (if any) on the current page. */ -export function locateNewLabelModal(page: test.Page) { - // This has no identifying features. - return page.getByTestId('new-label-modal') -} - -/** Find an "upsert secret" modal (if any) on the current page. */ -export function locateUpsertSecretModal(page: test.Page) { - // This has no identifying features. - return page.getByTestId('upsert-secret-modal') -} - -/** Find a user menu (if any) on the current page. */ -export function locateUserMenu(page: test.Page) { - return page.getByLabel(TEXT.userMenuLabel).and(page.getByRole('button')).locator('visible=true') -} - -/** Find a "set username" panel (if any) on the current page. */ -export function locateSetUsernamePanel(page: test.Page) { - // This has no identifying features. - return page.getByTestId('set-username-panel') -} - -/** Find a set of context menus (if any) on the current page. */ -export function locateContextMenus(page: test.Page) { - // This has no identifying features. - return page.getByTestId('context-menus') -} - -/** Find a labels panel (if any) on the current page. */ -export function locateLabelsPanel(page: test.Page) { - // This has no identifying features. - return page.getByTestId('labels') -} - -/** Find a list of labels (if any) on the current page. */ -export function locateLabelsList(page: test.Page) { - // This has no identifying features. - return page.getByTestId('labels-list') -} - -/** Find an asset panel (if any) on the current page. */ -export function locateAssetPanel(page: test.Page) { - // This has no identifying features. - return page.getByTestId('asset-panel').locator('visible=true') -} - -/** Find a search bar (if any) on the current page. */ -export function locateSearchBar(page: test.Page) { - // This has no identifying features. - return page.getByTestId('asset-search-bar') -} - -/** Find an extra columns button panel (if any) on the current page. */ -export function locateExtraColumns(page: test.Page) { - // This has no identifying features. - return page.getByTestId('extra-columns') -} - -/** - * Find a root directory dropzone (if any) on the current page. - * This is the empty space below the assets table, if it doesn't take up the whole screen - * vertically. - */ -export function locateRootDirectoryDropzone(page: test.Page) { - // This has no identifying features. - return page.getByTestId('root-directory-dropzone') -} - -// === Content locators === - -/** Find an asset description in an asset panel (if any) on the current page. */ -export function locateAssetPanelDescription(page: test.Page) { - // This has no identifying features. - return locateAssetPanel(page).getByTestId('asset-panel-description') -} - -/** Find asset permissions in an asset panel (if any) on the current page. */ -export function locateAssetPanelPermissions(page: test.Page) { - // This has no identifying features. - return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') -} - -export namespace settings { - export namespace tab { - export namespace organization { - /** Find an "organization" tab button. */ - export function locate(page: test.Page) { - return page.getByRole('button', { name: 'Organization' }).getByText('Organization') - } - } - export namespace members { - /** Find a "members" tab button. */ - export function locate(page: test.Page) { - return page.getByRole('button', { name: 'Members', exact: true }).getByText('Members') - } - } - } - - export namespace userAccount { - /** Navigate so that the "user account" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "user account" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "user account" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('User Account')).locator('..') - } - - /** Find a "name" input in the "user account" settings section. */ - export function locateNameInput(page: test.Page) { - return locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox') - } - } - - export namespace changePassword { - /** Navigate so that the "change password" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "change password" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "change password" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('Change Password')).locator('..') - } - - /** Find a "current password" input in the "user account" settings section. */ - export function locateCurrentPasswordInput(page: test.Page) { - return locate(page).getByRole('group', { name: 'Current password' }).getByRole('textbox') - } - - /** Find a "new password" input in the "user account" settings section. */ - export function locateNewPasswordInput(page: test.Page) { - return locate(page) - .getByRole('group', { name: /^New password/, exact: true }) - .getByRole('textbox') - } - - /** Find a "confirm new password" input in the "user account" settings section. */ - export function locateConfirmNewPasswordInput(page: test.Page) { - return locate(page) - .getByRole('group', { name: /^Confirm new password/, exact: true }) - .getByRole('textbox') - } - - /** Find a "save" button. */ - export function locateSaveButton(page: test.Page) { - return locate(page).getByRole('button', { name: 'Save' }).getByText('Save') - } - } - - export namespace profilePicture { - /** Navigate so that the "profile picture" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "profile picture" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "profile picture" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: test.Page) { - return locate(page).locator('label') - } - } - - export namespace organization { - /** Navigate so that the "organization" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "organization" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.organization.locate(page).click() - }) - } - - /** Find an "organization" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('Organization')).locator('..') - } - - /** Find a "name" input in the "organization" settings section. */ - export function locateNameInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox') - } - - /** Find an "email" input in the "organization" settings section. */ - export function locateEmailInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox') - } - - /** Find an "website" input in the "organization" settings section. */ - export function locateWebsiteInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox') - } - - /** Find an "location" input in the "organization" settings section. */ - export function locateLocationInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox') - } - } - - export namespace organizationProfilePicture { - /** Navigate so that the "organization profile picture" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "organization profile picture" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.organization.locate(page).click() - }) - } - - /** Find an "organization profile picture" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: test.Page) { - return locate(page).locator('label') - } - } - - export namespace members { - /** Navigate so that the "members" settings section is visible. */ - export async function go(page: test.Page, force = false) { - await test.test.step('Go to "members" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.members.locate(page).click({ force }) - }) - } - - /** Find a "members" settings section. */ - export function locate(page: test.Page) { - return page.getByRole('heading').and(page.getByText('Members')).locator('..') - } - - /** Find all rows representing members of the current organization. */ - export function locateMembersRows(page: test.Page) { - return locate(page).locator('tbody').getByRole('row') - } - } -} - -// =============================== -// === Visual layout utilities === -// =============================== - -/** - * Get the left side of the bounding box of an asset row. The locator MUST be for an asset row. - * DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE - * to do anything with the returned values other than comparing them. - */ -export function getAssetRowLeftPx(locator: test.Locator) { - return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +export const getText = (key: TextId, ...replacements: Replacements[TextId]) => { + return baseGetText(TEXT, key, ...replacements) } -// =================================== -// === Expect functions for themes === -// =================================== - -/** A test assertion to confirm that the element has the class `selected`. */ -export async function expectClassSelected(locator: test.Locator) { - await test.test.step('Expect `selected`', async () => { - await test.expect(locator).toHaveClass(/(?:^| )selected(?: |$)/) - }) -} - -// ============================== -// === Other expect functions === -// ============================== - -/** A test assertion to confirm that the element is fully transparent. */ -export async function expectOpacity0(locator: test.Locator) { - await test.test.step('Expect `opacity: 0`', async () => { - await test - .expect(async () => { - test.expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') - }) - .toPass() - }) -} - -/** A test assertion to confirm that the element is not fully transparent. */ -export async function expectNotOpacity0(locator: test.Locator) { - await test.test.step('Expect not `opacity: 0`', async () => { - await test - .expect(async () => { - test.expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') - }) - .toPass() - }) -} - -// ========================== -// === Keyboard utilities === -// ========================== - -/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -export async function modModifier(page: test.Page) { - let userAgent = '' - await test.test.step('Detect browser OS', async () => { - userAgent = await page.evaluate(() => navigator.userAgent) - }) - return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' -} - -/** - * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` - * on all other platforms. - */ -export async function press(page: test.Page, keyOrShortcut: string) { - await test.test.step(`Press '${keyOrShortcut}'`, async () => { - if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { - let userAgent = '' - await test.test.step('Detect browser OS', async () => { - userAgent = await page.evaluate(() => navigator.userAgent) - }) - const isMacOS = /\bMac OS\b/i.test(userAgent) - const ctrlKey = isMacOS ? 'Meta' : 'Control' - const deleteKey = isMacOS ? 'Backspace' : 'Delete' - const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) - await page.keyboard.press(shortcut) - } else { - await page.keyboard.press(keyOrShortcut) - } - }) -} - -// =============================== -// === Miscellaneous utilities === -// =============================== - /** Get the path to the auth file. */ export function getAuthFilePath() { const __dirname = path.dirname(new URL(import.meta.url).pathname) @@ -684,79 +37,48 @@ export function getAuthFilePath() { } /** Perform a successful login. */ -export async function login( - { page, setupAPI }: MockParams, - email = 'email@example.com', - password = VALID_PASSWORD, -) { +async function login({ page }: MockParams, email = 'email@example.com', password = VALID_PASSWORD) { const authFile = getAuthFilePath() await waitForLoaded(page) - const isLoggedIn = (await page.$('[data-testid="before-auth-layout"]')) === null + const isLoggedIn = (await page.getByTestId('before-auth-layout').count()) === 0 if (isLoggedIn) { - test.test.info().annotations.push({ + test.info().annotations.push({ type: 'skip', description: 'Already logged in', }) return } - return test.test.step('Login', async () => { - test.test.info().annotations.push({ + return test.step('Login', async () => { + test.info().annotations.push({ type: 'Login', description: 'Performing login', }) + await page.getByPlaceholder(TEXT.emailPlaceholder).fill(email) + await page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password) + await page.getByRole('button', { name: TEXT.login, exact: true }).getByText(TEXT.login).click() + + await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() - await locateEmailInput(page).fill(email) - await locatePasswordInput(page).fill(password) - await locateLoginButton(page).click() - await passAgreementsDialog({ page, setupAPI }) + await passAgreementsDialog({ page }) await page.context().storageState({ path: authFile }) }) } -/** - * Wait for the page to load. - */ -export async function waitForLoaded(page: test.Page) { +/** Wait for the page to load. */ +async function waitForLoaded(page: Page) { await page.waitForLoadState() - await test.expect(page.locator('[data-testid="spinner"]')).toHaveCount(0) - await test.expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 }) + await expect(page.getByTestId('loading-screen')).toHaveCount(0, { timeout: 30_000 }) } -/** - * Wait for the dashboard to load. - */ -export async function waitForDashboardToLoad(page: test.Page) { +/** Wait for the dashboard to load. */ +async function waitForDashboardToLoad(page: Page) { await waitForLoaded(page) - await test.expect(page.getByTestId('after-auth-layout')).toBeAttached() -} - -/** Reload. */ -export async function reload({ page }: MockParams) { - await test.test.step('Reload', async () => { - await page.reload() - await waitForLoaded(page) - }) -} - -/** Logout and then login again. */ -export async function relog( - { page, setupAPI }: MockParams, - email = 'email@example.com', - password = VALID_PASSWORD, -) { - await test.test.step('Relog', async () => { - await page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click() - await page - .getByRole('button', { name: TEXT.signOutShortcut }) - .getByText(TEXT.signOutShortcut) - .click() - await login({ page, setupAPI }, email, password) - }) + await expect(page.getByTestId('after-auth-layout')).toBeAttached() } /** A placeholder date for visual regression testing. */ @@ -764,14 +86,14 @@ const MOCK_DATE = Number(new Date('01/23/45 01:23:45')) /** Parameters for {@link mockDate}. */ interface MockParams { - readonly page: test.Page - readonly setupAPI?: apiModule.SetupAPI | undefined + readonly page: Page + readonly setupAPI?: SetupAPI | undefined } /** Replace `Date` with a version that returns a fixed time. */ async function mockDate({ page }: MockParams) { // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 - await test.test.step('Mock Date', async () => { + await test.step('Mock Date', async () => { await page.addInitScript(`{ Date = class extends Date { constructor(...args) { @@ -791,7 +113,7 @@ async function mockDate({ page }: MockParams) { /** Pass the Agreements dialog. */ export async function passAgreementsDialog({ page }: MockParams) { - await test.test.step('Accept Terms and Conditions', async () => { + await test.step('Accept Terms and Conditions', async () => { await page.waitForSelector('#agreements-modal') await page .getByRole('group', { name: TEXT.licenseAgreementCheckbox }) @@ -801,24 +123,33 @@ export async function passAgreementsDialog({ page }: MockParams) { .getByRole('group', { name: TEXT.privacyPolicyCheckbox }) .getByText(TEXT.privacyPolicyCheckbox) .click() - await page.getByRole('button', { name: 'Accept' }).click() + await page.getByRole('button', { name: TEXT.accept }).click() }) } -export const mockApi = apiModule.mockApi +interface Context { + readonly api: MockApi + calls: TrackedCalls +} /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - return new LoginPageActions(page) - .step('Execute all mocks', async () => { + const context: { -readonly [K in keyof Context]: Context[K] } = { + api: undefined!, + calls: INITIAL_CALLS_OBJECT, + } + return new LoginPageActions(page, context) + .step('Execute all mocks', async (page) => { await Promise.all([ - mockApi({ page, setupAPI }), - mockDate({ page, setupAPI }), + mockApi({ page, setupAPI }).then((api) => { + context.api = api + }), + mockDate({ page }), mockAllAnimations({ page }), mockUnneededUrls({ page }), ]) }) - .step('Navigate to the Root page', async () => { + .step('Navigate to the root page', async (page) => { await page.goto('/') await waitForLoaded(page) }) @@ -826,24 +157,22 @@ export function mockAll({ page, setupAPI }: MockParams) { /** Set up all mocks, and log in with dummy credentials. */ export function mockAllAndLogin({ page, setupAPI }: MockParams) { - return mockAll({ page, setupAPI }) - .step('Login', async () => { - await login({ page, setupAPI }) - }) - .step('Wait for dashboard to load', async () => { - await waitForDashboardToLoad(page) + const actions = mockAll({ page, setupAPI }) + return actions + .step('Login', (page) => login({ page })) + .step('Wait for dashboard to load', waitForDashboardToLoad) + .step('Check if start modal is shown', async (page) => { + // @ts-expect-error This is the only place in which the private member `.context` + // should be accessed. + const context = actions.context + await new StartModalActions(page, context).close() }) - .step('Check if start modal is shown', async () => { - await new StartModalActions(page).close() - }) - .into(DrivePageActions) + .into(DrivePageActions) } -/** - * Mock all animations. - */ -export async function mockAllAnimations({ page }: MockParams) { - await test.test.step('Mock all animations', async () => { +/** Mock all animations. */ +async function mockAllAnimations({ page }: MockParams) { + await test.step('Mock all animations', async () => { await page.addInitScript({ content: ` window.DISABLE_ANIMATIONS = true; @@ -855,15 +184,35 @@ export async function mockAllAnimations({ page }: MockParams) { }) } -/** - * Mock unneeded URLs. - */ -export async function mockUnneededUrls({ page }: MockParams) { - const EULA_JSON = JSON.stringify(apiModule.EULA_JSON) - const PRIVACY_JSON = JSON.stringify(apiModule.PRIVACY_JSON) +/** Mock unneeded URLs. */ +async function mockUnneededUrls({ page }: MockParams) { + const eulaJsonBody = JSON.stringify({ + path: '/eula.md', + size: 9472, + modified: '2024-05-21T10:47:27.000Z', + hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', + }) + const privacyJsonBody = JSON.stringify({ + path: '/privacy.md', + size: 1234, + modified: '2024-05-21T10:47:27.000Z', + hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', + }) - await test.test.step('Mock unneeded URLs', async () => { + await test.step('Mock unneeded URLs', async () => { return Promise.all([ + page.route('https://cdn.enso.org/**', async (route) => { + await route.fulfill() + }), + + page.route('https://www.google-analytics.com/**', async (route) => { + await route.fulfill() + }), + + page.route('https://www.googletagmanager.com/gtag/js*', async (route) => { + await route.fulfill({ contentType: 'text/javascript', body: 'export {};' }) + }), + page.route('https://*.ingest.sentry.io/api/*/envelope/*', async (route) => { await route.fulfill() }), @@ -873,33 +222,59 @@ export async function mockUnneededUrls({ page }: MockParams) { }), page.route('https://ensoanalytics.com/eula.json', async (route) => { - await route.fulfill({ contentType: 'text/json', body: EULA_JSON }) + await route.fulfill({ contentType: 'text/json', body: eulaJsonBody }) }), page.route('https://ensoanalytics.com/privacy.json', async (route) => { - await route.fulfill({ contentType: 'text/json', body: PRIVACY_JSON }) + await route.fulfill({ contentType: 'text/json', body: privacyJsonBody }) }), page.route('https://fonts.googleapis.com/css2*', async (route) => { await route.fulfill({ contentType: 'text/css', body: '' }) }), - ]) - }) -} -/** - * Set up all mocks, and log in with dummy credentials. - * @deprecated Prefer {@link mockAllAndLogin}. - */ -export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) { - return await test.test.step('Execute all mocks and login', async () => { - const api = await mockApi({ page, setupAPI }) - await mockDate({ page, setupAPI }) - await page.goto('/') - await login({ page, setupAPI }) - await waitForDashboardToLoad(page) - await new StartModalActions(page).close() - - return api + page.route('https://api.github.com/repos/enso-org/enso/releases/latest', async (route) => { + await route.fulfill({ json: LATEST_GITHUB_RELEASES }) + }), + + page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/html', + }, + body: '', + }) + }), + + page.route('https://objects.githubusercontent.com/**', async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'application/octet-stream', + 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', + etag: '"0x8DCAC053D058EA5"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', + 'x-ms-version': '2020-10-02', + 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-blob-type': 'BlockBlob', + 'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', + 'x-ms-server-encrypted': 'true', + via: '1.1 varnish, 1.1 varnish', + 'accept-ranges': 'bytes', + age: '1217', + date: 'Mon, 29 Jul 2024 09:40:09 GMT', + 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', + 'x-cache': 'HIT, HIT', + 'x-cache-hits': '48, 0', + 'x-timer': 'S1722246008.269342,VS0,VE895', + 'content-length': '1030383958', + }, + }) + }), + ]) }) } diff --git a/app/gui/integration-test/dashboard/latestGithubReleases.json b/app/gui/integration-test/dashboard/actions/latestGithubReleases.json similarity index 100% rename from app/gui/integration-test/dashboard/latestGithubReleases.json rename to app/gui/integration-test/dashboard/actions/latestGithubReleases.json diff --git a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts index 554a4f42251b..02e4c73aec3c 100644 --- a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts +++ b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts @@ -3,13 +3,9 @@ import { TEXT } from '.' import type BaseActions from './BaseActions' import type { PageCallback } from './BaseActions' -// ========================== -// === openUserMenuAction === -// ========================== - /** An action to open the User Menu. */ -export function openUserMenuAction( - step: (name: string, callback: PageCallback) => T, +export function openUserMenuAction, Context>( + step: (name: string, callback: PageCallback) => T, ) { return step('Open user menu', (page) => page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click(), diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index ec6f9d0d973d..7517cd3c2f34 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -1,49 +1,54 @@ /** @file Actions for the user menu. */ -import type * as test from 'playwright/test' +import type { Download } from '@playwright/test' -import type * as baseActions from './BaseActions' +import { TEXT } from '.' import type BaseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' import SettingsPageActions from './SettingsPageActions' -// ======================= -// === UserMenuActions === -// ======================= - /** Actions for the user menu. */ -export interface UserMenuActions { - readonly downloadApp: (callback: (download: test.Download) => Promise | void) => T - readonly settings: () => SettingsPageActions - readonly logout: () => LoginPageActions - readonly goToLoginPage: () => LoginPageActions +export interface UserMenuActions, Context> { + readonly downloadApp: (callback: (download: Download) => Promise | void) => T + readonly settings: () => SettingsPageActions + readonly logout: () => LoginPageActions + readonly goToLoginPage: () => LoginPageActions } -// ======================= -// === userMenuActions === -// ======================= - /** Generate actions for the user menu. */ -export function userMenuActions( - step: (name: string, callback: baseActions.PageCallback) => T, -): UserMenuActions { +export function userMenuActions, Context>( + step: (name: string, callback: PageCallback) => T, +): UserMenuActions { return { - downloadApp: (callback: (download: test.Download) => Promise | void) => + downloadApp: (callback: (download: Download) => Promise | void) => step('Download app (user menu)', async (page) => { const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click() + await page + .getByRole('button', { name: TEXT.downloadAppShortcut }) + .getByText(TEXT.downloadAppShortcut) + .click() await callback(await downloadPromise) }), settings: () => step('Go to Settings (user menu)', async (page) => { - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }).into(SettingsPageActions), + await page + .getByRole('button', { name: TEXT.settingsShortcut }) + .getByText(TEXT.settingsShortcut) + .click() + }).into(SettingsPageActions), logout: () => step('Logout (user menu)', (page) => - page.getByRole('button', { name: 'Logout' }).getByText('Logout').click(), - ).into(LoginPageActions), + page + .getByRole('button', { name: TEXT.signOutShortcut }) + .getByText(TEXT.signOutShortcut) + .click(), + ).into(LoginPageActions), goToLoginPage: () => step('Login (user menu)', (page) => - page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click(), - ).into(LoginPageActions), + page + .getByRole('button', { name: TEXT.signInShortcut, exact: true }) + .getByText(TEXT.signInShortcut) + .click(), + ).into(LoginPageActions), } } diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index 9f1cc4d572bf..37e8ffd05102 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -1,15 +1,30 @@ /** @file Tests for the asset panel. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as backend from '#/services/Backend' +import { EmailAddress, UserId } from '#/services/Backend' -import * as permissions from '#/utilities/permissions' +import { PermissionAction } from '#/utilities/permissions' -import * as actions from './actions' +import { mockAllAndLogin, TEXT } from './actions' -// ================= -// === Constants === -// ================= +/** Find an asset panel. */ +function locateAssetPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-panel').locator('visible=true') +} + +/** Find an asset description in an asset panel. */ +function locateAssetPanelDescription(page: Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-description') +} + +/** Find asset permissions in an asset panel. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateAssetPanelPermissions(page: Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') +} /** An example description for the asset selected in the asset panel. */ const DESCRIPTION = 'foo bar' @@ -18,13 +33,8 @@ const USERNAME = 'baz quux' /** An example owner email for the asset selected in the asset panel. */ const EMAIL = 'baz.quux@email.com' -// ============= -// === Tests === -// ============= - test('open and close asset panel', ({ page }) => - actions - .mockAllAndLogin({ page }) + mockAllAndLogin({ page }) .withAssetPanel(async (assetPanel) => { await expect(assetPanel).toBeVisible() }) @@ -34,50 +44,68 @@ test('open and close asset panel', ({ page }) => })) test('asset panel contents', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - const { defaultOrganizationId, defaultUserId } = api - api.addProject({ - description: DESCRIPTION, - permissions: [ - { - permission: permissions.PermissionAction.own, - user: { - organizationId: defaultOrganizationId, - // Using the default ID causes the asset to have a dynamic username. - userId: backend.UserId(defaultUserId + '2'), - name: USERNAME, - email: backend.EmailAddress(EMAIL), - }, + mockAllAndLogin({ + page, + setupAPI: (api) => { + const { defaultOrganizationId, defaultUserId } = api + api.addProject({ + description: DESCRIPTION, + permissions: [ + { + permission: PermissionAction.own, + user: { + organizationId: defaultOrganizationId, + // Using the default ID causes the asset to have a dynamic username. + userId: UserId(defaultUserId + '2'), + name: USERNAME, + email: EmailAddress(EMAIL), }, - ], - }) - }, - }) + }, + ], + }) + }, + }) .driveTable.clickRow(0) .toggleDescriptionAssetPanel() .do(async () => { - await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) + await expect(locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) // `getByText` is required so that this assertion works if there are multiple permissions. // This is not visible; "Shared with" should only be visible on the Enterprise plan. - // await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() + // await expect(locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() })) -test('Asset Panel Documentation view', ({ page }) => { - return actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addProject({}) - }, - }) +test('Asset Panel documentation view', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({}) + }, + }) .driveTable.clickRow(0) .toggleDocsAssetPanel() .withAssetPanel(async (assetPanel) => { await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/) + await expect(assetPanel.getByText(TEXT.arbitraryFetchImageError)).not.toBeVisible() + })) + +test('Assets Panel docs images', ({ page }) => { + return mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({}) + }, + }) + .do(() => {}) + .driveTable.clickRow(0) + .toggleDocsAssetPanel() + .withAssetPanel(async (assetPanel) => { + await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible() + + for (const image of await assetPanel.getByRole('img').all()) { + await expect(image).toBeVisible() + await expect(image).toHaveJSProperty('complete', true) + } }) }) diff --git a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts index 83851365b86c..5ca65d355957 100644 --- a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts +++ b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts @@ -1,71 +1,91 @@ /** @file Test the search bar and its suggestions. */ -import * as test from '@playwright/test' - -import * as backend from '#/services/Backend' - -import * as actions from './actions' - -test.test('tags (positive)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) - - await searchBarInput.click() - for (const positiveTag of await tags.all()) { - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - const text = (await positiveTag.textContent()) ?? '' - test.expect(text.length).toBeGreaterThan(0) - await positiveTag.click() - await test.expect(searchBarInput).toHaveValue(text) - } -}) - -test.test('tags (negative)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) - - await searchBarInput.click() - await page.keyboard.down('Shift') - for (const negativeTag of await tags.all()) { - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - const text = (await negativeTag.textContent()) ?? '' - test.expect(text.length).toBeGreaterThan(0) - await negativeTag.click() - await test.expect(searchBarInput).toHaveValue(text) - } -}) - -test.test('labels', async ({ page }) => { - await actions.mockAllAndLogin({ +import { expect, test, type Page } from '@playwright/test' + +import { COLORS } from '#/services/Backend' + +import { mockAllAndLogin } from './actions' + +/** Find a search bar. */ +function locateSearchBar(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-search-bar') +} + +/** Find a list of tags in the search bar. */ +function locateSearchBarTags(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button') +} + +/** Find a list of labels in the search bar. */ +function locateSearchBarLabels(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button') +} + +/** Find a list of labels in the search bar. */ +function locateSearchBarSuggestions(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-suggestion') +} + +const FIRST_ASSET_NAME = 'foo' + +test('tags (positive)', ({ page }) => + mockAllAndLogin({ page }).withSearchBar(async (searchBarInput) => { + const tags = locateSearchBarTags(page) + + await searchBarInput.click() + for (const positiveTag of await tags.all()) { + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + const text = (await positiveTag.textContent()) ?? '' + expect(text.length).toBeGreaterThan(0) + await positiveTag.click() + await expect(searchBarInput).toHaveValue(text) + } + })) + +test('tags (negative)', ({ page }) => + mockAllAndLogin({ page }).withSearchBar(async (searchBar) => { + const tags = locateSearchBarTags(page) + + await searchBar.click() + await page.keyboard.down('Shift') + for (const negativeTag of await tags.all()) { + await searchBar.selectText() + await searchBar.press('Backspace') + const text = (await negativeTag.textContent()) ?? '' + expect(text.length).toBeGreaterThan(0) + await negativeTag.click() + await expect(searchBar).toHaveValue(text) + } + })) + +test('labels', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel('aaaa', backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) + api.addLabel('aaaa', COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) }, - }) - const searchBarInput = actions.locateSearchBarInput(page) - const labels = actions.locateSearchBarLabels(page) - - await searchBarInput.click() - for (const label of await labels.all()) { - const name = (await label.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) - await label.click() - await test.expect(searchBarInput).toHaveValue('label:' + name) - await label.click() - await test.expect(searchBarInput).toHaveValue('-label:' + name) - await label.click() - await test.expect(searchBarInput).toHaveValue('') - } -}) - -test.test('suggestions', async ({ page }) => { - await actions.mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const labels = locateSearchBarLabels(page) + + await searchBar.click() + for (const label of await labels.all()) { + const name = (await label.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await label.click() + await expect(searchBar).toHaveValue('label:' + name) + await label.click() + await expect(searchBar).toHaveValue('-label:' + name) + await label.click() + await expect(searchBar).toHaveValue('') + } + })) + +test('suggestions', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory({ title: 'foo' }) @@ -73,25 +93,23 @@ test.test('suggestions', async ({ page }) => { api.addSecret({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, - }) - - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.locateSearchBarSuggestions(page) - - await searchBarInput.click() - - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) - await suggestion.click() - await test.expect(searchBarInput).toHaveValue('name:' + name) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - } -}) - -test.test('suggestions (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) + + await searchBar.click() + + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await suggestion.click() + await expect(searchBar).toHaveValue('name:' + name) + await searchBar.selectText() + await searchBar.press('Backspace') + } + })) + +test('suggestions (keyboard)', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory({ title: 'foo' }) @@ -99,40 +117,34 @@ test.test('suggestions (keyboard)', async ({ page }) => { api.addSecret({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, - }) - - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.locateSearchBarSuggestions(page) - - await searchBarInput.click() - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) - await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + name) - } -}) - -test.test('complex flows', async ({ page }) => { - const firstName = 'foo' - - await actions.mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) + + await searchBar.click() + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + name) + } + })) + +test('complex flows', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addDirectory({ title: firstName }) + api.addDirectory({ title: FIRST_ASSET_NAME }) api.addProject({ title: 'bar' }) api.addSecret({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, - }) - const searchBarInput = actions.locateSearchBarInput(page) - - await searchBarInput.click() - await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - await test.expect(searchBarInput).toHaveValue('') - await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) -}) + }).withSearchBar(async (searchBar) => { + await searchBar.click() + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME) + await searchBar.selectText() + await searchBar.press('Backspace') + await expect(searchBar).toHaveValue('') + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME) + })) diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 46ab233b5562..e2506c04c0e1 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,13 +1,38 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { EmailAddress, ProjectState } from '#/services/Backend' +import { getText, mockAllAndLogin, TEXT } from './actions' + +/** Find an extra columns button panel. */ +function locateExtraColumns(page: Page) { + // This has no identifying features. + return page.getByTestId('extra-columns') +} + +/** + * Get the left side of the bounding box of an asset row. The locator MUST be for an asset row. + * DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE + * to do anything with the returned values other than comparing them. + */ +function getAssetRowLeftPx(locator: Locator) { + return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +} + +/** + * Find a root directory dropzone. + * This is the empty space below the assets table, if it doesn't take up the whole screen + * vertically. + */ +function locateRootDirectoryDropzone(page: Page) { + // This has no identifying features. + return page.getByTestId('root-directory-dropzone') +} const PASS_TIMEOUT = 5_000 -test.test('extra columns should stick to right side of assets table', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('extra columns should stick to right side of assets table', ({ page }) => + mockAllAndLogin({ page }) .withAssetsTable(async (table) => { await table.evaluate((element) => { let scrollableParent: HTMLElement | SVGElement | null = element @@ -20,25 +45,21 @@ test.test('extra columns should stick to right side of assets table', ({ page }) scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) }) }) - .do(async (thePage) => { - const extraColumns = actions.locateExtraColumns(thePage) - const assetsTable = actions.locateAssetsTable(thePage) - await test - .expect(async () => { - const extraColumnsRight = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().right, - ) - const assetsTableRight = await assetsTable.evaluate( - (element) => element.getBoundingClientRect().right, - ) - test.expect(extraColumnsRight).toEqual(assetsTableRight - 12) - }) - .toPass({ timeout: PASS_TIMEOUT }) - }), -) + .withAssetsTable(async (assetsTable, _, thePage) => { + const extraColumns = locateExtraColumns(thePage) + await expect(async () => { + const extraColumnsRight = await extraColumns.evaluate( + (element) => element.getBoundingClientRect().right, + ) + const assetsTableRight = await assetsTable.evaluate( + (element) => element.getBoundingClientRect().right, + ) + expect(extraColumnsRight).toEqual(assetsTableRight - 8) + }).toPass({ timeout: PASS_TIMEOUT }) + })) -test.test('extra columns should stick to top of scroll container', async ({ page }) => { - await actions.mockAllAndLogin({ +test('extra columns should stick to top of scroll container', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { for (let i = 0; i < 100; i += 1) { @@ -46,25 +67,8 @@ test.test('extra columns should stick to top of scroll container', async ({ page } }, }) - - await actions.locateAssetsTable(page).evaluate((element) => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollHeight <= scrollableParent.clientHeight - ) { - scrollableParent = scrollableParent.parentElement - } - scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) - }) - const extraColumns = actions.locateExtraColumns(page) - const assetsTable = actions.locateAssetsTable(page) - await test - .expect(async () => { - const extraColumnsTop = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().top, - ) - const assetsTableTop = await assetsTable.evaluate((element) => { + .withAssetsTable(async (assetsTable) => { + await assetsTable.evaluate((element) => { let scrollableParent: HTMLElement | SVGElement | null = element while ( scrollableParent != null && @@ -72,29 +76,136 @@ test.test('extra columns should stick to top of scroll container', async ({ page ) { scrollableParent = scrollableParent.parentElement } - return scrollableParent?.getBoundingClientRect().top ?? 0 + scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) }) - test.expect(extraColumnsTop).toEqual(assetsTableTop + 2) }) - .toPass({ timeout: PASS_TIMEOUT }) -}) + .withAssetsTable(async (assetsTable, _, thePage) => { + const extraColumns = locateExtraColumns(thePage) + await expect(async () => { + const extraColumnsTop = await extraColumns.evaluate( + (element) => element.getBoundingClientRect().top, + ) + const assetsTableTop = await assetsTable.evaluate((element) => { + let scrollableParent: HTMLElement | SVGElement | null = element + while ( + scrollableParent != null && + scrollableParent.scrollHeight <= scrollableParent.clientHeight + ) { + scrollableParent = scrollableParent.parentElement + } + return scrollableParent?.getBoundingClientRect().top ?? 0 + }) + expect(extraColumnsTop).toEqual(assetsTableTop + 2) + }).toPass({ timeout: PASS_TIMEOUT }) + })) -test.test('can drop onto root directory dropzone', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('can drop onto root directory dropzone', ({ page }) => + mockAllAndLogin({ page }) .createFolder() .uploadFile('b', 'testing') .driveTable.doubleClickRow(0) .driveTable.withRows(async (rows, nonAssetRows) => { - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - await test.expect(nonAssetRows.nth(0)).toHaveText(actions.TEXT.thisFolderIsEmpty) - const childLeft = await actions.getAssetRowLeftPx(nonAssetRows.nth(0)) - test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + await expect(nonAssetRows.nth(0)).toHaveText(TEXT.thisFolderIsEmpty) + const childLeft = await getAssetRowLeftPx(nonAssetRows.nth(0)) + expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft) }) - .driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page)) + .driveTable.dragRow(1, locateRootDirectoryDropzone(page)) .driveTable.withRows(async (rows) => { - const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const secondLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) - }), -) + const firstLeft = await getAssetRowLeftPx(rows.nth(0)) + const secondLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) + })) + +test('can navigate to parent directory of an asset in the Recent category', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({ title: 'a' }) + api.addProject({ title: 'b' }) + + const directory = api.addDirectory({ title: 'd' }) + const subDirectory = api.addDirectory({ title: 'e', parentId: directory.id }) + + api.addProject({ title: 'c', parentId: subDirectory.id }) + }, + }) + .driveTable.expandDirectory(0) + .driveTable.expandDirectory(1) + // Project in the nested directory (c) + .driveTable.rightClickRow(2) + .contextMenu.moveNonFolderToTrash() + // Project in the root (a) + .driveTable.rightClickRow(2) + .contextMenu.moveNonFolderToTrash() + .goToCategory.trash() + .driveTable.withPathColumnCell('a', async (cell) => { + await expect(cell).toBeVisible() + + await cell.getByRole('button').click() + + await expect(cell).not.toBeVisible() + }) + .expectCategory(TEXT.cloudCategory) + .goToCategory.trash() + .driveTable.withPathColumnCell('c', async (cell) => { + await expect(cell).toBeVisible() + + await cell.getByRole('button').click() + + await page.getByTestId('path-column-item-d').click() + }) + .expectCategory(TEXT.cloudCategory) + .driveTable.withSelectedRows(async (rows) => { + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toHaveText(/^d/) + })) + +test("can't run a project in browser by default", ({ page }) => + mockAllAndLogin({ + page, + setupAPI: async (api) => { + api.addProject({ title: 'a' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.first() + + const startProjectButton = row.getByTestId('open-project') + await expect(startProjectButton).toBeDisabled() + })) + +test("can't start an already running by another user", ({ page }) => + mockAllAndLogin({ + page, + setupAPI: async (api) => { + await api.setFeatureFlags({ enableCloudExecution: true }) + + const userGroup = api.addUserGroup('Test Group') + + api.addUserGroupToUser(api.defaultUser.userId, userGroup.id) + + const peer = api.addUser('Test User', { + email: EmailAddress('test@test.com'), + userGroups: [userGroup.id], + }) + + api.addProject({ + title: 'a', + projectState: { + type: ProjectState.opened, + volumeId: '123', + openedBy: peer.email, + }, + }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.first() + const startProjectButton = row.getByTestId('open-project') + const stopProjectButton = row.getByTestId('stop-project') + + await expect(row).toBeVisible() + await expect(row.getByTestId('switch-to-project')).not.toBeVisible() + await expect(startProjectButton).not.toBeVisible() + await expect(stopProjectButton).toBeDisabled() + await expect(stopProjectButton).toHaveAccessibleName(getText('xIsUsingTheProject', 'Test User')) + })) diff --git a/app/gui/integration-test/dashboard/auth.setup.ts b/app/gui/integration-test/dashboard/auth.setup.ts index 1cd6c08f7803..1c2fca60f1f9 100644 --- a/app/gui/integration-test/dashboard/auth.setup.ts +++ b/app/gui/integration-test/dashboard/auth.setup.ts @@ -1,10 +1,12 @@ -import { test as setup } from '@playwright/test' import fs from 'node:fs' -import * as actions from './actions' + +import { test as setup } from '@playwright/test' + +import { getAuthFilePath, mockAllAndLogin } from './actions' setup('authenticate', ({ page }) => { - const authFilePath = actions.getAuthFilePath() + setup.slow() + const authFilePath = getAuthFilePath() setup.skip(fs.existsSync(authFilePath), 'Already authenticated') - - return actions.mockAllAndLogin({ page }) + return mockAllAndLogin({ page }) }) diff --git a/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts b/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts index bee0e0bdd448..f2ef355a1555 100644 --- a/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts +++ b/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts @@ -1,30 +1,30 @@ /** @file Test that emails are preserved when navigating between auth pages. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' + import { VALID_EMAIL, mockAll } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('preserve email input when changing pages', ({ page }) => +test('preserve email input when changing pages', ({ page }) => mockAll({ page }) .fillEmail(VALID_EMAIL) .goToPage.register() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(VALID_EMAIL) + await expect(emailInput).toHaveValue(VALID_EMAIL) }) .fillEmail(`2${VALID_EMAIL}`) .goToPage.login() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`2${VALID_EMAIL}`) + await expect(emailInput).toHaveValue(`2${VALID_EMAIL}`) }) .fillEmail(`3${VALID_EMAIL}`) .goToPage.forgotPassword() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`3${VALID_EMAIL}`) + await expect(emailInput).toHaveValue(`3${VALID_EMAIL}`) }) .fillEmail(`4${VALID_EMAIL}`) .goToPage.login() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`4${VALID_EMAIL}`) - }), -) + await expect(emailInput).toHaveValue(`4${VALID_EMAIL}`) + })) diff --git a/app/gui/integration-test/dashboard/contextMenus.spec.ts b/app/gui/integration-test/dashboard/contextMenus.spec.ts new file mode 100644 index 000000000000..b752fb064ab2 --- /dev/null +++ b/app/gui/integration-test/dashboard/contextMenus.spec.ts @@ -0,0 +1,64 @@ +/** @file Test the drive view. */ +import { expect, test, type Page } from '@playwright/test' + +import { COLORS } from 'enso-common/src/services/Backend' +import { mockAllAndLogin } from './actions' + +const LABEL_NAME = 'aaaa' + +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** Find labels in the "Labels" column of the assets table. */ +function locateAssetLabels(page: Page) { + return page.getByTestId('asset-label') +} + +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('drive view', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addLabel(LABEL_NAME, COLORS[0]) + }, + }) + .driveTable.expectPlaceholderRow() + .withDriveView(async (view) => { + await view.click({ button: 'right' }) + }) + .do(async (thePage) => { + await expect(locateContextMenu(thePage)).toHaveCount(1) + }) + .press('Escape') + .do(async (thePage) => { + await expect(locateContextMenu(thePage)).toHaveCount(0) + }) + .createFolder() + .driveTable.withRows(async (rows, _, _context, thePage) => { + await locateLabelsPanelLabels(thePage, LABEL_NAME).dragTo(rows.nth(0)) + await locateAssetLabels(thePage).first().click({ button: 'right' }) + await expect(locateContextMenu(thePage)).toHaveCount(1) + }) + .press('Escape') + .do(async (thePage) => { + await expect(locateContextMenu(thePage)).toHaveCount(0) + })) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index d695e8e6c374..7d489deefa60 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -1,57 +1,84 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -test.test('copy', ({ page }) => - actions - .mockAllAndLogin({ page }) +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** Find a button for the "Trash" category. */ +function locateTrashCategory(page: Page) { + return page.getByLabel('Trash').locator('visible=true') +} + +/** + * Get the left side of the bounding box of an asset row. The locator MUST be for an asset row. + * DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE + * to do anything with the returned values other than comparing them. + */ +function getAssetRowLeftPx(locator: Locator) { + return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +} + +test('copy', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] .createFolder() - .driveTable.rightClickRow(0) + .driveTable.rightClickRow(1) // Assets: [0: Folder 2 , 1: Folder 1] .contextMenu.copy() - .driveTable.rightClickRow(1) + .driveTable.rightClickRow(0) // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .contextMenu.paste() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(3) - await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) - -test.test('copy (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) + await expect(rows).toHaveCount(3) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1 [(]copy[)]*/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) + +test('copy (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] .createFolder() - .driveTable.clickRow(0) + .driveTable.clickRow(1) // Assets: [0: Folder 2 , 1: Folder 1] .press('Mod+C') - .driveTable.clickRow(1) + .driveTable.clickRow(0) // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .press('Mod+V') .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(3) - await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) - -test.test('move', ({ page }) => - actions - .mockAllAndLogin({ page }) + await expect(rows).toHaveCount(3) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1 [(]copy[)]*/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) + +test('move', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -63,55 +90,68 @@ test.test('move', ({ page }) => // Assets: [0: Folder 1, 1: Folder 2 ] .contextMenu.paste() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) - -test.test('move (drag)', ({ page }) => - actions - .mockAllAndLogin({ page }) - // Assets: [0: Folder 1] - .createFolder() - // Assets: [0: Folder 2, 1: Folder 1] - .createFolder() - // Assets: [0: Folder 1, 1: Folder 2 ] + await expect(rows).toHaveCount(2) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 2/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) + +test('move (drag)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ + title: 'New Folder 1', + }) + api.addDirectory({ + title: 'New Folder 2', + }) + }, + }) .driveTable.dragRowToRow(0, 1) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) - -test.test('move to trash', ({ page }) => - actions - .mockAllAndLogin({ page }) - // Assets: [0: Folder 1] - .createFolder() - // Assets: [0: Folder 2, 1: Folder 1] - .createFolder() + await expect(rows).toHaveCount(2) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) + +test('move to trash', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory() + api.addDirectory() + }, + }) // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still // held. - .withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1)) - .driveTable.dragRow(0, actions.locateTrashCategory(page)) + .withModPressed((modActions) => modActions.driveTable.clickRow(1).driveTable.clickRow(0)) + .driveTable.dragRow(0, locateTrashCategory(page)) .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/]) - }), -) + await expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/]) + })) -test.test('move (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('move (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -123,36 +163,30 @@ test.test('move (keyboard)', ({ page }) => // Assets: [0: Folder 1, 1: Folder 2 ] .press('Mod+V') .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) - -test.test('cut (keyboard)', async ({ page }) => - actions - .mockAllAndLogin({ page }) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Folder 2/) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + const childLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) + +test('cut (keyboard)', ({ page }) => + mockAllAndLogin({ page }) .createFolder() .driveTable.clickRow(0) .press('Mod+X') .driveTable.withRows(async (rows) => { // This action is not a builtin `expect` action, so it needs to be manually retried. - await test - .expect(async () => { - test - .expect(await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity))) - .toBeLessThan(1) - }) - .toPass() - }), -) - -test.test('duplicate', ({ page }) => - actions - .mockAllAndLogin({ page }) + await expect(async () => { + expect( + await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity)), + ).toBeLessThan(1) + }).toPass() + })) + +test('duplicate', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: New Project 1] .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 @@ -163,16 +197,14 @@ test.test('duplicate', ({ page }) => .contextMenu.duplicate() .driveTable.withRows(async (rows) => { // Assets: [0: New Project 1, 1: New Project 1 (copy)] - await test.expect(rows).toHaveCount(2) - await test.expect(actions.locateContextMenus(page)).not.toBeVisible() - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) - }), -) - -test.test('duplicate (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) + await expect(rows).toHaveCount(2) + await expect(locateContextMenu(page)).not.toBeVisible() + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) + })) + +test('duplicate (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: New Project 1] .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 @@ -183,8 +215,7 @@ test.test('duplicate (keyboard)', ({ page }) => .press('Mod+D') .driveTable.withRows(async (rows) => { // Assets: [0: New Project 1 (copy), 1: New Project 1] - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) - }), -) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) + })) diff --git a/app/gui/integration-test/dashboard/createAsset.spec.ts b/app/gui/integration-test/dashboard/createAsset.spec.ts index be482b8ec613..a9ae229e685f 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -1,11 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as actions from './actions' - -// ================= -// === Constants === -// ================= +import { mockAllAndLogin } from './actions' /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' @@ -16,50 +12,45 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' -// ============= -// === Tests === -// ============= +/** Find an editor container. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} -test.test('create folder', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create folder', ({ page }) => + mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(/^New Folder 1/) + })) -test.test('create project', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create project', ({ page }) => + mockAllAndLogin({ page }) .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. - // .do((thePage) => test.expect(actions.locateEditor(thePage)).toBeAttached()) + // .do((thePage) => expect(locateEditor(thePage)).toBeAttached()) // .goToPage.drive() - .driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)), -) + .driveTable.withRows((rows) => expect(rows).toHaveCount(1))) -test.test('upload file', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('upload file', ({ page }) => + mockAllAndLogin({ page }) .uploadFile(FILE_NAME, FILE_CONTENTS) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) + })) -test.test('create secret', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create secret', ({ page }) => + mockAllAndLogin({ page }) .createSecret(SECRET_NAME, SECRET_VALUE) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) + })) diff --git a/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts b/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts index ed50465d4e00..36c66dbd3dc8 100644 --- a/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts +++ b/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts @@ -1,15 +1,13 @@ /** @file Test the user settings tab. */ -import * as test from '@playwright/test' +import { test } from '@playwright/test' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' const DATA_LINK_NAME = 'a data link' -test.test('data link editor', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('data link editor', ({ page }) => + mockAllAndLogin({ page }) .openDataLinkModal() .withNameInput(async (input) => { await input.fill(DATA_LINK_NAME) - }), -) + })) diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index d7752874af20..360bc8f69c9d 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -1,39 +1,40 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' +import { modModifier } from 'integration-test/dashboard/actions/BaseActions' import { mockAllAndLogin, TEXT } from './actions' -test.test('delete and restore', ({ page }) => +test('delete and restore', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.rightClickRow(0) .contextMenu.moveFolderToTrash() .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.rightClickRow(0) .contextMenu.restoreFromTrash() .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() + .expectStartModal() .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() + await expect(startModal).toBeVisible() }) - .closeGetStartedModal() + .close() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(1) + })) -test.test('delete and restore (keyboard)', ({ page }) => +test('delete and restore (keyboard)', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.clickRow(0) .press('Delete') @@ -43,17 +44,57 @@ test.test('delete and restore (keyboard)', ({ page }) => .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.clickRow(0) .press('Mod+R') .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() + .expectStartModal() + .close() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(1) + })) + +test('clear trash', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory() + api.addDirectory() + api.addProject() + api.addProject() + api.addFile() + api.addSecret() + api.addDatalink() + }, + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .driveTable.withRows(async (rows, _nonRows, _context, page) => { + const mod = await modModifier(page) + // Parallelizing this using `Promise.all` makes it inconsistent. + const rowEls = await rows.all() + for (const row of rowEls) { + await row.click({ modifiers: [mod] }) + } + }) + .driveTable.rightClickRow(0) + .contextMenu.moveAllToTrash(true) + .driveTable.expectPlaceholderRow() + .goToCategory.trash() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .clearTrash() + .driveTable.expectTrashPlaceholderRow() + .goToCategory.cloud() + .expectStartModal() .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() + await expect(startModal).toBeVisible() }) - .closeGetStartedModal() + .close() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(0) + })) diff --git a/app/gui/integration-test/dashboard/driveView.spec.ts b/app/gui/integration-test/dashboard/driveView.spec.ts index c0d626621ffc..883f54ba9425 100644 --- a/app/gui/integration-test/dashboard/driveView.spec.ts +++ b/app/gui/integration-test/dashboard/driveView.spec.ts @@ -1,37 +1,49 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { TEXT, mockAllAndLogin } from './actions' -test.test('drive view', ({ page }) => - actions - .mockAllAndLogin({ page }) +/** Find an editor container. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a button to close the project. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateStopProjectButton(page: Locator) { + return page.getByLabel(TEXT.stopExecution) +} + +test('drive view', ({ page }) => + mockAllAndLogin({ page }) .withDriveView(async (view) => { - await test.expect(view).toBeVisible() + await expect(view).toBeVisible() }) .driveTable.expectPlaceholderRow() .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. // .do(async () => { - // await test.expect(actions.locateEditor(page)).toBeAttached() + // await expect(locateEditor(page)).toBeAttached() // }) // .goToPage.drive() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) - .do(async () => { - await test.expect(actions.locateAssetsTable(page)).toBeVisible() + .withAssetsTable(async (assetsTable) => { + await expect(assetsTable).toBeVisible() }) .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. // .do(async () => { - // await test.expect(actions.locateEditor(page)).toBeAttached() + // await expect(locateEditor(page)).toBeAttached() // }) // .goToPage.drive() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) + await expect(rows).toHaveCount(2) }) // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. @@ -39,12 +51,11 @@ test.test('drive view', ({ page }) => // // user that project creation may take a while. Previously opened projects are stopped when the // // new project is created. // .driveTable.withRows(async (rows) => { - // await actions.locateStopProjectButton(rows.nth(1)).click() + // await locateStopProjectButton(rows.nth(1)).click() // }) // Project context menu .driveTable.rightClickRow(0) .contextMenu.moveNonFolderToTrash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(1) + })) diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index 8cc7389cd54b..6e952642f361 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,129 +1,335 @@ /** @file Test copying, moving, cutting and pasting. */ -import { test } from '@playwright/test' - -import * as actions from './actions' - -test('edit name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' - - await actions.locateNewFolderIcon(page).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).fill(newName) - await actions.locateEditingTick(row).click() - await test.expect(row).toHaveText(new RegExp('^' + newName)) +import { expect, test, type Locator, type Page } from '@playwright/test' + +import { TEXT, getText, mockAllAndLogin } from './actions' + +const NEW_NAME = 'foo bar baz' +const NEW_NAME_2 = 'foo bar baz quux' + +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** Find the name column of the given assets table row. */ +function locateAssetRowName(locator: Locator) { + return locator.getByTestId('asset-row-name') +} + +function locateInput(nameLocator: Locator) { + return nameLocator.getByRole('textbox') +} + +/** Find a tick button. */ +function locateEditingTick(page: Locator) { + return page.getByLabel(TEXT.confirmEdit) +} + +/** Find a cross button. */ +function locateEditingCross(page: Locator) { + return page.getByLabel(TEXT.cancelEdit) +} + +test('edit name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + await locateInput(nameEl).fill(NEW_NAME) + const calls = api.trackCalls() + await locateEditingTick(row).click() + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }]) + })) + +test('edit name (context menu)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + await locateAssetRowName(row).click({ button: 'right' }) + await locateContextMenu(page) + .getByText(/Rename/) + .click() + const nameEl = locateAssetRowName(row) + await expect(locateInput(nameEl)).toBeVisible() + await expect(locateInput(nameEl)).toBeFocused() + await locateInput(nameEl).fill(NEW_NAME) + await expect(locateInput(nameEl)).toHaveValue(NEW_NAME) + const calls = api.trackCalls() + await nameEl.press('Enter') + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }]) + })) + +test('edit name (keyboard)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + await locateAssetRowName(rows.nth(0)).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + await locateInput(nameEl).fill(NEW_NAME_2) + const calls = api.trackCalls() + await nameEl.press('Enter') + await expect(row).toHaveText(new RegExp('^' + NEW_NAME_2)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME_2 }]) + })) + +test('cancel editing name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.click() + await nameEl.click() + await nameEl.getByTestId('input').fill(NEW_NAME) + const calls = api.trackCalls() + await locateEditingCross(row).click() + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) + +test('cancel editing name (keyboard)', ({ page }) => { + let oldName = '' + return mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + await rows.nth(0).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + oldName = (await nameEl.textContent()) ?? '' + await nameEl.getByTestId('input').fill(NEW_NAME_2) + const calls = api.trackCalls() + await nameEl.press('Escape') + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + }) }) -test('edit name (context menu)', async ({ page }) => { - await actions.mockAllAndLogin({ +test('change to blank name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.click() + await nameEl.click() + await nameEl.getByTestId('input').fill('') + await expect(locateEditingTick(row)).toBeVisible() + const calls = api.trackCalls() + await locateEditingCross(row).click() + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) + +test('change to blank name (keyboard)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + await locateAssetRowName(rows.nth(0)).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows, _, { api }) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.getByTestId('input').fill('') + const calls = api.trackCalls() + await nameEl.press('Enter') + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) + +test('edit name, error message is visible', ({ page }) => { + return mockAllAndLogin({ page, setupAPI: (api) => { - api.addAsset(api.createDirectory({ title: 'foo' })) + for (let i = 0; i < 100; i++) { + api.addProject({ title: 'Some Project ' + i }) + } }, - }) + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() - await actions.locateAssetRowName(row).click({ button: 'right' }) - await actions - .locateContextMenus(page) - .getByText(/Rename/) - .click() + const inputEl = locateInput(nameEl) - const input = page.getByTestId('asset-row-name') + await inputEl.fill('') - await test.expect(input).toBeVisible() - await test.expect(input).toBeFocused() + await locateEditingTick(row).click() - await input.fill(newName) + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorText = formElement.getByTestId('error-message-text') - await test.expect(input).toHaveValue(newName) + await expect(errorOutline).toBeVisible() + // Clicking the element to be sure it's not overlapped by another element. + await errorText.click() - await input.press('Enter') + await inputEl.fill('Another Project') + await locateEditingTick(row).click() - await test.expect(row).toHaveText(new RegExp('^' + newName)) + await expect(errorOutline).not.toBeAttached() + await expect(errorText).not.toBeAttached() + }) }) -test('edit name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) +test('edit name (empty name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({ title: 'Some Project' }) + api.addProject({ title: 'Other Project' }) + api.addProject({ title: 'Yet Another Project' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz quux' + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() - await actions.locateNewFolderIcon(page).click() - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill(newName) - await actions.locateAssetRowName(row).press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + newName)) -}) + const inputEl = locateInput(nameEl) -test('cancel editing name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await inputEl.fill('') - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' + await locateEditingTick(row).click() - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') - await actions.locateAssetRowName(row).fill(newName) - await actions.locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) + await expect(errorOutline).toBeVisible() + await expect(errorContainer).toBeVisible() + await expect(errorText).toHaveText(getText('arbitraryFieldRequired')) -test('cancel editing name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await inputEl.fill('Another Project') + await locateEditingTick(row).click() - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz quux' + await expect(row).toHaveText(/^Another Project/) + await expect(errorOutline).not.toBeAttached() + await expect(errorContainer).not.toBeAttached() + })) - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill(newName) - await actions.locateAssetRowName(row).press('Escape') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) +test('edit name (invalid name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ title: 'Some Directory' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() -test('change to blank name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) + const inputEl = locateInput(nameEl) - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).fill('') - await test.expect(actions.locateEditingTick(row)).not.toBeVisible() - await actions.locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) + await inputEl.fill('../') -test('change to blank name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await locateEditingTick(row).click() - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill('') - await actions.locateAssetRowName(row).press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) + await expect(errorOutline).toBeVisible() + await expect(errorContainer).toBeVisible({ + visible: true, + }) + await expect(errorText).toHaveText(getText('nameShouldNotContainInvalidCharacters')) + + await inputEl.fill('Other Directory') + await locateEditingTick(row).click() + + await expect(row).toHaveText(/^Other Directory/) + })) + +test('edit name (duplicate name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ title: 'Some Directory' }) + api.addProject({ title: 'Some Project' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('Some Project') + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorText).toHaveText(getText('nameShouldBeUnique')) + + await inputEl.fill('Other Directory') + await locateEditingTick(row).click() + + await expect(row).toHaveText(/^Other Directory/) + })) + +test('error should not overlay the table header', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + for (let i = 0; i < 100; i++) { + api.addProject({ title: 'Some Project ' + i }) + } + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(1) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('Some Project 0') + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorText).toHaveText(getText('nameShouldBeUnique')) + + await rows.nth(51).scrollIntoViewIfNeeded() + + await expect(errorOutline).not.toBeInViewport() + await expect(errorContainer).not.toBeInViewport() + await expect(errorText).not.toBeInViewport() + })) diff --git a/app/gui/integration-test/dashboard/labels.spec.ts b/app/gui/integration-test/dashboard/labels.spec.ts index 77b485aed2d4..00c15d1e8bcf 100644 --- a/app/gui/integration-test/dashboard/labels.spec.ts +++ b/app/gui/integration-test/dashboard/labels.spec.ts @@ -1,80 +1,90 @@ /** @file Test dragging of labels. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as backend from '#/services/Backend' +import { COLORS } from '#/services/Backend' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } +const LABEL = 'aaaa' +const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } /** Click an asset row. The center must not be clicked as that is the button for adding a label. */ -export async function clickAssetRow(assetRow: test.Locator) { +async function clickAssetRow(assetRow: Locator) { await assetRow.click({ position: ASSET_ROW_SAFE_POSITION }) } -test.test('drag labels onto single row', async ({ page }) => { - const label = 'aaaa' - return actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addLabel(label, backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) - api.addDirectory('foo') - api.addSecret('bar') - api.addFile('baz') - api.addSecret('quux') - }, - }) - .do(async () => { - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) +/** Find labels in the "Labels" column of the assets table. */ +function locateAssetLabels(page: Locator) { + return page.getByTestId('asset-label') +} - await test.expect(labelEl).toBeVisible() - await labelEl.dragTo(assetRows.nth(1)) - await test - .expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)) - .not.toBeVisible() - await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible() - await test - .expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)) - .not.toBeVisible() - await test - .expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)) - .not.toBeVisible() - }) -}) +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} -test.test('drag labels onto multiple rows', async ({ page }) => { - const label = 'aaaa' - await actions.mockAllAndLogin({ +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('drag labels onto single row', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel(label, backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) - api.addDirectory('foo') - api.addSecret('bar') - api.addFile('baz') - api.addSecret('quux') + api.addLabel(LABEL, COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) + api.addDirectory({ title: 'foo' }) + api.addSecret({ title: 'bar' }) + api.addFile({ title: 'baz' }) + api.addSecret({ title: 'quux' }) }, - }) + }).driveTable.withRows(async (rows, _, _context, page) => { + const labelEl = locateLabelsPanelLabels(page, LABEL) + await expect(labelEl).toBeVisible() + await labelEl.dragTo(rows.nth(1)) + await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible() + })) - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) - - await page.keyboard.down(await actions.modModifier(page)) - await test.expect(assetRows).toHaveCount(4) - await clickAssetRow(assetRows.nth(0)) - await clickAssetRow(assetRows.nth(2)) - await test.expect(labelEl).toBeVisible() - await labelEl.dragTo(assetRows.nth(2)) - await page.keyboard.up(await actions.modModifier(page)) - await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() - await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() - await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible() - await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() -}) +test('drag labels onto multiple rows', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addLabel(LABEL, COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) + api.addDirectory({ title: 'foo' }) + api.addSecret({ title: 'bar' }) + api.addFile({ title: 'baz' }) + api.addSecret({ title: 'quux' }) + }, + }) + .withModPressed((self) => + self.driveTable.withRows(async (rows, _, _context, page) => { + const labelEl = locateLabelsPanelLabels(page, LABEL) + await expect(rows).toHaveCount(4) + await clickAssetRow(rows.nth(0)) + await clickAssetRow(rows.nth(2)) + await expect(labelEl).toBeVisible() + await labelEl.dragTo(rows.nth(2)) + }), + ) + .driveTable.withRows(async (rows) => { + await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/labelsPanel.spec.ts b/app/gui/integration-test/dashboard/labelsPanel.spec.ts index bdc6f03e6981..f38f2204974a 100644 --- a/app/gui/integration-test/dashboard/labelsPanel.spec.ts +++ b/app/gui/integration-test/dashboard/labelsPanel.spec.ts @@ -1,57 +1,95 @@ /** @file Test the labels sidebar panel. */ -import * as test from '@playwright/test' - -import { - locateCreateButton, - locateLabelsPanel, - locateLabelsPanelLabels, - locateNewLabelButton, - locateNewLabelModal, - locateNewLabelModalColorButtons, - locateNewLabelModalNameInput, - mockAllAndLogin, - TEXT, -} from './actions' - -test.test.beforeEach(({ page }) => mockAllAndLogin({ page })) - -test.test('labels', async ({ page }) => { - // Empty labels panel - await test.expect(locateLabelsPanel(page)).toBeVisible() - - // "New Label" modal - await locateNewLabelButton(page).click() - await test.expect(locateNewLabelModal(page)).toBeVisible() - - // "New Label" modal with name set - await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) - - await page.press('html', 'Escape') - - // "New Label" modal with color set - // The exact number is allowed to vary; but to click the fourth color, there must be at least - // four colors. - await locateNewLabelButton(page).click() - test.expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) - // `force: true` is required because the `label` needs to handle the click event, not the - // `button`. - await locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) - await test.expect(locateNewLabelModal(page)).toBeVisible() - - // "New Label" modal with name and color set - await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) - - // Labels panel with one entry - await locateCreateButton(locateNewLabelModal(page)).click() - await test.expect(locateLabelsPanel(page)).toBeVisible() - - // Empty labels panel again, after deleting the only entry - await locateLabelsPanelLabels(page).first().hover() - - const labelsPanel = locateLabelsPanel(page) - await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() - await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() - test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) -}) +import { expect, test, type Locator, type Page } from '@playwright/test' + +import { mockAllAndLogin, TEXT } from './actions' + +/** Find a "new label" button. */ +function locateNewLabelButton(page: Page) { + return page.getByRole('button', { name: 'new label' }).getByText('new label') +} + +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find a "new label" modal. */ +function locateNewLabelModal(page: Page) { + // This has no identifying features. + return page.getByTestId('new-label-modal') +} + +/** Find a "name" input for a "new label" modal. */ +function locateNewLabelModalNameInput(page: Page) { + return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) +} + +/** Find all color radio button inputs for a "new label" modal. */ +function locateNewLabelModalColorButtons(page: Page) { + return ( + locateNewLabelModal(page) + .filter({ has: page.getByText('Color') }) + // The `radio` inputs are invisible, so they cannot be used in the locator. + .locator('label[data-rac]') + ) +} + +/** Find a "create" button. */ +function locateCreateButton(page: Locator) { + return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create) +} + +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('labels', ({ page }) => + mockAllAndLogin({ page }) + .do(async (page) => { + // Empty labels panel + await expect(locateLabelsPanel(page)).toBeVisible() + + // "New Label" modal + await locateNewLabelButton(page).click() + await expect(locateNewLabelModal(page)).toBeVisible() + + // "New Label" modal with name set + await locateNewLabelModalNameInput(page).fill('New Label') + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + }) + .press('Escape') + .do(async (page) => { + // "New Label" modal with color set + // The exact number is allowed to vary; but to click the fourth color, there must be at least + // four colors. + await locateNewLabelButton(page).click() + expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) + // `force: true` is required because the `label` needs to handle the click event, not the + // `button`. + await locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) + await expect(locateNewLabelModal(page)).toBeVisible() + + // "New Label" modal with name and color set + await locateNewLabelModalNameInput(page).fill('New Label') + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + + // Labels panel with one entry + await locateCreateButton(locateNewLabelModal(page)).click() + await expect(locateLabelsPanel(page)).toBeVisible() + + // Empty labels panel again, after deleting the only entry + await locateLabelsPanelLabels(page).first().hover() + + const labelsPanel = locateLabelsPanel(page) + await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) + })) diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index 157865d096fd..c4660ff68333 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -1,26 +1,36 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as actions from './actions' +import { TEXT, mockAll } from './actions' -// ============= -// === Tests === -// ============= +/** Find a "login" button.on the current locator. */ +function locateLoginButton(page: Page) { + return page.getByRole('button', { name: TEXT.login, exact: true }).getByText(TEXT.login) +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('login and logout', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('login and logout', ({ page }) => + mockAll({ page }) + .login() + .expectStartModal() + .close() + .withDriveView(async (driveView) => { + await expect(driveView).toBeVisible() + }) .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() + await expect(locateLoginButton(thePage)).not.toBeVisible() }) .openUserMenu() .userMenu.logout() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).toBeVisible() - }), -) + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateLoginButton(thePage)).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/loginScreen.spec.ts b/app/gui/integration-test/dashboard/loginScreen.spec.ts index ca0a5fb23940..a537ba765fd0 100644 --- a/app/gui/integration-test/dashboard/loginScreen.spec.ts +++ b/app/gui/integration-test/dashboard/loginScreen.spec.ts @@ -1,16 +1,12 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' -// ============= -// === Tests === -// ============= - // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('login screen', ({ page }) => +test('login screen', ({ page }) => mockAll({ page }) .loginThatShouldFail('invalid email', VALID_PASSWORD, { assert: { @@ -22,6 +18,5 @@ test.test('login screen', ({ page }) => // Technically it should not be allowed, but .login(VALID_EMAIL, INVALID_PASSWORD) .withDriveView(async (driveView) => { - await test.expect(driveView).toBeVisible() - }), -) + await expect(driveView).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/mock/example.png b/app/gui/integration-test/dashboard/mock/example.png new file mode 100644 index 000000000000..b4d6d8b3cb9c Binary files /dev/null and b/app/gui/integration-test/dashboard/mock/example.png differ diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 6fd4e02160b4..bc14b55744e8 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -1,106 +1,101 @@ /** @file Test the organization settings tab. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { Plan } from 'enso-common/src/services/Backend' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -test.test('organization settings', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ +const NEW_NAME = 'another organization-name' +const INVALID_EMAIL = 'invalid@email' +const NEW_EMAIL = 'organization@email.com' +const NEW_WEBSITE = 'organization.org' +const NEW_LOCATION = 'Somewhere, CA' +const PROFILE_PICTURE_FILENAME = 'bar.jpeg' +const PROFILE_PICTURE_CONTENT = 'organization profile picture' +const PROFILE_PICTURE_MIMETYPE = 'image/jpeg' + +test('organization settings', ({ page }) => + mockAllAndLogin({ page, - setupAPI: (theApi) => { - theApi.setPlan(Plan.team) + setupAPI: (api) => { + api.setPlan(Plan.team) + api.setCurrentOrganization(api.defaultOrganization) }, }) - const localActions = actions.settings.organization - - // Setup - api.setCurrentOrganization(api.defaultOrganization) - await test.test.step('Initial state', () => { - test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) - test.expect(api.currentOrganization()?.email).toBe(null) - test.expect(api.currentOrganization()?.picture).toBe(null) - test.expect(api.currentOrganization()?.website).toBe(null) - test.expect(api.currentOrganization()?.address).toBe(null) - }) - await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() - - await localActions.go(page) - const nameInput = localActions.locateNameInput(page) - const newName = 'another organization-name' - await test.test.step('Set name', async () => { - await nameInput.fill(newName) - await nameInput.press('Enter') - test.expect(api.currentOrganization()?.name).toBe(newName) - test.expect(api.currentUser()?.name).not.toBe(newName) - }) - - await test.test.step('Unset name (should fail)', async () => { - await nameInput.fill('') - await nameInput.press('Enter') - await test.expect(nameInput).toHaveValue('') - test.expect(api.currentOrganization()?.name).toBe(newName) - await page.getByRole('button', { name: actions.TEXT.cancel }).click() - }) - - const invalidEmail = 'invalid@email' - const emailInput = localActions.locateEmailInput(page) - - await test.test.step('Set invalid email', async () => { - await emailInput.fill(invalidEmail) - await emailInput.press('Enter') - test.expect(api.currentOrganization()?.email).toBe('') - }) - - const newEmail = 'organization@email.com' - - await test.test.step('Set email', async () => { - await emailInput.fill(newEmail) - await emailInput.press('Enter') - test.expect(api.currentOrganization()?.email).toBe(newEmail) - await test.expect(emailInput).toHaveValue(newEmail) - }) - - const websiteInput = localActions.locateWebsiteInput(page) - const newWebsite = 'organization.org' - - // NOTE: It's not yet possible to unset the website or the location. - await test.test.step('Set website', async () => { - await websiteInput.fill(newWebsite) - await websiteInput.press('Enter') - test.expect(api.currentOrganization()?.website).toBe(newWebsite) - await test.expect(websiteInput).toHaveValue(newWebsite) - }) - - const locationInput = localActions.locateLocationInput(page) - const newLocation = 'Somewhere, CA' - - await test.test.step('Set location', async () => { - await locationInput.fill(newLocation) - await locationInput.press('Enter') - test.expect(api.currentOrganization()?.address).toBe(newLocation) - await test.expect(locationInput).toHaveValue(newLocation) - }) -}) + .step('Verify initial organization state', (_, { api }) => { + expect(api.defaultUser.isOrganizationAdmin).toBe(true) + expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) + expect(api.currentOrganization()?.email).toBe(null) + expect(api.currentOrganization()?.picture).toBe(null) + expect(api.currentOrganization()?.website).toBe(null) + expect(api.currentOrganization()?.address).toBe(null) + }) + .goToPage.settings() + .goToSettingsTab.organization() + .organizationForm() + .fillName(NEW_NAME) + .do((_, context) => { + context.calls = context.api.trackCalls() + }) + .save() + .step('Set organization name', (_, { api, calls }) => { + expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(api.currentUser()?.name).not.toBe(NEW_NAME) + expect(calls.updateOrganization).toMatchObject([{ name: NEW_NAME }]) + }) + .organizationForm() + .fillName('') + .do((_, context) => { + context.calls = context.api.trackCalls() + }) + .save() + .step('Unsetting organization name should fail', (_, { api, calls }) => { + expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(calls.updateOrganization).toMatchObject([{ name: '' }]) + }) + .organizationForm() + .cancel() + .organizationForm() + .fillEmail(INVALID_EMAIL) + .save() + .step('Setting invalid email should fail', (_, { api }) => { + expect(api.currentOrganization()?.email).toBe('') + }) + .organizationForm() + .fillEmail(NEW_EMAIL) + .save() + .step('Set email', (_, { api }) => { + expect(api.currentOrganization()?.email).toBe(NEW_EMAIL) + }) + .organizationForm() + .fillWebsite(NEW_WEBSITE) + .save() + // NOTE: It is not yet possible to unset the website or the location. + .step('Set website', async (_, { api }) => { + expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE) + }) + .organizationForm() + .fillLocation(NEW_LOCATION) + .save() + .step('Set website', async (_, { api }) => { + expect(api.currentOrganization()?.address).toBe(NEW_LOCATION) + })) -test.test('upload organization profile picture', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ +test('upload organization profile picture', ({ page }) => + mockAllAndLogin({ page, setupAPI: (theApi) => { theApi.setPlan(Plan.team) }, }) - const localActions = actions.settings.organizationProfilePicture - - await localActions.go(page) - const fileChooserPromise = page.waitForEvent('filechooser') - await localActions.locateInput(page).click() - const fileChooser = await fileChooserPromise - const name = 'bar.jpeg' - const content = 'organization profile picture' - await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'image/jpeg' }]) - await test - .expect(() => { - test.expect(api.currentOrganizationProfilePicture()).toEqual(content) - }) - .toPass() -}) + .goToPage.settings() + .goToSettingsTab.organization() + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) + .step('Profile picture should be updated', async (_, { api }) => { + await expect(() => { + expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }).toPass() + })) diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index 31fa31200173..de26fe3bded7 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -1,27 +1,38 @@ /** @file Test the login flow. */ -// import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import * as actions from './actions' +import { mockAllAndLogin } from './actions' -// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. -// test.test('page switcher', ({ page }) => -// actions -// .mockAllAndLogin({ page }) -// // Create a new project so that the editor page can be switched to. -// .newEmptyProject() -// .do(async (thePage) => { -// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await test.expect(actions.locateEditor(thePage)).toBeVisible() -// }) -// .goToPage.drive() -// .do(async (thePage) => { -// await test.expect(actions.locateDriveView(thePage)).toBeVisible() -// await test.expect(actions.locateEditor(thePage)).not.toBeVisible() -// }) -// .goToPage.editor() -// .do(async (thePage) => { -// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await test.expect(actions.locateEditor(thePage)).toBeVisible() -// }), -// ) +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} + +test('page switcher', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => api.setFeatureFlags({ enableCloudExecution: true }), + }) + // Create a new project so that the editor page can be switched to. + .newEmptyProjectTest() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() + }) + .goToPage.drive() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).toBeVisible() + await expect(locateEditor(thePage)).not.toBeVisible() + }) + .goToPage.editor() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/setup.spec.ts b/app/gui/integration-test/dashboard/setup.spec.ts index 711f419d7b74..1ef76f9af1a5 100644 --- a/app/gui/integration-test/dashboard/setup.spec.ts +++ b/app/gui/integration-test/dashboard/setup.spec.ts @@ -1,54 +1,49 @@ /** @file Test the setup flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { Plan } from 'enso-common/src/services/Backend' -import * as actions from './actions' +import { mockAll } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('setup (free plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (free plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .stayOnFreePlan() .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (solo plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (solo plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectSoloPlan() .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (team plan, skipping invites)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (team plan, skipping invites)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectTeamPlan(Plan.team) @@ -57,18 +52,16 @@ test.test('setup (team plan, skipping invites)', ({ page }) => .setTeamName('test team') .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (team plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (team plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectTeamPlan(Plan.team, 10) @@ -77,8 +70,7 @@ test.test('setup (team plan)', ({ page }) => .setTeamName('test team') .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) // No test for enterprise plan as the plan must be set to enterprise manually. diff --git a/app/gui/integration-test/dashboard/signUp.spec.ts b/app/gui/integration-test/dashboard/signUp.spec.ts index 6d9d6b556325..166e58e1455b 100644 --- a/app/gui/integration-test/dashboard/signUp.spec.ts +++ b/app/gui/integration-test/dashboard/signUp.spec.ts @@ -1,16 +1,12 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { test } from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' -// ============= -// === Tests === -// ============= - // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('sign up without organization id', ({ page }) => +test('sign up without organization id', ({ page }) => mockAll({ page }) .goToPage.register() .registerThatShouldFail('invalid email', VALID_PASSWORD, VALID_PASSWORD, { @@ -37,5 +33,4 @@ test.test('sign up without organization id', ({ page }) => formError: null, }, }) - .register(), -) + .register()) diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index 5847e6f6b083..16483dcb6672 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -1,43 +1,62 @@ /** @file Test sorting of assets columns. */ -import * as test from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' -import * as dateTime from '#/utilities/dateTime' +import { toRfc3339 } from '#/utilities/dateTime' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -// ================= -// === Constants === -// ================= +/** A test assertion to confirm that the element is fully transparent. */ +async function expectOpacity0(locator: Locator) { + await test.step('Expect `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') + }).toPass() + }) +} + +/** A test assertion to confirm that the element is not fully transparent. */ +async function expectNotOpacity0(locator: Locator) { + await test.step('Expect not `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') + }).toPass() + }) +} + +/** Find a "sort ascending" icon. */ +function locateSortAscendingIcon(page: Locator) { + return page.getByAltText('Sort Ascending') +} + +/** Find a "sort descending" icon. */ +function locateSortDescendingIcon(page: Locator) { + return page.getByAltText('Sort Descending') +} const START_DATE_EPOCH_MS = 1.7e12 /** The number of milliseconds in a minute. */ const MIN_MS = 60_000 -// ============= -// === Tests === -// ============= - -test.test('sort', async ({ page }) => { - await actions.mockAll({ +test('sort', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS)) - const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS)) - const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS)) - const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS)) - const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS)) - const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS)) - const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS)) - const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS)) - + const date1 = toRfc3339(new Date(START_DATE_EPOCH_MS)) + const date2 = toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS)) + const date3 = toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS)) + const date4 = toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS)) + const date5 = toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS)) + const date6 = toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS)) + const date7 = toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS)) + const date8 = toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS)) api.addDirectory({ modifiedAt: date4, title: 'a directory' }) api.addDirectory({ modifiedAt: date6, title: 'G directory' }) api.addProject({ modifiedAt: date7, title: 'C project' }) + api.addSecret({ modifiedAt: date2, title: 'H secret' }) api.addProject({ modifiedAt: date1, title: 'b project' }) api.addFile({ modifiedAt: date8, title: 'd file' }) - api.addFile({ modifiedAt: date5, title: 'e file' }) - api.addSecret({ modifiedAt: date2, title: 'H secret' }) api.addSecret({ modifiedAt: date3, title: 'f secret' }) + api.addFile({ modifiedAt: date5, title: 'e file' }) // By date: // b project // h secret @@ -49,113 +68,135 @@ test.test('sort', async ({ page }) => { // d file }, }) - const assetRows = actions.locateAssetRows(page) - const nameHeading = actions.locateNameColumnHeading(page) - const modifiedHeading = actions.locateModifiedColumnHeading(page) - await actions.login({ page }) - - // By default, assets should be grouped by type. - // Assets in each group are ordered by insertion order. - await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible() - await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^f secret/), - ]) - - // Sort by name ascending. - await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^b project/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^d file/), - test.expect(assetRows.nth(4)).toHaveText(/^e file/), - test.expect(assetRows.nth(5)).toHaveText(/^f secret/), - test.expect(assetRows.nth(6)).toHaveText(/^G directory/), - test.expect(assetRows.nth(7)).toHaveText(/^H secret/), - ]) - - // Sort by name descending. - await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(nameHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^H secret/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^f secret/), - test.expect(assetRows.nth(3)).toHaveText(/^e file/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^C project/), - test.expect(assetRows.nth(6)).toHaveText(/^b project/), - test.expect(assetRows.nth(7)).toHaveText(/^a directory/), - ]) - - // Sorting should be unset. - await nameHeading.click() - await page.mouse.move(0, 0) - await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible() - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^f secret/), - ]) - - // Sort by date ascending. - await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^b project/), - test.expect(assetRows.nth(1)).toHaveText(/^H secret/), - test.expect(assetRows.nth(2)).toHaveText(/^f secret/), - test.expect(assetRows.nth(3)).toHaveText(/^a directory/), - test.expect(assetRows.nth(4)).toHaveText(/^e file/), - test.expect(assetRows.nth(5)).toHaveText(/^G directory/), - test.expect(assetRows.nth(6)).toHaveText(/^C project/), - test.expect(assetRows.nth(7)).toHaveText(/^d file/), - ]) - - // Sort by date descending. - await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(modifiedHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^d file/), - test.expect(assetRows.nth(1)).toHaveText(/^C project/), - test.expect(assetRows.nth(2)).toHaveText(/^G directory/), - test.expect(assetRows.nth(3)).toHaveText(/^e file/), - test.expect(assetRows.nth(4)).toHaveText(/^a directory/), - test.expect(assetRows.nth(5)).toHaveText(/^f secret/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^b project/), - ]) - - // Sorting should be unset. - await modifiedHeading.click() - await page.mouse.move(0, 0) - await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^f secret/), - ]) -}) + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + }) + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + // By default, assets should be grouped by type. + // Assets in each group are ordered by insertion order. + await expect(rows).toHaveText([ + /^G directory/, + /^a directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^f secret/, + /^H secret/, + ]) + }) + // Sort by name ascending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^a directory/, + /^b project/, + /^C project/, + /^d file/, + /^e file/, + /^f secret/, + /^G directory/, + /^H secret/, + ]) + }) + // Sort by name descending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^H secret/, + /^G directory/, + /^f secret/, + /^e file/, + /^d file/, + /^C project/, + /^b project/, + /^a directory/, + ]) + }) + // Sorting should be unset. + .driveTable.clickNameColumnHeading() + .do(async (thePage) => { + await thePage.mouse.move(0, 0) + }) + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^G directory/, + /^a directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^f secret/, + /^H secret/, + ]) + }) + // Sort by date ascending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^b project/, + /^H secret/, + /^f secret/, + /^a directory/, + /^e file/, + /^G directory/, + /^C project/, + /^d file/, + ]) + }) + // Sort by date descending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^d file/, + /^C project/, + /^G directory/, + /^e file/, + /^a directory/, + /^f secret/, + /^H secret/, + /^b project/, + ]) + }) + // Sorting should be unset. + .driveTable.clickModifiedColumnHeading() + .do(async (thePage) => { + await thePage.mouse.move(0, 0) + }) + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^G directory/, + /^a directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^f secret/, + /^H secret/, + ]) + })) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index f8ee96816abe..3c1ef1b6b8cb 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -1,17 +1,31 @@ /** @file Test the "change password" modal. */ -// import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import * as actions from './actions' +import { mockAllAndLogin } from './actions' -// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. -// test.test('create project from template', ({ page }) => -// actions -// .mockAllAndLogin({ page }) -// .openStartModal() -// .createProjectFromTemplate(0) -// .do(async (thePage) => { -// await test.expect(actions.locateEditor(thePage)).toBeAttached() -// await test.expect(actions.locateSamples(page).first()).not.toBeVisible() -// }), -// ) +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a samples list. */ +function locateSamplesList(page: Page) { + // This has no identifying features. + return page.getByTestId('samples') +} + +/** Find all samples list. */ +function locateSamples(page: Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} + +test('create project from template', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => api.setFeatureFlags({ enableCloudExecution: true }) }) + .openStartModal() + .createProjectFromTemplate(0) + .do(async (thePage) => { + await expect(locateEditor(thePage)).toBeAttached() + await expect(locateSamples(page).first()).not.toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index d491940f0f10..83a1793721da 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -1,89 +1,80 @@ /** @file Test the user settings tab. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { INVALID_PASSWORD, TEXT, VALID_PASSWORD, mockAllAndLogin } from './actions' -test.test('user settings', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.userAccount - test.expect(api.currentUser()?.name).toBe(api.defaultName) +const NEW_USERNAME = 'another user-name' +const NEW_PASSWORD = '1234!' + VALID_PASSWORD +const PROFILE_PICTURE_FILENAME = 'foo.png' +const PROFILE_PICTURE_CONTENT = 'a profile picture' +const PROFILE_PICTURE_MIMETYPE = 'image/png' - await localActions.go(page) - const nameInput = localActions.locateNameInput(page) - const newName = 'another user-name' - await nameInput.fill(newName) - await nameInput.press('Enter') - test.expect(api.currentUser()?.name).toBe(newName) - test.expect(api.currentOrganization()?.name).not.toBe(newName) -}) - -test.test('change password form', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.changePassword - - await localActions.go(page) - test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD) - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) +test('user settings', ({ page }) => + mockAllAndLogin({ page }) + .do((_, { api }) => { + expect(api.currentUser()?.name).toBe(api.defaultName) + }) + .goToPage.settings() + .accountForm() + .fillName(NEW_USERNAME) + .save() + .do((_, { api }) => { + expect(api.currentUser()?.name).toBe(NEW_USERNAME) + expect(api.currentOrganization()?.name).not.toBe(NEW_USERNAME) + })) - await test.test.step('Invalid new password', async () => { - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - await localActions.locateSaveButton(page).click() - await test - .expect( - localActions - .locate(page) +test('change password form', ({ page }) => + mockAllAndLogin({ page }) + .do((_, { api }) => { + expect(api.currentPassword()).toBe(VALID_PASSWORD) + }) + .goToPage.settings() + .changePasswordForm() + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(INVALID_PASSWORD) + .fillConfirmNewPassword(INVALID_PASSWORD) + .save() + .step('Invalid new password should fail', async (page) => { + await expect( + page .getByRole('group', { name: /^New password/, exact: true }) .locator('.text-danger') .last(), - ) - .toHaveText(actions.TEXT.passwordValidationError) - }) - - await test.test.step('Invalid new password confirmation', async () => { - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a') - await localActions.locateSaveButton(page).click() - await test - .expect( - localActions - .locate(page) + ).toHaveText(TEXT.passwordValidationError) + }) + .changePasswordForm() + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(VALID_PASSWORD) + .fillConfirmNewPassword(VALID_PASSWORD + 'a') + .save() + .step('Invalid new password confirmation should fail', async (page) => { + await expect( + page .getByRole('group', { name: /^Confirm new password/, exact: true }) .locator('.text-danger') .last(), - ) - .toHaveText(actions.TEXT.passwordMismatchError) - }) - - await test.test.step('Successful password change', async () => { - const newPassword = '1234!' + actions.VALID_PASSWORD - await localActions.locateNewPasswordInput(page).fill(newPassword) - await localActions.locateConfirmNewPasswordInput(page).fill(newPassword) - await localActions.locateSaveButton(page).click() - await test.expect(localActions.locateCurrentPasswordInput(page)).toHaveText('') - await test.expect(localActions.locateNewPasswordInput(page)).toHaveText('') - await test.expect(localActions.locateConfirmNewPasswordInput(page)).toHaveText('') - test.expect(api.currentPassword()).toBe(newPassword) - }) -}) - -test.test('upload profile picture', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.profilePicture - - await localActions.go(page) - const fileChooserPromise = page.waitForEvent('filechooser') - await localActions.locateInput(page).click() - const fileChooser = await fileChooserPromise - const name = 'foo.png' - const content = 'a profile picture' - await fileChooser.setFiles([{ name, mimeType: 'image/png', buffer: Buffer.from(content) }]) - await test - .expect(() => { - test.expect(api.currentProfilePicture()).toEqual(content) + ).toHaveText(TEXT.passwordMismatchError) }) - .toPass() -}) + .changePasswordForm() + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(NEW_PASSWORD) + .fillConfirmNewPassword(NEW_PASSWORD) + .save() + // TODO: consider checking that password inputs are now empty. + .step('Password change should be successful', (_, { api }) => { + expect(api.currentPassword()).toBe(NEW_PASSWORD) + })) + +test('upload profile picture', ({ page }) => + mockAllAndLogin({ page }) + .goToPage.settings() + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) + .step('Profile picture should be updated', async (_, { api }) => { + await expect(() => { + expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }).toPass() + })) diff --git a/app/gui/integration-test/project-view/actions.ts b/app/gui/integration-test/project-view/actions.ts index 96743db1f575..00522b0410aa 100644 --- a/app/gui/integration-test/project-view/actions.ts +++ b/app/gui/integration-test/project-view/actions.ts @@ -66,9 +66,9 @@ export async function dragNodeByBinding(page: Page, nodeBinding: string, x: numb } /** Move mouse away to avoid random hover events and wait for any circular menus to disappear. */ -export async function ensureNoCircularMenusVisibleDueToHovering(page: Page) { +export async function ensureNoComponentMenusVisibleDueToHovering(page: Page) { await page.mouse.move(-1000, 0) - await expect(locate.circularMenu(page)).toBeHidden() + await expect(locate.componentMenu(page)).toBeHidden() } /** Ensure no nodes are selected. */ diff --git a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts index 20ce3cdc97ae..a38ec0f5ff97 100644 --- a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts +++ b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts @@ -2,7 +2,7 @@ import { test, type Page } from '@playwright/test' import * as actions from './actions' import { expect } from './customExpect' import { mockCollapsedFunctionInfo } from './expressionUpdates' -import { CONTROL_KEY } from './keyboard' +import { CONTROL_KEY, DELETE_KEY } from './keyboard' import * as locate from './locate' import { edgesFromNode, edgesToNode } from './locate' import { mockSuggestion } from './suggestionUpdates' @@ -200,6 +200,37 @@ test('Input node is not collapsed', async ({ page }) => { await expect(locate.outputNode(page)).toHaveCount(1) }) +test('Collapsed call shows argument placeholders', async ({ page }) => { + await actions.goToGraph(page) + await mockCollapsedFunctionInfo(page, 'final', 'func1', [0]) + await mockSuggestion(page, { + type: 'method', + module: 'local.Mock_Project.Main', + name: 'func1', + arguments: [ + { + name: 'arg1', + reprType: 'Standard.Base.Any.Any', + isSuspended: false, + hasDefault: false, + defaultValue: null as any, + tagValues: null as any, + }, + ], + selfType: 'local.Mock_Project.Main', + returnType: 'Standard.Base.Any.Any', + isStatic: true, + documentation: '', + annotations: [], + }) + const collapsedCallComponent = locate.graphNodeByBinding(page, 'final') + await locate.graphNodeByBinding(page, 'prod').click() + await page.keyboard.press(DELETE_KEY) + await expect(await edgesToNode(page, collapsedCallComponent)).toHaveCount(0) + await expect(locate.selectedNodes(page)).toHaveCount(0) + await expect(collapsedCallComponent.locator('.WidgetArgumentName .name')).toHaveText('arg1') +}) + async function expectInsideMain(page: Page) { await actions.expectNodePositionsInitialized(page, -16) await expect(locate.graphNode(page)).toHaveCount(MAIN_FILE_NODES) diff --git a/app/gui/integration-test/project-view/componentBrowser.spec.ts b/app/gui/integration-test/project-view/componentBrowser.spec.ts index 3e49c831d834..95607c23504e 100644 --- a/app/gui/integration-test/project-view/componentBrowser.spec.ts +++ b/app/gui/integration-test/project-view/componentBrowser.spec.ts @@ -73,25 +73,21 @@ test('Different ways of opening Component Browser', async ({ page }) => { await expectAndCancelBrowser(page, '', 'selected') }) -test('Opening Component Browser with small plus buttons', async ({ page }) => { +test('Opening Component Browser from output port buttons', async ({ page }) => { await actions.goToGraph(page) // Small (+) button shown when node is hovered - await page.keyboard.press('Escape') - await page.mouse.move(100, 80) - await expect(locate.smallPlusButton(page)).toBeHidden() - await locate.graphNodeIcon(locate.graphNodeByBinding(page, 'selected')).hover() - await expect(locate.smallPlusButton(page)).toBeVisible() - await locate.smallPlusButton(page).click() + const node = locate.graphNodeByBinding(page, 'selected') + await locate.graphNodeIcon(node).hover() + await expect(locate.createNodeFromPort(node)).toBeVisible() + await locate.createNodeFromPort(node).click({ force: true }) await expectAndCancelBrowser(page, '', 'selected') - // Small (+) button shown when node is sole selection + // Small (+) button shown when node is selected await page.keyboard.press('Escape') - await page.mouse.move(300, 300) - await expect(locate.smallPlusButton(page)).toBeHidden() - await locate.graphNodeByBinding(page, 'selected').click() - await expect(locate.smallPlusButton(page)).toBeVisible() - await locate.smallPlusButton(page).click() + await node.click() + await expect(locate.createNodeFromPort(node)).toBeVisible() + await locate.createNodeFromPort(node).click({ force: true }) await expectAndCancelBrowser(page, '', 'selected') }) diff --git a/app/gui/integration-test/project-view/expressionUpdates.ts b/app/gui/integration-test/project-view/expressionUpdates.ts index 5846ef888f14..733c9896037f 100644 --- a/app/gui/integration-test/project-view/expressionUpdates.ts +++ b/app/gui/integration-test/project-view/expressionUpdates.ts @@ -8,6 +8,7 @@ export async function mockCollapsedFunctionInfo( page: Page, expression: ExpressionLocator, functionName: string, + notAppliedArguments: number[] = [], ) { await mockMethodCallInfo(page, expression, { methodPointer: { @@ -15,7 +16,7 @@ export async function mockCollapsedFunctionInfo( definedOnType: 'local.Mock_Project.Main', name: functionName, }, - notAppliedArguments: [], + notAppliedArguments, }) } diff --git a/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts b/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts index bf8556275791..af27ca9159c9 100644 --- a/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts +++ b/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts @@ -9,7 +9,7 @@ test('Node can open and load visualization', async ({ page }) => { await actions.goToGraph(page) const node = locate.graphNode(page).last() await node.click({ position: { x: 8, y: 8 } }) - await expect(locate.circularMenu(page)).toExist() + await expect(locate.componentMenu(page)).toExist() await locate.toggleVisualizationButton(page).click() await expect(locate.anyVisualization(page)).toExist() await expect(locate.loadingVisualization(page)).toHaveCount(0) diff --git a/app/gui/integration-test/project-view/locate.ts b/app/gui/integration-test/project-view/locate.ts index e57546a852a2..e8a4a04b769f 100644 --- a/app/gui/integration-test/project-view/locate.ts +++ b/app/gui/integration-test/project-view/locate.ts @@ -79,11 +79,11 @@ export const graphEditor = componentLocator('.GraphEditor') export const codeEditor = componentLocator('.CodeEditor') export const anyVisualization = componentLocator('.GraphVisualization') export const loadingVisualization = componentLocator('.LoadingVisualization') -export const circularMenu = componentLocator('.CircularMenu') +export const componentMenu = componentLocator('.ComponentMenu') export const addNewNodeButton = componentLocator('.PlusButton') export const componentBrowser = componentLocator('.ComponentBrowser') export const nodeOutputPort = componentLocator('.outputPortHoverArea') -export const smallPlusButton = componentLocator('.SmallPlusButton') +export const createNodeFromPort = componentLocator('.CreateNodeFromPortButton .plusIcon') export const editorRoot = componentLocator('.CodeMirror') export const nodeComment = componentLocator('.GraphNodeComment') export const nodeCommentContent = componentLocator('.GraphNodeComment div[contentEditable]') diff --git a/app/gui/integration-test/project-view/nodeComments.spec.ts b/app/gui/integration-test/project-view/nodeComments.spec.ts index c9e386cae4e0..d48c1f7ece7e 100644 --- a/app/gui/integration-test/project-view/nodeComments.spec.ts +++ b/app/gui/integration-test/project-view/nodeComments.spec.ts @@ -22,8 +22,8 @@ test('Start editing comment via menu', async ({ page }) => { await actions.goToGraph(page) const node = locate.graphNodeByBinding(page, 'final') await node.click() - await locate.circularMenu(node).getByRole('button', { name: 'More' }).click() - await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click() + await locate.componentMenu(node).getByRole('button', { name: 'More' }).click() + await locate.componentMenu(node).getByRole('button', { name: 'Comment' }).click() await expect(locate.nodeCommentContent(node)).toBeFocused() }) @@ -60,8 +60,8 @@ test('Add new comment via menu', async ({ page }) => { const nodeComment = locate.nodeCommentContent(node) await node.click() - await locate.circularMenu(node).getByRole('button', { name: 'More' }).click() - await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click() + await locate.componentMenu(node).getByRole('button', { name: 'More' }).click() + await locate.componentMenu(node).getByRole('button', { name: 'Comment' }).click() await expect(locate.nodeCommentContent(node)).toBeFocused() const NEW_COMMENT = 'New comment text' await nodeComment.fill(NEW_COMMENT) diff --git a/app/gui/integration-test/project-view/typesOnNodeHover.spec.ts b/app/gui/integration-test/project-view/typesOnNodeHover.spec.ts index 32811c648382..eea37fd602d7 100644 --- a/app/gui/integration-test/project-view/typesOnNodeHover.spec.ts +++ b/app/gui/integration-test/project-view/typesOnNodeHover.spec.ts @@ -15,17 +15,17 @@ async function assertTypeLabelOnNode( ) { // Ensure the visualization button won't be covered by any other parts of another node (e.g. a comment). await bringNodeToFront(page, node) - await node.hover({ position: { x: 8, y: 8 } }) - await locate.toggleVisualizationButton(node).click() + await node.hover({ position: { x: 8, y: 8 }, force: true }) + await locate.toggleVisualizationButton(node).click({ force: true }) const targetLabel = node.locator('.node-type').first() await expect(targetLabel).toHaveText(type.short) await expect(targetLabel).toHaveAttribute('title', type.full) - await locate.toggleVisualizationButton(node).click() + await locate.toggleVisualizationButton(node).click({ force: true }) await actions.deselectNodes(page) } async function bringNodeToFront(page: Page, node: Locator) { - await node.click({ position: { x: 8, y: 8 } }) + await node.click({ position: { x: 0, y: 8 }, force: true }) await page.keyboard.press('Escape') } diff --git a/app/gui/package.json b/app/gui/package.json index 389e9b83fcd9..d66afd5df297 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -15,10 +15,15 @@ "bugs": { "url": "https://github.com/enso-org/enso/issues" }, - "//": {}, + "//": [ + "--max-old-space-size=4096 is required when sourcemaps are enabled,", + "otherwise Rollup runs out of memory when Vite is rendering chunks.", + "ResizeObserver is required for the dashboard tests to work.", + "ResizeObserver is not supported in vitest, so we need to stub it." + ], "scripts": { "typecheck": "vue-tsc --noEmit -p tsconfig.app.json", - "build": "vite build", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build", "build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build", "preview": "vite preview", "lint": "eslint . --cache --max-warnings=0", @@ -41,6 +46,11 @@ "playwright:install": "playwright install chromium" }, "dependencies": { + "@ag-grid-community/client-side-row-model": "^32.3.3", + "@ag-grid-community/core": "^32.3.3", + "@ag-grid-community/styles": "^32.3.3", + "@ag-grid-enterprise/core": "^32.3.3", + "@ag-grid-enterprise/range-selection": "^32.3.3", "@aws-amplify/auth": "5.6.5", "@aws-amplify/core": "5.8.5", "@hookform/resolvers": "^3.4.0", @@ -48,24 +58,46 @@ "@lexical/html": "^0.21.0", "@lexical/link": "^0.21.0", "@lexical/markdown": "^0.21.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.1.6", "@monaco-editor/react": "4.6.0", - "@sentry/react": "^7.74.0", + "@noble/hashes": "^1.4.0", "@react-aria/interactions": "^3.22.3", + "@sentry/react": "^7.74.0", + "@sentry/vite-plugin": "^2.22.7", "@stripe/react-stripe-js": "^2.7.1", "@stripe/stripe-js": "^3.5.0", - "@tanstack/react-query": "5.55.0", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0", + "@tanstack/react-query": "5.59.20", + "@tanstack/vue-query": "5.59.20", + "@vueuse/core": "^10.4.1", + "@vueuse/gesture": "^2.0.0", + "ag-grid-community": "^32.3.3", + "ag-grid-enterprise": "^32.3.3", + "ag-grid-vue3": "^32.3.3", "ajv": "^8.12.0", "amazon-cognito-identity-js": "6.3.6", + "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", "clsx": "^2.1.1", - "papaparse": "^5.4.1", + "codemirror": "^6.0.1", + "culori": "^3.2.0", "enso-common": "workspace:*", + "events": "^3.3.0", "framer-motion": "11.3.0", + "hash-sum": "^2.0.0", "idb-keyval": "^6.2.1", "input-otp": "1.2.4", + "install": "^0.13.0", "is-network-error": "^1.0.1", + "isomorphic-ws": "^5.0.0", "lexical": "^0.21.0", + "lib0": "^0.2.85", + "magic-string": "^0.30.3", + "marked": "14.1.3", "monaco-editor": "0.48.0", + "murmurhash": "^2.0.1", + "papaparse": "^5.4.1", + "postcss-inline-svg": "^6.0.0", + "postcss-nesting": "^12.0.1", "qrcode.react": "3.1.0", "react": "^18.3.1", "react-aria": "^3.34.3", @@ -83,43 +115,16 @@ "tiny-invariant": "^1.3.3", "ts-results": "^3.3.0", "validator": "^13.12.0", - "zod": "^3.23.8", - "zustand": "^4.5.4", - "@ag-grid-community/client-side-row-model": "^32.3.3", - "@ag-grid-community/core": "^32.3.3", - "@ag-grid-community/styles": "^32.3.3", - "@ag-grid-enterprise/core": "^32.3.3", - "@ag-grid-enterprise/range-selection": "^32.3.3", "@babel/parser": "^7.24.7", - "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", - "@codemirror/commands": "^6.6.0", - "@codemirror/language": "^6.10.2", + "@codemirror/commands": "^6.7.1", + "@codemirror/language": "^6.10.6", "@codemirror/lang-markdown": "^v6.3.0", - "@codemirror/lint": "^6.8.1", - "@codemirror/search": "^6.5.6", - "@codemirror/state": "^6.4.1", - "@codemirror/view": "^6.35.0", + "@codemirror/lint": "^6.8.4", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", "@fast-check/vitest": "^0.0.8", "@floating-ui/vue": "^1.0.6", - "@lezer/common": "^1.1.0", - "@lezer/highlight": "^1.1.6", - "@noble/hashes": "^1.4.0", - "@vueuse/core": "^10.4.1", - "@vueuse/gesture": "^2.0.0", - "ag-grid-community": "^32.3.3", - "ag-grid-enterprise": "^32.3.3", - "ag-grid-vue3": "^32.3.3", - "codemirror": "^6.0.1", - "culori": "^3.2.0", - "events": "^3.3.0", - "hash-sum": "^2.0.0", - "install": "^0.13.0", - "isomorphic-ws": "^5.0.0", - "lib0": "^0.2.85", - "magic-string": "^0.30.3", - "murmurhash": "^2.0.1", - "postcss-inline-svg": "^6.0.0", - "postcss-nesting": "^12.0.1", "sucrase": "^3.34.0", "veaury": "^2.3.18", "vue": "^3.5.2", @@ -129,14 +134,19 @@ "y-websocket": "^1.5.0", "ydoc-shared": "workspace:*", "yjs": "^13.6.7", - "marked": "14.1.3" + "zod": "^3.23.8", + "zustand": "^4.5.4" }, "devDependencies": { + "@babel/plugin-syntax-import-attributes": "^7.24.7", "@chromatic-com/storybook": "^3.2.2", + "@codemirror/theme-one-dark": "^6.1.2", + "@danmarshall/deckgl-typings": "^4.9.28", "@fast-check/vitest": "^0.0.8", + "@histoire/plugin-vue": "^0.17.12", "@modyfi/vite-plugin-yaml": "^1.0.4", + "@open-rpc/server-js": "^1.9.4", "@playwright/test": "^1.40.0", - "@babel/plugin-syntax-import-attributes": "^7.24.7", "@react-types/shared": "^3.22.1", "@storybook/addon-essentials": "^8.4.2", "@storybook/addon-interactions": "^8.4.2", @@ -147,73 +157,70 @@ "@storybook/test": "^8.4.2", "@storybook/vue3": "^8.4.2", "@storybook/vue3-vite": "^8.4.2", - "@tanstack/react-query-devtools": "5.45.1", - "@types/node": "^22.9.0", - "@types/papaparse": "^5.3.15", - "@types/react": "^18.0.27", - "@types/react-dom": "^18.0.10", - "@types/validator": "^13.11.7", - "@vitejs/plugin-react": "^4.3.3", - "chalk": "^5.3.0", - "cross-env": "^7.0.3", - "fast-check": "^3.15.0", - "playwright": "^1.39.0", - "postcss": "^8.4.29", - "prettier-plugin-organize-imports": "^4.0.0", - "prettier-plugin-tailwindcss": "^0.5.11", - "react-toastify": "^9.1.3", - "tailwindcss": "^3.4.1", - "tailwindcss-animate": "1.0.7", - "tailwindcss-react-aria-components": "^1.1.1", - "typescript": "^5.5.3", - "vite": "^5.4.10", - "vitest": "^1.3.1", - "@codemirror/theme-one-dark": "^6.1.2", - "@danmarshall/deckgl-typings": "^4.9.28", - "@histoire/plugin-vue": "^0.17.12", - "@open-rpc/server-js": "^1.9.4", + "@tanstack/react-query-devtools": "5.59.20", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@testing-library/react-hooks": "8.0.1", + "@testing-library/user-event": "14.5.2", "@tsconfig/node20": "^20.1.4", "@types/css.escape": "^1.5.2", "@types/culori": "^2.0.1", "@types/d3": "^7.4.0", "@types/hash-sum": "^1.0.0", "@types/jsdom": "^21.1.1", - "@types/mapbox-gl": "^2.7.13", + "@types/mapbox-gl": "^3.4.1", + "@types/node": "^22.9.0", + "@types/papaparse": "^5.3.15", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", "@types/shuffle-seed": "^1.1.0", "@types/tar": "^6.1.4", + "@types/validator": "^13.11.7", "@types/wicg-file-system-access": "^2023.10.2", "@types/ws": "^8.5.5", + "@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "^1.3.1", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.5.1", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", - "@testing-library/user-event": "14.5.2", - "@testing-library/react-hooks": "8.0.1", + "chalk": "^5.3.0", + "chromatic": "11.18.1", + "cross-env": "^7.0.3", "css.escape": "^1.5.1", "d3": "^7.4.0", "enso-common": "workspace:*", + "fast-check": "^3.15.0", "floating-vue": "^2.0.0-beta.24", "hash-wasm": "^4.11.0", "histoire": "^0.17.2", "jsdom": "^24.1.0", + "playwright": "^1.39.0", + "postcss": "^8.4.29", "postcss-nesting": "^12.0.1", "prettier": "^3.3.2", + "prettier-plugin-organize-imports": "^4.0.0", + "prettier-plugin-tailwindcss": "^0.5.11", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-toastify": "^9.1.3", "shuffle-seed": "^1.1.6", "sql-formatter": "^13.0.0", "storybook": "^8.4.2", - "chromatic": "11.18.1", + "tailwindcss": "^3.4.1", + "tailwindcss-animate": "1.0.7", + "tailwindcss-react-aria-components": "^1.1.1", "tar": "^6.2.1", "tsx": "^4.7.1", + "typescript": "^5.5.3", + "vite": "^5.4.10", "vite-plugin-vue-devtools": "7.6.3", "vite-plugin-wasm": "^3.3.0", + "vitest": "^1.3.1", "vue-react-wrapper": "^0.3.1", "vue-tsc": "^2.0.24", "yaml": "^2.4.5", - "ydoc-server": "workspace:*" + "ydoc-server": "workspace:*", + "resize-observer-polyfill": "1.5.1" }, "overrides": { "@aws-amplify/auth": "../_IGNORED_", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index fe8d68ee04e0..6d04f6c9c6b6 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -50,7 +50,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings' import AuthProvider, * as authProvider from '#/providers/AuthProvider' import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider' -import DriveProvider from '#/providers/DriveProvider' import { useHttpClientStrict } from '#/providers/HttpClientProvider' import InputBindingsProvider from '#/providers/InputBindingsProvider' import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' @@ -88,7 +87,6 @@ import LocalBackend from '#/services/LocalBackend' import ProjectManager, * as projectManager from '#/services/ProjectManager' import RemoteBackend from '#/services/RemoteBackend' -import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider' import * as appBaseUrl from '#/utilities/appBaseUrl' import * as eventModule from '#/utilities/event' import LocalStorage from '#/utilities/LocalStorage' @@ -98,6 +96,8 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' +import { useMutation } from '@tanstack/react-query' +import { useOffline } from './hooks/offlineHooks' // ============================ // === Global configuration === @@ -215,6 +215,9 @@ export default function App(props: AppProps) { }, }) + const { isOffline } = useOffline() + const { getText } = textProvider.useText() + const queryClient = props.queryClient // Force all queries to be stale @@ -236,6 +239,24 @@ export default function App(props: AppProps) { refetchInterval: 2 * 60 * 1000, }) + const { mutate: executeBackgroundUpdate } = useMutation({ + mutationKey: ['refetch-queries', { isOffline }], + scope: { id: 'refetch-queries' }, + mutationFn: () => queryClient.refetchQueries({ type: 'all' }), + networkMode: 'online', + onError: () => { + toastify.toast.error(getText('refetchQueriesError'), { + position: 'bottom-right', + }) + }, + }) + + React.useEffect(() => { + if (!isOffline) { + executeBackgroundUpdate() + } + }, [executeBackgroundUpdate, isOffline]) + // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // will redirect the user between the login/register pages and the dashboard. @@ -519,40 +540,34 @@ function AppRouter(props: AppRouterProps) { ) return ( - - - - - - - {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here - * due to modals being in `TheModal`. */} - - - - {routes} - - - - - - - - - - - - + + + + + + + + {routes} + + + + + + + + + + ) } diff --git a/app/gui/src/dashboard/assets/cross.svg b/app/gui/src/dashboard/assets/cross.svg index 3190ad3d1e04..a9e60f676baa 100644 --- a/app/gui/src/dashboard/assets/cross.svg +++ b/app/gui/src/dashboard/assets/cross.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/cross2.svg b/app/gui/src/dashboard/assets/cross2.svg index 920ef74ad3d0..0dc9486b155b 100644 --- a/app/gui/src/dashboard/assets/cross2.svg +++ b/app/gui/src/dashboard/assets/cross2.svg @@ -1,7 +1,7 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/error_filled.svg b/app/gui/src/dashboard/assets/error_filled.svg new file mode 100644 index 000000000000..7012571bae24 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/error_outline.svg b/app/gui/src/dashboard/assets/error_outline.svg new file mode 100644 index 000000000000..550619be3a84 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/offline_filled.svg b/app/gui/src/dashboard/assets/offline_filled.svg new file mode 100644 index 000000000000..a85d8b336489 --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_filled.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/offline_outline.svg b/app/gui/src/dashboard/assets/offline_outline.svg new file mode 100644 index 000000000000..da5fe69221db --- /dev/null +++ b/app/gui/src/dashboard/assets/offline_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/warning.svg b/app/gui/src/dashboard/assets/warning.svg new file mode 100644 index 000000000000..ff83234b968b --- /dev/null +++ b/app/gui/src/dashboard/assets/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 4a3d8dc5a59a..dcc52b9aaaef 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -46,7 +46,12 @@ export interface BaseButtonProps readonly tooltip?: ReactElement | string | false | null readonly tooltipPlacement?: aria.Placement /** The icon to display in the button */ - readonly icon?: ReactElement | string | ((render: Render) => ReactElement | string | null) | null + readonly icon?: + | ReactElement + | string + | ((render: Render) => ReactElement | string | null) + | null + | undefined /** When `true`, icon will be shown only when hovered. */ readonly showIconOnHover?: boolean /** @@ -163,7 +168,7 @@ export const BUTTON_STYLES = tv({ color: 'custom', weight: 'medium', disableLineHeightCompensation: true, - className: 'flex px-[5px] pt-[0.5px] pb-[2.5px]', + className: 'flex px-[5px] pt-[1px] pb-[2px]', }), icon: '-mb-0.5 h-3 w-3', content: 'gap-1', @@ -289,12 +294,13 @@ export const BUTTON_STYLES = tv({ }, compoundVariants: [ { isFocused: true, iconOnly: true, class: 'focus-visible:outline-offset-[3px]' }, + { size: 'custom', iconOnly: true, class: { icon: 'w-full h-full' } }, { size: 'xxsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-2.5 h-2.5' } }, { size: 'xsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3 h-3' } }, { size: 'small', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3.5 h-3.5' } }, { size: 'medium', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4 h-4' } }, - { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4.5 h-4.5' } }, + { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-5 h-5' } }, { size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } }, { size: 'xsmall', class: { addonStart: '-ml-[3.5px]', addonEnd: '-mr-[3.5px]' } }, @@ -318,197 +324,200 @@ export const BUTTON_STYLES = tv({ const ICON_LOADER_DELAY = 150 /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ -export const Button = memo( - forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { - const { - className, - contentClassName, - children, - variant, - icon, - loading = false, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - addonStart, - addonEnd, - ...ariaProps - } = props - - const [implicitlyLoading, setImplicitlyLoading] = useState(false) - - const contentRef = useRef(null) - const loaderRef = useRef(null) - - const isLink = ariaProps.href != null - - const Tag = isLink ? aria.Link : aria.Button - - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } +// Manually casting types to make TS infer the final type correctly (e.g. RenderProps in icon) +export const Button: (props: ButtonProps & { ref?: ForwardedRef }) => ReactNode = + memo( + forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { + const { + className, + contentClassName, + children, + variant, + icon, + loading = false, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + addonStart, + addonEnd, + ...ariaProps + } = props + + const [implicitlyLoading, setImplicitlyLoading] = useState(false) + + const contentRef = useRef(null) + const loaderRef = useRef(null) + + const isLink = ariaProps.href != null + + const Tag = isLink ? aria.Link : aria.Button + + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, + } - const isIconOnly = (children == null || children === '' || children === false) && icon != null + const isIconOnly = (children == null || children === '' || children === false) && icon != null - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null - } - })() - - const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null - - const isLoading = loading || implicitlyLoading - const isDisabled = props.isDisabled ?? isLoading - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - const extraClickZone = extraClickZoneProp ?? variant === 'icon' - - useLayoutEffect(() => { - const delay = ICON_LOADER_DELAY - - if (isLoading) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, - ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) - ) + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true + } else { + return tooltip != null + } + })() + + const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null + + const isLoading = loading || implicitlyLoading + const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + const extraClickZone = extraClickZoneProp ?? variant === 'icon' + + useLayoutEffect(() => { + const delay = ICON_LOADER_DELAY - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() + if (isLoading) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', + }) + ) + + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() + } + } else { + return () => {} } - } else { - return () => {} - } - }, [isLoading, loaderPosition]) + }, [isLoading, loaderPosition]) - const handlePress = useEventCallback((event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) + const handlePress = useEventCallback((event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) - if (result instanceof Promise) { - setImplicitlyLoading(true) + if (result instanceof Promise) { + setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) + void result.finally(() => { + setImplicitlyLoading(false) + }) + } } - } - }) - - const styles = variants({ - isDisabled, - isActive, - loading: isLoading, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone, - iconOnly: isIconOnly, - }) - - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), - }) - - const button = ( - ()(goodDefaults, ariaProps, { - isDisabled, - // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger - // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered - onPressEnd: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - - - ()(goodDefaults, ariaProps, { + isDisabled, + // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger + // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered + onPressEnd: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( + + - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - - - - {isLoading && loaderPosition === 'full' && ( - - + + {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} + {typeof children === 'function' ? children(render) : children} + - )} - {shouldShowTooltip && visualTooltip} - - )} - - ) + {isLoading && loaderPosition === 'full' && ( + + + + )} - if (tooltipElement == null) { - return button - } + {shouldShowTooltip && visualTooltip} + + )} + + ) - return ( - - {button} + if (tooltipElement == null) { + return button + } - - {tooltipElement} - - - ) - }), -) + return ( + + {button} + + + {tooltipElement} + + + ) + }), + ) /** * Props for {@link ButtonContent}. @@ -517,7 +526,7 @@ interface ButtonContentProps { readonly isIconOnly: boolean readonly isLoading: boolean readonly loaderPosition: 'full' | 'icon' - readonly icon: ButtonProps['icon'] + readonly icon: ReactElement | string | null | undefined readonly styles: ReturnType readonly children: ReactNode readonly addonStart?: ReactElement | string | false | null | undefined @@ -567,7 +576,7 @@ const ButtonContent = memo(function ButtonContent(props: ButtonContentProps) { interface IconProps { readonly isLoading: boolean readonly loaderPosition: 'full' | 'icon' - readonly icon: ButtonProps['icon'] + readonly icon: ReactElement | string | null | undefined readonly styles: ReturnType } @@ -600,13 +609,9 @@ const Icon = memo(function Icon(props: IconProps) { } const actualIcon = (() => { - /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const iconRender = typeof icon === 'function' ? icon(render) : icon - - return typeof iconRender === 'string' ? - - : {iconRender} + return typeof icon === 'string' ? + + : {icon} })() if (shouldShowLoader) { diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx index 1a60155914c0..11bb5d822aca 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx @@ -8,7 +8,7 @@ import * as twv from '#/utilities/tailwindVariants' // ================= const STYLES = twv.tv({ - base: 'flex flex-1 shrink-0', + base: 'flex flex-1 shrink-0 max-h-max', variants: { wrap: { true: 'flex-wrap' }, direction: { column: 'flex-col', row: 'flex-row' }, diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx index 529e5072b9ee..4e3258921ae6 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx @@ -16,6 +16,7 @@ export type CloseButtonProps = Omit twMerge( 'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1', isOnMacOS() ? 'bg-primary/30' : ( 'text-primary/90 hover:text-primary focus-visible:text-primary' ), - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + // @ts-expect-error TypeScript fails to infer the type of the `className` prop + // But it's safe because we are passing all values transparently + // and they are typed outside. typeof className === 'function' ? className(values) : className, ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index bec43019b7b4..802243bb6fb1 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -20,15 +20,15 @@ import { tv } from '#/utilities/tailwindVariants' import { Close } from './Close' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' +import { DialogTrigger } from './DialogTrigger' import type * as types from './types' import * as utlities from './utilities' import { DIALOG_BACKGROUND } from './variants' - // eslint-disable-next-line no-restricted-syntax const MotionDialog = motion(aria.Dialog) const OVERLAY_STYLES = tv({ - base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20 z-tooltip', + base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20', variants: { isEntering: { true: 'animate-in fade-in duration-200 ease-out' }, isExiting: { true: 'animate-out fade-out duration-200 ease-in' }, @@ -551,3 +551,4 @@ const DialogHeader = React.memo(function DialogHeader(props: DialogHeaderProps) }) Dialog.Close = Close +Dialog.Trigger = DialogTrigger diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx index 7c855b0fc47a..6032bcd0e247 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx @@ -20,7 +20,7 @@ export interface DialogTriggerRenderProps { export interface DialogTriggerProps extends Omit { /** The trigger element. */ readonly children: [ - React.ReactElement, + React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement), React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement), ] readonly onOpen?: () => void @@ -68,7 +68,7 @@ export function DialogTrigger(props: DialogTriggerProps) { return ( - {trigger} + {typeof trigger === 'function' ? trigger(renderProps) : trigger} {typeof dialog === 'function' ? dialog(renderProps) : dialog} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 1cb6cdce4598..1a4abbfc9d04 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -15,6 +15,7 @@ import * as twv from '#/utilities/tailwindVariants' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' +import { DialogTrigger } from './DialogTrigger' import * as utlities from './utilities' import * as variants from './variants' @@ -29,8 +30,13 @@ export interface PopoverProps } export const POPOVER_STYLES = twv.tv({ - base: 'shadow-xl w-full overflow-clip z-tooltip', + base: 'shadow-xl w-full overflow-clip', variants: { + variant: { + custom: { dialog: '' }, + primary: { dialog: variants.DIALOG_BACKGROUND({ variant: 'light' }) }, + inverted: { dialog: variants.DIALOG_BACKGROUND({ variant: 'dark' }) }, + }, isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200', }, @@ -38,12 +44,12 @@ export const POPOVER_STYLES = twv.tv({ true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150', }, size: { - auto: { base: 'w-[unset]', dialog: 'p-2.5' }, - xxsmall: { base: 'max-w-[206px]', dialog: 'p-2' }, - xsmall: { base: 'max-w-xs', dialog: 'p-2.5' }, - small: { base: 'max-w-sm', dialog: 'p-3.5' }, - medium: { base: 'max-w-md', dialog: 'p-3.5' }, - large: { base: 'max-w-lg', dialog: 'px-4 py-4' }, + auto: { base: 'w-[unset]', dialog: 'p-2.5 px-0' }, + xxsmall: { base: 'max-w-[206px]', dialog: 'p-2 px-0' }, + xsmall: { base: 'max-w-xs', dialog: 'p-2.5 px-0' }, + small: { base: 'max-w-sm', dialog: 'py-3 px-2' }, + medium: { base: 'max-w-md', dialog: 'p-3.5 px-2.5' }, + large: { base: 'max-w-lg', dialog: 'px-4 py-3' }, hero: { base: 'max-w-xl', dialog: 'px-6 py-5' }, }, rounded: { @@ -58,9 +64,9 @@ export const POPOVER_STYLES = twv.tv({ }, }, slots: { - dialog: variants.DIALOG_BACKGROUND({ class: 'flex-auto overflow-y-auto max-h-[inherit]' }), + dialog: 'flex-auto overflow-y-auto [scrollbar-gutter:stable_both-edges] max-h-[inherit]', }, - defaultVariants: { rounded: 'xxxlarge', size: 'small' }, + defaultVariants: { rounded: 'xxxlarge', size: 'small', variant: 'primary' }, }) const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const @@ -75,6 +81,7 @@ export function Popover(props: PopoverProps) { className, size, rounded, + variant, placement = 'bottom start', isDismissable = true, ...ariaPopoverProps @@ -93,8 +100,10 @@ export function Popover(props: PopoverProps) { isExiting: values.isExiting, size, rounded, + variant, + }).base({ className: typeof className === 'function' ? className(values) : className, - }).base() + }) } UNSTABLE_portalContainer={root} placement={placement} @@ -109,6 +118,7 @@ export function Popover(props: PopoverProps) { rounded={rounded} opts={opts} isDismissable={isDismissable} + variant={variant} > {children} @@ -127,13 +137,14 @@ interface PopoverContentProps { readonly opts: aria.PopoverRenderProps readonly popoverRef: React.RefObject readonly isDismissable: boolean + readonly variant: PopoverProps['variant'] } /** * The content of a popover. */ function PopoverContent(props: PopoverContentProps) { - const { children, size, rounded, opts, isDismissable, popoverRef } = props + const { children, size, rounded, opts, isDismissable, popoverRef, variant } = props const dialogRef = React.useRef(null) const dialogId = aria.useId() @@ -179,7 +190,12 @@ function PopoverContent(props: PopoverContentProps) { role="dialog" aria-labelledby={labelledBy} tabIndex={-1} - className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()} + className={POPOVER_STYLES({ + ...opts, + size, + rounded, + variant, + }).dialog()} > @@ -192,3 +208,5 @@ function PopoverContent(props: PopoverContentProps) { ) } + +Popover.Trigger = DialogTrigger diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx index 2651eb2f4601..968674b6609b 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx @@ -130,6 +130,7 @@ export const Form = forwardRef(function Form< Field: typeof components.Field FormError: typeof components.FormError FieldValue: typeof components.FieldValue + Provider: typeof components.FormProvider useFormSchema: typeof components.useFormSchema Controller: typeof components.Controller FIELD_STYLES: typeof components.FIELD_STYLES @@ -138,6 +139,7 @@ export const Form = forwardRef(function Form< useWatch: typeof components.useWatch useFieldRegister: typeof components.useFieldRegister useFieldState: typeof components.useFieldState + useFormError: typeof components.useFormError /* eslint-enable @typescript-eslint/naming-convention */ } @@ -153,7 +155,9 @@ Form.useFormContext = components.useFormContext Form.useOptionalFormContext = components.useOptionalFormContext Form.Field = components.Field Form.Controller = components.Controller +Form.Provider = components.FormProvider Form.useWatch = components.useWatch Form.FIELD_STYLES = components.FIELD_STYLES Form.useFieldRegister = components.useFieldRegister Form.useFieldState = components.useFieldState +Form.useFormError = components.useFormError diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx index 950b32c89696..eccad3d50ef5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx @@ -39,7 +39,7 @@ export interface FieldChildrenRenderProps { readonly isTouched: boolean readonly isValidating: boolean readonly hasError: boolean - readonly error?: string | undefined + readonly error?: string | null | undefined } export const FIELD_STYLES = tv({ @@ -88,7 +88,7 @@ export const Field = forwardRef(function Field( const classes = variants({ fullWidth, isInvalid: invalid, isHidden }) - const hasError = (error ?? fieldState.error) != null + const hasError = (error !== undefined ? error : fieldState.error) != null return (
( {label} {isRequired && ( -