diff --git a/.github/workflows/ci-e2e.yaml b/.github/workflows/ci-e2e.yaml index 464c8a2e5..4b5bf5603 100644 --- a/.github/workflows/ci-e2e.yaml +++ b/.github/workflows/ci-e2e.yaml @@ -69,11 +69,12 @@ jobs: - name: Build twenty-shared run: npx nx build twenty-shared + - name: Install Playwright Browsers + run: npx nx setup twenty-e2e-testing + - name: Setup environment files 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 @@ -87,7 +88,7 @@ jobs: 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 run: | npx nx start twenty-server & @@ -105,9 +106,6 @@ jobs: npx nx run twenty-server:worker:ci & echo "Worker started" - - name: Install Playwright Browsers - run: npx nx setup twenty-e2e-testing - - name: Run Playwright tests run: npx nx test twenty-e2e-testing diff --git a/packages/twenty-e2e-testing/.env.example b/packages/twenty-e2e-testing/.env.example index c00e8e0d4..9f0b80f99 100644 --- a/packages/twenty-e2e-testing/.env.example +++ b/packages/twenty-e2e-testing/.env.example @@ -1,5 +1,6 @@ # 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 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 62a602ef8..000000000 --- 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 768b1872c..000000000 --- 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 cf293c032..000000000 --- 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 000000000..c6cdf62cf --- /dev/null +++ b/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts @@ -0,0 +1,69 @@ +import { test as base, expect, 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'; + +export class WorkflowVisualizerPage { + #page: Page; + + workflowId: string; + workflowName: string; + + constructor({ page, workflowName }: { page: Page; workflowName: string }) { + this.#page = page; + this.workflowName = workflowName; + } + + 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 goToWorkflowVisualizerPage() { + await this.#page.goto(`/object/workflow/${this.workflowId}`); + + const workflowName = this.#page.getByRole('button', { + name: this.workflowName, + }); + + await expect(workflowName).toBeVisible(); + } +} + +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 15aa1ca22..6f69a683f 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 000000000..73d57b1a5 --- /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 000000000..33aa22783 --- /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 000000000..299768d67 --- /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 000000000..2191a3e3d --- /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/utils/getAuthToken.ts b/packages/twenty-e2e-testing/lib/utils/getAuthToken.ts new file mode 100644 index 000000000..38534fdf2 --- /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/playwright.config.ts b/packages/twenty-e2e-testing/playwright.config.ts index 448c9f8d8..6fea9a702 100644 --- a/packages/twenty-e2e-testing/playwright.config.ts +++ b/packages/twenty-e2e-testing/playwright.config.ts @@ -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,24 +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: 'setup', + testMatch: /.*\.setup\.ts/, }, { - name: 'Demo check', + name: 'chrome', use: { ...devices['Desktop Chrome'], - }, - testMatch: /demo\/demo_basic\.e2e-spec\.ts/, - }, - { - name: 'Authentication', - use: { permissions: ['clipboard-read', 'clipboard-write'], storageState: path.resolve(__dirname, '.auth', 'user.json'), // takes saved cookies from directory }, - dependencies: ['Login setup'], - testMatch: /authentication\/.+\.e2e-spec\.ts/, // forces to run login setup before running tests from this project - CASE SENSITIVE + dependencies: ['setup'], }, //{ diff --git a/packages/twenty-e2e-testing/project.json b/packages/twenty-e2e-testing/project.json index 9dd38917f..e820fd172 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/authentication/signup_invite_email.e2e-spec.ts b/packages/twenty-e2e-testing/tests/authentication/signup_invite_email.e2e-spec.ts deleted file mode 100644 index fc7f3537d..000000000 --- a/packages/twenty-e2e-testing/tests/authentication/signup_invite_email.e2e-spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, test } from './fixture'; - -test.describe('Authentication test', () => { - const email = 'test@apple.dev'; - const firstName = 'John'; - const lastName = 'Doe'; - let testCompleted = false; - - test('Sign up with invite link via email', async ({ - page, - loginPage, - leftMenu, - membersSection, - settingsPage, - }) => { - 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 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(); - testCompleted = true; - }); - }); - - test.afterEach( - async ({ - page, - confirmationModal, - leftMenu, - profileSection, - settingsPage, - }) => { - if (testCompleted) { - // security measurement to clean up only after test is completed, - // otherwise default account used for tests may be deleted and resetting database will be necessary - await test.step('Cleanup - deleting account', async () => { - await leftMenu.goToSettings(); - await settingsPage.goToProfileSection(); - await profileSection.deleteAccount(); - await confirmationModal.typePlaceholderToInput(); - await confirmationModal.clickConfirmButton(); - expect(page.url()).toContain('/welcome'); - }); - } - }, - ); -}); 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 000000000..f36ba1094 --- /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 3d68ccbcb..bb730f9b9 100644 --- a/packages/twenty-e2e-testing/tests/login.setup.ts +++ b/packages/twenty-e2e-testing/tests/login.setup.ts @@ -1,6 +1,6 @@ -import { expect, test as base } from '@playwright/test'; -import { LoginPage } from '../lib/pom/loginPage'; +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 }>({ @@ -29,7 +29,7 @@ test('Login test', async ({ loginPage, page }) => { await loginPage.typePassword(process.env.DEFAULT_PASSWORD); await page.waitForLoadState('networkidle'); await loginPage.clickSignInButton(); - await expect(page.getByText('Welcome to Twenty')).not.toBeVisible(); + await expect(page.getByText(/Welcome to .+/)).not.toBeVisible(); }, ); 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 000000000..026dd5b7b --- /dev/null +++ b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts @@ -0,0 +1,67 @@ +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 nameInputClosedState = page.getByText('Name').first(); + await nameInputClosedState.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 newWorkflowRowEntryName = page + .getByTestId(`row-id-${newWorkflowId}`) + .locator('div') + .filter({ hasText: NEW_WORKFLOW_NAME }) + .nth(2); + + await Promise.all([ + page.waitForURL( + (url) => url.pathname === `/object/workflow/${newWorkflowId}`, + ), + + newWorkflowRowEntryName.click(), + ]); + + const workflowName = page.getByRole('button', { name: NEW_WORKFLOW_NAME }); + + await expect(workflowName).toBeVisible(); + } 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 000000000..8c9b9f62e --- /dev/null +++ b/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { test } from '../lib/fixtures/blank-workflow'; + +test('Create simple workflow', async ({ workflowVisualizer, page }) => { + const addTriggerButton = page.getByText('Add a Trigger'); + await addTriggerButton.click(); + + const triggerOption = page.getByText('Database Event'); + await triggerOption.click(); + + await expect( + page.getByTestId('command-menu').getByRole('textbox'), + ).toHaveValue('When a Company is Created'); + + const triggerNode = page.getByTestId('rf__node-trigger'); + await expect(triggerNode).toHaveClass(/selected/); + await expect(triggerNode).toHaveText(/Company is Created/); + + const addStepButton = page.getByLabel('Add a step'); + await addStepButton.click(); + + const createRecordOption = page.getByText('Create Record'); + + await createRecordOption.click(); + + await expect( + page.getByTestId('command-menu').getByRole('textbox').first(), + ).toHaveValue('Create Record'); + + const createRecordNode = page + .locator('.react-flow__node.selected') + .getByText('Create Record'); + await expect(createRecordNode).toBeVisible(); + await expect(triggerNode).not.toHaveClass(/selected/); +}); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx index ddffd3fe8..b3124e701 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx @@ -91,6 +91,7 @@ export const CommandMenuContainer = ({ {isCommandMenuOpened && ( { - + ); };