diff --git a/.cursorrules b/.cursorrules index 13fcbabadc26..aed6eed699f1 100644 --- a/.cursorrules +++ b/.cursorrules @@ -65,6 +65,13 @@ - Types in types/ - PascalCase components, camelCase others +## Translation +- Use @lingui/react/macro +- Use within components +- Use t`string` elsewhere (from useLingui hook) +- Don't translate metadata (field names, object names, etc) +- Don't translate mocks + ## Code Style - Early returns - No nested ternaries diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 95f91b2a78be..e1345956eb1e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,7 +1,14 @@ module.exports = { root: true, - extends: ['plugin:prettier/recommended'], - plugins: ['@nx', 'prefer-arrow', 'import', 'unused-imports', 'unicorn'], + extends: ['plugin:prettier/recommended', 'plugin:lingui/recommended'], + plugins: [ + '@nx', + 'prefer-arrow', + 'import', + 'unused-imports', + 'unicorn', + 'lingui', + ], rules: { 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], 'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }], @@ -29,6 +36,10 @@ module.exports = { sourceTag: 'scope:frontend', onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'], }, + { + sourceTag: 'scope:zapier', + onlyDependOnLibsWithTags: ['scope:shared'], + }, ], }, ], @@ -96,7 +107,11 @@ module.exports = { rules: {}, }, { - files: ['*.spec.@(ts|tsx|js|jsx)', '*.test.@(ts|tsx|js|jsx)'], + files: [ + '*.spec.@(ts|tsx|js|jsx)', + '*.integration-spec.@(ts|tsx|js|jsx)', + '*.test.@(ts|tsx|js|jsx)', + ], env: { jest: true, }, diff --git a/.github/workflows/actions/nx-affected/action.yaml b/.github/workflows/actions/nx-affected/action.yaml index af5ba29aa47e..7c2cdcfe7601 100644 --- a/.github/workflows/actions/nx-affected/action.yaml +++ b/.github/workflows/actions/nx-affected/action.yaml @@ -2,14 +2,11 @@ name: Nx Affected CI inputs: parallel: required: false - types: [number] - default: 3 + default: '3' tag: required: false - types: [string] tasks: required: true - types: [string] runs: using: "composite" diff --git a/.github/workflows/actions/restore-cache/action.yaml b/.github/workflows/actions/restore-cache/action.yaml new file mode 100644 index 000000000000..c970556032ba --- /dev/null +++ b/.github/workflows/actions/restore-cache/action.yaml @@ -0,0 +1,35 @@ +name: Restore cache +inputs: + key: + required: true + description: Prefix to the cache key + additional-paths: + required: false +outputs: + cache-primary-key: + description: actions/cache/restore cache-primary-key outputs proxy + value: ${{ steps.restore-cache.outputs.cache-primary-key }} + cache-hit: + description: String bool indicating whether cache has been directly or indirectly hit + value: ${{ steps.restore-cache.outputs.cache-hit == 'true' || steps.restore-cache.outputs.cache-matched-key != '' }} + +runs: + using: composite + steps: + - name: Cache primary key builder + id: cache-primary-key-builder + shell: bash + run: | + echo "CACHE_PRIMARY_KEY_PREFIX=${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}" + - name: Restore cache + uses: actions/cache/restore@v4 + id: restore-cache + with: + key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }} + restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}- + path: | + .cache + .nx/cache + node_modules/.cache + packages/*/node_modules/.cache + ${{ inputs.additional-paths }} \ No newline at end of file diff --git a/.github/workflows/actions/save-cache/action.yaml b/.github/workflows/actions/save-cache/action.yaml new file mode 100644 index 000000000000..b51a5d478bdd --- /dev/null +++ b/.github/workflows/actions/save-cache/action.yaml @@ -0,0 +1,21 @@ +name: Save cache +inputs: + key: + required: true + description: Primary key to the cache, should be retrieved from `cache-restore` composite action outputs. + additional-paths: + required: false + +runs: + using: "composite" + steps: + - name: Save cache + uses: actions/cache/save@v4 + with: + key: ${{ inputs.key }} + path: | + .cache + .nx/cache + node_modules/.cache + packages/*/node_modules/.cache + ${{ inputs.additional-paths }} \ No newline at end of file diff --git a/.github/workflows/actions/task-cache/action.yaml b/.github/workflows/actions/task-cache/action.yaml deleted file mode 100644 index c622c84a1e05..000000000000 --- a/.github/workflows/actions/task-cache/action.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: Restore Tasks Cache CI -inputs: - tag: - required: false - types: [string] - tasks: - required: false - types: [string] - default: all - suffix: - required: false - types: [string] - -runs: - using: "composite" - steps: - - name: Compute tasks key - id: tasks-key - shell: bash - run: echo "key=${{ inputs.tasks }}" | tr , - >> $GITHUB_OUTPUT - - name: Restore tasks cache - uses: actions/cache@v3 - with: - path: | - .cache - .nx/cache - node_modules/.cache - packages/*/node_modules/.cache - key: tasks-cache-${{ github.ref_name }}-${{ inputs.tag }}-${{ steps.tasks-key.outputs.key }}${{ inputs.suffix }}-${{ github.sha }} - restore-keys: | - tasks-cache-${{ github.ref_name }}-${{ inputs.tag }}-${{ steps.tasks-key.outputs.key }}${{ inputs.suffix }}- \ No newline at end of file diff --git a/.github/workflows/actions/yarn-install/action.yaml b/.github/workflows/actions/yarn-install/action.yaml index d17134a6131c..3cdef5493eab 100644 --- a/.github/workflows/actions/yarn-install/action.yaml +++ b/.github/workflows/actions/yarn-install/action.yaml @@ -1,23 +1,43 @@ name: Yarn Install +inputs: + node-version: + required: false + default: '18' runs: using: "composite" steps: + - name: Cache primary key builder + id: globals + shell: bash + run: | + echo "ACTION_SHELL=bash" >> "${GITHUB_OUTPUT}" + echo "CACHE_KEY_PREFIX=node_modules-cache-node-${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }}" >> "${GITHUB_OUTPUT}" + echo 'PATH_TO_CACHE<> $GITHUB_OUTPUT + echo "node_modules" >> $GITHUB_OUTPUT + echo "packages/*/node_modules" >> $GITHUB_OUTPUT + echo 'EOF' >> $GITHUB_OUTPUT - name: Setup Node.js and get yarn cache - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: "18" - cache: yarn - - name: Cache node modules - id: node-modules-cache - uses: actions/cache@v3 + node-version: ${{ inputs.node-version }} + - name: Restore node_modules + id: cache-node-modules + uses: actions/cache/restore@v4 with: - path: | - node_modules - packages/*/node_modules - key: root-node_modules-${{ hashFiles('yarn.lock') }} - restore-keys: root-node_modules- + key: ${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}} + restore-keys: ${{ steps.globals.outputs.CACHE_KEY_PREFIX }}- + path: ${{ steps.globals.outputs.PATH_TO_CACHE }} - name: Install Dependencies - shell: bash - run: yarn --immutable --check-cache - if: steps.node-modules-cache.outputs.cache-hit != 'true' \ No newline at end of file + if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }} + shell: ${{ steps.globals.outputs.ACTION_SHELL }} + run: | + yarn config set enableHardenedMode true + yarn --immutable --check-cache + - name: Save cache + if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }} + uses: actions/cache/save@v4 + with: + key: ${{ steps.cache-node-modules.outputs.cache-primary-key }} + path: ${{ steps.globals.outputs.PATH_TO_CACHE }} + \ No newline at end of file diff --git a/.github/workflows/changed-files.yaml b/.github/workflows/changed-files.yaml new file mode 100644 index 000000000000..b1d028dde345 --- /dev/null +++ b/.github/workflows/changed-files.yaml @@ -0,0 +1,27 @@ +name: Changed files reusable workflow +on: + workflow_call: + inputs: + files: + required: true + type: string + outputs: + any_changed: + value: ${{ jobs.changed-files.outputs.any_changed }} + +jobs: + changed-files: + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + any_changed: ${{ steps.changed-files.outputs.any_changed }} + steps: + - name: Fetch custom Github Actions and base branch history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v45 + with: + files: ${{ inputs.files }} diff --git a/.github/workflows/ci-chrome-extension.yaml b/.github/workflows/ci-chrome-extension.yaml index 63c7094f7044..3bdbfb61eb49 100644 --- a/.github/workflows/ci-chrome-extension.yaml +++ b/.github/workflows/ci-chrome-extension.yaml @@ -3,15 +3,23 @@ on: push: branches: - main - + pull_request: - + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + package.json + packages/twenty-chrome-extension/** chrome-extension-build: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 15 runs-on: ubuntu-latest env: @@ -25,23 +33,16 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - package.json - packages/twenty-chrome-extension/** - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Chrome Extension / Run build - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-chrome-extension - - - name: Mark as Valid if No Changes - if: steps.changed-files.outputs.changed != 'true' - run: | - echo "No relevant changes detected. Marking as valid." + ci-chrome-extension-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: ubuntu-latest + needs: [changed-files-check, chrome-extension-build] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yaml similarity index 70% rename from .github/workflows/ci-e2e.yml rename to .github/workflows/ci-e2e.yaml index 9b94cb938b2f..4b5bf5603c3b 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yaml @@ -1,18 +1,27 @@ -name: CI E2E Tests +name: CI E2E Playwright Tests on: push: branches: - main pull_request: + types: [opened, synchronize, reopened, labeled] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + packages/** + playwright.config.ts + .github/workflows/ci-e2e.yaml test: runs-on: ubuntu-latest - if: github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')) + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e'))) timeout-minutes: 30 env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 @@ -54,76 +63,50 @@ jobs: echo "CPU info:" lscpu - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/** - playwright.config.ts - .github/workflows/ci-e2e.yml - - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes detected. Marking as valid." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Build twenty-shared - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-shared + - name: Install Playwright Browsers + run: npx nx setup twenty-e2e-testing + - name: Setup environment files - if: steps.changed-files.outputs.any_changed == 'true' run: | - cp packages/twenty-e2e-testing/.env.example packages/twenty-e2e-testing/.env cp packages/twenty-front/.env.example packages/twenty-front/.env - cp packages/twenty-e2e-testing/.env.example packages/twenty-e2e-testing/.env npx nx reset:env twenty-server - name: Build frontend - if: steps.changed-files.outputs.any_changed == 'true' run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front - name: Build server - if: steps.changed-files.outputs.any_changed == 'true' run: NODE_ENV=production npx nx build twenty-server - name: Create and setup database - if: steps.changed-files.outputs.any_changed == 'true' run: | PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";' PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";' npx nx run twenty-server:database:reset - + - name: Start server - if: steps.changed-files.outputs.any_changed == 'true' run: | npx nx start twenty-server & echo "Waiting for server to be ready..." timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done' - name: Start frontend - if: steps.changed-files.outputs.any_changed == 'true' run: | npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 & echo "Waiting for frontend to be ready..." timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done' - name: Start worker - if: steps.changed-files.outputs.any_changed == 'true' run: | npx nx run twenty-server:worker:ci & echo "Worker started" - - name: Install Playwright Browsers - if: steps.changed-files.outputs.any_changed == 'true' - run: npx nx setup twenty-e2e-testing - - name: Run Playwright tests - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx test twenty-e2e-testing - uses: actions/upload-artifact@v4 @@ -139,3 +122,12 @@ jobs: name: playwright-report path: packages/twenty-e2e-testing/playwright-report/ retention-days: 30 + ci-e2e-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: ubuntu-latest + needs: [changed-files-check, test] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-front.yaml b/.github/workflows/ci-front.yaml index 95f18fc990fc..f22bbb556da2 100644 --- a/.github/workflows/ci-front.yaml +++ b/.github/workflows/ci-front.yaml @@ -3,17 +3,30 @@ on: push: branches: - main - + pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + STORYBOOK_BUILD_CACHE_KEY: storybook-build-depot-ubuntu-24.04-8-runner + jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + package.json + packages/twenty-front/** + packages/twenty-ui/** + packages/twenty-shared/** front-sb-build: + needs: [changed-files-check] + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 env: REACT_APP_SERVER_BASE_URL: http://localhost:3000 NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 @@ -26,47 +39,34 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - package.json - packages/twenty-front/** - packages/twenty-ui/** - packages/twenty-shared/** - - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Diagnostic disk space issue - if: steps.changed-files.outputs.any_changed == 'true' run: df -h - - name: Front / Restore Storybook Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Restore storybook build cache + id: restore-storybook-build-cache + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:frontend - tasks: storybook:build + key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }} - name: Front / Write .env - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-front - name: Front / Build storybook - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx storybook:build twenty-front + - name: Save storybook build cache + uses: ./.github/workflows/actions/save-cache + with: + key: ${{ steps.restore-storybook-build-cache.outputs.cache-primary-key }} front-sb-test: timeout-minutes: 30 - runs-on: shipfox-8vcpu-ubuntu-2204 + runs-on: depot-ubuntu-24.04-8 needs: front-sb-build strategy: + fail-fast: false matrix: - storybook_scope: [pages, modules] + shard: [1, 2, 3, 4] + storybook_scope: [modules, pages, performance] env: + SHARD_COUNTER: 4 REACT_APP_SERVER_BASE_URL: http://localhost:3000 NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 steps: @@ -74,73 +74,57 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-front/** - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Install Playwright - if: steps.changed-files.outputs.any_changed == 'true' run: cd packages/twenty-front && npx playwright install - - name: Front / Restore Storybook Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Restore storybook build cache + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:frontend - tasks: storybook:build + key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }} - name: Front / Write .env - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-front - name: Run storybook tests - if: steps.changed-files.outputs.any_changed == 'true' - run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} - front-sb-test-performance: + run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false + - name: Rename coverage file + run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }} + path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json + merge-reports-and-check-coverage: timeout-minutes: 30 - runs-on: shipfox-8vcpu-ubuntu-2204 + runs-on: depot-ubuntu-24.04-8 + needs: front-sb-test env: - REACT_APP_SERVER_BASE_URL: http://localhost:3000 - NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 + PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook + strategy: + matrix: + storybook_scope: [modules, pages, performance] steps: - - name: Fetch local actions - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-front/** - - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - - name: Install Playwright - if: steps.changed-files.outputs.any_changed == 'true' - run: cd packages/twenty-front && npx playwright install - - name: Front / Write .env - if: steps.changed-files.outputs.any_changed == 'true' - run: npx nx reset:env twenty-front - - name: Run storybook tests - if: steps.changed-files.outputs.any_changed == 'true' - run: npx nx run twenty-front:storybook:serve-and-test:static:performance + - uses: actions/download-artifact@v4 + with: + pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-* + merge-multiple: true + path: coverage-artifacts + - name: Merge coverage reports + run: | + mkdir -p ${{ env.PATH_TO_COVERAGE }} + npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json + - name: Checking coverage + run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }} front-chromatic-deployment: timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' needs: front-sb-build - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 env: REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000 CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} @@ -149,41 +133,27 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-front/** - - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - - name: Front / Restore Storybook Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Restore storybook build cache + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:frontend - tasks: storybook:build + key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }} - name: Front / Write .env - if: steps.changed-files.outputs.any_changed == 'true' run: | cd packages/twenty-front touch .env echo "REACT_APP_SERVER_BASE_URL: $REACT_APP_SERVER_BASE_URL" >> .env - name: Publish to Chromatic - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx run twenty-front:chromatic:ci front-task: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 + TASK_CACHE_KEY: front-task-${{ matrix.task }} strategy: matrix: task: [lint, typecheck, test] @@ -196,35 +166,41 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-front/** - - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - - name: Front / Restore ${{ matrix.task }} task cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Restore ${{ matrix.task }} cache + id: restore-task-cache + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:frontend - tasks: ${{ matrix.task }} + key: ${{ env.TASK_CACHE_KEY }} - name: Reset .env - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:frontend tasks: reset:env - name: Run ${{ matrix.task }} task - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:frontend tasks: ${{ matrix.task }} + - name: Save ${{ matrix.task }} cache + uses: ./.github/workflows/actions/save-cache + with: + key: ${{ steps.restore-task-cache.outputs.cache-primary-key }} + ci-front-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: depot-ubuntu-24.04-8 + needs: + [ + changed-files-check, + front-task, + front-chromatic-deployment, + merge-reports-and-check-coverage, + front-sb-test, + front-sb-build, + ] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 5e71284fdc01..ba7a40e342b7 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -5,15 +5,28 @@ on: - main pull_request: - + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + SERVER_SETUP_CACHE_KEY: server-setup + jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + package.json + packages/twenty-server/** + packages/twenty-emails/** + packages/twenty-shared/** server-setup: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 services: @@ -22,8 +35,8 @@ jobs: env: PGUSER_SUPERUSER: postgres PGPASSWORD_SUPERUSER: postgres - ALLOW_NOSSL: "true" - SPILO_PROVIDER: "local" + ALLOW_NOSSL: 'true' + SPILO_PROVIDER: 'local' ports: - 5432:5432 options: >- @@ -40,52 +53,33 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - package.json - packages/twenty-server/** - packages/twenty-emails/** - packages/twenty-shared/** - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install + - name: Restore server setup + id: restore-server-setup-cache + uses: ./.github/workflows/actions/restore-cache + with: + key: ${{ env.SERVER_SETUP_CACHE_KEY }} - name: Build twenty-shared - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-shared - - name: Server / Restore Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache - with: - tag: scope:backend - name: Server / Run lint & typecheck - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend tasks: lint,typecheck - name: Server / Build - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-server - name: Server / Write .env - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-server - name: Server / Create DB - if: steps.changed-files.outputs.any_changed == 'true' run: | PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";' PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";' npx nx run twenty-server:database:init:prod npx nx run twenty-server:database:migrate:prod - name: Worker / Run - if: steps.changed-files.outputs.any_changed == 'true' run: npx nx run twenty-server:worker:ci - name: Server / Check for Pending Migrations - if: steps.changed-files.outputs.any_changed == 'true' run: | METADATA_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate metadata-migration-check -d src/database/typeorm/metadata/metadata.datasource.ts || true) @@ -103,10 +97,27 @@ jobs: exit 1 fi + - name: GraphQL / Check for Pending Generation + if: steps.changed-files.outputs.any_changed == 'true' + run: | + GRAPHQL_GENERATE_OUTPUT=$(npx nx run twenty-front:graphql:generate || true) + GRAPHQL_METADATA_OUTPUT=$(npx nx run twenty-front:graphql:generate --configuration=metadata || true) + if [[ $GRAPHQL_GENERATE_OUTPUT == *"No changes detected"* && $GRAPHQL_METADATA_OUTPUT == *"No changes detected"* ]]; then + echo "GraphQL generation check passed." + else + echo "::error::Unexpected GraphQL changes detected. Please run the required commands and commit the changes." + echo "$GRAPHQL_GENERATE_OUTPUT" + echo "$GRAPHQL_METADATA_OUTPUT" + exit 1 + fi + - name: Save server setup + uses: ./.github/workflows/actions/save-cache + with: + key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }} server-test: timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 needs: server-setup env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 @@ -115,27 +126,13 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - package.json - packages/twenty-server/** - packages/twenty-emails/** - packages/twenty-shared/** - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - - name: Server / Restore Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Restore server setup + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:backend - - name: Server / Run Tests - if: steps.changed-files.outputs.any_changed == 'true' + key: ${{ env.SERVER_SETUP_CACHE_KEY }} + - name: Server / Run Tests uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend @@ -143,7 +140,7 @@ jobs: server-integration-test: timeout-minutes: 30 - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04-8 needs: server-setup services: postgres: @@ -151,8 +148,8 @@ jobs: env: PGUSER_SUPERUSER: postgres PGPASSWORD_SUPERUSER: postgres - ALLOW_NOSSL: "true" - SPILO_PROVIDER: "local" + ALLOW_NOSSL: 'true' + SPILO_PROVIDER: 'local' ports: - 5432:5432 options: >- @@ -171,33 +168,35 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - package.json - packages/twenty-server/** - packages/twenty-emails/** - - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - - name: Server / Restore Task Cache - if: steps.changed-files.outputs.any_changed == 'true' - uses: ./.github/workflows/actions/task-cache + - name: Update .env.test for billing + run: | + echo "IS_BILLING_ENABLED=true" >> .env.test + echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test + echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test + echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test + - name: Restore server setup + uses: ./.github/workflows/actions/restore-cache with: - tag: scope:backend + key: ${{ env.SERVER_SETUP_CACHE_KEY }} - name: Server / Run Integration Tests - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend - tasks: "test:integration:with-db-reset" + tasks: 'test:integration:with-db-reset' - name: Server / Upload reset-logs file if: always() uses: actions/upload-artifact@v4 with: name: reset-logs path: reset-logs.log + ci-server-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: depot-ubuntu-24.04-8 + needs: [changed-files-check, server-setup, server-test, server-integration-test] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-shared.yaml b/.github/workflows/ci-shared.yaml index 41c334e054ed..1df2b36c399e 100644 --- a/.github/workflows/ci-shared.yaml +++ b/.github/workflows/ci-shared.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - + pull_request: concurrency: @@ -11,7 +11,14 @@ concurrency: cancel-in-progress: true jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + packages/twenty-shared/** shared-test: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest env: @@ -28,21 +35,19 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-shared/** - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed == 'false' - run: echo "No relevant changes. Skipping CI." - name: Install dependencies - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Run ${{ matrix.task }} task - if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:frontend tasks: ${{ matrix.task }} + ci-shared-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: ubuntu-latest + needs: [changed-files-check, shared-test] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index a9a076da33a3..21a8db3863df 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -1,92 +1,97 @@ name: 'Test Docker Compose' on: pull_request: - + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + packages/twenty-docker/** + docker-compose.yml test: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: | - packages/twenty-docker/** - docker-compose.yml - - name: Skip if no relevant changes - if: steps.changed-files.outputs.any_changed != 'true' - run: echo "No relevant changes detected. Marking as valid." - - - name: Run compose - if: steps.changed-files.outputs.any_changed == 'true' - run: | - echo "Patching docker-compose.yml..." - # change image to localbuild using yq - yq eval 'del(.services.server.image)' -i docker-compose.yml - yq eval '.services.server.build.context = "../../"' -i docker-compose.yml - yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml - yq eval '.services.server.restart = "no"' -i docker-compose.yml + - name: Checkout + uses: actions/checkout@v4 + - name: Run compose + run: | + echo "Patching docker-compose.yml..." + # change image to localbuild using yq + yq eval 'del(.services.server.image)' -i docker-compose.yml + yq eval '.services.server.build.context = "../../"' -i docker-compose.yml + yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml + yq eval '.services.server.restart = "no"' -i docker-compose.yml - yq eval 'del(.services.db.image)' -i docker-compose.yml - yq eval '.services.db.build.context = "../../"' -i docker-compose.yml - yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres-spilo/Dockerfile"' -i docker-compose.yml + yq eval 'del(.services.db.image)' -i docker-compose.yml + yq eval '.services.db.build.context = "../../"' -i docker-compose.yml + yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres-spilo/Dockerfile"' -i docker-compose.yml - echo "Setting up .env file..." - cp .env.example .env - echo "Generating secrets..." - echo "# === Randomly generated secrets ===" >>.env - echo "APP_SECRET=$(openssl rand -base64 32)" >>.env - echo "PGPASSWORD_SUPERUSER=$(openssl rand -base64 32)" >>.env + echo "Setting up .env file..." + cp .env.example .env + echo "Generating secrets..." + echo "# === Randomly generated secrets ===" >>.env + echo "APP_SECRET=$(openssl rand -base64 32)" >>.env + echo "PGPASSWORD_SUPERUSER=$(openssl rand -base64 32)" >>.env - echo "Docker compose up..." - docker compose up -d || { - echo "Docker compose failed to start" - docker compose logs - exit 1 - } - docker compose logs db server -f & - pid=$! + echo "Docker compose up..." + docker compose up -d || { + echo "Docker compose failed to start" + docker compose logs + exit 1 + } + docker compose logs db server -f & + pid=$! - echo "Waiting for database to start..." - count=0 - while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ]; do - sleep 1; - count=$((count+1)); - if [ $(docker inspect --format='{{.State.Status}}' twenty-db-1) = "exited" ]; then - echo "Database exited" - docker compose logs db - exit 1 - fi - if [ $count -gt 300 ]; then - echo "Failed to start database after 5 minutes" - docker compose logs db - exit 1 - fi - echo "Still waiting for database... (${count}/60)" - done + echo "Waiting for database to start..." + count=0 + while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ]; do + sleep 1; + count=$((count+1)); + if [ $(docker inspect --format='{{.State.Status}}' twenty-db-1) = "exited" ]; then + echo "Database exited" + docker compose logs db + exit 1 + fi + if [ $count -gt 300 ]; then + echo "Failed to start database after 5 minutes" + docker compose logs db + exit 1 + fi + echo "Still waiting for database... (${count}/60)" + done - echo "Waiting for server to start..." - count=0 - while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do - sleep 1; - count=$((count+1)); - if [ $(docker inspect --format='{{.State.Status}}' twenty-server-1) = "exited" ]; then - echo "Server exited" - docker compose logs server - exit 1 - fi - if [ $count -gt 300 ]; then - echo "Failed to start server after 5 minutes" - docker compose logs server - exit 1 - fi - echo "Still waiting for server... (${count}/300s)" - done - working-directory: ./packages/twenty-docker/ + echo "Waiting for server to start..." + count=0 + while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do + sleep 1; + count=$((count+1)); + if [ $(docker inspect --format='{{.State.Status}}' twenty-server-1) = "exited" ]; then + echo "Server exited" + docker compose logs server + exit 1 + fi + if [ $count -gt 300 ]; then + echo "Failed to start server after 5 minutes" + docker compose logs server + exit 1 + fi + echo "Still waiting for server... (${count}/300s)" + done + working-directory: ./packages/twenty-docker/ + ci-test-docker-compose-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: ubuntu-latest + needs: [changed-files-check, test] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/ci-website.yaml b/.github/workflows/ci-website.yaml index ed5247a487c5..b92d2bae1a28 100644 --- a/.github/workflows/ci-website.yaml +++ b/.github/workflows/ci-website.yaml @@ -5,14 +5,21 @@ on: - main pull_request: - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changed-files-check: + uses: ./.github/workflows/changed-files.yaml + with: + files: | + package.json + packages/twenty-website/** website-build: + needs: changed-files-check + if: needs.changed-files-check.outputs.any_changed == 'true' timeout-minutes: 3 runs-on: ubuntu-latest services: @@ -21,8 +28,8 @@ jobs: env: PGUSER_SUPERUSER: postgres PGPASSWORD_SUPERUSER: postgres - ALLOW_NOSSL: "true" - SPILO_PROVIDER: "local" + ALLOW_NOSSL: 'true' + SPILO_PROVIDER: 'local' ports: - 5432:5432 options: >- @@ -34,31 +41,27 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check for changed files - id: changed-files - uses: tj-actions/changed-files@v11 - with: - files: 'package.json, packages/twenty-website/**' - name: Install dependencies - if: steps.changed-files.outputs.changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Server / Create DB - if: steps.changed-files.outputs.any_changed == 'true' run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";' - name: Website / Run migrations - if: steps.changed-files.outputs.changed == 'true' - run: npx nx database:migrate twenty-website + run: npx nx database:migrate twenty-website env: DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default - name: Website / Build Website - if: steps.changed-files.outputs.changed == 'true' run: npx nx build twenty-website env: DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default - - - name: Mark as VALID - if: steps.changed-files.outputs.changed != 'true' # If no changes, mark as valid - run: echo "No relevant changes detected. CI is valid." \ No newline at end of file + ci-website-status-check: + if: always() && !cancelled() + timeout-minutes: 1 + runs-on: ubuntu-latest + needs: [changed-files-check, website-build] + steps: + - name: Fail job if any needs failed + if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.gitignore b/.gitignore index 48296f0cb14f..03c7ae81b143 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ !.yarn/sdks !.yarn/versions .vercel +.swc **/**/logs/** diff --git a/.vscode/settings.json b/.vscode/settings.json index 15a0022d81d1..03197b7470c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,4 +49,6 @@ "files.associations": { ".cursorrules": "markdown" }, + "jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}" + } } diff --git a/README.md b/README.md index 430d5015a2b0..be50ee1439f7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

The #1 Open-Source CRM

-

🌐 Website · 📚 Documentation · Discord · Figma

+

🌐 Website · 📚 Documentation · Roadmap · Discord · Figma


@@ -26,12 +26,6 @@ # Demo - Go to demo.twenty.com and login with the following credentials: ``` @@ -158,6 +152,7 @@ Below are a few features we have implemented to date: - [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/) - [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/) and [Emotion](https://emotion.sh/) - [Greptile](https://greptile.com) for code reviews. +- [TranslationIO](https://translation.io/) for translations. # Join the Community diff --git a/nx.json b/nx.json index d73a57ed16e1..f001673687d2 100644 --- a/nx.json +++ b/nx.json @@ -14,6 +14,7 @@ "!{projectRoot}/**/tsconfig.spec.json", "!{projectRoot}/**/*.test.(ts|tsx)", "!{projectRoot}/**/*.spec.(ts|tsx)", + "!{projectRoot}/**/*.integration-spec.ts", "!{projectRoot}/**/__tests__/*" ], "production": [ @@ -115,7 +116,7 @@ "outputs": ["{projectRoot}/{options.output-dir}"], "options": { "cwd": "{projectRoot}", - "command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build", + "command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test", "output-dir": "storybook-static", "config-dir": ".storybook" }, @@ -151,12 +152,14 @@ "options": { "cwd": "{projectRoot}", "commands": [ - "test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir}", - "nx storybook:coverage {projectName} --coverageDir={args.coverageDir}" + "test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir} --shard={args.shard}", + "nx storybook:coverage {projectName} --coverageDir={args.coverageDir} --checkCoverage={args.checkCoverage}" ], + "shard": "1/1", "parallel": false, "coverageDir": "coverage/storybook", - "port": 6006 + "port": 6006, + "checkCoverage": true } }, "storybook:test:no-coverage": { @@ -183,9 +186,10 @@ "!{projectRoot}/coverage/storybook/coverage-storybook.json" ], "options": { - "command": "npx nyc report --reporter={args.reporter} --reporter=text-summary -t {args.coverageDir} --report-dir {args.coverageDir} --check-coverage --cwd={projectRoot}", + "command": "npx nyc report --reporter={args.reporter} --reporter=text-summary -t {args.coverageDir} --report-dir {args.coverageDir} --check-coverage={args.checkCoverage} --cwd={projectRoot}", "coverageDir": "coverage/storybook", - "reporter": "lcov" + "reporter": "lcov", + "checkCoverage": true }, "configurations": { "text": { "reporter": "text" } @@ -195,8 +199,10 @@ "executor": "nx:run-commands", "options": { "commands": [ - "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port} --configuration={args.performance}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" + "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --shard={args.shard} --checkCoverage={args.checkCoverage} --port={args.port} --configuration={args.scope}'" ], + "shard": "1/1", + "checkCoverage": true, "port": 6006 } }, diff --git a/package.json b/package.json index f9b0f86ca8d3..cc2b07dfed49 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@jsdevtools/rehype-toc": "^3.0.2", "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", + "@lingui/core": "^5.1.2", + "@lingui/react": "^5.1.2", "@mdx-js/react": "^3.0.0", "@microsoft/microsoft-graph-client": "^3.0.7", "@nestjs/apollo": "^11.0.5", @@ -88,7 +90,7 @@ "esbuild-plugin-svgr": "^2.1.0", "facepaint": "^1.2.1", "file-type": "16.5.4", - "framer-motion": "^10.12.17", + "framer-motion": "^11.18.0", "googleapis": "105", "graphiql": "^3.1.1", "graphql": "16.8.0", @@ -202,6 +204,9 @@ "@graphql-codegen/typescript": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4", "@graphql-codegen/typescript-react-apollo": "^3.3.7", + "@lingui/cli": "^5.1.2", + "@lingui/swc-plugin": "^5.0.2", + "@lingui/vite-plugin": "^5.1.2", "@microsoft/microsoft-graph-types": "^2.40.0", "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", @@ -297,6 +302,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "2.29.1", "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-lingui": "^0.9.0", "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.1.2", "eslint-plugin-project-structure": "^3.9.1", diff --git a/packages/twenty-chrome-extension/tsconfig.json b/packages/twenty-chrome-extension/tsconfig.json index 840e10a78c08..d9bd2f66b549 100644 --- a/packages/twenty-chrome-extension/tsconfig.json +++ b/packages/twenty-chrome-extension/tsconfig.json @@ -9,8 +9,8 @@ "skipLibCheck": true, "esModuleInterop": true, "paths": { - "@/*": ["packages/twenty-chrome-extension/src/options/modules/*"], - "~/*": ["packages/twenty-chrome-extension/src/*"] + "@/*": ["./src/options/modules/*"], + "~/*": ["./src/*"] }, /* Bundler mode */ diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index 40efdf33e7d0..591e4c91be11 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -6,6 +6,7 @@ TAG=latest #REDIS_URL=redis://redis:6379 SERVER_URL=http://localhost:3000 +SIGN_IN_PREFILLED=false # Use openssl rand -base64 32 for each secret # APP_SECRET=replace_me_with_a_random_string diff --git a/packages/twenty-docker/docker-compose.yml b/packages/twenty-docker/docker-compose.yml index 0f32467e2331..df3293cbfc40 100644 --- a/packages/twenty-docker/docker-compose.yml +++ b/packages/twenty-docker/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.9" name: twenty services: @@ -14,7 +13,7 @@ services: && chown -R 1000:1000 /tmp/docker-data" server: - image: twentycrm/twenty:${TAG} + image: twentycrm/twenty:${TAG:-latest} volumes: - server-local-data:/app/packages/twenty-server/${STORAGE_LOCAL_PATH:-.local-storage} - docker-data:/app/docker-data @@ -26,7 +25,6 @@ services: SERVER_URL: ${SERVER_URL} REDIS_URL: ${REDIS_URL:-redis://redis:6379} - SIGN_IN_PREFILLED: ${SIGN_IN_PREFILLED} STORAGE_TYPE: ${STORAGE_TYPE} STORAGE_S3_REGION: ${STORAGE_S3_REGION} STORAGE_S3_NAME: ${STORAGE_S3_NAME} @@ -46,7 +44,7 @@ services: restart: always worker: - image: twentycrm/twenty:${TAG} + image: twentycrm/twenty:${TAG:-latest} command: ["yarn", "worker:prod"] environment: PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-postgres}@${PG_DATABASE_HOST:-db:5432}/default @@ -68,7 +66,7 @@ services: restart: always db: - image: twentycrm/twenty-postgres-spilo:${TAG} + image: twentycrm/twenty-postgres-spilo:${TAG:-latest} volumes: - db-data:/home/postgres/pgdata environment: diff --git a/packages/twenty-docker/k8s/manifests/deployment-db.yaml b/packages/twenty-docker/k8s/manifests/deployment-db.yaml index 02a97c31bcfd..c797972e0578 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-db.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-db.yaml @@ -27,13 +27,17 @@ spec: claimName: twentycrm-db-pvc containers: - name: twentycrm - image: twentycrm/twenty-postgres:latest + image: twentycrm/twenty-postgres-spilo:latest imagePullPolicy: Always env: - name: PGUSER_SUPERUSER value: "postgres" - name: PGPASSWORD_SUPERUSER value: "postgres" + - name: SPILO_PROVIDER + value: "local" + - name: ALLOW_NOSSL + value: "true" ports: - containerPort: 5432 name: tcp diff --git a/packages/twenty-docker/scripts/install.sh b/packages/twenty-docker/scripts/install.sh index 0eec572b80de..5d560d6c773c 100755 --- a/packages/twenty-docker/scripts/install.sh +++ b/packages/twenty-docker/scripts/install.sh @@ -44,10 +44,10 @@ function on_exit { trap on_exit EXIT # Use environment variables VERSION and BRANCH, with defaults if not set -version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/tags | grep '"name":' | head -n 1 | cut -d '"' -f 4)} -branch=${BRANCH:-$version} +version=${VERSION:-$(curl -s "https://hub.docker.com/v2/repositories/twentycrm/twenty/tags" | grep -o '"name":"[^"]*"' | grep -v 'latest' | cut -d'"' -f4 | sort -V | tail -n1)} +branch=${BRANCH:-$(curl -s https://api.github.com/repos/twentyhq/twenty/tags | grep '"name":' | head -n 1 | cut -d '"' -f 4)} -echo "🚀 Using version $version and branch $branch" +echo "🚀 Using docker version $version and Github branch $branch" dir_name="twenty" function ask_directory { @@ -90,10 +90,12 @@ else fi # Generate random strings for secrets -echo "# === Randomly generated secrets ===" >>.env +echo "# === Randomly generated secret ===" >>.env echo "APP_SECRET=$(openssl rand -base64 32)" >>.env -echo "" >>.env -echo "PGPASSWORD_SUPERUSER=$(openssl rand -hex 16)" >>.env + +# Issue with Postgres spilo? +#echo "" >>.env +#echo "PGPASSWORD_SUPERUSER=$(openssl rand -hex 16)" >>.env echo -e "\t• .env configuration completed" diff --git a/packages/twenty-e2e-testing/.env.example b/packages/twenty-e2e-testing/.env.example index 4cebcc1107c1..9f0b80f99380 100644 --- a/packages/twenty-e2e-testing/.env.example +++ b/packages/twenty-e2e-testing/.env.example @@ -1,8 +1,7 @@ # Note that provide always without trailing forward slash to have expected behaviour FRONTEND_BASE_URL=http://localhost:3001 +BACKEND_BASE_URL=http://localhost:3000 DEFAULT_LOGIN=tim@apple.dev -NEW_WORKSPACE_LOGIN=test@apple.dev -DEMO_DEFAULT_LOGIN=noah@demo.dev DEFAULT_PASSWORD=Applecar2025 WEBSITE_URL=https://twenty.com diff --git a/packages/twenty-e2e-testing/config/customreporter.ts b/packages/twenty-e2e-testing/config/customreporter.ts deleted file mode 100644 index 62a602ef8e7b..000000000000 --- a/packages/twenty-e2e-testing/config/customreporter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - Reporter, - FullConfig, - Suite, - TestCase, - TestResult, - FullResult, -} from '@playwright/test/reporter'; - -class CustomReporter implements Reporter { - constructor(options: { customOption?: string } = {}) { - console.log( - `my-awesome-reporter setup with customOption set to ${options.customOption}`, - ); - } - - onBegin(config: FullConfig, suite: Suite) { - console.log(`Starting the run with ${suite.allTests().length} tests`); - } - - onTestBegin(test: TestCase) { - console.log(`Starting test ${test.title}`); - } - - onTestEnd(test: TestCase, result: TestResult) { - console.log(`Finished test ${test.title}: ${result.status}`); - } - - onEnd(result: FullResult) { - console.log(`Finished the run: ${result.status}`); - } -} -export default CustomReporter; diff --git a/packages/twenty-e2e-testing/drivers/env_variables.ts b/packages/twenty-e2e-testing/drivers/env_variables.ts deleted file mode 100644 index 768b1872c9ef..000000000000 --- a/packages/twenty-e2e-testing/drivers/env_variables.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as fs from 'fs'; -import path from 'path'; - -export const envVariables = (variables: string) => { - let payload = ` - PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default - ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access - LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login - REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh - FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh - REDIS_URL=redis://localhost:6379 - `; - payload = payload.concat(variables); - fs.writeFile( - path.join(__dirname, '..', '..', 'twenty-server', '.env'), - payload, - (err) => { - throw err; - }, - ); -}; diff --git a/packages/twenty-e2e-testing/drivers/shell_driver.ts b/packages/twenty-e2e-testing/drivers/shell_driver.ts deleted file mode 100644 index cf293c032bf1..000000000000 --- a/packages/twenty-e2e-testing/drivers/shell_driver.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { exec } from 'child_process'; - -export async function sh(cmd) { - return new Promise((resolve, reject) => { - exec(cmd, (err, stdout, stderr) => { - if (err) { - reject(err); - } else { - resolve({ stdout, stderr }); - } - }); - }); -} diff --git a/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts b/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts new file mode 100644 index 000000000000..b6f96a20ab97 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts @@ -0,0 +1,249 @@ +import { test as base, expect, Locator, Page } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { createWorkflow } from '../requests/create-workflow'; +import { deleteWorkflow } from '../requests/delete-workflow'; +import { destroyWorkflow } from '../requests/destroy-workflow'; +import { WorkflowActionType, WorkflowTriggerType } from '../types/workflows'; + +export class WorkflowVisualizerPage { + #page: Page; + + workflowId: string; + workflowName: string; + + readonly addStepButton: Locator; + readonly workflowStatus: Locator; + readonly activateWorkflowButton: Locator; + readonly deactivateWorkflowButton: Locator; + readonly addTriggerButton: Locator; + readonly commandMenu: Locator; + readonly workflowNameButton: Locator; + readonly triggerNode: Locator; + readonly background: Locator; + + #actionNames: Record = { + 'create-record': 'Create Record', + 'update-record': 'Update Record', + 'delete-record': 'Delete Record', + code: 'Code', + 'send-email': 'Send Email', + }; + + #createdActionNames: Record = { + 'create-record': 'Create Record', + 'update-record': 'Update Record', + 'delete-record': 'Delete Record', + code: 'Code - Serverless Function', + 'send-email': 'Send Email', + }; + + #triggerNames: Record = { + 'record-created': 'Record is Created', + 'record-updated': 'Record is Updated', + 'record-deleted': 'Record is Deleted', + manual: 'Launch manually', + }; + + #createdTriggerNames: Record = { + 'record-created': 'Record is Created', + 'record-updated': 'Record is Updated', + 'record-deleted': 'Record is Deleted', + manual: 'Manual Trigger', + }; + + constructor({ page, workflowName }: { page: Page; workflowName: string }) { + this.#page = page; + this.workflowName = workflowName; + + this.addStepButton = page.getByLabel('Add a step'); + this.workflowStatus = page.getByTestId('workflow-visualizer-status'); + this.activateWorkflowButton = page.getByLabel('Activate Workflow', { + exact: true, + }); + this.deactivateWorkflowButton = page.getByLabel('Deactivate Workflow', { + exact: true, + }); + this.addTriggerButton = page.getByText('Add a Trigger'); + this.commandMenu = page.getByTestId('command-menu'); + this.workflowNameButton = page.getByRole('button', { + name: this.workflowName, + }); + this.triggerNode = this.#page.getByTestId('rf__node-trigger'); + this.background = page.locator('.react-flow__pane'); + } + + async createOneWorkflow() { + const id = randomUUID(); + + const response = await createWorkflow({ + page: this.#page, + workflowId: id, + workflowName: this.workflowName, + }); + + expect(response.status()).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.data.createWorkflow.id).toBe(id); + + this.workflowId = id; + } + + async waitForWorkflowVisualizerLoad() { + await expect(this.workflowNameButton).toBeVisible(); + } + + async goToWorkflowVisualizerPage() { + await Promise.all([ + this.#page.goto(`/object/workflow/${this.workflowId}`), + + this.waitForWorkflowVisualizerLoad(), + ]); + } + + async createInitialTrigger(trigger: WorkflowTriggerType) { + await this.addTriggerButton.click(); + + const triggerName = this.#triggerNames[trigger]; + const createdTriggerName = this.#createdTriggerNames[trigger]; + + const triggerOption = this.#page.getByText(triggerName); + await triggerOption.click(); + + await expect(this.triggerNode).toHaveClass(/selected/); + await expect(this.triggerNode).toContainText(createdTriggerName); + } + + async createStep(action: WorkflowActionType) { + await this.addStepButton.click(); + + const actionName = this.#actionNames[action]; + const createdActionName = this.#createdActionNames[action]; + + const actionToCreateOption = this.commandMenu.getByText(actionName); + + const [createWorkflowStepResponse] = await Promise.all([ + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return requestBody.operationName === 'CreateWorkflowVersionStep'; + }), + + actionToCreateOption.click(), + ]); + const createWorkflowStepResponseBody = + await createWorkflowStepResponse.json(); + const createdStepId = + createWorkflowStepResponseBody.data.createWorkflowVersionStep.id; + + await expect( + this.#page.getByTestId('command-menu').getByRole('textbox').first(), + ).toHaveValue(createdActionName); + + const createdActionNode = this.#page + .locator('.react-flow__node.selected') + .getByText(createdActionName); + + await expect(createdActionNode).toBeVisible(); + + const selectedNodes = this.#page.locator('.react-flow__node.selected'); + + await expect(selectedNodes).toHaveCount(1); + + return { + createdStepId, + }; + } + + getStepNode(stepId: string) { + return this.#page.getByTestId(`rf__node-${stepId}`); + } + + getDeleteNodeButton(nodeLocator: Locator) { + return nodeLocator.getByRole('button'); + } + + getAllStepNodes() { + return this.#page + .getByTestId(/^rf__node-.+$/) + .and(this.#page.getByTestId(/^((?!rf__node-trigger).)*$/)) + .and( + this.#page.getByTestId(/^((?!rf__node-branch-\d+__create-step).)*$/), + ); + } + + async deleteStep(stepId: string) { + const stepNode = this.getStepNode(stepId); + + await stepNode.click(); + + await Promise.all([ + expect(stepNode).not.toBeVisible(), + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return ( + requestBody.operationName === 'DeleteWorkflowVersionStep' && + requestBody.variables.input.stepId === stepId + ); + }), + + this.getDeleteNodeButton(stepNode).click(), + ]); + } + + async deleteTrigger() { + await this.triggerNode.click(); + + await Promise.all([ + expect(this.triggerNode).toContainText('Add a Trigger'), + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return ( + requestBody.operationName === 'UpdateOneWorkflowVersion' && + requestBody.variables.input.trigger === null + ); + }), + + this.getDeleteNodeButton(this.triggerNode).click(), + ]); + } +} + +export const test = base.extend<{ workflowVisualizer: WorkflowVisualizerPage }>( + { + workflowVisualizer: async ({ page }, use) => { + const workflowVisualizer = new WorkflowVisualizerPage({ + page, + workflowName: 'Test Workflow', + }); + + await workflowVisualizer.createOneWorkflow(); + await workflowVisualizer.goToWorkflowVisualizerPage(); + + await use(workflowVisualizer); + + await deleteWorkflow({ + page, + workflowId: workflowVisualizer.workflowId, + }); + await destroyWorkflow({ + page, + workflowId: workflowVisualizer.workflowId, + }); + }, + }, +); diff --git a/packages/twenty-e2e-testing/lib/pom/loginPage.ts b/packages/twenty-e2e-testing/lib/pom/loginPage.ts index 15aa1ca22277..6f69a683feb2 100644 --- a/packages/twenty-e2e-testing/lib/pom/loginPage.ts +++ b/packages/twenty-e2e-testing/lib/pom/loginPage.ts @@ -1,4 +1,4 @@ -import { Locator, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export class LoginPage { private readonly loginWithGoogleButton: Locator; @@ -98,6 +98,8 @@ export class LoginPage { } async typeEmail(email: string) { + await expect(this.emailField).toBeVisible(); + await this.emailField.fill(email); } diff --git a/packages/twenty-e2e-testing/lib/requests/backend.ts b/packages/twenty-e2e-testing/lib/requests/backend.ts new file mode 100644 index 000000000000..73d57b1a52a8 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/requests/backend.ts @@ -0,0 +1,4 @@ +export const backendGraphQLUrl = new URL( + '/graphql', + process.env.BACKEND_BASE_URL, +).toString(); diff --git a/packages/twenty-e2e-testing/lib/requests/create-workflow.ts b/packages/twenty-e2e-testing/lib/requests/create-workflow.ts new file mode 100644 index 000000000000..33aa22783790 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/requests/create-workflow.ts @@ -0,0 +1,32 @@ +import { Page } from '@playwright/test'; +import { getAuthToken } from '../utils/getAuthToken'; +import { backendGraphQLUrl } from './backend'; + +export const createWorkflow = async ({ + page, + workflowId, + workflowName, +}: { + page: Page; + workflowId: string; + workflowName: string; +}) => { + const { authToken } = await getAuthToken(page); + + return page.request.post(backendGraphQLUrl, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + data: { + operationName: 'CreateOneWorkflow', + query: + 'mutation CreateOneWorkflow($input: WorkflowCreateInput!) { createWorkflow(data: $input) { __typename id } }', + variables: { + input: { + id: workflowId, + name: workflowName, + }, + }, + }, + }); +}; diff --git a/packages/twenty-e2e-testing/lib/requests/delete-workflow.ts b/packages/twenty-e2e-testing/lib/requests/delete-workflow.ts new file mode 100644 index 000000000000..299768d6725a --- /dev/null +++ b/packages/twenty-e2e-testing/lib/requests/delete-workflow.ts @@ -0,0 +1,25 @@ +import { Page } from '@playwright/test'; +import { getAuthToken } from '../utils/getAuthToken'; +import { backendGraphQLUrl } from './backend'; + +export const deleteWorkflow = async ({ + page, + workflowId, +}: { + page: Page; + workflowId: string; +}) => { + const { authToken } = await getAuthToken(page); + + return page.request.post(backendGraphQLUrl, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + data: { + operationName: 'DeleteOneWorkflow', + variables: { idToDelete: workflowId }, + query: + 'mutation DeleteOneWorkflow($idToDelete: ID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}', + }, + }); +}; diff --git a/packages/twenty-e2e-testing/lib/requests/destroy-workflow.ts b/packages/twenty-e2e-testing/lib/requests/destroy-workflow.ts new file mode 100644 index 000000000000..2191a3e3dc52 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/requests/destroy-workflow.ts @@ -0,0 +1,25 @@ +import { Page } from '@playwright/test'; +import { getAuthToken } from '../utils/getAuthToken'; +import { backendGraphQLUrl } from './backend'; + +export const destroyWorkflow = async ({ + page, + workflowId, +}: { + page: Page; + workflowId: string; +}) => { + const { authToken } = await getAuthToken(page); + + return page.request.post(backendGraphQLUrl, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + data: { + operationName: 'DestroyOneWorkflow', + variables: { idToDestroy: workflowId }, + query: + 'mutation DestroyOneWorkflow($idToDestroy: ID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}', + }, + }); +}; diff --git a/packages/twenty-e2e-testing/lib/types/workflows.ts b/packages/twenty-e2e-testing/lib/types/workflows.ts new file mode 100644 index 000000000000..6b1f4bec8d0f --- /dev/null +++ b/packages/twenty-e2e-testing/lib/types/workflows.ts @@ -0,0 +1,12 @@ +export type WorkflowTriggerType = + | 'record-created' + | 'record-updated' + | 'record-deleted' + | 'manual'; + +export type WorkflowActionType = + | 'create-record' + | 'update-record' + | 'delete-record' + | 'code' + | 'send-email'; diff --git a/packages/twenty-e2e-testing/lib/utils/getAuthToken.ts b/packages/twenty-e2e-testing/lib/utils/getAuthToken.ts new file mode 100644 index 000000000000..38534fdf23c4 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/utils/getAuthToken.ts @@ -0,0 +1,15 @@ +import { Page } from '@playwright/test'; + +export const getAuthToken = async (page: Page) => { + const storageState = await page.context().storageState(); + const authCookie = storageState.cookies.find( + (cookie) => cookie.name === 'tokenPair', + ); + if (!authCookie) { + throw new Error('No auth cookie found'); + } + const token = JSON.parse(decodeURIComponent(authCookie.value)).accessToken + .token; + + return { authToken: token }; +}; diff --git a/packages/twenty-e2e-testing/package.json b/packages/twenty-e2e-testing/package.json index 8f21de70c784..1794579297e8 100644 --- a/packages/twenty-e2e-testing/package.json +++ b/packages/twenty-e2e-testing/package.json @@ -1,6 +1,6 @@ { "name": "twenty-e2e-testing", - "version": "0.40.0-canary", + "version": "0.41.0-canary", "description": "", "author": "", "private": true, diff --git a/packages/twenty-e2e-testing/playwright.config.ts b/packages/twenty-e2e-testing/playwright.config.ts index 6e76d0c5e238..6fea9a702145 100644 --- a/packages/twenty-e2e-testing/playwright.config.ts +++ b/packages/twenty-e2e-testing/playwright.config.ts @@ -16,10 +16,10 @@ if (envResult.error) { * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: '.', + testDir: './tests', outputDir: 'run_results/', // directory for screenshots and videos snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', // just in case, do not delete it - fullyParallel: true, // false only for specific tests, overwritten in specific projects or global setups of projects + fullyParallel: false, // parallelization of tests will be done later in the future forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, // 1 worker = 1 test at the time, tests can't be parallelized @@ -30,10 +30,6 @@ export default defineConfig({ screenshot: 'on', // either 'on' here or in different method in modules, if 'on' all screenshots are overwritten each time the test is run headless: true, // instead of changing it to false, run 'yarn test:e2e:debug' or 'yarn test:e2e:ui' testIdAttribute: 'data-testid', // taken from Twenty source - viewport: { width: 1920, height: 1080 }, // most laptops use this resolution - launchOptions: { - slowMo: 500, // time in milliseconds between each step, better to use it than explicitly define timeout in tests - }, }, expect: { timeout: 5000, @@ -41,33 +37,17 @@ export default defineConfig({ reporter: process.env.CI ? 'github' : 'list', projects: [ { - name: 'Login setup', - testMatch: /login\.setup\.ts/, // finds all tests matching this regex, in this case only 1 test should be found - }, - { - name: 'Demo check', - use: { - ...devices['Desktop Chrome'], - }, - testMatch: /demo\/demo_basic\.spec\.ts/, + name: 'setup', + testMatch: /.*\.setup\.ts/, }, { - name: 'chromium', + name: 'chrome', use: { ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], storageState: path.resolve(__dirname, '.auth', 'user.json'), // takes saved cookies from directory }, - dependencies: ['Login setup'], // forces to run login setup before running tests from this project - CASE SENSITIVE - testMatch: /all\/.+\.e2e-spec\.ts/, - }, - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - storageState: path.resolve(__dirname, '.auth', 'user.json'), - }, - dependencies: ['Login setup'], - testMatch: /all\/.+\.e2e-spec\.ts/, + dependencies: ['setup'], }, //{ diff --git a/packages/twenty-e2e-testing/project.json b/packages/twenty-e2e-testing/project.json index 9dd38917f8df..e820fd17297e 100644 --- a/packages/twenty-e2e-testing/project.json +++ b/packages/twenty-e2e-testing/project.json @@ -8,7 +8,8 @@ "options": { "cwd": "packages/twenty-e2e-testing", "commands": [ - "yarn playwright install" + "yarn playwright install", + "cp .env.example .env" ] } }, diff --git a/packages/twenty-e2e-testing/tests/all/companies.e2e-spec.ts b/packages/twenty-e2e-testing/tests/all/companies.e2e-spec.ts deleted file mode 100644 index a2cdd2ad7d3c..000000000000 --- a/packages/twenty-e2e-testing/tests/all/companies.e2e-spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from '../../lib/fixtures/screenshot'; - -test.describe('Basic check', () => { - test('Checking if table in Companies is visible', async ({ page }) => { - await expect(page.getByTestId('tooltip').nth(0)).toHaveText('Companies'); - await expect(page.getByTestId('tooltip').nth(0)).toBeVisible(); - expect(page.url()).toContain('/companies'); - await expect(page.locator('table')).toBeVisible(); - await expect(page.locator('tbody > tr')).toHaveCount(13); // shouldn't be hardcoded in case of tests on demo - }); - - test('', async ({ page }) => { - await page.getByRole('link', { name: 'Opportunities' }).click(); - await expect(page.locator('table')).toBeVisible(); - }); -}); diff --git a/packages/twenty-e2e-testing/tests/all/workspaces.e2e-spec.ts b/packages/twenty-e2e-testing/tests/all/workspaces.e2e-spec.ts deleted file mode 100644 index 24816e13e8b9..000000000000 --- a/packages/twenty-e2e-testing/tests/all/workspaces.e2e-spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from '@playwright/test'; -import { sh } from '../../drivers/shell_driver'; - -test.describe('', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - /* - - test('Creating new workspace', async ({ page, browserName }) => { - // this test must use only 1 browser, otherwise it will lead to success and fail (1 workspace is created instead of x workspaces) - if (browserName == 'chromium') { - await sh( - 'npx nx run twenty-server:database:reset --configuration=no-seed', - ); - - await page.goto('/'); - await page.getByRole('button', { name: 'Continue With Email' }).click(); - await page.getByPlaceholder('Email').fill('test@apple.dev'); // email must be changed each time test is run - await page.getByPlaceholder('Email').press('Enter'); // otherwise if tests fails after this step, new workspace is created - await page.getByPlaceholder('Password').fill('Applecar2025'); - await page.getByPlaceholder('Password').press('Enter'); - await page.getByPlaceholder('Apple').fill('Test'); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByPlaceholder('Tim').click(); - await page.getByPlaceholder('Tim').fill('Test2'); - await page.getByPlaceholder('Cook').click(); - await page.getByPlaceholder('Cook').fill('Test2'); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByText('Continue without sync').click(); - await page.getByRole('button', { name: 'Finish' }).click(); - await expect(page.locator('table')).toBeVisible({ timeout: 1000 }); - await sh('npx nx run twenty-server:database:reset'); - } - }); - */ - - test('Syncing all workspaces', async () => { - await sh('npx nx run twenty-server:command workspace:sync-metadata -f'); - }); -}); diff --git a/packages/twenty-e2e-testing/tests/authentication/fixture.ts b/packages/twenty-e2e-testing/tests/authentication/fixture.ts new file mode 100644 index 000000000000..16cf35becaf8 --- /dev/null +++ b/packages/twenty-e2e-testing/tests/authentication/fixture.ts @@ -0,0 +1,45 @@ +import { test as base } from '../../lib/fixtures/screenshot'; +import { LoginPage } from '../../lib/pom/loginPage'; +import { LeftMenu } from '../../lib/pom/leftMenu'; +import { SettingsPage } from '../../lib/pom/settingsPage'; +import { MembersSection } from '../../lib/pom/settings/membersSection'; +import { ProfileSection } from '../../lib/pom/settings/profileSection'; +import { ConfirmationModal } from '../../lib/pom/helper/confirmationModal'; + +type Fixtures = { + confirmationModal: ConfirmationModal; + loginPage: LoginPage; + leftMenu: LeftMenu; + settingsPage: SettingsPage; + membersSection: MembersSection; + profileSection: ProfileSection; +}; + +export const test = base.extend({ + confirmationModal: async ({ page }, use) => { + const confirmationModal = new ConfirmationModal(page); + await use(confirmationModal); + }, + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await use(loginPage); + }, + leftMenu: async ({ page }, use) => { + const leftMenu = new LeftMenu(page); + await use(leftMenu); + }, + settingsPage: async ({ page }, use) => { + const settingsPage = new SettingsPage(page); + await use(settingsPage); + }, + membersSection: async ({ page }, use) => { + const membersSection = new MembersSection(page); + await use(membersSection); + }, + profileSection: async ({ page }, use) => { + const profileSection = new ProfileSection(page); + await use(profileSection); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/packages/twenty-e2e-testing/tests/authentication/signup_invite_email.spec.ts b/packages/twenty-e2e-testing/tests/authentication/signup_invite_email.spec.ts new file mode 100644 index 000000000000..f36ba109483b --- /dev/null +++ b/packages/twenty-e2e-testing/tests/authentication/signup_invite_email.spec.ts @@ -0,0 +1,60 @@ +import { randomUUID } from 'crypto'; +import { expect, test } from './fixture'; + +test('Sign up with invite link via email', async ({ + page, + loginPage, + leftMenu, + membersSection, + settingsPage, + profileSection, + confirmationModal, +}) => { + const email = `test${randomUUID().replaceAll('-', '')}@apple.dev`; + const firstName = 'John'; + const lastName = 'Doe'; + + const inviteLink: string = + await test.step('Go to Settings and copy invite link', async () => { + await page.goto(process.env.LINK); // skip login page (and redirect) when running on environments with multi-workspace enabled + await leftMenu.goToSettings(); + await settingsPage.goToMembersSection(); + await membersSection.copyInviteLink(); + return await page.evaluate('navigator.clipboard.readText()'); + }); + + await test.step('Go to invite link', async () => { + await settingsPage.logout(); + + await Promise.all([ + expect(page.getByText(/Join .+ team/)).toBeVisible(), + + page.goto(inviteLink), + ]); + }); + + await test.step('Create new account', async () => { + await loginPage.clickLoginWithEmail(); + await loginPage.typeEmail(email); + await loginPage.clickContinueButton(); + await loginPage.typePassword(process.env.DEFAULT_PASSWORD); + await loginPage.clickSignUpButton(); + await loginPage.typeFirstName(firstName); + await loginPage.typeLastName(lastName); + await loginPage.clickContinueButton(); + await loginPage.noSyncWithGoogle(); + }); + + await test.step('Delete account from workspace', async () => { + await leftMenu.goToSettings(); + await settingsPage.goToProfileSection(); + await profileSection.deleteAccount(); + await confirmationModal.typePlaceholderToInput(); + + await Promise.all([ + page.waitForURL('/welcome'), + + confirmationModal.clickConfirmButton(), + ]); + }); +}); diff --git a/packages/twenty-e2e-testing/tests/demo/demo_basic.e2e-spec.ts b/packages/twenty-e2e-testing/tests/demo/demo_basic.spec.ts similarity index 100% rename from packages/twenty-e2e-testing/tests/demo/demo_basic.e2e-spec.ts rename to packages/twenty-e2e-testing/tests/demo/demo_basic.spec.ts diff --git a/packages/twenty-e2e-testing/tests/login.setup.ts b/packages/twenty-e2e-testing/tests/login.setup.ts index 74150ea3ff0d..bb730f9b94ba 100644 --- a/packages/twenty-e2e-testing/tests/login.setup.ts +++ b/packages/twenty-e2e-testing/tests/login.setup.ts @@ -1,38 +1,42 @@ -import { expect, test as setup } from '@playwright/test'; +import { test as base, expect } from '@playwright/test'; import path from 'path'; +import { LoginPage } from '../lib/pom/loginPage'; + +// fixture +const test = base.extend<{ loginPage: LoginPage }>({ + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await use(loginPage); + }, +}); -setup('Login test', async ({ page }) => { - console.log('Starting login test'); - - await page.goto('/'); - console.log('Navigated to homepage'); - - await page.getByRole('button', { name: 'Continue With Email' }).click(); - console.log('Clicked email login button'); - - console.log('Default login', process.env.DEFAULT_LOGIN); - await page.getByPlaceholder('Email').fill(process.env.DEFAULT_LOGIN ?? ''); - console.log('Filled email field'); - - await page.getByRole('button', { name: 'Continue', exact: true }).click(); - console.log('Clicked continue button'); - - await page - .getByPlaceholder('Password') - .fill(process.env.DEFAULT_PASSWORD ?? ''); - console.log('Filled password field'); - - await page.getByRole('button', { name: 'Sign in' }).click(); - console.log('Clicked sign in button'); - - await page.waitForLoadState('networkidle'); - console.log('Waited for network to be idle'); - - await expect(page.getByText('Welcome to Twenty')).not.toBeVisible(); - console.log('Verified welcome message not visible'); - - await page.context().storageState({ - path: path.resolve(__dirname, '..', '.auth', 'user.json'), +test('Login test', async ({ loginPage, page }) => { + await test.step('Navigated to login page', async () => { + await page.goto('/'); + }); + await test.step( + 'Logging in '.concat(page.url(), ' as ', process.env.DEFAULT_LOGIN), + async () => { + await page.waitForLoadState('networkidle'); + if ( + page.url().includes('demo.twenty.com') || + !page.url().includes('app.localhost:3001') + ) { + await loginPage.clickLoginWithEmail(); + } + await loginPage.typeEmail(process.env.DEFAULT_LOGIN); + await loginPage.clickContinueButton(); + await loginPage.typePassword(process.env.DEFAULT_PASSWORD); + await page.waitForLoadState('networkidle'); + await loginPage.clickSignInButton(); + await expect(page.getByText(/Welcome to .+/)).not.toBeVisible(); + }, + ); + + await test.step('Saved auth state', async () => { + await page.context().storageState({ + path: path.resolve(__dirname, '..', '.auth', 'user.json'), + }); + process.env.LINK = page.url(); }); - console.log('Saved auth state'); }); diff --git a/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts new file mode 100644 index 000000000000..049837891b57 --- /dev/null +++ b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import { deleteWorkflow } from '../lib/requests/delete-workflow'; +import { destroyWorkflow } from '../lib/requests/destroy-workflow'; + +test('Create workflow', async ({ page }) => { + const NEW_WORKFLOW_NAME = 'Test Workflow'; + + await page.goto('/'); + + const workflowsLink = page.getByRole('link', { name: 'Workflows' }); + await workflowsLink.click(); + + const createWorkflowButton = page.getByRole('button', { name: 'New record' }); + + const [createWorkflowResponse] = await Promise.all([ + page.waitForResponse(async (response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return requestBody.operationName === 'CreateOneWorkflow'; + }), + + await createWorkflowButton.click(), + ]); + + const nameInput = page.getByRole('textbox'); + await nameInput.fill(NEW_WORKFLOW_NAME); + await nameInput.press('Enter'); + + const body = await createWorkflowResponse.json(); + const newWorkflowId = body.data.createWorkflow.id; + + try { + const workflowName = page.getByRole('button', { name: NEW_WORKFLOW_NAME }); + + await expect(workflowName).toBeVisible(); + + await expect(page).toHaveURL(`/object/workflow/${newWorkflowId}`); + } finally { + await deleteWorkflow({ + page, + workflowId: newWorkflowId, + }); + await destroyWorkflow({ + page, + workflowId: newWorkflowId, + }); + } +}); diff --git a/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts b/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts new file mode 100644 index 000000000000..e411cad0c9b0 --- /dev/null +++ b/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts @@ -0,0 +1,186 @@ +import { expect } from '@playwright/test'; +import { test } from '../lib/fixtures/blank-workflow'; + +test('Create workflow with every possible step', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + await workflowVisualizer.createStep('create-record'); + await workflowVisualizer.createStep('update-record'); + await workflowVisualizer.createStep('delete-record'); + await workflowVisualizer.createStep('code'); + await workflowVisualizer.createStep('send-email'); + + await workflowVisualizer.background.click(); + + const draftWorkflowStatus = + workflowVisualizer.workflowStatus.getByText('Draft'); + + await expect(draftWorkflowStatus).toBeVisible(); + + await workflowVisualizer.activateWorkflowButton.click(); + + const activeWorkflowStatus = + workflowVisualizer.workflowStatus.getByText('Active'); + + await expect(draftWorkflowStatus).not.toBeVisible(); + await expect(activeWorkflowStatus).toBeVisible(); + await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible(); + await expect(workflowVisualizer.deactivateWorkflowButton).toBeVisible(); +}); + +test('Delete steps from draft version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + const { createdStepId: firstStepId } = + await workflowVisualizer.createStep('create-record'); + const { createdStepId: secondStepId } = + await workflowVisualizer.createStep('update-record'); + const { createdStepId: thirdStepId } = + await workflowVisualizer.createStep('delete-record'); + const { createdStepId: fourthStepId } = + await workflowVisualizer.createStep('code'); + const { createdStepId: fifthStepId } = + await workflowVisualizer.createStep('send-email'); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + 'Send Email', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(5); + + await workflowVisualizer.deleteStep(firstStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + 'Send Email', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(4); + + await workflowVisualizer.deleteStep(fifthStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(3); + + await workflowVisualizer.deleteStep(secondStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Delete Record', + 'Code - Serverless Function', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2); + + await workflowVisualizer.deleteStep(fourthStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Delete Record', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + + await workflowVisualizer.deleteStep(thirdStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0); + + await Promise.all([ + page.reload(), + + expect(workflowVisualizer.triggerNode).toBeVisible(), + ]); + + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0); +}); + +test('Add a step to an active version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + await workflowVisualizer.createStep('create-record'); + + await expect(workflowVisualizer.workflowStatus).toHaveText('Draft'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible(); + + const assertEndState = async () => { + await expect(workflowVisualizer.workflowStatus).toHaveText('Active'); + await expect(workflowVisualizer.triggerNode).toContainText( + 'Record is Created', + ); + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + }; + + await assertEndState(); + + await page.reload(); + + await assertEndState(); +}); + +test('Replace the trigger of an active version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + await workflowVisualizer.createStep('create-record'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Draft'), + + workflowVisualizer.deleteTrigger(), + ]); + + await workflowVisualizer.createInitialTrigger('record-deleted'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await page.reload(); + + await expect(workflowVisualizer.triggerNode).toContainText( + 'Record is Deleted', + ); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + ]); +}); diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json index 9887a8b94024..19c1cc660708 100644 --- a/packages/twenty-emails/package.json +++ b/packages/twenty-emails/package.json @@ -1,6 +1,6 @@ { "name": "twenty-emails", - "version": "0.40.0-canary", + "version": "0.41.0-canary", "description": "", "author": "", "private": true, diff --git a/packages/twenty-emails/src/components/Footer.tsx b/packages/twenty-emails/src/components/Footer.tsx new file mode 100644 index 000000000000..919686fb86a7 --- /dev/null +++ b/packages/twenty-emails/src/components/Footer.tsx @@ -0,0 +1,55 @@ +import { Column, Row } from '@react-email/components'; +import { Link } from 'src/components/Link'; +import { ShadowText } from 'src/components/ShadowText'; + +export const Footer = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + Twenty.com Public Benefit Corporation +
+ 2261 Market Street #5275 +
+ San Francisco, CA 94114 +
+ + ); +}; diff --git a/packages/twenty-emails/src/components/WhatIsTwenty.tsx b/packages/twenty-emails/src/components/WhatIsTwenty.tsx index 9b3913f78331..6b905a1f3e1a 100644 --- a/packages/twenty-emails/src/components/WhatIsTwenty.tsx +++ b/packages/twenty-emails/src/components/WhatIsTwenty.tsx @@ -1,8 +1,7 @@ -import { Column, Row } from '@react-email/components'; -import { Link } from 'src/components/Link'; +import { Footer } from 'src/components/Footer'; import { MainText } from 'src/components/MainText'; -import { ShadowText } from 'src/components/ShadowText'; import { SubTitle } from 'src/components/SubTitle'; + export const WhatIsTwenty = () => { return ( <> @@ -11,35 +10,7 @@ export const WhatIsTwenty = () => { It's a CRM, a software to help businesses manage their customer data and relationships efficiently. - - - - - - - - - - - - - - - - - - - - - - - - Twenty.com Public Benefit Corporation -
- 2261 Market Street #5275 -
- San Francisco, CA 94114 -
+