diff --git a/.github/new_unfinished_workflows/build.yaml b/.github/new_unfinished_workflows/build.yaml deleted file mode 100644 index f704c3f2a..000000000 --- a/.github/new_unfinished_workflows/build.yaml +++ /dev/null @@ -1,492 +0,0 @@ -name: Build Backend and Frontend - -on: - push: - branches: - - production - pull_request: - branches: - - production - workflow_dispatch: - inputs: - rebuild-backend: - description: Build backend binaries - type: boolean - default: true - rebuild-control-station: - description: Build control station frontend - type: boolean - default: true - rebuild-ethernet-view: - description: Build ethernet view frontend - type: boolean - default: true - workflow_call: - inputs: - build-backend: - description: Build backend binaries - type: boolean - default: true - required: false - build-control-station: - description: Build control station frontend - type: boolean - default: true - required: false - build-ethernet-view: - description: Build ethernet view frontend - type: boolean - default: true - required: false - -jobs: - # Detect what changed - detect-changes: - name: Detect Changes - runs-on: ubuntu-latest - # We can't use this condition here because it would skip the whole job without setting the outputs - # if: github.event_name == 'push' || github.event_name == 'pull_request' - # Insted, we skip steps inside the job - outputs: - backend_needs_rebuild: ${{ steps.changes.outputs.backend_any_changed == 'true' || github.event.inputs.rebuild-backend == 'true' }} - control-station_needs_rebuild: ${{ steps.changes.outputs.control-station_any_changed == 'true' || github.event.inputs.rebuild-control-station == 'true' }} - ethernet-view_needs_rebuild: ${{ steps.changes.outputs.ethernet-view_any_changed == 'true' || github.event.inputs.rebuild-ethernet-view == 'true' }} - common-front_needs_rebuild: ${{ steps.changes.outputs.common-front_any_changed == 'true' || github.event.inputs.rebuild-common-front == 'true' }} - steps: - # Only run on push or pull request events - # Skip on workflow_call or workflow_dispatch - - name: Checkout repository - if: github.event_name == 'push' || github.event_name == 'pull_request' - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Only run on push or pull request events - # Skip on workflow_call or workflow_dispatch - - name: Detect changed files - if: github.event_name == 'push' || github.event_name == 'pull_request' - id: changes - uses: tj-actions/changed-files@v41 - with: - files_yaml: | - backend: - - 'backend/**/*' - control-station: - - 'control-station/**/*' - ethernet-view: - - 'ethernet-view/**/*' - common-front: - - 'common-front/**/*' - - # We want to set outputs even if the job should be skipped - - name: Set outputs - id: set-outputs - run: | - if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event_name }}" = "pull_request" ]; then - echo "backend_needs_rebuild=${{ steps.changes.outputs.backend_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT - echo "control-station_needs_rebuild=${{ steps.changes.outputs.control-station_any_changed == 'true' || steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT - echo "ethernet-view_needs_rebuild=${{ steps.changes.outputs.ethernet-view_any_changed == 'true' || steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT - echo "common-front_needs_rebuild=${{ steps.changes.outputs.common-front_any_changed == 'true' && 'true' || 'false' }}" >> $GITHUB_OUTPUT - else - # On workflow_call/dispatch, default to false (no changes detected) - echo "backend_needs_rebuild=false" >> $GITHUB_OUTPUT - echo "control-station_needs_rebuild=false" >> $GITHUB_OUTPUT - echo "ethernet-view_needs_rebuild=false" >> $GITHUB_OUTPUT - echo "common-front_needs_rebuild=false" >> $GITHUB_OUTPUT - fi - - - name: Debug changed files - if: github.event_name == 'push' || github.event_name == 'pull_request' - run: | - echo "Changed backend: ${{ steps.changes.outputs.backend_any_changed }}" - echo "Changed control-station: ${{ steps.changes.outputs.control-station_any_changed }}" - echo "Changed ethernet-view: ${{ steps.changes.outputs.ethernet-view_any_changed }}" - echo "Changed common-front: ${{ steps.changes.outputs.common-front_any_changed }}" - - # Download backend from previous build if no changes - download-backend: - name: Download Backend - ${{ matrix.platform }} - needs: detect-changes - # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), - # values for inputs will be undefined which gives false for any condition - # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events - # Thus, it's outputs are undefined - if: | - needs.detect-changes.outputs.backend_needs_rebuild != 'true' && - inputs.build-backend != 'true' - runs-on: ubuntu-latest - # Continue on error is necessary because download-backend job can fail if backend artifact is not found - # and in this case it is not necessarily has to fail the build - continue-on-error: true - outputs: - downloaded: ${{ steps.download.outcome == 'success' }} - strategy: - fail-fast: false - matrix: - platform: [linux, windows, macos-intel, macos-arm64] - - steps: - - name: Download backend from latest build - id: download - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-${{ matrix.platform }} - path: backend-artifacts - if_no_artifact_found: fail - - - name: Re-upload backend artifact - if: steps.download.outcome == 'success' - uses: actions/upload-artifact@v4 - with: - name: backend-${{ matrix.platform }} - path: backend-artifacts/* - retention-days: 30 - - - name: Write matrix output - if: always() - uses: cloudposse/github-action-matrix-outputs-write@v1 - with: - matrix-step-name: download-backend - matrix-key: ${{ matrix.platform }} - outputs: | - downloaded: ${{ steps.download.outcome == 'success' }} - - # Aggregate download results - aggregate-downloads: - name: Aggregate Download Results - # It is important to use != 'true' and not == 'false' because if they are no defined - # values for inputs they will be undefined which gives false for any condition - if: | - always() && - ( - needs.detect-changes.outputs.backend_needs_rebuild != 'true' && - inputs.build-backend != 'true' - ) - needs: [detect-changes, download-backend] - runs-on: ubuntu-latest - steps: - - uses: cloudposse/github-action-matrix-outputs-read@v1 - id: read - with: - matrix-step-name: download-backend - - - name: Check if any platform needs rebuild - if: always() - id: check-rebuild - run: | - # Parse the result JSON to check if any download failed - RESULT='${{ steps.read.outputs.result }}' - echo "Parsing result: $RESULT" - - # Check if any platform has downloaded=false - # The result format is: {downloaded:{linux:true,windows:false,...}} - if echo "$RESULT" | grep -q '"downloaded".*"linux":false' || \ - echo "$RESULT" | grep -q '"downloaded".*"windows":false' || \ - echo "$RESULT" | grep -q '"downloaded".*"macos-intel":false' || \ - echo "$RESULT" | grep -q '"downloaded".*"macos-arm64":false'; then - echo "any_needs_rebuild=true" >> $GITHUB_OUTPUT - echo "At least one platform download failed, rebuild needed" - else - echo "any_needs_rebuild=false" >> $GITHUB_OUTPUT - echo "All platform downloads succeeded, no rebuild needed" - fi - shell: bash - - - name: Debug aggregate-downloads outputs - if: always() - run: | - echo "=== aggregate-downloads Debug ===" - echo "inputs.build-backend: ${{ inputs.build-backend }}" - echo "" - echo "=== detect-changes outputs ===" - echo "needs.detect-changes.outputs.backend_needs_rebuild: ${{ needs.detect-changes.outputs.backend_needs_rebuild }}" - echo "needs.detect-changes.outcome: ${{ needs.detect-changes.outcome }}" - echo "" - echo "=== aggregate-downloads Debug ===" - echo "steps.read.outputs.result: ${{ steps.read.outputs.result }}" - echo "steps.read.outcome: ${{ steps.read.outcome }}" - echo "" - echo "=== All read outputs ===" - echo "${{ toJSON(steps.read.outputs) }}" - echo "" - echo "=== download-backend outcomes ===" - echo "needs.download-backend.outcome: ${{ needs.download-backend.outcome }}" - outputs: - outcome: ${{ steps.read.outcome }} - any_needs_rebuild: ${{ steps.check-rebuild.outputs.any_needs_rebuild }} - result: ${{ steps.read.outputs.result }} - - # Build Go backends on native platforms - # only if backend changed or download-backend failed - build-backend: - name: Build Backend - ${{ matrix.platform }} - needs: [detect-changes, download-backend, aggregate-downloads] - # Always is needed to execute this job even if backend download failed - # By default it would be skipped because dependent job failed - # Build if explicitly requested for this platform - # Also build if backend changed or download-backend failed - if: | - always() && - ( - inputs.build-backend == true || - needs.detect-changes.outputs.backend_needs_rebuild == 'true' || - needs.aggregate-downloads.outputs.outcome == 'success' && - needs.aggregate-downloads.outputs.any_needs_rebuild == 'true' - ) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - platform: linux - binary_name: backend-linux-amd64 - - - os: windows-latest - platform: windows - binary_name: backend-windows-amd64.exe - - - os: macos-latest - platform: macos-intel - binary_name: backend-darwin-amd64 - goarch: amd64 - - - os: macos-latest - platform: macos-arm64 - binary_name: backend-darwin-arm64 - goarch: arm64 - - steps: - - name: Debug - run: | - echo "=== aggregate-downloads Debug ===" - echo "needs.aggregate-downloads.outputs.result: ${{ needs.aggregate-downloads.outputs.result }}" - echo "needs.aggregate-downloads.outputs.result.downloaded: ${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded }}" - echo "needs.aggregate-downloads.outputs.result.downloaded.platform: ${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded[matrix.platform] }}" - - - name: Check if artifact exists for this platform - id: check-artifact - shell: bash - run: | - ARTIFACT_EXISTS="${{ fromJSON(needs.aggregate-downloads.outputs.result).downloaded[matrix.platform] }}" - BUILD_EXPLICITLY="${{ inputs.build-backend == true }}" - CHANGES_DETECTED="${{ needs.detect-changes.outputs.backend_needs_rebuild == 'true' }}" - - if [ "$ARTIFACT_EXISTS" != "true" ] || [ "$BUILD_EXPLICITLY" = "true" ] || [ "$CHANGES_DETECTED" = "true" ]; then - echo "needs_build=true" >> $GITHUB_OUTPUT - echo "Building for ${{ matrix.platform }}" - else - echo "needs_build=false" >> $GITHUB_OUTPUT - echo "Artifact exists for ${{ matrix.platform }}, skipping build" - fi - - - name: Exit if no build needed - if: steps.check-artifact.outputs.needs_build != 'true' - shell: bash - run: | - echo "Skipping build - artifact already exists" - - - name: Checkout repository - if: steps.check-artifact.outputs.needs_build == 'true' - uses: actions/checkout@v4 - - - name: Setup Go - if: steps.check-artifact.outputs.needs_build == 'true' - uses: actions/setup-go@v4 - with: - go-version: "1.21" - - - name: Install Linux dependencies - if: runner.os == 'Linux' && steps.check-artifact.outputs.needs_build == 'true' - run: sudo apt-get update && sudo apt-get install -y gcc - - - - name: Build backend (Linux) - if: runner.os == 'Linux' && steps.check-artifact.outputs.needs_build == 'true' - working-directory: backend/cmd - run: | - CGO_ENABLED=1 go build -o ${{ matrix.binary_name }} . - - - name: Build backend (Windows) - if: runner.os == 'Windows' && steps.check-artifact.outputs.needs_build == 'true' - working-directory: backend/cmd - run: | - $env:CGO_ENABLED="1" - go build -o ${{ matrix.binary_name }} . - shell: pwsh - - - name: Build backend (macOS) - if: runner.os == 'macOS' && steps.check-artifact.outputs.needs_build == 'true' - working-directory: backend/cmd - run: | - CGO_ENABLED=1 GOARCH=${{ matrix.goarch }} go build -o ${{ matrix.binary_name }} . - - - name: Upload backend binary - if: steps.check-artifact.outputs.needs_build == 'true' - uses: actions/upload-artifact@v4 - with: - name: backend-${{ matrix.platform }} - path: backend/cmd/${{ matrix.binary_name }} - retention-days: 30 - - # Download control-station from previous build if no changes - download-control-station: - name: Download Control Station - needs: detect-changes - # The condition checks if control station changed or common front changed - # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), - # values for inputs will be undefined which gives false for any condition - # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events - # Thus, it's outputs are undefined - if: | - needs.detect-changes.outputs.control-station_needs_rebuild != 'true' && - needs.detect-changes.outputs.common-front_needs_rebuild != 'true' && - inputs.build-control-station != 'true' - runs-on: ubuntu-latest - # Continue on error is necessary because download-control-station job can fail if control station artifact is not found - # and it is not necessary to fail the build in this case - continue-on-error: true - outputs: - downloaded: ${{ steps.download.outcome == 'success' }} - steps: - - name: Download control-station from latest build - id: download - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: control-station - path: control-station-artifacts - if_no_artifact_found: fail - - - name: Re-upload control-station artifact - if: steps.download.outcome == 'success' - uses: actions/upload-artifact@v4 - with: - name: control-station - path: control-station-artifacts/** - retention-days: 30 - - # Build control-station (if control-station or common-front changed) - build-control-station: - name: Build Control Station - needs: [detect-changes, download-control-station] - # Always is needed to execute this job even if control station download failed - # By default it would be skipped because dependent job failed - # The condition checks if control station changed or common front changed or download control station failed - if: always() && - (needs.detect-changes.outputs.control-station_needs_rebuild == 'true' || - needs.detect-changes.outputs.common-front_needs_rebuild == 'true' || - needs.download-control-station.outputs.downloaded != 'true' || - inputs.build-control-station == 'true') - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Build common-front - working-directory: common-front - run: | - npm ci - npm run build - - - name: Build control-station - working-directory: control-station - run: | - npm ci - npm run build - - - name: Upload control-station artifact - uses: actions/upload-artifact@v4 - with: - name: control-station - path: control-station/static/** - retention-days: 30 - - # Download ethernet-view from previous build if no changes - download-ethernet-view: - name: Download Ethernet View - needs: detect-changes - # It is important to use != 'true' and not == 'false' because if they are no inputs (executed on push or pull request events), - # values for inputs will be undefined which gives false for any condition - # The same thing applies for detect changes outputs, because we skip this job on dispatch and call events - # Thus, it's outputs are undefined - if: | - needs.detect-changes.outputs.ethernet-view_needs_rebuild != 'true' && - needs.detect-changes.outputs.common-front_needs_rebuild != 'true' && - inputs.build-ethernet-view != 'true' - runs-on: ubuntu-latest - # Continue on error is necessary because download-ethernet-view job can fail if ethernet view artifact is not found - # and it is not necessary to fail the build in this case - continue-on-error: true - outputs: - downloaded: ${{ steps.download.outcome == 'success' }} - steps: - - name: Download ethernet-view from latest build - id: download - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: ethernet-view - path: ethernet-view-artifacts - if_no_artifact_found: fail - - - name: Re-upload ethernet-view artifact - if: steps.download.outcome == 'success' - uses: actions/upload-artifact@v4 - with: - name: ethernet-view - path: ethernet-view-artifacts/** - retention-days: 30 - - # Build ethernet-view (if ethernet-view or common-front changed) - build-ethernet-view: - name: Build Ethernet View - needs: [detect-changes, download-ethernet-view] - # Always is needed to execute this job even if ethernet view download failed - # By default it would be skipped because dependent job failed - # The condition checks if ethernet view changed or common front changed or download ethernet view failed - if: always() && - (needs.detect-changes.outputs.ethernet-view_needs_rebuild == 'true' || - needs.detect-changes.outputs.common-front_needs_rebuild == 'true' || - needs.download-ethernet-view.outputs.downloaded != 'true' || - inputs.build-ethernet-view == 'true') - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Build common-front - working-directory: common-front - run: | - npm ci - npm run build - - - name: Build ethernet-view - working-directory: ethernet-view - run: | - npm ci - npm run build - - - name: Upload ethernet-view artifact - uses: actions/upload-artifact@v4 - with: - name: ethernet-view - path: ethernet-view/static/** - retention-days: 30 diff --git a/.github/new_unfinished_workflows/release.yaml b/.github/new_unfinished_workflows/release.yaml deleted file mode 100644 index 955ceb56e..000000000 --- a/.github/new_unfinished_workflows/release.yaml +++ /dev/null @@ -1,331 +0,0 @@ -name: Release Electron App - -on: - # Commented because provokes duplicated builds - # push: - # tags: - # - "v*.*.*" - workflow_dispatch: - inputs: - version: - description: "Release version (e.g., 1.0.0)" - required: true - draft: - description: "Create as draft release" - type: boolean - default: true - -jobs: - # Determine version once on Linux - determine-version: - name: Determine Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.get_version.outputs.version }} - is_draft: ${{ steps.get_version.outputs.is_draft }} - steps: - - name: Determine version - id: get_version - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - echo "is_draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT - else - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - echo "is_draft=false" >> $GITHUB_OUTPUT - fi - echo "Version determined: $(cat $GITHUB_OUTPUT | grep version)" - - # Verify that required artifacts exist from build.yaml workflow - verify-artifacts: - name: Verify Artifacts Exist - needs: determine-version - runs-on: ubuntu-latest - outputs: - artifacts_exist: ${{ steps.check.outputs.all_exist }} - backend_linux_exists: ${{ steps.check.outputs.backend_linux }} - backend_windows_exists: ${{ steps.check.outputs.backend_windows }} - backend_macos_intel_exists: ${{ steps.check.outputs.backend_macos_intel }} - backend_macos_arm64_exists: ${{ steps.check.outputs.backend_macos_arm64 }} - control_station_exists: ${{ steps.check.outputs.control_station }} - ethernet_view_exists: ${{ steps.check.outputs.ethernet_view }} - steps: - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Get latest successful run from build.yaml - id: get_run - run: | - echo "🧿 Looking up build.yaml workflow..." - WORKFLOW_RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/repos/${{ github.repository }}/actions/workflows/build.yaml") - - echo "🧿 API Response:" - echo "$WORKFLOW_RESPONSE" | jq '.' - - WORKFLOW_ID=$(echo "$WORKFLOW_RESPONSE" | jq -r '.id // empty') - - echo "🧿 Workflow ID: $WORKFLOW_ID" - - echo "Finding latest successful run on production branch..." - RUN_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/actions/workflows/$WORKFLOW_ID/runs?branch=production&status=success&per_page=1" | \ - jq -r '.workflow_runs[0].id // empty') - - if [ -z "$RUN_ID" ]; then - echo "✖ No successful workflow run found" - else - echo "✓ Found workflow run ID: $RUN_ID" - fi - - echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT - - - name: Check artifacts - id: check - run: | - RUN_ID="${{ steps.get_run.outputs.run_id }}" - - if [ -z "$RUN_ID" ]; then - echo "✖ No workflow run found - all artifacts missing" - echo "all_exist=false" >> $GITHUB_OUTPUT - echo "backend_linux=false" >> $GITHUB_OUTPUT - echo "backend_windows=false" >> $GITHUB_OUTPUT - echo "backend_macos_intel=false" >> $GITHUB_OUTPUT - echo "backend_macos_arm64=false" >> $GITHUB_OUTPUT - echo "control_station=false" >> $GITHUB_OUTPUT - echo "ethernet_view=false" >> $GITHUB_OUTPUT - exit 0 - fi - - echo "🧿 Fetching artifacts from run $RUN_ID..." - ARTIFACTS=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/actions/runs/$RUN_ID/artifacts" | \ - jq -r '.artifacts[].name') - - echo "📦 Artifacts found in workflow run:" - echo "$ARTIFACTS" | while read artifact; do - echo " - $artifact" - done - - check() { - if echo "$ARTIFACTS" | grep -q "^$1$"; then - echo "true" - else - echo "false" - fi - } - - echo "" - echo "🧿 Verifying required artifacts:" - BACKEND_LINUX=$(check backend-linux) - echo "backend_linux=$BACKEND_LINUX" >> $GITHUB_OUTPUT - - BACKEND_WINDOWS=$(check backend-windows) - echo "backend_windows=$BACKEND_WINDOWS" >> $GITHUB_OUTPUT - - BACKEND_MACOS_INTEL=$(check backend-macos-intel) - echo "backend_macos_intel=$BACKEND_MACOS_INTEL" >> $GITHUB_OUTPUT - - BACKEND_MACOS_ARM64=$(check backend-macos-arm64) - echo "backend_macos_arm64=$BACKEND_MACOS_ARM64" >> $GITHUB_OUTPUT - - CONTROL_STATION=$(check control-station) - echo "control_station=$CONTROL_STATION" >> $GITHUB_OUTPUT - - ETHERNET_VIEW=$(check ethernet-view) - echo "ethernet_view=$ETHERNET_VIEW" >> $GITHUB_OUTPUT - - COUNT=$(echo "$ARTIFACTS" | grep -cE "^(backend-linux|backend-windows|backend-macos-intel|backend-macos-arm64|control-station|ethernet-view)$" || echo "0") - - echo " Required artifacts found: $COUNT/6" - if [ "$COUNT" -eq 6 ]; then - # ALL_EXIST="true" - ALL_EXIST="true" - echo "✓ All artifacts exist!" - else - # ALL_EXIST="false" - ALL_EXIST="false" - echo "✖ Some artifacts are missing" - fi - - echo "all_exist=$ALL_EXIST" >> $GITHUB_OUTPUT - - # Build artifacts using reusable workflow (placeholder for now) - build-artifacts: - name: Build Artifacts - needs: [determine-version, verify-artifacts] - if: needs.verify-artifacts.outputs.artifacts_exist != 'true' - uses: ./.github/workflows/build.yaml - with: - # prettier-ignore - build-backend: ${{ needs.verify-artifacts.outputs.backend_linux_exists != 'true' || - needs.verify-artifacts.outputs.backend_windows_exists != 'true' || - needs.verify-artifacts.outputs.backend_macos_intel_exists != 'true' || - needs.verify-artifacts.outputs.backend_macos_arm64_exists != 'true' }} - build-control-station: ${{ needs.verify-artifacts.outputs.control_station_exists != 'true' }} - build-ethernet-view: ${{ needs.verify-artifacts.outputs.ethernet_view_exists != 'true' }} - - build-electron: - name: Build Electron App - needs: [determine-version, verify-artifacts, build-artifacts] - runs-on: ${{ matrix.os }} - if: always() && needs.build-artifacts.result != 'failure' - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - # Update package.json with release version - - name: Update version in package.json - working-directory: electron-app - shell: bash - run: | - npm version ${{ needs.determine-version.outputs.version }} --no-git-tag-version - echo "Updated version to:" - cat package.json | grep version - - # Download ONLY the appropriate backend for this platform - - name: Download Linux backend - if: runner.os == 'Linux' - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-linux - path: electron-app/binaries - - - name: Download Windows backend - if: runner.os == 'Windows' - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-windows - path: electron-app/binaries - - # macOS needs both Intel and ARM64 for universal support - - name: Download macOS Intel backend - if: runner.os == 'macOS' - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-macos-intel - path: electron-app/binaries - - - name: Download macOS ARM64 backend - if: runner.os == 'macOS' - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-macos-arm64 - path: electron-app/binaries - - # Download frontend builds from latest build - - name: Download control-station - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: control-station - path: electron-app/renderer/control-station - - - name: Download ethernet-view - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: ethernet-view - path: electron-app/renderer/ethernet-view - - - name: Set executable permissions (Unix) - if: runner.os != 'Windows' - run: chmod +x electron-app/binaries/* - - - name: Install Electron dependencies - working-directory: electron-app - run: npm ci - - - name: Build Electron distribution - working-directory: electron-app - run: npm run dist - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_IDENTITY_AUTO_DISCOVERY: false - ELECTRON_BUILDER_PUBLISH: never - - - name: Display structure (Windows) - if: matrix.os == 'windows-latest' - working-directory: electron-app - shell: pwsh - run: Get-ChildItem -Recurse dist/ | Select-Object FullName - - - name: Display structure (Unix) - if: matrix.os != 'windows-latest' - working-directory: electron-app - run: ls -laR dist/ - - - name: Upload electron artifacts - uses: actions/upload-artifact@v4 - with: - name: electron-${{ runner.os }} - path: | - electron-app/dist/*.exe - electron-app/dist/*.AppImage - electron-app/dist/*.deb - electron-app/dist/*.dmg - electron-app/dist/*.zip - !electron-app/dist/*-unpacked - !electron-app/dist/mac - !electron-app/dist/win-unpacked - !electron-app/dist/linux-unpacked - if-no-files-found: error - retention-days: 7 - - # Create GitHub Release - create-release: - name: Create GitHub Release - needs: [build-electron, determine-version] - if: always() && needs.build-electron.result == 'success' - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download all electron artifacts - uses: actions/download-artifact@v4 - with: - pattern: electron-* - path: dist - merge-multiple: true - - - name: Display structure - run: ls -laR dist/ - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ needs.determine-version.outputs.version }} - name: Hyperloop Control Station v${{ needs.determine-version.outputs.version }} - draft: ${{ needs.determine-version.outputs.is_draft }} - generate_release_notes: true - files: dist/**/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index 4b4f8571d..000000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,210 +0,0 @@ -name: Build Backend and Frontend - -on: - push: - branches: - - production - pull_request: - branches: - - production - workflow_dispatch: - inputs: - rebuild-backend: - description: Force rebuild backend - type: boolean - default: false - rebuild-testing-view: - description: Force rebuild testing-view - type: boolean - default: false - rebuild-competition-view: - description: Force rebuild competition-view - type: boolean - default: false - # workflow_call: - # inputs: - # build-backend: - # description: Build backend binaries - # type: boolean - # default: false - # required: false - # build-testing-view: - # description: Build testing-view - # type: boolean - # default: false - # required: false - # build-competition-view: - # description: Build competition-view - # type: boolean - # default: false - # required: false - -jobs: - # ------------------------------------------------------------------ - # 1. DETECT CHANGES - # Checks which parts of the codebase changed to avoid unnecessary builds - # ------------------------------------------------------------------ - detect-changes: - name: Detect Changes - runs-on: ubuntu-latest - outputs: - backend: ${{ steps.filter.outputs.backend == 'true' || github.event.inputs.rebuild-backend == 'true' || inputs.build-backend == true }} - testing-view: ${{ steps.filter.outputs.testing-view == 'true' || github.event.inputs.rebuild-testing-view == 'true' || inputs.build-testing-view == true }} - competition-view: ${{ steps.filter.outputs.competition-view == 'true' || github.event.inputs.rebuild-competition-view == 'true' || inputs.build-competition-view == true }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: dorny/paths-filter@v3 - id: filter - with: - base: ${{ github.event.before }} - filters: | - backend: - - 'backend/**/*' - - 'go.work' - - 'go.work.sum' - testing-view: - - 'frontend/testing-view/**/*' - - 'frontend/frontend-kit/**/*' # Shared lib dependency - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - competition-view: - - 'frontend/competition-view/**/*' - - 'frontend/frontend-kit/**/*' # Shared lib dependency - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - # ------------------------------------------------------------------ - # 2. BUILD BACKEND (MATRIX) - # Builds Go binaries for Linux, Windows, and macOS (Intel & Arm) - # ------------------------------------------------------------------ - build-backend: - name: Backend - ${{ matrix.platform }} - needs: detect-changes - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - platform: linux - binary: backend-linux-amd64 - goarch: amd64 - - os: windows-latest - platform: windows - binary: backend-windows-amd64.exe - goarch: amd64 - - os: macos-latest - platform: macos-intel - binary: backend-darwin-amd64 - goarch: amd64 - - os: macos-latest - platform: macos-arm64 - binary: backend-darwin-arm64 - goarch: arm64 - steps: - # OPTIMIZATION: Try to download existing artifact first - # Only runs if NO changes were detected and NO rebuild was forced - - name: Try Download Cache - if: needs.detect-changes.outputs.backend != 'true' - id: download - uses: dawidd6/action-download-artifact@v3 - continue-on-error: true - with: - workflow: build.yaml - branch: production - workflow_conclusion: completed - name: backend-${{ matrix.platform }} - path: backend/cmd - - # BUILD: Only runs if download failed OR changes detected - - uses: actions/checkout@v4 - if: steps.download.outcome != 'success' - - - uses: actions/setup-go@v5 - if: steps.download.outcome != 'success' - with: - go-version: "1.23" - - - name: Install Linux Deps - if: runner.os == 'Linux' && steps.download.outcome != 'success' - run: sudo apt-get update && sudo apt-get install -y gcc - - - - name: Build Binary - if: steps.download.outcome != 'success' - working-directory: backend/cmd - shell: bash - run: | - go build -o ${{ matrix.binary }} . - env: - CGO_ENABLED: 1 - GOARCH: ${{ matrix.goarch }} - - # UPLOAD: Always runs to ensure artifact availability for release - - uses: actions/upload-artifact@v4 - with: - name: backend-${{ matrix.platform }} - path: backend/cmd/${{ matrix.binary }} - retention-days: 30 - - # ------------------------------------------------------------------ - # 3. BUILD FRONTEND (MATRIX) - # Builds Testing View and Competition View using pnpm & turbo - # ------------------------------------------------------------------ - build-frontend: - name: Build ${{ matrix.view }} - needs: detect-changes - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - view: [testing-view, - # competition-view] # TODO: Uncomment when competition view is ready - ] - steps: - # OPTIMIZATION: Try to download existing artifact first - # Only runs if NO changes were detected and NO rebuild was forced - - name: Try Download Cache - if: needs.detect-changes.outputs[matrix.view] != 'true' - id: download - uses: dawidd6/action-download-artifact@v3 - continue-on-error: true - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: ${{ matrix.view }} - path: frontend/${{ matrix.view }}/dist - - # BUILD: Only runs if download failed OR changes detected - - uses: actions/checkout@v4 - if: steps.download.outcome != 'success' - - - uses: pnpm/action-setup@v4 - if: steps.download.outcome != 'success' - with: - version: 10.26.0 - - - uses: actions/setup-node@v4 - if: steps.download.outcome != 'success' - with: - node-version: 20 - cache: "pnpm" - - - name: Install Dependencies - if: steps.download.outcome != 'success' - run: pnpm install --frozen-lockfile - - - name: Build with Turbo - if: steps.download.outcome != 'success' - run: pnpm turbo build --filter=${{ matrix.view }} - - # UPLOAD: Always runs to ensure artifact availability for release - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.view }} - path: frontend/${{ matrix.view }}/dist/** - retention-days: 30 diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 000000000..ca99709d1 --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,77 @@ +name: E2E Tests + +on: + pull_request: + branches: + - main + - develop + - production + - "frontend/**" + - "control-station/**" + - "testing-view/**" + - "e2e/**" + paths: + - "frontend/testing-view/**" + - "electron-app/**" + - "e2e/**" + - "pnpm-lock.yaml" + - ".github/workflows/e2e-tests.yaml" + +jobs: + e2e: + name: Playwright E2E Tests + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + - name: Build backend binary + run: | + mkdir -p electron-app/binaries + cd backend/cmd && go build -o ../../electron-app/binaries/backend-windows-amd64.exe . + + - name: Patch backend config + run: sed -i 's/validate = true/validate = false/' backend/cmd/dev-config.toml + + - name: Debug - test backend binary + run: | + ./electron-app/binaries/backend-windows-amd64.exe --config backend/cmd/dev-config.toml 2>&1 & + PID=$! + sleep 5 + kill $PID 2>/dev/null || true + continue-on-error: true + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Electron binary + run: node node_modules/electron/install.js + working-directory: e2e + + - name: Debug - verify Electron binary + run: | + node -e "const e = require('electron'); console.log('Electron path:', e);" + ls -la "$(node -e "process.stdout.write(require('electron'))")" + working-directory: e2e + + - name: Build testing-view (e2e mode) + run: | + pnpm --filter testing-view build:e2e + mkdir -p electron-app/renderer/testing-view + cp -r frontend/testing-view/dist/. electron-app/renderer/testing-view/ + + - name: Run UI tests + run: pnpm --filter e2e exec playwright test tests/ui/ diff --git a/.github/workflows/electron-tests.yaml b/.github/workflows/electron-tests.yaml new file mode 100644 index 000000000..0e5c1f1f0 --- /dev/null +++ b/.github/workflows/electron-tests.yaml @@ -0,0 +1,34 @@ +name: Electron Tests + +on: + pull_request: + branches: + - main + - develop + - production + - "frontend/**" + - "control-station/**" + - "testing-view/**" + paths: + - "electron-app/**" + - ".github/workflows/electron-tests.yaml" + +jobs: + test: + name: Run Electron Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter=hyperloop-control-station + + - name: Run tests + run: pnpm test --filter=hyperloop-control-station diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml index 0599a5097..f96ebfe4d 100644 --- a/.github/workflows/frontend-tests.yaml +++ b/.github/workflows/frontend-tests.yaml @@ -14,19 +14,9 @@ on: - "pnpm-lock.yaml" - ".github/workflows/frontend-tests.yaml" - push: - branches: - - "frontend/**" - - "testing-view/**" - - "competition-view/**" - paths: - - "frontend/**" - - "pnpm-lock.yaml" - - ".github/workflows/frontend-tests.yaml" - jobs: test: - name: Run Frontend Tests + name: Run Frontend Unit Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a8b3f41dd..4413187c3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,10 +1,6 @@ name: Release Electron App on: - # Commented because provokes duplicated builds - # push: - # tags: - # - "v*.*.*" workflow_dispatch: inputs: version: @@ -15,8 +11,10 @@ on: type: boolean default: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: - # Determine version once on Linux determine-version: name: Determine Version runs-on: ubuntu-latest @@ -27,135 +25,150 @@ jobs: - name: Determine version id: get_version run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - echo "is_draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT - else - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - echo "is_draft=false" >> $GITHUB_OUTPUT - fi - echo "Version determined: $(cat $GITHUB_OUTPUT | grep version)" + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + echo "is_draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT - build-electron: - name: Build Electron App + create-draft-release: + name: Create Draft Release needs: determine-version - runs-on: ${{ matrix.os }} - strategy: - fail-fast: true - matrix: - os: [windows-latest, ubuntu-latest, macos-latest, macos-15-intel] + runs-on: ubuntu-latest steps: - - name: Debug all contexts + - uses: actions/checkout@v4 + + - name: Create release + shell: bash run: | - echo "=== Runner Context ===" - echo "runner.os: ${{ runner.os }}" - echo "runner.arch: ${{ runner.arch }}" - echo "runner.name: ${{ runner.name }}" - echo "runner.temp: ${{ runner.temp }}" - echo "runner.workspace: ${{ runner.workspace }}" - echo "" - echo "=== Matrix Context ===" - echo "matrix.os: ${{ matrix.os }}" - echo "matrix.platform: ${{ matrix.platform }}" - echo "matrix.arch: ${{ matrix.arch }}" - echo "" - echo "=== Environment Variables ===" - echo "RUNNER_OS: $RUNNER_OS" - echo "RUNNER_ARCH: $RUNNER_ARCH" - echo "RUNNER_NAME: $RUNNER_NAME" - - - name: Checkout repository - uses: actions/checkout@v4 + DRAFT_FLAG="" + if [ "${{ needs.determine-version.outputs.is_draft }}" = "true" ]; then + DRAFT_FLAG="--draft" + fi + gh release create v${{ needs.determine-version.outputs.version }} \ + --title "Control Station v${{ needs.determine-version.outputs.version }}" \ + --generate-notes \ + $DRAFT_FLAG + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-frontend: + name: Build Frontend + needs: determine-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 10.26.0 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" - cache: "pnpm" - # Update package.json with release version - - name: Update version in package.json - working-directory: electron-app - shell: bash - run: | - pnpm version ${{ needs.determine-version.outputs.version }} --no-git-tag-version - echo "Updated version to:" - cat package.json | grep version + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Install Linux build dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update + - name: Build with Turbo + run: pnpm turbo build --filter=testing-view - # Download ONLY the appropriate backend for this platform - - name: Download Linux backend - if: runner.os == 'Linux' - uses: dawidd6/action-download-artifact@v3 + - uses: actions/upload-artifact@v4 with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-linux - path: electron-app/binaries + name: frontend-dist + path: frontend/testing-view/dist/** + retention-days: 1 - - name: Download Windows backend - if: runner.os == 'Windows' - uses: dawidd6/action-download-artifact@v3 - with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-windows - path: electron-app/binaries + build-backend: + name: Build Backend - ${{ matrix.os }} + needs: determine-version + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: windows-latest + binary: backend-windows-amd64.exe + goarch: amd64 + - os: ubuntu-latest + binary: backend-linux-amd64 + goarch: amd64 + - os: macos-latest + binary: backend-darwin-arm64 + goarch: arm64 + - os: macos-15-intel + binary: backend-darwin-amd64 + goarch: amd64 + steps: + - uses: actions/checkout@v4 - - name: Download macOS Intel backend - if: runner.os == 'macOS' && runner.arch == 'X64' - uses: dawidd6/action-download-artifact@v3 + - uses: actions/setup-go@v5 with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-macos-intel - path: electron-app/binaries + go-version: "1.23" + + - name: Install Linux deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y gcc - - name: Download macOS ARM64 backend - if: runner.os == 'macOS' && runner.arch == 'ARM64' - uses: dawidd6/action-download-artifact@v3 + - name: Build backend binary + working-directory: backend/cmd + shell: bash + run: go build -o ../../electron-app/binaries/${{ matrix.binary }} . + env: + CGO_ENABLED: 1 + GOARCH: ${{ matrix.goarch }} + + - uses: actions/upload-artifact@v4 with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: backend-macos-arm64 - path: electron-app/binaries + name: backend-${{ matrix.os }} + path: electron-app/binaries/${{ matrix.binary }} + retention-days: 1 + + package-and-upload: + name: Package & Upload - ${{ matrix.os }} + needs: [determine-version, create-draft-release, build-frontend, build-backend] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: windows-latest + binary: backend-windows-amd64.exe + - os: ubuntu-latest + binary: backend-linux-amd64 + - os: macos-latest + binary: backend-darwin-arm64 + - os: macos-15-intel + binary: backend-darwin-amd64 + steps: + - uses: actions/checkout@v4 - # Download frontend builds from latest build - # TODO: Uncomment when competition view is ready - # - name: Download competition-view - # uses: dawidd6/action-download-artifact@v3 - # with: - # workflow: build.yaml - # branch: production - # workflow_conclusion: success - # name: competition-view - # path: electron-app/renderer/competition-view - - - name: Download testing-view - uses: dawidd6/action-download-artifact@v3 + - name: Download backend binary + uses: actions/download-artifact@v4 with: - workflow: build.yaml - branch: production - workflow_conclusion: success - name: testing-view - path: electron-app/renderer/testing-view + name: backend-${{ matrix.os }} + path: electron-app/binaries - name: Set executable permissions (Unix) if: runner.os != 'Windows' run: chmod +x electron-app/binaries/* + - name: Download frontend dist + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: electron-app/renderer/testing-view + + - uses: pnpm/action-setup@v4 + with: + version: 10.26.0 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Update version in package.json + working-directory: electron-app + shell: bash + run: pnpm version ${{ needs.determine-version.outputs.version }} --no-git-tag-version + - name: Install Electron dependencies working-directory: electron-app run: pnpm install @@ -168,63 +181,12 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: false ELECTRON_BUILDER_PUBLISH: never - - name: Display structure (Windows) - if: runner.os == 'Windows' - working-directory: electron-app - shell: pwsh - run: Get-ChildItem -Recurse dist/ | Select-Object FullName - - - name: Display structure (Unix) - if: runner.os != 'Windows' - working-directory: electron-app - run: ls -laR dist/ - - - name: Upload electron artifacts - uses: actions/upload-artifact@v4 - with: - name: electron-${{ runner.os }}-${{ runner.arch }} - path: | - electron-app/dist/*.exe - electron-app/dist/*.AppImage - electron-app/dist/*.deb - electron-app/dist/*.dmg - electron-app/dist/*.zip - electron-app/dist/*.yml - electron-app/dist/*.blockmap - !electron-app/dist/*-unpacked - !electron-app/dist/mac - !electron-app/dist/win-unpacked - !electron-app/dist/linux-unpacked - if-no-files-found: error - retention-days: 7 - - # Create GitHub Release - create-release: - name: Create GitHub Release - needs: [build-electron, determine-version] - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download all electron artifacts - uses: actions/download-artifact@v4 - with: - pattern: electron-* - path: dist - merge-multiple: true - - - name: Display structure - run: ls -laR dist/ - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ needs.determine-version.outputs.version }} - name: Hyperloop Control Station v${{ needs.determine-version.outputs.version }} - draft: ${{ needs.determine-version.outputs.is_draft }} - generate_release_notes: true - files: dist/**/* + - name: Upload to GitHub Release + shell: bash + run: | + find electron-app/dist -maxdepth 1 -type f \ + \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \ + -o -name "*.dmg" -o -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) \ + | xargs gh release upload v${{ needs.determine-version.outputs.version }} --clobber env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 6f2466a18..fca4c0051 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Our `pnpm-workspace.yaml` defines the following workspaces: | `backend` | Go | Data ingestion and pod communication server | | `packet-sender` | Rust | Utility for simulating vehicle packets | | `hyperloop-control-station` | JS | The main Control Station electron desktop application | +| `e2e` | TS | End-to-end tests for the whole app (Playwright) | | `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | | `@workspace/core` | TS | Shared business logic and types (frontend-kit) | | `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | @@ -57,9 +58,19 @@ All Turbo scripts support filtering to target specific workspaces: #### Lifecycle Scripts - `pnpm build` – Compiles every package in the monorepo (Go binaries, Rust crates, and Vite apps). -- `pnpm test` – Runs all test suites across the repo (Vitest, Go tests, and Cargo tests). +- `pnpm test` – Runs all test suites across the repo (Vitest, Go tests, Cargo tests, and Playwright e2e tests). - `pnpm lint` – Runs ESLint across all TypeScript packages. - `pnpm preview` – Previews the production Vite builds for the frontend applications. + +#### Electron App Scripts + +- `pnpm start` – Launches the Electron app directly (requires a prior build). +- `pnpm build:win` – Packages the Electron app for Windows. +- `pnpm build:linux` – Packages the Electron app for Linux. +- `pnpm build:mac` – Packages the Electron app for macOS. + +#### Utility Scripts + - `pnpm ui:add ` - To add shadcn/ui components > Note: don't forget to also include it in frontend-kit/ui/src/components/shadcn/index.ts to be able to access it from @workspace/ui diff --git a/backend/cmd/setup_vehicle.go b/backend/cmd/setup_vehicle.go index 8fe8999e2..19ce8765b 100644 --- a/backend/cmd/setup_vehicle.go +++ b/backend/cmd/setup_vehicle.go @@ -161,6 +161,7 @@ func configureHTTPServer( ) httpServer := h.NewServer(server.Addr, mux) + trace.Info().Str("localAddr", server.Addr).Msg("http server listening") go httpServer.ListenAndServe() } diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..945fcd0d8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..b3556080e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,57 @@ +# e2e + +End-to-end tests for the whole app. Tests run against the real Electron application using **Playwright** with the `@playwright/test` electron driver. + +--- + +## Overview + +Tests are split into two Playwright projects: + +| Project | Directory | Description | +| :------------ | :--------------- | :------------------------------------------------------------ | +| `ui` | `tests/ui/` | UI tests — launch the Electron app and interact with the UI | +| `integration` | `tests/integration/` | Integration tests (reserved for future use) | + +--- + +## UI Tests + +These tests launch the full Electron app and drive it through Playwright. They cover app startup, window titles, mode badge state, chart interactions, and filter dialog behaviour. + +--- + +## Fixtures + +`fixtures/electron.ts` provides three custom Playwright fixtures: + +| Fixture | Description | +| :--------- | :------------------------------------------------------------------ | +| `app` | Launches the Electron app and waits for both windows to be ready | +| `page` | The main Control Station window — waits for the app to leave loading state | +| `logPage` | The Backend Logs window | + +--- + +## Scripts + +| Script | Description | +| :--------------------- | :---------------------------------------------------------------- | +| `pnpm test` | Build all dependencies then run all tests | +| `pnpm test:ui` | Build all dependencies then run only `tests/ui` | +| `pnpm test:integration`| Build the electron app then run only `tests/integration` | +| `pnpm test:fast` | Run all tests without rebuilding (assumes already built) | +| `pnpm test:fast:ui` | Run `tests/ui` without rebuilding | +| `pnpm report` | Open the last Playwright HTML report | + +> **Note:** `pnpm test` and `pnpm test:ui` always build the `testing-view` (e2e mode) and the electron app before running. Use the `:fast` variants when iterating to skip the build step. + +--- + +## Requirements + +- The `hyperloop-control-station` electron app must be built (handled automatically by `pnpm test`). +- `testing-view` must be built in e2e mode (`build:e2e`), also handled automatically. +- Workers are set to `1` — Electron tests must run serially since only one app instance can run at a time. + +> **Note:** The app runs in its normal production mode during tests — there is no special test environment or mock mode. diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts new file mode 100644 index 000000000..22ea3d4ab --- /dev/null +++ b/e2e/fixtures/electron.ts @@ -0,0 +1,56 @@ +import { + _electron as electron, + test as base, + type ElectronApplication, + type Page, +} from "@playwright/test"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ELECTRON_APP_PATH = path.resolve(__dirname, "../../electron-app"); + +type ElectronFixtures = { + app: ElectronApplication; + page: Page; + logPage: Page; +}; + +export const test = base.extend({ + app: async ({}, use) => { + const app = await electron.launch({ + args: ["--no-sandbox", path.join(ELECTRON_APP_PATH, "main.js")], + cwd: ELECTRON_APP_PATH, + env: { + ...process.env, + NODE_ENV: "test", + }, + }); + + // Wait for both windows to open before yielding the app fixture, + // so logPage and logWindow fixtures can safely index into app.windows() + await app.firstWindow(); // Backend Logs — always first + await app.waitForEvent("window"); // Control Station — always second + + await use(app); + await app.close(); + }, + + // Backend logs window — always opens first + logPage: async ({ app }, use) => { + const page = app.windows()[0]; + await page.waitForLoadState("domcontentloaded"); + await use(page); + }, + + // Main control station window — always opens second + // Waits for the app to reach "active" mode before yielding + page: async ({ app }, use) => { + const page = app.windows()[1]; + await page.waitForLoadState("domcontentloaded"); + await page.waitForSelector('[data-testid="mode-badge"]:not([data-mode="loading"])', { timeout: 15000 }); + await use(page); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..0d7986fa6 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,19 @@ +{ + "name": "e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm --filter testing-view build:e2e && pnpm --filter hyperloop-control-station build:testing", + "test": "pnpm run build && playwright test", + "test:ui": "pnpm run build && playwright test tests/ui", + "test:integration": "pnpm --filter hyperloop-control-station build:testing && playwright test tests/integration", + "test:fast": "playwright test", + "test:fast:ui": "playwright test tests/ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "electron": "^40.1.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..881282100 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "@playwright/test"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + retries: 0, + workers: 1, // Electron tests must run serially — only one app instance at a time + + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report", open: "never" }], + ], + + projects: [ + { + name: "ui", + testDir: "./tests/ui", + use: {}, + }, + { + name: "integration", + testDir: "./tests/integration", + use: {}, + }, + ], +}); diff --git a/e2e/tests/ui/charts.test.ts b/e2e/tests/ui/charts.test.ts new file mode 100644 index 000000000..78c7aa9ed --- /dev/null +++ b/e2e/tests/ui/charts.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "../../fixtures/electron"; + +/** Wait for the Zustand persist middleware to rehydrate from localStorage. */ +async function waitForHydration(page: import("@playwright/test").Page) { + await page.waitForSelector('html[data-store-hydrated="true"]'); + return await page.getByTestId("chart").count(); +} + +test("add chart button is visible in toolbar", async ({ page }) => { + await expect(page.getByTestId("add-chart-button")).toBeVisible(); +}); + +test("add chart button adds a chart", async ({ page }) => { + const initialCount = await waitForHydration(page); + + await page.getByTestId("add-chart-button").click(); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 1); +}); + +test("clicking add chart multiple times adds multiple charts", async ({ + page, +}) => { + const initialCount = await waitForHydration(page); + + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 3); +}); + +test("charts are restored from localStorage after reload", async ({ page }) => { + const initialCount = await waitForHydration(page); + + // Add two charts on top of whatever is already persisted + await page.getByTestId("add-chart-button").click(); + await page.getByTestId("add-chart-button").click(); + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 2); + + // Reload — Zustand should restore all charts from localStorage + await page.reload(); + await waitForHydration(page); + + await expect(page.getByTestId("chart")).toHaveCount(initialCount + 2); +}); diff --git a/e2e/tests/ui/filter-dialog.test.ts b/e2e/tests/ui/filter-dialog.test.ts new file mode 100644 index 000000000..b68b41327 --- /dev/null +++ b/e2e/tests/ui/filter-dialog.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "../../fixtures/electron"; + +test("telemetry filter dialog opens with correct title", async ({ page }) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading")).toHaveText( + "Filter telemetry packets", + ); +}); + +test("commands filter dialog opens with correct title", async ({ page }) => { + await page.getByTestId("filter-button-commands").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading")).toHaveText("Filter commands"); +}); + +test("filter dialog select all and clear all buttons work", async ({ + page, +}) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + + await dialog.getByTestId("filter-clear-all").click(); + await dialog.getByTestId("filter-select-all").click(); + + // Dialog should still be open after interactions + await expect(dialog).toBeVisible(); +}); + +test("filter dialog closes on overlay click", async ({ page }) => { + await page.getByTestId("filter-button-telemetry").click(); + + const dialog = page.getByTestId("filter-dialog"); + await expect(dialog).toBeVisible(); + + // Press Escape to close + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); +}); diff --git a/e2e/tests/ui/mode-badge.test.ts b/e2e/tests/ui/mode-badge.test.ts new file mode 100644 index 000000000..70bcf5c5e --- /dev/null +++ b/e2e/tests/ui/mode-badge.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "../../fixtures/electron"; + +const VALID_MODES = ["mock", "active", "mock-active", "loading", "error"]; + +test("mode badge is visible with a valid mode", async ({ page }) => { + const badge = page.getByTestId("mode-badge"); + await expect(badge).toBeVisible(); + + const mode = await badge.getAttribute("data-mode"); + expect(VALID_MODES).toContain(mode); +}); + +test("mode badge reaches active mode", async ({ page }) => { + await expect(page.getByTestId("mode-badge")).toHaveAttribute( + "data-mode", + "active", + ); +}); + diff --git a/e2e/tests/ui/startup.test.ts b/e2e/tests/ui/startup.test.ts new file mode 100644 index 000000000..4bbc087ac --- /dev/null +++ b/e2e/tests/ui/startup.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "../../fixtures/electron"; + +test("backend logs window opens with correct title", async ({ logPage }) => { + await expect(logPage).toHaveTitle("Backend Logs"); +}); + +test("control station window opens with correct title", async ({ page }) => { + await expect(page).toHaveTitle("Hyperloop Testing View"); +}); diff --git a/electron-app/main.js b/electron-app/main.js index ae651860a..cc9be7bf1 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -17,20 +17,11 @@ import { createWindow } from "./src/windows/mainWindow.js"; const { autoUpdater } = pkg; -// Disable sandbox for Linux +// Disable sandbox on Linux — sandbox restrictions vary across distros +// (AppArmor on Ubuntu, SELinux on Fedora, etc.) and this is an internal +// app where all content is trusted. if (process.platform === "linux") { - try { - const userns = fs - .readFileSync("/proc/sys/kernel/unprivileged_userns_clone", "utf8") - .trim(); - if (userns === "0") { - app.commandLine.appendSwitch("no-sandbox"); - } - } catch (e) {} - - if (process.getuid && process.getuid() === 0) { - app.commandLine.appendSwitch("no-sandbox"); - } + app.commandLine.appendSwitch("no-sandbox"); } // Setup IPC handlers for renderer process communication diff --git a/electron-app/package.json b/electron-app/package.json index 2d936c068..0a1fc3b24 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -57,7 +57,7 @@ "owner": "Hyperloop-UPV", "repo": "software" }, - "productName": "Hyperloop-Ctrl", + "productName": "Control-Station", "directories": { "output": "dist" }, diff --git a/electron-app/preload.js b/electron-app/preload.js index 5c3d5c023..5fda40215 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -36,6 +36,8 @@ contextBridge.exposeInMainWorld("electronAPI", { importConfig: () => ipcRenderer.invoke("import-config"), // Open folder selection dialog selectFolder: () => ipcRenderer.invoke("select-folder"), + // Open a folder path in the OS file explorer + openFolder: (path) => ipcRenderer.invoke("open-folder", path), // Receive log message from backend onLog: (callback) => { const listener = (_event, value) => callback(value); diff --git a/electron-app/src/config/configInstance.js b/electron-app/src/config/configInstance.js index 33467fb80..ec3c9339e 100644 --- a/electron-app/src/config/configInstance.js +++ b/electron-app/src/config/configInstance.js @@ -42,9 +42,9 @@ async function getConfigManager() { ); logger.config.info("ConfigManager initialized"); - logger.config.path("User config", userConfigPath); - logger.config.path("User version config", versionFilePath); - logger.config.path("Template path", templatePath); + logger.config.debug("User config", userConfigPath); + logger.config.debug("User version config", versionFilePath); + logger.config.debug("Template path", templatePath); } // Return the singleton instance diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 79d8c1075..269ea5235 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -7,13 +7,15 @@ * - Folder selection dialogs */ -import { dialog, ipcMain } from "electron"; +import { dialog, ipcMain, shell } from "electron"; +import fs from "fs"; +import { isAbsolute, join } from "path"; import { importConfig, readConfig, writeConfig, } from "../config/configInstance.js"; -import { restartBackend } from "../processes/backend.js"; +import { getBackendWorkingDir, restartBackend } from "../processes/backend.js"; import { logger } from "../utils/logger.js"; import { getCurrentView, @@ -136,6 +138,28 @@ function setupIpcHandlers() { throw error; } }); + + /** + * @event open-folder + * @async + * @description Opens the specified folder path in the OS file explorer. + * @param {import("electron").IpcMainInvokeEvent} event - The IPC event object. + * @param {string} folderPath - The folder path to open. + * @returns {Promise} + * @throws {Error} If opening the folder fails. + */ + ipcMain.handle("open-folder", async (event, folderPath) => { + try { + const resolvedPath = isAbsolute(folderPath) + ? folderPath + : join(getBackendWorkingDir(), folderPath); + const loggerPath = join(resolvedPath, "logger"); + await shell.openPath(fs.existsSync(loggerPath) ? loggerPath : resolvedPath); + } catch (error) { + logger.electron.error("Error opening folder:", error); + throw error; + } + }); } export { setupIpcHandlers }; diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 92dffe7eb..b0894ca31 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -75,13 +75,22 @@ async function startBackend(logWindow = null) { // Log stdout output from backend backendProcess.stdout.on("data", (data) => { - logger.backend.info(`${data.toString().trim()}`); + const text = data.toString().trim(); + logger.backend.info(text); // Send log message to log window if (currentLogWindow && !currentLogWindow.isDestroyed()) { - const htmlData = convert.toHtml(data.toString().trim()); + const htmlData = convert.toHtml(text); currentLogWindow.webContents.send("log", htmlData); } + + // Resolve as soon as the HTTP server confirms it is listening. + // Matches: "INF ... > http server listening localAddr=..." + if (text.includes("http server listening")) { + logger.backend.info("Backend ready (HTTP server listening)"); + clearTimeout(startupTimer); + resolve(backendProcess); + } }); // Capture stderr output (where Go errors/panics are written) @@ -130,10 +139,13 @@ async function startBackend(logWindow = null) { backendProcess = null; }); - // If the backend didn't fail in this period of time, resolve the promise + // Fallback: if the ready message never appears, resolve anyway after timeout const startupTimer = setTimeout(() => { + logger.backend.warning( + "Backend ready signal not received - resolving after timeout", + ); resolve(backendProcess); - }, 2000); + }, 5000); }); } @@ -200,4 +212,10 @@ async function restartBackend() { } } -export { restartBackend, startBackend, stopBackend }; +function getBackendWorkingDir() { + return !app.isPackaged + ? path.join(appPath, "..", "backend", "cmd") + : path.dirname(getUserConfigPath()); +} + +export { getBackendWorkingDir, restartBackend, startBackend, stopBackend }; diff --git a/electron-app/src/utils/__tests__/getAppPath.test.js b/electron-app/src/utils/__tests__/getAppPath.test.js new file mode 100644 index 000000000..c931595be --- /dev/null +++ b/electron-app/src/utils/__tests__/getAppPath.test.js @@ -0,0 +1,34 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getAppPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getAppPath", () => { + it("dev: returns the electron-app root (2 levels up from src/utils)", () => { + expect(path.normalize(getAppPath())).toBe( + path.normalize(ELECTRON_APP_ROOT), + ); + }); + + it("prod: returns app.getAppPath()", () => { + app.isPackaged = true; + app.getAppPath.mockReturnValue("/prod/app"); + + expect(getAppPath()).toBe("/prod/app"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getBinaryPath.test.js b/electron-app/src/utils/__tests__/getBinaryPath.test.js new file mode 100644 index 000000000..5e0742b0e --- /dev/null +++ b/electron-app/src/utils/__tests__/getBinaryPath.test.js @@ -0,0 +1,68 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT, mockArch, mockPlatform } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getBinaryPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; + mockPlatform("linux"); + mockArch("x64"); + process.resourcesPath = "/mock/resources"; +}); + +describe("getBinaryPath", () => { + it("dev win32/x64: appends .exe and maps to windows-amd64", () => { + mockPlatform("win32"); + mockArch("x64"); + + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-windows-amd64.exe"), + ); + }); + + it("dev darwin/arm64: no .exe extension, maps to darwin-arm64", () => { + mockPlatform("darwin"); + mockArch("arm64"); + + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-darwin-arm64"), + ); + }); + + it("dev linux/x64: no .exe extension, maps to linux-amd64", () => { + expect(getBinaryPath("backend")).toBe( + path.join(ELECTRON_APP_ROOT, "binaries", "backend-linux-amd64"), + ); + }); + + it("prod: uses process.resourcesPath instead of app root", () => { + app.isPackaged = true; + + expect(getBinaryPath("backend")).toBe( + path.join("/mock/resources", "binaries", "backend-linux-amd64"), + ); + }); + + it("unknown platform: falls back to raw platform name", () => { + mockPlatform("freebsd"); + + expect(getBinaryPath("backend")).toContain("backend-freebsd-"); + }); + + it("unknown arch: falls back to raw arch name", () => { + mockArch("mips"); + + expect(getBinaryPath("backend")).toContain("-mips"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getTemplatePath.test.js b/electron-app/src/utils/__tests__/getTemplatePath.test.js new file mode 100644 index 000000000..33ac13424 --- /dev/null +++ b/electron-app/src/utils/__tests__/getTemplatePath.test.js @@ -0,0 +1,36 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getTemplatePath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; + process.resourcesPath = "/mock/resources"; +}); + +describe("getTemplatePath", () => { + it("dev: returns dev-config.toml in backend/cmd/", () => { + expect(getTemplatePath()).toBe( + path.join(ELECTRON_APP_ROOT, "..", "backend", "cmd", "dev-config.toml"), + ); + }); + + it("prod: returns config.toml under process.resourcesPath", () => { + app.isPackaged = true; + + expect(getTemplatePath()).toBe( + path.join("/mock/resources", "config.toml"), + ); + }); +}); diff --git a/electron-app/src/utils/__tests__/getUserConfigPath.test.js b/electron-app/src/utils/__tests__/getUserConfigPath.test.js new file mode 100644 index 000000000..28d32d18c --- /dev/null +++ b/electron-app/src/utils/__tests__/getUserConfigPath.test.js @@ -0,0 +1,37 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getUserConfigPath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getUserConfigPath", () => { + it("dev: returns config.toml in electron-app root", () => { + expect(getUserConfigPath()).toBe( + path.join(ELECTRON_APP_ROOT, "config.toml"), + ); + }); + + it("prod: returns config.toml under userData/configs/", () => { + app.isPackaged = true; + app.getPath.mockReturnValue("/mock/userData"); + + expect(getUserConfigPath()).toBe( + path.join("/mock/userData", "configs", "config.toml"), + ); + expect(app.getPath).toHaveBeenCalledWith("userData"); + }); +}); diff --git a/electron-app/src/utils/__tests__/getVersionFilePath.test.js b/electron-app/src/utils/__tests__/getVersionFilePath.test.js new file mode 100644 index 000000000..a91333de5 --- /dev/null +++ b/electron-app/src/utils/__tests__/getVersionFilePath.test.js @@ -0,0 +1,37 @@ +import path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ELECTRON_APP_ROOT } from "./helpers.js"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: vi.fn().mockReturnValue("/mock/packaged/app"), + getPath: vi.fn().mockReturnValue("/mock/userData"), + }, +})); + +const { app } = await import("electron"); +const { getVersionFilePath } = await import("../paths.js"); + +beforeEach(() => { + vi.clearAllMocks(); + app.isPackaged = false; +}); + +describe("getVersionFilePath", () => { + it("dev: returns version.toml in electron-app root", () => { + expect(getVersionFilePath()).toBe( + path.join(ELECTRON_APP_ROOT, "version.toml"), + ); + }); + + it("prod: returns version.toml directly under userData/", () => { + app.isPackaged = true; + app.getPath.mockReturnValue("/mock/userData"); + + expect(getVersionFilePath()).toBe( + path.join("/mock/userData", "version.toml"), + ); + expect(app.getPath).toHaveBeenCalledWith("userData"); + }); +}); diff --git a/electron-app/src/utils/__tests__/helpers.js b/electron-app/src/utils/__tests__/helpers.js new file mode 100644 index 000000000..9a2ece5a2 --- /dev/null +++ b/electron-app/src/utils/__tests__/helpers.js @@ -0,0 +1,26 @@ +import { dirname } from "path"; +import path from "path"; +import { fileURLToPath } from "url"; + +// electron-app root is 3 levels up from this file: +// electron-app/src/utils/__tests__/helpers.js -> ../../../ = electron-app/ +export const ELECTRON_APP_ROOT = path.resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", +); + +export function mockPlatform(platform) { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function mockArch(arch) { + Object.defineProperty(process, "arch", { + value: arch, + configurable: true, + }); +} diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 0e994eeac..0bf125b85 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -54,11 +54,16 @@ function createWindow(screenWidth, screenHeight) { const menu = createMenu(mainWindow); mainWindow.setMenu(menu); - // Open DevTools in development mode - if (!app.isPackaged) { + // Open DevTools in development mode (skip in test env to keep window order predictable) + if (!app.isPackaged && process.env.NODE_ENV !== "test") { mainWindow.webContents.openDevTools(); } + // Quit the app when main window is closed + mainWindow.on("close", () => { + app.quit(); + }); + // Clear window reference when closed mainWindow.on("closed", () => { mainWindow = null; diff --git a/frontend/README.md b/frontend/README.md index de434e676..258ca1b17 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -26,6 +26,7 @@ The frontend is organized as 6 workspaces out of 9 in the whole monorepo, divide - **Zustand** for state management - **React Router** for navigation - **Radix UI / shadcn/ui** for UI components +- **RxJS** for reactive data streams - **WebSocket** for real-time backend communication - **@dnd-kit** for drag-and-drop functionality diff --git a/frontend/frontend-kit/ui/src/icons/files.ts b/frontend/frontend-kit/ui/src/icons/files.ts index a15685d9f..c9e221541 100644 --- a/frontend/frontend-kit/ui/src/icons/files.ts +++ b/frontend/frontend-kit/ui/src/icons/files.ts @@ -1 +1,5 @@ -export { Folder, Trash2 } from "lucide-react"; +export { + Folder, + FolderOpen, + Trash2, +} from "lucide-react"; diff --git a/frontend/testing-view/.env.e2e b/frontend/testing-view/.env.e2e new file mode 100644 index 000000000..38345cea9 --- /dev/null +++ b/frontend/testing-view/.env.e2e @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://127.0.0.1:4000/backend diff --git a/frontend/testing-view/README.md b/frontend/testing-view/README.md index fc32fc980..f1a35b067 100644 --- a/frontend/testing-view/README.md +++ b/frontend/testing-view/README.md @@ -1,73 +1,49 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from "eslint-plugin-react-x"; -import reactDom from "eslint-plugin-react-dom"; - -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs["recommended-typescript"], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` +# Testing View + +The Testing View is the web interface used during vehicle testing sessions. It provides real-time telemetry charts and a filtering system for monitoring packet data from the pod. + +It is built with **React**, **TypeScript**, and **Vite**, and runs embedded inside the Hyperloop Control Station Electron app. + +--- + +## Features + +### Workspaces + +Workspaces are named tabs that each hold their own independent set of charts. You can create, rename, and delete workspaces, and switch between them at any time. The active workspace and its charts are persisted across sessions. + +### Charts + +The charts panel displays live telemetry data as line charts within the active workspace. You can add, remove, and reorder charts via drag and drop. Each chart supports multiple data series and a configurable history limit that controls how many data points are kept in memory. + +### Filtering + +The filtering system lets you select which telemetry packets and commands are visible. Filters are organized in a tree matching the packet catalog structure, with search, select all, and clear all controls. + +### Settings + +A settings dialog exposes runtime configuration for the vehicle connection, including vehicle board selection, ADJ branch, TCP/TFTP connection parameters, BLCU addresses, and logging options (time unit and output path). + +### Key Bindings + +The key bindings system lets you assign keyboard shortcuts to commands sent to the vehicle, as well as special built-in actions like starting, stopping, or toggling the logger. + +--- + +## Scripts + +| Script | Description | +| :---------------- | :---------------------------------------------- | +| `pnpm dev` | Start the Vite dev server | +| `pnpm build` | Type-check and build for production | +| `pnpm build:e2e` | Build in e2e mode (used by the `e2e` workspace) | +| `pnpm preview` | Preview the production build | +| `pnpm lint` | Run ESLint | +| `pnpm test` | Run unit tests once (Vitest) | +| `pnpm test:watch` | Run unit tests in watch mode | + +--- + +## Tests + +Unit tests are written with **Vitest**. The charts store, filtering store, and utility functions are covered. diff --git a/frontend/testing-view/index.html b/frontend/testing-view/index.html index 09e38dc71..5f7ed82d4 100644 --- a/frontend/testing-view/index.html +++ b/frontend/testing-view/index.html @@ -4,7 +4,7 @@ - Testing View + Hyperloop Testing View
diff --git a/frontend/testing-view/package.json b/frontend/testing-view/package.json index b6f2627dd..cf135f1cb 100644 --- a/frontend/testing-view/package.json +++ b/frontend/testing-view/package.json @@ -10,6 +10,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "build:e2e": "tsc -b && vite build --mode e2e", "test": "vitest run", "test:watch": "vitest" }, diff --git a/frontend/testing-view/src/components/header/ModeBadge.tsx b/frontend/testing-view/src/components/header/ModeBadge.tsx index d96e08d6f..d4ec401b3 100644 --- a/frontend/testing-view/src/components/header/ModeBadge.tsx +++ b/frontend/testing-view/src/components/header/ModeBadge.tsx @@ -31,6 +31,8 @@ export const ModeBadge = () => { return (
) => { + const { openFolder } = useOpenFolder(); + const handleBrowse = async () => { - // Accessing the Electron API exposed via preload script - const path = await window.electronAPI?.selectFolder(); + if (!window.electronAPI) { + logger.testingView.warn("electronAPI is not available"); + return; + } + const path = await window.electronAPI.selectFolder(); if (path) { onChange(path); } @@ -20,6 +28,9 @@ export const PathField = ({ field, value, onChange }: FieldProps) => { placeholder={field.placeholder || "No path selected"} className="bg-muted/50" /> + diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index 7a37b7e98..33db5109d 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -13,6 +13,7 @@ export const SettingsDialog = () => { const isSettingsOpen = useStore((s) => s.isSettingsOpen); const setSettingsOpen = useStore((s) => s.setSettingsOpen); const setRestarting = useStore((s) => s.setRestarting); + const setConfig = useStore((s) => s.setConfig); const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); const [isSaving, startSaving] = useTransition(); @@ -24,6 +25,7 @@ export const SettingsDialog = () => { try { const config = await window.electronAPI.getConfig(); setLocalConfig(config); + setConfig(config); setIsSynced(true); } catch (error) { console.error("Error loading config:", error); @@ -37,24 +39,33 @@ export const SettingsDialog = () => { } }; - const loadBranches = () => { + const loadBranches = (signal: AbortSignal) => { startBranchesTransition(async () => { try { const res = await fetch( "https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100", + { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }, ); const data = await res.json(); setBranches(data.map((b: { name: string }) => b.name)); } catch (error) { - console.error("Error loading branches:", error); + if ( + error instanceof Error && + error.name !== "AbortError" && + error.name !== "TimeoutError" + ) { + console.error("Error loading branches:", error); + } } }); }; useEffect(() => { if (isSettingsOpen) { + const controller = new AbortController(); loadConfig(); - loadBranches(); + loadBranches(controller.signal); + return () => controller.abort(); } }, [isSettingsOpen]); diff --git a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx index ca788495a..296624a64 100644 --- a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx +++ b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx @@ -96,6 +96,8 @@ export const ChartSurface = memo( .getPropertyValue(varName) .trim(); + const enumOptions = series[0]?.enumOptions; + const opts: uPlot.Options = { width: containerRef.current.clientWidth - 32, height: config.DEFAULT_CHART_HEIGHT, @@ -106,14 +108,16 @@ export const ChartSurface = memo( padding: [20, 10, 5, 15], scales: { x: { time: false }, - y: { - range: (_, min, max) => { - if (min === max) return [min - 1, max + 1]; - const span = max - min; - const buffer = span * 0.15; - return [min - buffer, max + buffer]; - }, - }, + y: enumOptions?.length + ? { range: () => [0, enumOptions.length - 1] } + : { + range: (_, min, max) => { + if (min === max) return [min - 1, max + 1]; + const span = max - min; + const buffer = span * 0.15; + return [min - buffer, max + buffer]; + }, + }, }, series: [ {}, @@ -141,7 +145,12 @@ export const ChartSurface = memo( stroke: getStyle("--muted-foreground"), grid: { stroke: getStyle("--border") }, font: "10px Archivo", - size: 40, + size: enumOptions?.length ? 80 : 40, + ...(enumOptions?.length && { + splits: () => enumOptions.map((_, i) => i), + values: (_u: uPlot, vals: number[]) => + vals.map((v) => enumOptions[v] ?? ""), + }), }, ], cursor: { drag: { setScale: true, x: true, y: true } }, @@ -184,6 +193,7 @@ export const ChartSurface = memo( const m = pkt?.measurementUpdates?.[p.variable]; if (typeof m === "boolean") return m ? 1 : 0; if (typeof m === "object" && m !== null && "last" in m) return m.last; + if (typeof m === "string") return p.enumOptions?.indexOf(m) ?? 0; return m ?? 0; }), }; diff --git a/frontend/testing-view/src/features/charts/components/SortableChart.tsx b/frontend/testing-view/src/features/charts/components/SortableChart.tsx index 76efbfc37..f9353058b 100644 --- a/frontend/testing-view/src/features/charts/components/SortableChart.tsx +++ b/frontend/testing-view/src/features/charts/components/SortableChart.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { canAddSeriesToChart } from "../../../lib/utils"; import type { WorkspaceChartSeries } from "../types/charts"; import { TelemetryChart } from "./TelemetryChart"; @@ -24,6 +25,10 @@ export function SortableChart({ id, series }: SortableChartProps) { }); const isVariableOver = isOver && active?.data.current?.type === "variable"; + const draggedIsEnum = + (active?.data.current?.variableEnumOptions?.length ?? 0) > 0; + const isIncompatibleDrop = + isVariableOver && !canAddSeriesToChart(series, draggedIsEnum); const style = { transform: CSS.Transform.toString(transform), @@ -37,7 +42,8 @@ export function SortableChart({ id, series }: SortableChartProps) { id={id} series={series} isDragging={false} - isOver={isVariableOver} + isOver={isVariableOver && !isIncompatibleDrop} + isIncompatibleDrop={isIncompatibleDrop} dragAttributes={attributes} dragListeners={listeners} /> diff --git a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx index 81355f499..1cac96207 100644 --- a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx +++ b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx @@ -14,6 +14,7 @@ interface TelemetryChartProps { series: WorkspaceChartSeries[]; isDragging: boolean; isOver?: boolean; + isIncompatibleDrop?: boolean; dragAttributes?: DraggableAttributes; dragListeners?: SyntheticListenerMap; } @@ -34,6 +35,7 @@ export const TelemetryChart = ({ series, isDragging, isOver, + isIncompatibleDrop, dragAttributes, dragListeners, }: TelemetryChartProps) => { @@ -74,9 +76,11 @@ export const TelemetryChart = ({ return (
@@ -101,6 +105,14 @@ export const TelemetryChart = ({
+ {isIncompatibleDrop && ( +
+ + Cannot mix enum and numeric series + +
+ )} + { if (u.series[i + 1].show) { row.classList.remove("hidden"); row.classList.add("flex"); - vals[i].textContent = u.data[i + 1][idx]?.toFixed(2) ?? "0.00"; + const rawVal = u.data[i + 1][idx]; + const enumOptions = series[i].enumOptions; + if (enumOptions?.length && rawVal != null) { + vals[i].textContent = enumOptions[Math.round(rawVal)] ?? String(rawVal); + } else { + vals[i].textContent = rawVal?.toFixed(2) ?? "0.00"; + } anyVisible = true; } else { row.classList.add("hidden"); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts new file mode 100644 index 000000000..bb9cc6203 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/addChart.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { config } from "../../../../../config"; +import { WORKSPACE_ID, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("addChart", () => { + it("adds a chart to the specified workspace", () => { + store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(1); + }); + + it("returns the new chart's ID", () => { + const id = store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts[WORKSPACE_ID][0].id).toBe(id); + }); + + it("initializes the chart with an empty series array", () => { + const id = store.getState().addChart(WORKSPACE_ID); + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === id); + + expect(chart?.series).toStrictEqual([]); + }); + + it("initializes the chart with the default history limit", () => { + const id = store.getState().addChart(WORKSPACE_ID); + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === id); + + expect(chart?.historyLimit).toBe(config.DEFAULT_CHART_HISTORY_LIMIT); + }); + + it("does not affect other workspaces", () => { + const chartsBefore = store.getState().charts["workspace-2"]; + store.getState().addChart(WORKSPACE_ID); + + expect(store.getState().charts["workspace-2"]).toStrictEqual(chartsBefore); + }); + + it("each call adds a distinct chart", () => { + const id1 = store.getState().addChart(WORKSPACE_ID); + const id2 = store.getState().addChart(WORKSPACE_ID); + + expect(id1).not.toBe(id2); + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(2); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts new file mode 100644 index 000000000..271cd06c4 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/chartHistoryLimit.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let chartId: string; + +beforeEach(() => { + store = createTestStore(); + chartId = addChart(store); +}); + +describe("setChartHistoryLimit", () => { + it("updates the history limit for the correct chart", () => { + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.historyLimit).toBe(500); + }); + + it("does not affect other charts in the same workspace", () => { + const otherId = addChart(store); + const otherLimitBefore = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId)?.historyLimit; + + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.historyLimit).toBe(otherLimitBefore); + }); + + it("does not affect charts in other workspaces", () => { + const otherChartId = addChart(store, "workspace-2"); + const otherLimitBefore = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId)?.historyLimit; + + store.getState().setChartHistoryLimit(WORKSPACE_ID, chartId, 500); + + const otherChart = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId); + expect(otherChart?.historyLimit).toBe(otherLimitBefore); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts new file mode 100644 index 000000000..83f65c2c0 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/chartSeries.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { SERIES_A, SERIES_B, SERIES_ENUM, WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let chartId: string; + +beforeEach(() => { + store = createTestStore(); + chartId = addChart(store); +}); + +// ─── addSeriesToChart ───────────────────────────────────────────────────────── + +describe("addSeriesToChart", () => { + it("adds a series to the correct chart", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toHaveLength(1); + expect(chart?.series[0]).toStrictEqual(SERIES_A); + }); + + it("appends multiple series in order", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_B); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toStrictEqual([SERIES_A, SERIES_B]); + }); + + it("supports enum series", () => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_ENUM); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series[0].enumOptions).toStrictEqual(["Idle", "Running", "Fault"]); + }); + + it("does not affect other charts in the same workspace", () => { + const otherId = addChart(store); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.series).toHaveLength(0); + }); + + it("does not affect charts in other workspaces", () => { + const otherChartId = addChart(store, "workspace-2"); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + + const otherChart = store.getState().charts["workspace-2"].find((c) => c.id === otherChartId); + expect(otherChart?.series).toHaveLength(0); + }); +}); + +// ─── removeSeriesFromChart ──────────────────────────────────────────────────── + +describe("removeSeriesFromChart", () => { + beforeEach(() => { + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_A); + store.getState().addSeriesToChart(WORKSPACE_ID, chartId, SERIES_B); + }); + + it("removes the series with the given variable name", () => { + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series.find((s) => s.variable === SERIES_A.variable)).toBeUndefined(); + }); + + it("keeps other series in the same chart", () => { + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const chart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === chartId); + expect(chart?.series).toHaveLength(1); + expect(chart?.series[0]).toStrictEqual(SERIES_B); + }); + + it("does not affect other charts", () => { + const otherId = addChart(store); + store.getState().addSeriesToChart(WORKSPACE_ID, otherId, SERIES_A); + + store.getState().removeSeriesFromChart(WORKSPACE_ID, chartId, SERIES_A.variable); + + const otherChart = store.getState().charts[WORKSPACE_ID].find((c) => c.id === otherId); + expect(otherChart?.series).toHaveLength(1); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts new file mode 100644 index 000000000..b62d4c3cb --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/clearCharts.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("clearCharts", () => { + it("removes all charts from the active workspace", () => { + addChart(store); + addChart(store); + + store.getState().clearCharts(); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(0); + }); + + it("does not affect charts in other workspaces", () => { + addChart(store, "workspace-2"); + addChart(store); + + store.getState().clearCharts(); + + expect(store.getState().charts["workspace-2"]).toHaveLength(1); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts new file mode 100644 index 000000000..b2b7c4977 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/getActiveWorkspaceCharts.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("getActiveWorkspaceCharts", () => { + it("returns the charts for the active workspace", () => { + const id = addChart(store); + + const charts = store.getState().getActiveWorkspaceCharts(); + expect(charts).toHaveLength(1); + expect(charts[0].id).toBe(id); + }); + + it("returns an empty array when the workspace has no charts", () => { + expect(store.getState().getActiveWorkspaceCharts()).toStrictEqual([]); + }); + + it("reflects the active workspace — switching workspaces returns different charts", () => { + addChart(store); + + const workspace2 = store.getState().workspaces[1]; + store.getState().setActiveWorkspace(workspace2); + + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(0); + }); + + it("updates after a chart is added", () => { + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(0); + + addChart(store); + + expect(store.getState().getActiveWorkspaceCharts()).toHaveLength(1); + }); + + it("updates after a chart is removed", () => { + const id = addChart(store); + store.getState().removeChart(WORKSPACE_ID, id); + + expect(store.getState().getActiveWorkspaceCharts()).toStrictEqual([]); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts b/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts new file mode 100644 index 000000000..7740e369b --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/helpers.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { createChartsSlice } from "../chartsSlice"; +import { createAppSlice } from "../../../../store/slices/appSlice"; +import { createCatalogSlice } from "../../../../store/slices/catalogSlice"; +import { createConnectionsSlice } from "../../../../store/slices/connectionsSlice"; +import { createMessagesSlice } from "../../../../store/slices/messagesSlice"; +import { createTelemetrySlice } from "../../../../store/slices/telemetrySlice"; +import { createRightSidebarSlice } from "../../../workspace/store/rightSidebarSlice"; +import { createWorkspacesSlice } from "../../../workspace/store/workspacesSlice"; +import { createFilteringSlice } from "../../../filtering/store/filteringSlice"; +import type { Store } from "../../../../store/store"; +import type { WorkspaceChartSeries } from "../../types/charts"; + +export const createTestStore = () => + create()((...a) => ({ + ...createAppSlice(...a), + ...createCatalogSlice(...a), + ...createWorkspacesSlice(...a), + ...createTelemetrySlice(...a), + ...createRightSidebarSlice(...a), + ...createConnectionsSlice(...a), + ...createMessagesSlice(...a), + ...createChartsSlice(...a), + ...createFilteringSlice(...a), + })); + +export const WORKSPACE_ID = "workspace-1"; + +export const SERIES_A: WorkspaceChartSeries = { + packetId: 1, + variable: "speed", +}; + +export const SERIES_B: WorkspaceChartSeries = { + packetId: 2, + variable: "temperature", +}; + +export const SERIES_ENUM: WorkspaceChartSeries = { + packetId: 3, + variable: "state", + enumOptions: ["Idle", "Running", "Fault"], +}; + +/** Adds a chart to the given workspace and returns its ID. */ +export function addChart( + store: ReturnType, + workspaceId = WORKSPACE_ID, +) { + return store.getState().addChart(workspaceId); +} diff --git a/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts new file mode 100644 index 000000000..06e23b6c8 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/removeChart.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("removeChart", () => { + it("removes the correct chart", () => { + const id = addChart(store); + store.getState().removeChart(WORKSPACE_ID, id); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(0); + }); + + it("does not remove other charts in the same workspace", () => { + const id1 = addChart(store); + const id2 = addChart(store); + + store.getState().removeChart(WORKSPACE_ID, id1); + + const remaining = store.getState().charts[WORKSPACE_ID]; + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(id2); + }); + + it("does not affect charts in other workspaces", () => { + const id = addChart(store, "workspace-2"); + addChart(store); + + store.getState().removeChart(WORKSPACE_ID, store.getState().charts[WORKSPACE_ID][0].id); + + expect(store.getState().charts["workspace-2"].find((c) => c.id === id)).toBeDefined(); + }); + + it("does nothing when the chart ID does not exist", () => { + addChart(store); + const countBefore = store.getState().charts[WORKSPACE_ID].length; + + store.getState().removeChart(WORKSPACE_ID, "non-existent-id"); + + expect(store.getState().charts[WORKSPACE_ID]).toHaveLength(countBefore); + }); +}); diff --git a/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts b/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts new file mode 100644 index 000000000..993cd65e2 --- /dev/null +++ b/frontend/testing-view/src/features/charts/store/__tests__/reorderCharts.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { WORKSPACE_ID, addChart, createTestStore } from "./helpers"; + +let store: ReturnType; +let id1: string; +let id2: string; +let id3: string; + +beforeEach(() => { + store = createTestStore(); + id1 = addChart(store); + id2 = addChart(store); + id3 = addChart(store); +}); + +const chartIds = () => + store.getState().charts[WORKSPACE_ID].map((c) => c.id); + +describe("reorderCharts", () => { + it("moves a chart forward in the list", () => { + store.getState().reorderCharts(WORKSPACE_ID, 0, 2); + + expect(chartIds()).toStrictEqual([id2, id3, id1]); + }); + + it("moves a chart backward in the list", () => { + store.getState().reorderCharts(WORKSPACE_ID, 2, 0); + + expect(chartIds()).toStrictEqual([id3, id1, id2]); + }); + + it("is a no-op when old and new index are the same", () => { + store.getState().reorderCharts(WORKSPACE_ID, 1, 1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does nothing when oldIndex is negative", () => { + store.getState().reorderCharts(WORKSPACE_ID, -1, 1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does nothing when newIndex is negative", () => { + store.getState().reorderCharts(WORKSPACE_ID, 0, -1); + + expect(chartIds()).toStrictEqual([id1, id2, id3]); + }); + + it("does not affect other workspaces", () => { + const otherId = addChart(store, "workspace-2"); + store.getState().reorderCharts(WORKSPACE_ID, 0, 2); + + expect(store.getState().charts["workspace-2"].map((c) => c.id)).toStrictEqual([otherId]); + }); +}); diff --git a/frontend/testing-view/src/features/charts/types/charts.ts b/frontend/testing-view/src/features/charts/types/charts.ts index b1277bda0..f446408b8 100644 --- a/frontend/testing-view/src/features/charts/types/charts.ts +++ b/frontend/testing-view/src/features/charts/types/charts.ts @@ -4,6 +4,7 @@ export interface WorkspaceChartSeries { packetId: number; variable: string; + enumOptions?: string[]; } /** diff --git a/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx b/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx index ad74f5175..150d4098c 100644 --- a/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx +++ b/frontend/testing-view/src/features/filtering/components/FilterDialog.tsx @@ -35,17 +35,17 @@ export const FilterDialog = ({ }: FilterDialogProps) => { return ( - + {title} {description && {description}}
- -
diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts new file mode 100644 index 000000000..bd3eb9a08 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/expandedItems.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { BOARDS, createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── isItemExpanded / toggleExpandedItem ────────────────────────────────────── + +describe("isItemExpanded", () => { + it("is false by default", () => { + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + false, + ); + }); +}); + +describe("toggleExpandedItem", () => { + it("expands a collapsed item", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + true, + ); + }); + + it("collapses an already-expanded item", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "BCU")).toBe( + false, + ); + }); + + it("does not expand other items in the same scope", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("telemetry", "board", "PCU")).toBe( + false, + ); + }); + + it("is scoped — expanding in 'telemetry' does not affect 'commands'", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + expect(store.getState().isItemExpanded("commands", "board", "BCU")).toBe( + false, + ); + }); +}); + +// ─── getFlattenedRows ───────────────────────────────────────────────────────── + +describe("getFlattenedRows", () => { + it("returns only board rows when all boards are collapsed", () => { + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows.every((r) => r.type === "board")).toBe(true); + expect(rows).toHaveLength(2); + }); + + it("returns correct board row shape", () => { + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU", count: 2 }); + expect(rows[1]).toMatchObject({ type: "board", id: "PCU", count: 1 }); + }); + + it("includes packet rows under an expanded board", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + const types = rows.map((r) => r.type); + + expect(types).toContain("packet"); + // BCU board header + 2 BCU packets + PCU board header + expect(rows).toHaveLength(4); + }); + + it("packet rows appear after their board header", () => { + store.getState().toggleExpandedItem("telemetry", "board", "BCU"); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU" }); + expect(rows[1].type).toBe("packet"); + expect(rows[2].type).toBe("packet"); + expect(rows[3]).toMatchObject({ type: "board", id: "PCU" }); + }); + + it("skips boards with no filtered items", () => { + store.getState().toggleCategoryFilter("telemetry", "PCU", false); + + const rows = store.getState().getFlattenedRows("telemetry", BOARDS); + + expect(rows.some((r) => r.id === "PCU")).toBe(false); + }); + + it("reflects commands catalog when scope is 'commands'", () => { + const rows = store.getState().getFlattenedRows("commands", BOARDS); + + expect(rows[0]).toMatchObject({ type: "board", id: "BCU", count: 2 }); + expect(rows[1]).toMatchObject({ type: "board", id: "PCU", count: 1 }); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts new file mode 100644 index 000000000..0fde4a071 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterActions.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── selectAllFilters ───────────────────────────────────────────────────────── + +describe("selectAllFilters", () => { + it("restores all command IDs after a clear", () => { + store.getState().clearFilters("commands"); + store.getState().selectAllFilters("commands"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual({ + BCU: [1, 2], + PCU: [3], + }); + }); + + it("restores all telemetry IDs after a clear", () => { + store.getState().clearFilters("telemetry"); + store.getState().selectAllFilters("telemetry"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual({ + BCU: [10, 20], + PCU: [30], + }); + }); + + it("does not affect the other scope", () => { + store.getState().clearFilters("commands"); + const telemetryBefore = store.getState().getActiveFilters("telemetry"); + + store.getState().selectAllFilters("commands"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual( + telemetryBefore, + ); + }); +}); + +// ─── clearFilters ───────────────────────────────────────────────────────────── + +describe("clearFilters", () => { + it("empties all command category arrays", () => { + store.getState().clearFilters("commands"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual({ + BCU: [], + PCU: [], + }); + }); + + it("empties all telemetry category arrays", () => { + store.getState().clearFilters("telemetry"); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual({ + BCU: [], + PCU: [], + }); + }); + + it("does not affect the other scope", () => { + const commandsBefore = store.getState().getActiveFilters("commands"); + store.getState().clearFilters("telemetry"); + + expect(store.getState().getActiveFilters("commands")).toStrictEqual( + commandsBefore, + ); + }); +}); + +// ─── toggleItemFilter ───────────────────────────────────────────────────────── + +describe("toggleItemFilter", () => { + it("removes an ID that is currently selected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).not.toContain(1); + }); + + it("adds an ID that is not currently selected", () => { + store.getState().clearFilters("commands"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect(store.getState().getActiveFilters("commands")?.["BCU"]).toContain(1); + }); + + it("does not affect other categories", () => { + const pcuBefore = store.getState().getActiveFilters("commands")?.["PCU"]; + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect( + store.getState().getActiveFilters("commands")?.["PCU"], + ).toStrictEqual(pcuBefore); + }); + + it("does not affect the other scope", () => { + const telemetryBefore = store.getState().getActiveFilters("telemetry"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + expect(store.getState().getActiveFilters("telemetry")).toStrictEqual( + telemetryBefore, + ); + }); +}); + +// ─── toggleCategoryFilter ───────────────────────────────────────────────────── + +describe("toggleCategoryFilter", () => { + it("checked=true selects all IDs in the commands category", () => { + store.getState().clearFilters("commands"); + store.getState().toggleCategoryFilter("commands", "BCU", true); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).toStrictEqual([1, 2]); + }); + + it("checked=true selects all IDs in the telemetry category", () => { + store.getState().clearFilters("telemetry"); + store.getState().toggleCategoryFilter("telemetry", "BCU", true); + + expect( + store.getState().getActiveFilters("telemetry")?.["BCU"], + ).toStrictEqual([10, 20]); + }); + + it("checked=false clears all IDs in the category", () => { + store.getState().toggleCategoryFilter("commands", "BCU", false); + + expect( + store.getState().getActiveFilters("commands")?.["BCU"], + ).toStrictEqual([]); + }); + + it("does not affect other categories", () => { + const pcuBefore = store.getState().getActiveFilters("commands")?.["PCU"]; + store.getState().toggleCategoryFilter("commands", "BCU", false); + + expect( + store.getState().getActiveFilters("commands")?.["PCU"], + ).toStrictEqual(pcuBefore); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts new file mode 100644 index 000000000..060073e5c --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterDialog.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("filterDialog", () => { + it("is closed by default", () => { + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: false, + scope: null, + }); + }); + + it("opens with the given scope", () => { + store.getState().openFilterDialog("commands"); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: true, + scope: "commands", + }); + }); + + it("can open with 'telemetry' scope", () => { + store.getState().openFilterDialog("telemetry"); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: true, + scope: "telemetry", + }); + }); + + it("closes and clears the scope", () => { + store.getState().openFilterDialog("commands"); + store.getState().closeFilterDialog(); + + expect(store.getState().filterDialog).toStrictEqual({ + isOpen: false, + scope: null, + }); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts new file mode 100644 index 000000000..6f68bba6a --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/filterQueries.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { BoardName } from "../../../../types/data/board"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + seedStore(store); +}); + +// ─── getFilteredItems ───────────────────────────────────────────────────────── + +describe("getFilteredItems", () => { + it("returns all command items when all are selected", () => { + const items = store.getState().getFilteredItems("commands"); + expect(items.map((i) => i.id)).toEqual(expect.arrayContaining([1, 2, 3])); + expect(items).toHaveLength(3); + }); + + it("returns all telemetry items when all are selected", () => { + const items = store.getState().getFilteredItems("telemetry"); + expect(items.map((i) => i.id)).toEqual( + expect.arrayContaining([10, 20, 30]), + ); + expect(items).toHaveLength(3); + }); + + it("returns only selected items when a partial filter is applied", () => { + store.getState().clearFilters("commands"); + store.getState().toggleItemFilter("commands", "BCU", 1); + + const items = store.getState().getFilteredItems("commands"); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(1); + }); + + it("returns empty array when nothing is selected", () => { + store.getState().clearFilters("commands"); + expect(store.getState().getFilteredItems("commands")).toHaveLength(0); + }); + + it("does not mix commands and telemetry items", () => { + const commandItems = store.getState().getFilteredItems("commands"); + const telemetryItems = store.getState().getFilteredItems("telemetry"); + + const commandIds = commandItems.map((i) => i.id); + const telemetryIds = telemetryItems.map((i) => i.id); + + expect(commandIds.some((id) => telemetryIds.includes(id))).toBe(false); + }); +}); + +// ─── getFilteredItemsIds ────────────────────────────────────────────────────── + +describe("getFilteredItemsIds", () => { + it("returns flat list of all selected command IDs", () => { + const ids = store.getState().getFilteredItemsIds("commands"); + expect(ids).toEqual(expect.arrayContaining([1, 2, 3])); + }); + + it("returns flat list of all selected telemetry IDs", () => { + const ids = store.getState().getFilteredItemsIds("telemetry"); + expect(ids).toEqual(expect.arrayContaining([10, 20, 30])); + }); +}); + +describe("getFilteredItemsIdsByCategory", () => { + it("returns IDs only for the given board", () => { + expect( + store.getState().getFilteredItemsIdsByCategory("commands", "BCU"), + ).toStrictEqual([1, 2]); + expect( + store.getState().getFilteredItemsIdsByCategory("commands", "PCU"), + ).toStrictEqual([3]); + }); + + it("returns IDs from the correct catalog for telemetry", () => { + expect( + store.getState().getFilteredItemsIdsByCategory("telemetry", "BCU"), + ).toStrictEqual([10, 20]); + }); +}); + +// ─── getFilteredCount / getTotalCount ───────────────────────────────────────── + +describe("getFilteredCount", () => { + it("returns total number of selected command IDs", () => { + expect(store.getState().getFilteredCount("commands")).toBe(3); + }); + + it("decreases when an item is deselected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + expect(store.getState().getFilteredCount("commands")).toBe(2); + }); +}); + +describe("getFilteredCountByCategory", () => { + it("returns count per board for commands", () => { + expect(store.getState().getFilteredCountByCategory("commands", "BCU")).toBe( + 2, + ); + expect(store.getState().getFilteredCountByCategory("commands", "PCU")).toBe( + 1, + ); + }); + + it("returns count per board for telemetry", () => { + expect( + store.getState().getFilteredCountByCategory("telemetry", "BCU"), + ).toBe(2); + }); +}); + +describe("getTotalCount", () => { + it("returns total catalog size for commands", () => { + expect(store.getState().getTotalCount("commands")).toBe(3); + }); + + it("returns total catalog size for telemetry", () => { + expect(store.getState().getTotalCount("telemetry")).toBe(3); + }); +}); + +// ─── getSelectionState ──────────────────────────────────────────────────────── + +describe("getSelectionState", () => { + it("returns true when all items in the category are selected", () => { + expect(store.getState().getSelectionState("commands", "BCU")).toBe(true); + }); + + it("returns false when no items are selected", () => { + store.getState().clearFilters("commands"); + expect(store.getState().getSelectionState("commands", "BCU")).toBe(false); + }); + + it("returns 'indeterminate' when only some items are selected", () => { + store.getState().toggleItemFilter("commands", "BCU", 1); + expect(store.getState().getSelectionState("commands", "BCU")).toBe( + "indeterminate", + ); + }); + + it("returns false for a board with no catalog items", () => { + expect( + store.getState().getSelectionState("commands", "EMPTY" as BoardName), + ).toBe(false); + }); + + it("works independently for commands and telemetry", () => { + store.getState().clearFilters("commands"); + + expect(store.getState().getSelectionState("commands", "BCU")).toBe(false); + expect(store.getState().getSelectionState("telemetry", "BCU")).toBe(true); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts new file mode 100644 index 000000000..e1b78faa6 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/getCatalog.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + COMMANDS_CATALOG, + TELEMETRY_CATALOG, + createTestStore, +} from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); + store.getState().setCommandsCatalog(COMMANDS_CATALOG); + store.getState().setTelemetryCatalog(TELEMETRY_CATALOG); +}); + +describe("getCatalog", () => { + it("returns commandsCatalog for 'commands' scope", () => { + expect(store.getState().getCatalog("commands")).toBe( + store.getState().commandsCatalog, + ); + }); + + it("returns telemetryCatalog for 'telemetry' scope", () => { + expect(store.getState().getCatalog("telemetry")).toBe( + store.getState().telemetryCatalog, + ); + }); + + it("returns telemetryCatalog for 'logs' scope", () => { + expect(store.getState().getCatalog("logs")).toBe( + store.getState().telemetryCatalog, + ); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts b/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts new file mode 100644 index 000000000..6f727d3df --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/helpers.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { createAppSlice } from "../../../../store/slices/appSlice"; +import { createCatalogSlice } from "../../../../store/slices/catalogSlice"; +import { createConnectionsSlice } from "../../../../store/slices/connectionsSlice"; +import { createMessagesSlice } from "../../../../store/slices/messagesSlice"; +import { createTelemetrySlice } from "../../../../store/slices/telemetrySlice"; +import type { Store } from "../../../../store/store"; +import type { BoardName } from "../../../../types/data/board"; +import { createChartsSlice } from "../../../charts/store/chartsSlice"; +import { createRightSidebarSlice } from "../../../workspace/store/rightSidebarSlice"; +import { createWorkspacesSlice } from "../../../workspace/store/workspacesSlice"; +import { createFilteringSlice } from "../filteringSlice"; + +export const createTestStore = () => + create()((...a) => ({ + ...createAppSlice(...a), + ...createCatalogSlice(...a), + ...createWorkspacesSlice(...a), + ...createTelemetrySlice(...a), + ...createRightSidebarSlice(...a), + ...createConnectionsSlice(...a), + ...createMessagesSlice(...a), + ...createChartsSlice(...a), + ...createFilteringSlice(...a), + })); + +export const BOARDS: BoardName[] = ["BCU", "PCU"]; + +export const COMMANDS_CATALOG = { + BCU: [ + { id: 1, name: "cmd_start", label: "Start" }, + { id: 2, name: "cmd_stop", label: "Stop" }, + ], + PCU: [{ id: 3, name: "cmd_reset", label: "Reset" }], +}; + +export const TELEMETRY_CATALOG = { + BCU: [ + { id: 10, name: "bcu_speed", label: "Speed" }, + { id: 20, name: "bcu_temp", label: "Temperature" }, + ], + PCU: [{ id: 30, name: "pcu_voltage", label: "Voltage" }], +}; + +export function seedStore(store: ReturnType) { + const s = store.getState(); + s.setBoards(BOARDS); + s.setCommandsCatalog(COMMANDS_CATALOG); + s.setTelemetryCatalog(TELEMETRY_CATALOG); + s.initializeWorkspaceFilters(); +} diff --git a/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts b/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts new file mode 100644 index 000000000..39ad25f78 --- /dev/null +++ b/frontend/testing-view/src/features/filtering/store/__tests__/initializeFilters.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestStore, seedStore } from "./helpers"; + +let store: ReturnType; + +beforeEach(() => { + store = createTestStore(); +}); + +describe("initializeWorkspaceFilters", () => { + it("populates all default workspaces", () => { + seedStore(store); + const filters = store.getState().workspaceFilters; + + expect(Object.keys(filters)).toEqual( + expect.arrayContaining(["workspace-1", "workspace-2", "workspace-3"]), + ); + }); + + it("initializes commands filters with all command IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.commands["BCU"]).toEqual([1, 2]); + expect(filters.commands["PCU"]).toEqual([3]); + }); + + it("initializes telemetry filters with all telemetry IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.telemetry["BCU"]).toEqual([10, 20]); + expect(filters.telemetry["PCU"]).toEqual([30]); + }); + + it("initializes logs filters with all telemetry IDs", () => { + seedStore(store); + const filters = store.getState().workspaceFilters["workspace-1"]; + + expect(filters.logs["BCU"]).toEqual([10, 20]); + expect(filters.logs["PCU"]).toEqual([30]); + }); + + it("is idempotent — does not overwrite existing filters", () => { + seedStore(store); + store.getState().clearFilters("commands"); + const filtersAfterClear = store.getState().workspaceFilters; + + store.getState().initializeWorkspaceFilters(); + + expect(store.getState().workspaceFilters).toStrictEqual(filtersAfterClear); + }); +}); diff --git a/frontend/testing-view/src/features/filtering/store/filteringSlice.ts b/frontend/testing-view/src/features/filtering/store/filteringSlice.ts index 58cefaccc..bbf530283 100644 --- a/frontend/testing-view/src/features/filtering/store/filteringSlice.ts +++ b/frontend/testing-view/src/features/filtering/store/filteringSlice.ts @@ -22,11 +22,6 @@ import type { } from "../types/filters"; export interface FilteringSlice { - /** Sidebar Navigation */ - activeTab: Record; - getActiveTab: () => SidebarTab; - setActiveTab: (tab: SidebarTab) => void; - filterDialog: { isOpen: boolean; scope: FilterScope | null; @@ -73,6 +68,7 @@ export interface FilteringSlice { category: BoardName, ) => number; getTotalCount: (scope: FilterScope) => number; + isAllSelected: (scope: FilterScope) => boolean; getSelectionState: (scope: FilterScope, category: BoardName) => CheckboxState; /** Virtualization & Expansion */ @@ -100,22 +96,6 @@ export const createFilteringSlice: StateCreator< [], FilteringSlice > = (set, get) => ({ - // Tabs (per workspace) - activeTab: {}, - getActiveTab: () => { - const activeWorkspaceId = get().getActiveWorkspaceId(); - if (!activeWorkspaceId) return "commands"; - return get().activeTab[activeWorkspaceId] || "commands"; - }, - setActiveTab: (tab) => { - const activeWorkspaceId = get().getActiveWorkspaceId(); - if (!activeWorkspaceId) return; - - set((state) => ({ - activeTab: { ...state.activeTab, [activeWorkspaceId]: tab }, - })); - }, - openFilterDialog: (scope: FilterScope) => set({ filterDialog: { isOpen: true, scope } }), closeFilterDialog: () => @@ -357,6 +337,10 @@ export const createFilteringSlice: StateCreator< const catalog = get().getCatalog(scope); return Object.values(catalog).reduce((acc, items) => acc + items.length, 0); }, + isAllSelected: (scope) => { + const total = get().getTotalCount(scope); + return total > 0 && get().getFilteredCount(scope) === total; + }, getSelectionState: (scope, category) => { const selectedCount = get().getFilteredCountByCategory(scope, category); diff --git a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx index 2db0457ce..a8eb99928 100644 --- a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx +++ b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx @@ -116,7 +116,16 @@ export const AddKeyBindingDialog = ({ setParameterValues((prev) => ({ ...prev, [fieldId]: value })); }; - const canSubmit = selectedCommandId !== null && capturedKey !== ""; + const hasInvalidNumericParams = + selectedCommand !== null && + selectedCommand !== undefined && + Object.entries(selectedCommand.fields).some( + ([key, field]) => + field.kind === "numeric" && isNaN(parseFloat(parameterValues[key])), + ); + + const canSubmit = + selectedCommandId !== null && capturedKey !== "" && !hasInvalidNumericParams; return ( diff --git a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts index 3fcd118c6..456f83cb6 100644 --- a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts @@ -100,8 +100,18 @@ export const useGlobalKeyBindings = () => { ]; } + const numericValue = + field.kind === "numeric" ? parseFloat(value) : value; + + if (field.kind === "numeric" && isNaN(numericValue as number)) { + logger.testingView.warn( + `Skipping command: numeric field "${fieldKey}" has no valid value`, + ); + return acc; + } + acc[fieldKey] = { - value: field.kind === "numeric" ? parseFloat(value) : value, + value: numericValue, isEnabled: true, type: field.type, }; diff --git a/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx b/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx index 3ea592df5..460db5e9e 100644 --- a/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx +++ b/frontend/testing-view/src/features/workspace/components/LoggerControl.tsx @@ -1,8 +1,9 @@ import { Button, Separator } from "@workspace/ui"; -import { Settings2 } from "@workspace/ui/icons"; +import { FolderOpen, Settings2 } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import { LOGGER_CONTROL_CONFIG } from "../../../constants/loggerControlConfig"; import { useLogger } from "../../../hooks/useLogger"; +import { useOpenFolder } from "../../../hooks/useOpenFolder"; import { useStore } from "../../../store/store"; interface LoggerControlProps { @@ -11,8 +12,10 @@ interface LoggerControlProps { export const LoggerControl = ({ disabled }: LoggerControlProps) => { const { status, startLogging, stopLogging } = useLogger(); + const { openFolder } = useOpenFolder(); const openFilterDialog = useStore((s) => s.openFilterDialog); const filteredCount = useStore((state) => state.getFilteredCount("logs")); + const loggingPath = useStore((s) => s.config?.logging?.logging_path as string | undefined); const handleToggle = () => { if (status === "loading") return; @@ -66,6 +69,17 @@ export const LoggerControl = ({ disabled }: LoggerControlProps) => { {config.icon} + +
{/* Chart Picker */} - {!isEnum && ( -
- -
- )} +
+ +
{/* Live Value */} diff --git a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts index 821d99e59..1909a6ef8 100644 --- a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts +++ b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts @@ -6,6 +6,7 @@ import { useSensors, } from "@dnd-kit/core"; import { useState } from "react"; +import { canAddSeriesToChart } from "../../../lib/utils"; import { useStore } from "../../../store/store"; import type { DndActiveData } from "../types/dndData"; @@ -34,12 +35,17 @@ export function useDnd() { // Logic for adding a variable to a chart if (active.data.current?.type === "variable") { const chartId = over.data.current?.chartId; - const { packetId, variableId } = active.data.current; + const { packetId, variableId, variableEnumOptions } = + active.data.current; + const incomingIsEnum = (variableEnumOptions?.length ?? 0) > 0; // Add a variable to an existing chart if (chartId) { + const chart = charts.find((c) => c.id === chartId); + if (!canAddSeriesToChart(chart?.series ?? [], incomingIsEnum)) return; addSeries(activeWorkspaceId, chartId, { packetId, variable: variableId, + enumOptions: variableEnumOptions, }); // Add a new chart to the main panel } else if (over.id === "main-panel-droppable") { @@ -47,6 +53,7 @@ export function useDnd() { addSeries(activeWorkspaceId, newChartId, { packetId, variable: variableId, + enumOptions: variableEnumOptions, }); } } diff --git a/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts b/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts index 55d39eecd..cd9764fc0 100644 --- a/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts +++ b/frontend/testing-view/src/features/workspace/store/workspacesSlice.ts @@ -3,7 +3,6 @@ import { createFullFilter } from "../../../lib/utils"; import type { Store } from "../../../store/store"; import type { KeyBinding } from "../../keyBindings/types/keyBinding"; import { DEFAULT_WORKSPACES } from "../constants/defaultWorkspaces"; -import type { SidebarTab } from "../types/sidebar"; import type { Workspace } from "../types/workspace"; export interface WorkspacesSlice { @@ -79,12 +78,6 @@ export const createWorkspacesSlice: StateCreator< }, }; - // Initialize active tab for the new workspace - const newActiveTabs = { - ...state.activeTab, - [newWorkspaceId]: "commands" as SidebarTab, - }; - // Initialize charts for the new workspace const newCharts = { ...state.charts, @@ -96,7 +89,6 @@ export const createWorkspacesSlice: StateCreator< activeWorkspace: newWorkspace, // Auto-switch to the new workspace workspaceFilters: newWorkspaceFilters, expandedItems: newExpandedItems, - activeTab: newActiveTabs, charts: newCharts, }; }); @@ -137,12 +129,10 @@ export const createWorkspacesSlice: StateCreator< // Clean up workspace-specific data const newWorkspaceFilters = { ...state.workspaceFilters }; const newExpandedItems = { ...state.expandedItems }; - const newActiveTabs = { ...state.activeTab }; const newCharts = { ...state.charts }; delete newWorkspaceFilters[id]; delete newExpandedItems[id]; - delete newActiveTabs[id]; delete newCharts[id]; return { @@ -150,7 +140,6 @@ export const createWorkspacesSlice: StateCreator< activeWorkspace: newActiveWorkspace, workspaceFilters: newWorkspaceFilters, expandedItems: newExpandedItems, - activeTab: newActiveTabs, charts: newCharts, }; }); diff --git a/frontend/testing-view/src/features/workspace/types/dndData.ts b/frontend/testing-view/src/features/workspace/types/dndData.ts index 3a13adf3d..7f9c8ca7e 100644 --- a/frontend/testing-view/src/features/workspace/types/dndData.ts +++ b/frontend/testing-view/src/features/workspace/types/dndData.ts @@ -6,6 +6,7 @@ type DndVariableData = { variableId: string; variableType: string; variableName: string; + variableEnumOptions?: string[]; }; type DndChartData = { diff --git a/frontend/testing-view/src/hooks/useAppConfigs.ts b/frontend/testing-view/src/hooks/useAppConfigs.ts index 4a8d29d98..07b885fda 100644 --- a/frontend/testing-view/src/hooks/useAppConfigs.ts +++ b/frontend/testing-view/src/hooks/useAppConfigs.ts @@ -1,9 +1,16 @@ import { useFetchConfig } from "@workspace/ui/hooks"; import { useEffect } from "react"; +import { useStore } from "../store/store"; import type { OrdersData, PacketsData } from "../types/data/board"; const useAppConfigs = (isConnected: boolean) => { - const backendUrl = import.meta.env.VITE_BACKEND_URL; + const backendUrl = + import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:4000/backend"; + const setConfig = useStore((s) => s.setConfig); + + useEffect(() => { + window.electronAPI?.getConfig().then(setConfig); + }, [setConfig]); const { data: packets, diff --git a/frontend/testing-view/src/hooks/useOpenFolder.ts b/frontend/testing-view/src/hooks/useOpenFolder.ts new file mode 100644 index 000000000..25c65b3f1 --- /dev/null +++ b/frontend/testing-view/src/hooks/useOpenFolder.ts @@ -0,0 +1,13 @@ +import { logger } from "@workspace/core"; + +export const useOpenFolder = () => { + const openFolder = (path?: string) => { + if (!window.electronAPI) { + logger.testingView.warn("electronAPI is not available"); + return; + } + window.electronAPI.openFolder(path ?? "."); + }; + + return { openFolder }; +}; diff --git a/frontend/testing-view/src/hooks/useTransformedBoards.ts b/frontend/testing-view/src/hooks/useTransformedBoards.ts index aa7cda62b..b1210c2cb 100644 --- a/frontend/testing-view/src/hooks/useTransformedBoards.ts +++ b/frontend/testing-view/src/hooks/useTransformedBoards.ts @@ -24,6 +24,11 @@ export function useTransformedBoards( ) return; + const store = useStore.getState(); + const wasAllCommands = store.isAllSelected("commands"); + const wasAllTelemetry = store.isAllSelected("telemetry"); + const wasAllLogs = store.isAllSelected("logs"); + setTelemetryCatalog(transformedBoards.telemetryCatalog); setCommandsCatalog(transformedBoards.commandsCatalog); setBoards(Array.from(transformedBoards.boards)); @@ -33,7 +38,12 @@ export function useTransformedBoards( const hasCommandsData = Object.keys(transformedBoards.commandsCatalog).length > 0; - if (hasTelemetryData && hasCommandsData) initializeWorkspaceFilters(); + if (hasTelemetryData && hasCommandsData) { + initializeWorkspaceFilters(); + if (wasAllCommands) useStore.getState().selectAllFilters("commands"); + if (wasAllTelemetry) useStore.getState().selectAllFilters("telemetry"); + if (wasAllLogs) useStore.getState().selectAllFilters("logs"); + } }, [ transformedBoards, setTelemetryCatalog, diff --git a/frontend/testing-view/src/lib/commandUtils.ts b/frontend/testing-view/src/lib/commandUtils.ts index 8b81ef888..e81161668 100644 --- a/frontend/testing-view/src/lib/commandUtils.ts +++ b/frontend/testing-view/src/lib/commandUtils.ts @@ -10,7 +10,7 @@ export const getDefaultParameterValues = ( Object.entries(fields).forEach(([key, field]) => { if (field.kind === "numeric") { - defaults[key] = 0; + defaults[key] = ""; } else if (field.kind === "enum") { defaults[key] = (field as EnumCommandParameter).options[0] || ""; } else if (field.kind === "boolean") { diff --git a/frontend/testing-view/src/lib/utils.test.ts b/frontend/testing-view/src/lib/utils.test.ts index fa667af7a..ed2000942 100644 --- a/frontend/testing-view/src/lib/utils.test.ts +++ b/frontend/testing-view/src/lib/utils.test.ts @@ -3,10 +3,12 @@ import { variablesBadgeClasses } from "../constants/variablesBadgeClasses"; import type { FilterScope } from "../features/filtering/types/filters"; import type { MessageTimestamp } from "../types/data/message"; import { + canAddSeriesToChart, createEmptyFilter, createFullFilter, formatName, formatTimestamp, + formatVariableValue, getCatalogKey, getTypeBadgeClass, } from "./utils"; @@ -103,6 +105,63 @@ describe("getTypeBadgeClass", () => { }); }); +describe("canAddSeriesToChart", () => { + it("should allow adding a numeric series to an empty chart", () => { + expect(canAddSeriesToChart([], false)).toBe(true); + }); + + it("should allow adding an enum series to an empty chart", () => { + expect(canAddSeriesToChart([], true)).toBe(true); + }); + + it("should prevent adding an enum series to a chart with existing series", () => { + expect(canAddSeriesToChart([{}], true)).toBe(false); + expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], true)).toBe(false); + }); + + it("should prevent adding a numeric series to a chart with an enum series", () => { + expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], false)).toBe(false); + }); + + it("should allow adding a numeric series to a chart with existing numeric series", () => { + expect(canAddSeriesToChart([{}], false)).toBe(true); + expect(canAddSeriesToChart([{}, {}], false)).toBe(true); + }); +}); + +describe("formatVariableValue", () => { + it("should return '—' for null or undefined", () => { + expect(formatVariableValue(null)).toBe("—"); + expect(formatVariableValue(undefined)).toBe("—"); + }); + + it("should return enum label by string value", () => { + expect(formatVariableValue("Running", ["Idle", "Running", "Fault"])).toBe("Running"); + }); + + it("should return enum label by numeric index", () => { + expect(formatVariableValue(1, ["Idle", "Running", "Fault"])).toBe("Running"); + }); + + it("should return raw string if index is out of bounds", () => { + expect(formatVariableValue(5, ["Idle", "Running"])).toBe("5"); + }); + + it("should format booleans as 0/1", () => { + expect(formatVariableValue(true)).toBe("1"); + expect(formatVariableValue(false)).toBe("0"); + }); + + it("should format numbers with 2 decimal places", () => { + expect(formatVariableValue(3.14159)).toBe("3.14"); + expect(formatVariableValue(42)).toBe("42.00"); + }); + + it("should format object with last/average using last", () => { + expect(formatVariableValue({ last: 1.5, average: 1.2 })).toBe("1.50"); + }); +}); + describe("emptyFilter", () => { it("should return the correct empty filter", () => { const boards = [ diff --git a/frontend/testing-view/src/lib/utils.ts b/frontend/testing-view/src/lib/utils.ts index 0df30e9c3..dc73e8ba9 100644 --- a/frontend/testing-view/src/lib/utils.ts +++ b/frontend/testing-view/src/lib/utils.ts @@ -1,3 +1,4 @@ +import type { VariableValue } from "@workspace/core"; import { ACRONYMS } from "../constants/acronyms"; import { variablesBadgeClasses } from "../constants/variablesBadgeClasses"; import type { @@ -120,6 +121,30 @@ export const formatTimestamp = (ts: MessageTimestamp) => { return `${ts.hour.toString().padStart(2, "0")}:${ts.minute.toString().padStart(2, "0")}:${ts.second.toString().padStart(2, "0")}`; }; +export const canAddSeriesToChart = ( + chartSeries: { enumOptions?: string[] }[], + incomingIsEnum: boolean, +): boolean => { + const chartHasEnum = chartSeries.some((s) => (s.enumOptions?.length ?? 0) > 0); + if (incomingIsEnum && chartSeries.length > 0) return false; + if (!incomingIsEnum && chartHasEnum) return false; + return true; +}; + +export const formatVariableValue = ( + m: VariableValue | null | undefined, + enumOptions?: string[], +): string => { + if (m == null) return "—"; + if (enumOptions?.length) { + return typeof m === "string" ? m : (enumOptions[m as number] ?? String(m)); + } + if (typeof m === "boolean") return m ? "1" : "0"; + if (typeof m === "object" && "last" in m) return m.last.toFixed(2); + if (typeof m === "number") return m.toFixed(2); + return String(m); +}; + export const detectExtraBoards = ( activeFilters: TabFilter | undefined, boards: BoardName[], diff --git a/frontend/testing-view/src/store/store.ts b/frontend/testing-view/src/store/store.ts index 26c3602f8..dfc7c58c8 100644 --- a/frontend/testing-view/src/store/store.ts +++ b/frontend/testing-view/src/store/store.ts @@ -58,6 +58,9 @@ export const useStore = create()( { // Partial persist name: "testing-view-storage", + onRehydrateStorage: () => () => { + document.documentElement.setAttribute("data-store-hydrated", "true"); + }, partialize: (state) => ({ // Charts charts: state.charts, @@ -72,7 +75,6 @@ export const useStore = create()( testingPage: state.testingPage, // Workspace UI state - activeTab: state.activeTab, workspaceFilters: state.workspaceFilters, }), }, diff --git a/frontend/testing-view/src/vite-end.d.ts b/frontend/testing-view/src/vite-end.d.ts index 040a51601..28748e194 100644 --- a/frontend/testing-view/src/vite-end.d.ts +++ b/frontend/testing-view/src/vite-end.d.ts @@ -7,6 +7,7 @@ interface ElectronAPI { getConfig: () => Promise; importConfig: () => Promise; selectFolder: () => Promise; + openFolder: (path: string) => Promise; } declare global { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72223697e..3eb9ad9ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: backend: {} + e2e: + devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.58.2 + electron: + specifier: ^40.1.0 + version: 40.1.0 + electron-app: dependencies: '@iarna/toml': @@ -833,6 +842,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3045,6 +3059,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4050,6 +4069,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -5642,6 +5671,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6696,7 +6729,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -6732,7 +6765,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/lodash@4.17.23': {} @@ -6766,7 +6799,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 '@types/through@0.0.33': dependencies: @@ -6779,7 +6812,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.10.10 + '@types/node': 25.2.0 optional: true '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': @@ -8265,6 +8298,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9308,6 +9344,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b2f6ce5b..f0086a994 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - "backend" - "electron-app" - "packet-sender" + - "e2e"