diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000000..cffb50287629 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm install -g yarn && yarn + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + - name: Run Playwright tests + run: yarn test:e2e companies + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 376216c452c4..65a5a2e7472f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ storybook-static .eslintcache .cache .nyc_output +test-results/ \ No newline at end of file diff --git a/package.json b/package.json index 0b146a6bb6a8..93885d1098cf 100644 --- a/package.json +++ b/package.json @@ -223,6 +223,7 @@ "@nx/storybook": "18.3.3", "@nx/vite": "18.3.3", "@nx/web": "18.3.3", + "@playwright/test": "^1.46.0", "@sentry/types": "^7.109.0", "@storybook/addon-actions": "^7.6.3", "@storybook/addon-coverage": "^1.0.0", diff --git a/packages/twenty-e2e-testing/.env.example b/packages/twenty-e2e-testing/.env.example index 9ff92d0193a7..126768866eac 100644 --- a/packages/twenty-e2e-testing/.env.example +++ b/packages/twenty-e2e-testing/.env.example @@ -1,2 +1,19 @@ # Note that provide always without trailing forward slash to have expected behaviour FRONTEND_BASE_URL="http://localhost:3001" +DEFAULT_LOGIN=tim@apple.dev +DEFAULT_PASSWORD=Applecar2025 +NEW_WORKSPACE_LOGIN=test@apple.dev + +# === DO NOT USE, WORK IN PROGRESS === +# This URL must have trailing forward slash as all REST API endpoints have object after it +# Documentation for REST API: https://twenty.com/developers/rest-api/core#/ +# REST_API_BASE_URL=http://localhost:3000/rest/ + +# Documentation for GraphQL API: https://twenty.com/developers/graphql/core +# GRAPHQL_BASE_URL=http://localhost:3000/graphql + +# Without this key, all API tests will fail, to generate this key +# In order to use it, header Authorization: Bearer token must be used +# Log in to Twenty workspace, go to Settings > Developers, generate new key and paste it here +# This key works for REST and GraphQL API +# API_DEV_KEY=fill_with_proper_key \ No newline at end of file diff --git a/packages/twenty-e2e-testing/.gitignore b/packages/twenty-e2e-testing/.gitignore index 68c5d18f00dc..a3c4dbdc0a16 100644 --- a/packages/twenty-e2e-testing/.gitignore +++ b/packages/twenty-e2e-testing/.gitignore @@ -1,5 +1,9 @@ -node_modules/ -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ +run_results/ +playwright-report/.last-run.json +results/ +run_results/.playwright-artifacts-0/ +run_results/.playwright-artifacts-1/ \ No newline at end of file diff --git a/packages/twenty-e2e-testing/README.md b/packages/twenty-e2e-testing/README.md index 222f1d8070db..dc113a09c5a8 100644 --- a/packages/twenty-e2e-testing/README.md +++ b/packages/twenty-e2e-testing/README.md @@ -1,8 +1,8 @@ # Twenty e2e Testing -## Install +## Prerequisition -Don't forget to install the browsers before launching the tests : +Installing the browsers: ``` yarn playwright install diff --git a/packages/twenty-e2e-testing/config/customreporter.ts b/packages/twenty-e2e-testing/config/customreporter.ts new file mode 100644 index 000000000000..62a602ef8e7b --- /dev/null +++ b/packages/twenty-e2e-testing/config/customreporter.ts @@ -0,0 +1,33 @@ +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/shell_driver.ts b/packages/twenty-e2e-testing/drivers/shell_driver.ts new file mode 100644 index 000000000000..cf293c032bf1 --- /dev/null +++ b/packages/twenty-e2e-testing/drivers/shell_driver.ts @@ -0,0 +1,13 @@ +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/e2e/companies.spec.ts b/packages/twenty-e2e-testing/e2e/companies.spec.ts deleted file mode 100644 index 48485da04df6..000000000000 --- a/packages/twenty-e2e-testing/e2e/companies.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test.describe('visible table', () => { - test('table should be visible on navigation to /objects/companies', async ({ - page, - }) => { - // Navigate to the page - await page.goto('/objects/companies'); - - // Check if the table is visible - const table = page.locator('table'); - await expect(table).toBeVisible(); - }); -}); diff --git a/packages/twenty-e2e-testing/playwright.config.ts b/packages/twenty-e2e-testing/playwright.config.ts index 4b4f081de794..d53bf904f9e5 100644 --- a/packages/twenty-e2e-testing/playwright.config.ts +++ b/packages/twenty-e2e-testing/playwright.config.ts @@ -1,43 +1,74 @@ import { defineConfig, devices } from '@playwright/test'; - import { config } from 'dotenv'; + config(); /** * See https://playwright.dev/docs/test-configuration. - * See https://playwright.dev/docs/trace-viewer to Collect trace when retrying the failed test */ export default defineConfig({ - testDir: 'e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + testDir: '.', + 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 + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 30 * 1000, use: { - /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.FRONTEND_BASE_URL ?? 'http://localhost:3001', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + headless: true, + testIdAttribute: 'data-testid', + viewport: { width: 1920, height: 1080 }, // most laptops use this resolution + launchOptions: { + slowMo: 50, + }, }, - - /* Configure projects for major browsers */ + expect: { + timeout: 5000, + }, + reporter: [['html', { open: 'never' }]], projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + //{ + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + //}, - { - name: 'Google Chrome', - use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - }, + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + //{ + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + //}, + //{ + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + //}, ], + /* Run your local dev server before starting the tests */ + //webServer: { + // command: 'npx nx start', + // url: 'http://localhost:3000', // somehow `localhost` is not mapped to 127.0.0.1 + // reuseExistingServer: !process.env.CI, + //}, }); diff --git a/packages/twenty-e2e-testing/tests/companies.spec.ts b/packages/twenty-e2e-testing/tests/companies.spec.ts new file mode 100644 index 000000000000..60fcc4c74da8 --- /dev/null +++ b/packages/twenty-e2e-testing/tests/companies.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { config } from 'dotenv'; +import path = require('path'); +config({ path: path.resolve(__dirname, '..', '.env') }); + +const date = new Date(); + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Continue With Email' }).click(); + await page.getByPlaceholder('Email').fill(process.env.DEFAULT_LOGIN); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByPlaceholder('Password').fill(process.env.DEFAULT_PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); +}); + +test.afterEach(async ({ page, browserName }, workerInfo) => { + await page.screenshot({ + path: + `./packages/twenty-e2e-testing/results/screenshots/${browserName}/` + + workerInfo.project.name + + `${date.toISOString()}.png`, + }); +}); + +test.describe('Basic check', () => { + test('Checking if table in Companies is visible', async ({ page }) => { + await page.getByRole('link', { name: 'Companies' }).click(); + expect(page.url()).toContain('/companies'); + await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('tbody > tr')).toHaveCount(13); + }); +}); diff --git a/packages/twenty-e2e-testing/tests/workspaces.spec.ts b/packages/twenty-e2e-testing/tests/workspaces.spec.ts new file mode 100644 index 000000000000..ca2eefd4eefa --- /dev/null +++ b/packages/twenty-e2e-testing/tests/workspaces.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import { sh } from '../drivers/shell_driver'; + +const date = new Date(); + +test.afterEach(async ({ page, browserName }) => { + await page.screenshot({ + path: `./packages/twenty-e2e-testing/results/screenshots/${browserName}/${date.toISOString()}.png`, + }); +}); + +test.describe('', () => { + test('Testing logging', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Continue With Email' }).click(); + await page.getByPlaceholder('Email').fill('tim@apple.dev'); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByPlaceholder('Password').fill('Applecar2025'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.getByRole('link', { name: 'Opportunities' }).click(); + await expect(page.locator('tbody > tr')).toHaveCount(4); + expect(page.url()).not.toContain('/welcome'); + }); + + 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 == 'firefox') { + 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 }); + } + }); + + test('Syncing all workspaces', async () => { + await sh('npx nx run twenty-server:command workspace:sync-metadata -f'); + await sh('npx nx run twenty-server:command workspace:sync-metadata -f'); + }); + + test('Resetting database', async ({ page, browserName }) => { + if (browserName === 'firefox') { + await sh('yarn nx database:reset twenty-server'); // if this command fails for any reason, database must be restarted manually using the same command because database is in unstable state + await page.goto('/'); + await page.getByRole('button', { name: 'Continue With Email' }).click(); + await page.getByPlaceholder('Email').fill('tim@apple.dev'); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByPlaceholder('Password').fill('Applecar2025'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.getByRole('link', { name: 'Companies' }).click(); + expect(page.url()).toContain('/companies'); + await expect(page.locator('table')).toBeVisible(); + } + }); + + test('Seeding database', async ({ page, browserName }) => { + if (browserName === 'firefox') { + await sh('npx nx workspace:seed:demo'); + await page.goto('/'); + } + }); +}); diff --git a/packages/twenty-front/.storybook/main.ts b/packages/twenty-front/.storybook/main.ts index 633df49b3dae..3bd4d16b2a57 100644 --- a/packages/twenty-front/.storybook/main.ts +++ b/packages/twenty-front/.storybook/main.ts @@ -45,5 +45,16 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, + viteFinal: async (config) => { + // Merge custom configuration into the default config + const { mergeConfig } = await import('vite'); + + return mergeConfig(config, { + // Add dependencies to pre-optimization + optimizeDeps: { + exclude: ['@tabler/icons-react'], + }, + }); + }, }; export default config; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f3c3f0e781f2..f5e61e07afd5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -344,9 +344,9 @@ export type FieldConnection = { /** Type of the field */ export enum FieldMetadataType { + Actor = 'ACTOR', Address = 'ADDRESS', Boolean = 'BOOLEAN', - Actor = 'ACTOR', Currency = 'CURRENCY', Date = 'DATE', DateTime = 'DATE_TIME', @@ -452,13 +452,13 @@ export type Mutation = { generateTransientToken: TransientToken; impersonate: Verify; renewToken: AuthTokens; + runWorkflowVersion: WorkflowTriggerResult; sendInviteLink: SendInviteLink; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; syncRemoteTable: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; - triggerWorkflow: WorkflowTriggerResult; unsyncRemoteTable: RemoteTable; updateBillingSubscription: UpdateBillingEntity; updateOneField: Field; @@ -610,6 +610,11 @@ export type MutationRenewTokenArgs = { }; +export type MutationRunWorkflowVersionArgs = { + input: RunWorkflowVersionInput; +}; + + export type MutationSendInviteLinkArgs = { emails: Array; }; @@ -639,11 +644,6 @@ export type MutationTrackArgs = { }; -export type MutationTriggerWorkflowArgs = { - workflowVersionId: Scalars['String']['input']; -}; - - export type MutationUnsyncRemoteTableArgs = { input: RemoteTableInput; }; @@ -1001,6 +1001,13 @@ export enum RemoteTableStatus { Synced = 'SYNCED' } +export type RunWorkflowVersionInput = { + /** Execution result in JSON format */ + payload?: InputMaybe; + /** Workflow version ID */ + workflowVersionId: Scalars['String']['input']; +}; + export type SendInviteLink = { __typename?: 'SendInviteLink'; /** Boolean that confirms query was dispatched */ @@ -1400,6 +1407,7 @@ export type WorkspaceFeatureFlagsArgs = { export enum WorkspaceActivationStatus { Active = 'ACTIVE', Inactive = 'INACTIVE', + OngoingCreation = 'ONGOING_CREATION', PendingCreation = 'PENDING_CREATION' } diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index ce20dd81f9d7..cb973a45cf52 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -249,9 +249,9 @@ export type FieldConnection = { /** Type of the field */ export enum FieldMetadataType { + Actor = 'ACTOR', Address = 'ADDRESS', Boolean = 'BOOLEAN', - Actor = 'ACTOR', Currency = 'CURRENCY', Date = 'DATE', DateTime = 'DATE_TIME', @@ -344,11 +344,11 @@ export type Mutation = { generateTransientToken: TransientToken; impersonate: Verify; renewToken: AuthTokens; + runWorkflowVersion: WorkflowTriggerResult; sendInviteLink: SendInviteLink; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; - triggerWorkflow: WorkflowTriggerResult; updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; updateOneServerlessFunction: ServerlessFunction; @@ -457,6 +457,11 @@ export type MutationRenewTokenArgs = { }; +export type MutationRunWorkflowVersionArgs = { + input: RunWorkflowVersionInput; +}; + + export type MutationSendInviteLinkArgs = { emails: Array; }; @@ -476,11 +481,6 @@ export type MutationTrackArgs = { }; -export type MutationTriggerWorkflowArgs = { - workflowVersionId: Scalars['String']; -}; - - export type MutationUpdateOneObjectArgs = { input: UpdateOneObjectInput; }; @@ -743,6 +743,13 @@ export enum RemoteTableStatus { Synced = 'SYNCED' } +export type RunWorkflowVersionInput = { + /** Execution result in JSON format */ + payload?: InputMaybe; + /** Workflow version ID */ + workflowVersionId: Scalars['String']; +}; + export type SendInviteLink = { __typename?: 'SendInviteLink'; /** Boolean that confirms query was dispatched */ @@ -1087,6 +1094,7 @@ export type WorkspaceFeatureFlagsArgs = { export enum WorkspaceActivationStatus { Active = 'ACTIVE', Inactive = 'INACTIVE', + OngoingCreation = 'ONGOING_CREATION', PendingCreation = 'PENDING_CREATION' } diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx index d1b35d7f2293..ecb7bc90d51f 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx @@ -1,4 +1,4 @@ -import { IconCirclePlus, IconEditCircle, useIcons } from 'twenty-ui'; +import { IconCirclePlus, IconEditCircle, IconTrash, useIcons } from 'twenty-ui'; import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; @@ -19,6 +19,9 @@ export const EventIconDynamicComponent = ({ if (eventAction === 'updated') { return ; } + if (eventAction === 'deleted') { + return ; + } const IconComponent = getIcon(linkedObjectMetadataItem?.icon); diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx index f2dcc69055fa..053e9217bb66 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx @@ -45,6 +45,17 @@ export const EventRowMainObject = ({ /> ); } + case 'deleted': { + return ( + + + {labelIdentifierValue} + + was deleted by + {authorFullName} + + ); + } default: return null; } diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx index 2581dd48934c..39d2437bf6f7 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx @@ -1,6 +1,6 @@ import { Button } from '@/ui/input/button/components/Button'; import styled from '@emotion/styled'; -import { Banner, IconComponent } from 'twenty-ui'; +import { Banner, BannerVariant, IconComponent } from 'twenty-ui'; const StyledBanner = styled(Banner)` position: absolute; @@ -14,26 +14,30 @@ const StyledText = styled.div` export const InformationBanner = ({ message, + variant = 'default', buttonTitle, buttonIcon, buttonOnClick, }: { message: string; - buttonTitle: string; + variant?: BannerVariant; + buttonTitle?: string; buttonIcon?: IconComponent; - buttonOnClick: () => void; + buttonOnClick?: () => void; }) => { return ( - + {message} -