snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +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=tim@apple.dev
WEBSITE_URL=https://twenty.com

10
packages/twenty-e2e-testing/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
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/
.auth/

View File

@@ -0,0 +1,48 @@
# Twenty end-to-end (E2E) Testing
## Prerequisite
Installing the browsers:
```
npx nx setup twenty-e2e-testing
```
### Run end-to-end tests
```
npx nx test twenty-e2e-testing
```
### Start the interactive UI mode
```
npx nx test:ui twenty-e2e-testing
```
### Run test in specific file
```
npx nx test twenty-e2e-testing <filename>
```
Example (location of the test must be specified from the root of `twenty-e2e-testing` package):
```
npx nx test twenty-e2e-testing tests/login.spec.ts
```
### Runs the tests in debug mode.
```
npx nx test:debug twenty-e2e-testing
```
### Show report after tests
```
npx nx test:report twenty-e2e-testing
```
## Q&A
#### Why there's `path.resolve()` everywhere?
That's thanks to differences in root directory when running tests using commands and using IDE. When running tests with commands,
the root directory is `twenty/packages/twenty-e2e-testing`, for IDE it depends on how someone sets the configuration. This way, it
ensures that no matter which IDE or OS Shell is used, the result will be the same.

View File

@@ -0,0 +1,321 @@
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 stepHeaderInCommandMenu: Locator;
readonly workflowNameLabel: Locator;
readonly triggerNode: Locator;
readonly background: Locator;
readonly useAsDraftButton: Locator;
readonly overrideDraftButton: Locator;
readonly discardDraftButton: Locator;
readonly seeRunsButton: Locator;
readonly goBackInCommandMenu: Locator;
#actionNames: Record<WorkflowActionType, string> = {
'create-record': 'Create Record',
'update-record': 'Update Record',
'delete-record': 'Delete Record',
code: 'Code',
'send-email': 'Send Email',
form: 'Form',
};
#createdActionNames: Record<WorkflowActionType, string> = {
'create-record': 'Create Record',
'update-record': 'Update Record',
'delete-record': 'Delete Record',
code: 'Code - Serverless Function',
'send-email': 'Send Email',
form: 'Form',
};
#triggerNames: Record<WorkflowTriggerType, string> = {
'record-created': 'Record is created',
'record-updated': 'Record is updated',
'record-deleted': 'Record is deleted',
manual: 'Launch manually',
};
#createdTriggerNames: Record<WorkflowTriggerType, string> = {
'record-created': 'Record is created',
'record-updated': 'Record is updated',
'record-deleted': 'Record is deleted',
manual: 'Launch manually',
};
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.stepHeaderInCommandMenu = this.commandMenu.getByTestId(
'workflow-step-header',
);
this.workflowNameLabel = page
.getByTestId('top-bar-title')
.getByText(this.workflowName);
this.triggerNode = this.#page.getByTestId('rf__node-trigger');
this.background = page.locator('.react-flow__pane');
this.useAsDraftButton = page.getByRole('button', { name: 'Use as draft' });
this.overrideDraftButton = page.getByRole('button', {
name: 'Override Draft',
});
this.discardDraftButton = page.getByRole('button', {
name: 'Discard Draft',
});
this.seeRunsButton = page.getByRole('link', { name: 'See runs' });
this.goBackInCommandMenu = this.commandMenu
.getByRole('button')
.and(this.commandMenu.getByTestId('command-menu-go-back-button'));
}
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.workflowNameLabel).toBeVisible();
}
async goToWorkflowVisualizerPage() {
await this.#page.goto(`/`);
const workflowsLink = this.#page.getByRole('link', { name: 'Workflows' });
await workflowsLink.click();
const workflowLink = this.#page
.getByTestId(`row-id-${this.workflowId}`)
.getByRole('link', { name: this.workflowName });
await workflowLink.click({ force: true });
await 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);
await actionToCreateOption.click();
const createWorkflowStepResponse = await this.#page.waitForResponse(
(response) => {
if (!response.url().endsWith('/graphql')) {
return false;
}
const requestBody = response.request().postDataJSON();
return requestBody.operationName === 'CreateWorkflowVersionStep';
},
);
const createWorkflowStepResponseBody =
await createWorkflowStepResponse.json();
const createdStepId =
createWorkflowStepResponseBody.data.createWorkflowVersionStep.id;
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(),
]);
}
async closeSidePanel() {
const closeButton = this.#page.getByRole('button', {
name: 'Close command menu',
});
await closeButton.click();
await expect(this.#page.getByTestId('command-menu')).not.toBeVisible();
}
async goToWorkflowsIndexPage() {
await this.#page.goto('/objects/workflows');
}
async setWorkflowsOpenInMode(mode: 'side-panel' | 'record-page') {
const recordTableOptionsButton = this.#page.getByText('Options');
await recordTableOptionsButton.click();
const layoutButton = this.#page.getByText('Layout');
await layoutButton.click();
const openInButton = this.#page.getByText('Open in');
await openInButton.click();
if (mode === 'side-panel') {
const openInSidePanelOption = this.#page.getByRole('option', {
name: 'Side Panel',
});
await openInSidePanelOption.click();
} else {
const openInRecordPageOption = this.#page.getByRole('option', {
name: 'Record Page',
});
await openInRecordPageOption.click();
}
// Close the dropdown
await recordTableOptionsButton.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,
});
await workflowVisualizer.goToWorkflowsIndexPage();
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
},
});

View File

@@ -0,0 +1,33 @@
import { test as base } from '@playwright/test';
import path from 'path';
const date = new Date();
export const test = base.extend<{ screenshotHook: void }>({
screenshotHook: [
async ({ page, browserName }, use, workerInfo) => {
// here everything is the same as beforeEach()
// goto is to go to website as login setup saves the cookies of logged-in user, not the state of browser
await page.goto('/');
await use(); // here is the code of test
// here everything is the same as afterEach()
// automatic fixture of making screenshot after each test
await page.screenshot({
path: path.resolve(
__dirname,
'..',
'..',
'results',
'screenshots',
`${workerInfo.project.name}`,
browserName,
`${date.toISOString()}.png`,
),
});
},
{ auto: true },
],
baseURL: process.env.LINK ? new URL(process.env.LINK).origin : 'http://localhost:3001',
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,36 @@
import { Locator, Page } from '@playwright/test';
export class ConfirmationModal {
private readonly input: Locator;
private readonly cancelButton: Locator;
private readonly confirmButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.input = page.getByTestId('confirmation-modal-input');
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.confirmButton = page.getByTestId('confirmation-modal-confirm-button');
}
async typePlaceholderToInput() {
await this.page
.getByTestId('confirmation-modal-input')
.fill(
await this.page
.getByTestId('confirmation-modal-input')
.getAttribute('placeholder'),
);
}
async typePhraseToInput(value: string) {
await this.page.getByTestId('confirmation-modal-input').fill(value);
}
async clickCancelButton() {
await this.page.getByRole('button', { name: 'Cancel' }).click();
}
async clickConfirmButton() {
await this.page.getByTestId('confirmation-modal-confirm-button').click();
}
}

View File

@@ -0,0 +1,28 @@
const nth = (d: number) => {
if (d > 3 && d < 21) return 'th';
switch (d % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
};
// label looks like this: Choose Wednesday, October 30th, 2024
// oxlint-disable-next-line prefer-arrow/prefer-arrow-functions
export function formatDate(value: string): string {
const date = new Date(value);
return 'Choose '.concat(
date.toLocaleDateString('en-US', { weekday: 'long' }),
', ',
date.toLocaleDateString('en-US', { month: 'long' }),
' ',
nth(date.getDate()),
', ',
date.toLocaleDateString('en-US', { year: 'numeric' }),
);
}

View File

@@ -0,0 +1,6 @@
import { Locator, Page } from '@playwright/test';
export class GoogleLogin {
// TODO: map all things like inputs and buttons
// (what's the correct way for proceeding with Google interaction? log in each time test is performed?)
}

View File

@@ -0,0 +1,23 @@
import { Locator, Page } from '@playwright/test';
export class IconSelect {
private readonly iconSelectButton: Locator;
private readonly iconSearchInput: Locator;
constructor(public readonly page: Page) {
this.iconSelectButton = page.getByLabel('Click to select icon (');
this.iconSearchInput = page.getByPlaceholder('Search icon');
}
async selectIcon(name: string) {
await this.iconSelectButton.click();
await this.iconSearchInput.fill(name);
await this.page.getByTitle(name).click();
}
async selectRelationIcon(name: string) {
await this.iconSelectButton.nth(1).click();
await this.iconSearchInput.fill(name);
await this.page.getByTitle(name).click();
}
}

View File

@@ -0,0 +1,267 @@
import { Locator, Page } from '@playwright/test';
import { formatDate } from './formatDate.function';
export class InsertFieldData {
private readonly address1Input: Locator;
private readonly address2Input: Locator;
private readonly cityInput: Locator;
private readonly stateInput: Locator;
private readonly postCodeInput: Locator;
private readonly countrySelect: Locator;
private readonly arrayValueInput: Locator;
private readonly arrayAddValueButton: Locator;
// boolean react after click so no need to write special locator
private readonly currencySelect: Locator;
private readonly currencyAmountInput: Locator;
private readonly monthSelect: Locator;
private readonly yearSelect: Locator;
private readonly previousMonthButton: Locator;
private readonly nextMonthButton: Locator;
private readonly clearDateButton: Locator;
private readonly dateInput: Locator;
private readonly firstNameInput: Locator;
private readonly lastNameInput: Locator;
private readonly addUrlButton: Locator;
private readonly setAsPrimaryButton: Locator;
private readonly addPhoneButton: Locator;
private readonly addMailButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.address1Input = page.locator(
'//label[contains(., "ADDRESS 1")]/../div[last()]/input',
);
this.address2Input = page.locator(
'//label[contains(., "ADDRESS 2")]/../div[last()]/input',
);
this.cityInput = page.locator(
'//label[contains(., "CITY")]/../div[last()]/input',
);
this.stateInput = page.locator(
'//label[contains(., "STATE")]/../div[last()]/input',
);
this.postCodeInput = page.locator(
'//label[contains(., "POST CODE")]/../div[last()]/input',
);
this.countrySelect = page.locator(
'//span[contains(., "COUNTRY")]/../div[last()]/div',
);
this.arrayValueInput = page.locator("//input[@placeholder='Enter value']");
this.arrayAddValueButton = page.locator(
"//div[@data-testid='tooltip' and contains(.,'Add item')]",
);
this.currencySelect = page.locator(
'//body/div[last()]/div/div/div[first()]/div/div',
);
this.currencyAmountInput = page.locator("//input[@placeholder='Currency']");
this.monthSelect; // TODO: add once some other attributes are added
this.yearSelect;
this.previousMonthButton;
this.nextMonthButton;
this.clearDateButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Clear')]",
);
this.dateInput = page.locator("//input[@placeholder='Type date and time']");
this.firstNameInput = page.locator("//input[@placeholder='First name']"); // may fail if placeholder is `F&zwnj;&zwnj;irst name` instead of `First name`
this.lastNameInput = page.locator("//input[@placeholder='Last name']"); // may fail if placeholder is `L&zwnj;&zwnj;ast name` instead of `Last name`
this.addUrlButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add URL')]",
);
this.setAsPrimaryButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Set as primary')]",
);
this.addPhoneButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add Phone')]",
);
this.addMailButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add Email')]",
);
}
// address
async typeAddress1(value: string) {
await this.address1Input.fill(value);
}
async typeAddress2(value: string) {
await this.address2Input.fill(value);
}
async typeCity(value: string) {
await this.cityInput.fill(value);
}
async typeState(value: string) {
await this.stateInput.fill(value);
}
async typePostCode(value: string) {
await this.postCodeInput.fill(value);
}
async selectCountry(value: string) {
await this.countrySelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
// array
async typeArrayValue(value: string) {
await this.arrayValueInput.fill(value);
}
async clickAddItemButton() {
await this.arrayAddValueButton.click();
}
// currency
async selectCurrency(value: string) {
await this.currencySelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async typeCurrencyAmount(value: string) {
await this.currencyAmountInput.fill(value);
}
// date(-time)
async typeDate(value: string) {
await this.dateInput.fill(value);
}
async selectMonth(value: string) {
await this.monthSelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async selectYear(value: string) {
await this.yearSelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async clickPreviousMonthButton() {
await this.previousMonthButton.click();
}
async clickNextMonthButton() {
await this.nextMonthButton.click();
}
async selectDay(value: string) {
await this.page
.locator(`//div[@aria-label='${formatDate(value)}']`)
.click();
}
async clearDate() {
await this.clearDateButton.click();
}
// email
async typeEmail(value: string) {
await this.page.locator(`//input[@placeholder='Email']`).fill(value);
}
async clickAddMailButton() {
await this.addMailButton.click();
}
// full name
async typeFirstName(name: string) {
await this.firstNameInput.fill(name);
}
async typeLastName(name: string) {
await this.lastNameInput.fill(name);
}
// JSON
// placeholder is dependent on the name of field
async typeJSON(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
// link
async typeLink(value: string) {
await this.page.locator("//input[@placeholder='URL']").fill(value);
}
async clickAddURL() {
await this.addUrlButton.click();
}
// (multi-)select
async selectValue(value: string) {
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
// number
// placeholder is dependent on the name of field
async typeNumber(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
// phones
async selectCountryPhoneCode(countryCode: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${countryCode}')]`,
)
.click();
}
async typePhoneNumber(value: string) {
await this.page.locator(`//input[@placeholder='Phone']`).fill(value);
}
async clickAddPhoneButton() {
await this.addPhoneButton.click();
}
// rating
// if adding rating for the first time, hover must be used
async selectRating(rating: number) {
await this.page.locator(`//div[@role='slider']/div[${rating}]`).click();
}
// text
// placeholder is dependent on the name of field
async typeText(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
async clickSetAsPrimaryButton() {
await this.setAsPrimaryButton.click();
}
async searchValue(value: string) {
await this.page.locator(`//div[@placeholder='Search']`).fill(value);
}
async clickEditButton() {
await this.page
.locator("//div[@data-testid='tooltip' and contains(., 'Edit')]")
.click();
}
async clickDeleteButton() {
await this.page
.locator("//div[@data-testid='tooltip' and contains(., 'Delete')]")
.click();
}
}

View File

@@ -0,0 +1,55 @@
import { Locator, Page } from '@playwright/test';
export class StripePage {
private readonly cardNumberInput: Locator;
private readonly cardExpiryInput: Locator;
private readonly cardCvcInput: Locator;
private readonly cardholderNameInput: Locator;
private readonly startTrialButton: Locator;
private readonly cancelSubscriptionButton: Locator;
private readonly confirmButton: Locator;
private readonly returnToTwentyLink: Locator;
constructor(public readonly page: Page) {
this.cardNumberInput = page.getByPlaceholder('1234 1234 1234 1234');
this.cardExpiryInput = page.getByPlaceholder('MM / YY');
this.cardCvcInput = page.getByPlaceholder('CVC');
this.cardholderNameInput = page.getByPlaceholder('Full name on card');
this.startTrialButton = page.getByTestId('hosted-payment-submit-button');
this.cancelSubscriptionButton = page.locator(
'a[data-test="cancel-subscription"]',
);
this.confirmButton = page.getByTestId('confirm');
this.returnToTwentyLink = page.getByTestId('return-to-business-link');
}
async fillCardNumber(number: string) {
await this.cardNumberInput.fill(number);
}
async fillCardExpiry(expiry: string) {
await this.cardExpiryInput.fill(expiry);
}
async fillCardCvc(cvc: string) {
await this.cardCvcInput.fill(cvc);
}
async fillCardholderName(name: string) {
await this.cardholderNameInput.fill(name);
}
async startTrial() {
await this.startTrialButton.click();
}
async clickCancelSubscription() {
await this.cancelSubscriptionButton.click();
await this.confirmButton.click();
await this.page.getByTestId('cancellation_reason_cancel').click();
}
async returnToTwenty() {
await this.returnToTwentyLink.click();
}
}

View File

@@ -0,0 +1,25 @@
import { Locator, Page } from '@playwright/test';
export class UploadImage {
private readonly imagePreview: Locator;
private readonly uploadButton: Locator;
private readonly removeButton: Locator;
constructor(public readonly page: Page) {
this.imagePreview = page.locator('.css-6eut39'); //TODO: add attribute to make it independent of theme
this.uploadButton = page.getByRole('button', { name: 'Upload' });
this.removeButton = page.getByRole('button', { name: 'Remove' });
}
async clickImagePreview() {
await this.imagePreview.click();
}
async clickUploadButton() {
await this.uploadButton.click();
}
async clickRemoveButton() {
await this.removeButton.click();
}
}

View File

@@ -0,0 +1,118 @@
import { Locator, Page } from '@playwright/test';
export class LeftMenu {
private readonly workspaceDropdown: Locator;
private readonly leftMenu: Locator;
private readonly searchSubTab: Locator;
private readonly settingsTab: Locator;
private readonly peopleTab: Locator;
private readonly companiesTab: Locator;
private readonly opportunitiesTab: Locator;
private readonly opportunitiesTabAll: Locator;
private readonly opportunitiesTabByStage: Locator;
private readonly tasksTab: Locator;
private readonly tasksTabAll: Locator;
private readonly tasksTabByStatus: Locator;
private readonly notesTab: Locator;
private readonly rocketsTab: Locator;
private readonly workflowsTab: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.workspaceDropdown = page.getByTestId('workspace-dropdown');
this.leftMenu = page.getByRole('button').first();
this.searchSubTab = page.getByText('Search');
this.settingsTab = page.getByRole('button', {
name: 'Settings',
exact: true,
});
this.peopleTab = page.getByRole('link', { name: 'People' });
this.companiesTab = page.getByRole('link', { name: 'Companies' });
this.opportunitiesTab = page.getByRole('link', { name: 'Opportunities' });
this.opportunitiesTabAll = page.getByRole('link', {
name: 'All',
exact: true,
});
this.opportunitiesTabByStage = page.getByRole('link', { name: 'By Stage' });
this.tasksTab = page.getByRole('link', { name: 'Tasks' });
this.tasksTabAll = page.getByRole('link', { name: 'All tasks' });
this.tasksTabByStatus = page.getByRole('link', { name: 'Notes' });
this.notesTab = page.getByRole('link', { name: 'Notes' });
this.rocketsTab = page.getByRole('link', { name: 'Rockets' });
this.workflowsTab = page.getByRole('link', { name: 'Workflows' });
}
async selectWorkspace(workspaceName: string) {
await this.workspaceDropdown.click();
await this.page
.getByTestId('tooltip')
.filter({ hasText: workspaceName })
.click();
}
async changeLeftMenu() {
await this.leftMenu.click();
}
async openSearchTab() {
await this.searchSubTab.click();
}
async goToSettings() {
await this.settingsTab.click();
}
async goToPeopleTab() {
await this.peopleTab.click();
}
async goToCompaniesTab() {
await this.companiesTab.click();
}
async goToOpportunitiesTab() {
await this.opportunitiesTab.click();
}
async goToOpportunitiesTableView() {
await this.opportunitiesTabAll.click();
}
async goToOpportunitiesKanbanView() {
await this.opportunitiesTabByStage.click();
}
async goToTasksTab() {
await this.tasksTab.click();
}
async goToTasksTableView() {
await this.tasksTabAll.click();
}
async goToTasksKanbanView() {
await this.tasksTabByStatus.click();
}
async goToNotesTab() {
await this.notesTab.click();
}
async goToRocketsTab() {
await this.rocketsTab.click();
}
async goToWorkflowsTab() {
await this.workflowsTab.click();
}
async goToCustomObject(customObjectName: string) {
await this.page.getByRole('link', { name: customObjectName }).click();
}
async goToCustomObjectView(name: string) {
await this.page.getByRole('link', { name: name }).click();
}
}
export default LeftMenu;

View File

@@ -0,0 +1,218 @@
import { type Locator, type Page } from '@playwright/test';
export class LoginPage {
private readonly loginWithGoogleButton: Locator;
private readonly loginWithEmailButton: Locator;
private readonly termsOfServiceLink: Locator;
private readonly privacyPolicyLink: Locator;
private readonly emailField: Locator;
private readonly continueButton: Locator;
private readonly forgotPasswordButton: Locator;
private readonly passwordField: Locator;
private readonly revealPasswordButton: Locator;
readonly signInButton: Locator;
private readonly signUpButton: Locator;
private readonly previewImageButton: Locator;
private readonly uploadImageButton: Locator;
private readonly deleteImageButton: Locator;
private readonly workspaceNameField: Locator;
private readonly subdomainField: Locator;
private readonly createWorkspaceButton: Locator;
private readonly skipOnboardingStepButton: Locator;
private readonly firstNameField: Locator;
private readonly lastNameField: Locator;
private readonly syncEverythingWithGoogleRadio: Locator;
private readonly syncSubjectAndMetadataWithGoogleRadio: Locator;
private readonly syncMetadataWithGoogleRadio: Locator;
private readonly syncWithGoogleButton: Locator;
private readonly noSyncButton: Locator;
private readonly inviteLinkField1: Locator;
private readonly inviteLinkField2: Locator;
private readonly inviteLinkField3: Locator;
private readonly copyInviteLink: Locator;
private readonly finishButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.loginWithGoogleButton = page.getByRole('button', {
name: 'Continue with Google',
});
this.loginWithEmailButton = page.getByRole('button', {
name: 'Continue with Email',
});
this.termsOfServiceLink = page.getByRole('link', {
name: 'Terms of Service',
});
this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' });
this.emailField = page.getByPlaceholder('Email');
this.continueButton = page.getByRole('button', {
name: 'Continue',
exact: true,
});
this.forgotPasswordButton = page.getByText('Forgot your password?');
this.passwordField = page.getByPlaceholder('Password');
this.revealPasswordButton = page.getByTestId('reveal-password-button');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
this.previewImageButton = page.locator('.css-1qzw107'); // TODO: fix
this.uploadImageButton = page.getByRole('button', { name: 'Upload' });
this.deleteImageButton = page.getByRole('button', { name: 'Remove' });
this.workspaceNameField = page.getByLabel('Name', { exact: true });
this.subdomainField = page.getByLabel('Subdomain', { exact: true });
this.createWorkspaceButton = page.getByRole('button', {
name: 'Create workspace',
});
this.skipOnboardingStepButton = page.getByRole('button', {
name: 'Skip',
exact: true,
});
this.firstNameField = page.getByLabel('First Name', { exact: true });
this.lastNameField = page.getByLabel('Last name', { exact: true });
this.syncEverythingWithGoogleRadio = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.syncSubjectAndMetadataWithGoogleRadio = page.locator(
'input[value="SUBJECT"]',
);
this.syncMetadataWithGoogleRadio = page.locator('input[value="METADATA"]');
this.syncWithGoogleButton = page.getByRole('button', {
name: 'Sync with Google',
});
this.noSyncButton = page.getByText('Continue without sync');
this.inviteLinkField1 = page.getByPlaceholder('tim@apple.dev');
this.inviteLinkField2 = page.getByPlaceholder('craig@apple.dev');
this.inviteLinkField3 = page.getByPlaceholder('mike@apple.dev');
this.copyInviteLink = page.getByRole('button', {
name: 'Copy invitation link',
});
this.finishButton = page.getByRole('button', { name: 'Finish' });
}
async loginWithGoogle() {
await this.loginWithGoogleButton.click();
}
async clickLoginWithEmail() {
await this.loginWithEmailButton.click();
}
async clickLoginWithEmailIfVisible() {
try {
await this.loginWithEmailButton.click({ timeout: 3000 });
} catch {
// Button not found - email field might already be visible (SSO-only or different auth flow)
}
}
async clickContinueButton() {
await this.continueButton.click();
}
async clickTermsLink() {
await this.termsOfServiceLink.click();
}
async clickPrivacyPolicyLink() {
await this.privacyPolicyLink.click();
}
async typeEmail(email: string) {
await this.emailField.fill(email, { timeout: 30000 });
}
async typePassword(email: string) {
await this.passwordField.fill(email);
}
async clickSignInButton() {
await this.signInButton.click();
}
async clickSignUpButton() {
await this.signUpButton.click();
}
async clickForgotPassword() {
await this.forgotPasswordButton.click();
}
async revealPassword() {
await this.revealPasswordButton.click();
}
async previewImage() {
await this.previewImageButton.click();
}
async clickUploadImage() {
await this.uploadImageButton.click();
}
async deleteImage() {
await this.deleteImageButton.click();
}
async typeWorkspaceName(workspaceName: string) {
await this.workspaceNameField.fill(workspaceName);
}
async typeSubdomain(subdomain: string) {
await this.subdomainField.fill(subdomain);
}
async clickCreateWorkspaceButton() {
await this.createWorkspaceButton.click();
}
async clickSkipOnboardingStep() {
await this.skipOnboardingStepButton.click();
}
async typeFirstName(firstName: string) {
await this.firstNameField.fill(firstName);
}
async typeLastName(lastName: string) {
await this.lastNameField.fill(lastName);
}
async clickSyncEverythingWithGoogleRadio() {
await this.syncEverythingWithGoogleRadio.click();
}
async clickSyncSubjectAndMetadataWithGoogleRadio() {
await this.syncSubjectAndMetadataWithGoogleRadio.click();
}
async clickSyncMetadataWithGoogleRadio() {
await this.syncMetadataWithGoogleRadio.click();
}
async clickSyncWithGoogleButton() {
await this.syncWithGoogleButton.click();
}
async noSyncWithGoogle() {
await this.noSyncButton.click();
}
async typeInviteLink1(email: string) {
await this.inviteLinkField1.fill(email);
}
async typeInviteLink2(email: string) {
await this.inviteLinkField2.fill(email);
}
async typeInviteLink3(email: string) {
await this.inviteLinkField3.fill(email);
}
async clickCopyInviteLink() {
await this.copyInviteLink.click();
}
async clickFinishButton() {
await this.finishButton.click();
}
}

View File

@@ -0,0 +1,187 @@
import { Locator, Page } from '@playwright/test';
export class MainPage {
// TODO: add missing elements (advanced filters, import/export popups)
private readonly tableViews: Locator;
private readonly addViewButton: Locator;
private readonly viewIconSelect: Locator;
private readonly viewNameInput: Locator;
private readonly viewTypeSelect: Locator;
private readonly createViewButton: Locator;
private readonly deleteViewButton: Locator;
private readonly filterButton: Locator;
private readonly searchFieldInput: Locator;
private readonly advancedFilterButton: Locator;
private readonly addFilterButton: Locator;
private readonly resetFilterButton: Locator;
private readonly saveFilterAsViewButton: Locator;
private readonly sortButton: Locator;
private readonly sortOrderButton: Locator;
private readonly optionsButton: Locator;
private readonly fieldsButton: Locator;
private readonly goBackButton: Locator;
private readonly hiddenFieldsButton: Locator;
private readonly editFieldsButton: Locator;
private readonly importButton: Locator;
private readonly deletedRecordsButton: Locator;
private readonly createNewRecordButton: Locator;
private readonly addToFavoritesButton: Locator;
private readonly deleteFromFavoritesButton: Locator;
private readonly exportBottomBarButton: Locator;
private readonly deleteRecordsButton: Locator;
constructor(public readonly page: Page) {
this.tableViews = page.getByText('·');
this.addViewButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Add view$/ });
this.viewIconSelect = page.getByLabel('Click to select icon (');
this.viewNameInput; // can be selected using only actual value
this.viewTypeSelect = page.locator(
"//span[contains(., 'View type')]/../div",
);
this.createViewButton = page.getByRole('button', { name: 'Create' });
this.deleteViewButton = page.getByRole('button', { name: 'Delete' });
this.filterButton = page.getByText('Filter');
this.searchFieldInput = page.getByPlaceholder('Search fields');
this.advancedFilterButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Advanced filter$/ });
this.addFilterButton = page.getByRole('button', { name: 'Add Filter' });
this.resetFilterButton = page.getByTestId('cancel-button');
this.saveFilterAsViewButton = page.getByRole('button', {
name: 'Save as new view',
});
this.sortButton = page.getByText('Sort');
this.sortOrderButton = page.locator('//li');
this.optionsButton = page.getByText('Options');
this.fieldsButton = page.getByText('Fields');
this.goBackButton = page.getByTestId('dropdown-menu-header-end-icon');
this.hiddenFieldsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Hidden Fields$/ });
this.editFieldsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Edit Fields$/ });
this.importButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Import$/ });
this.deletedRecordsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Deleted */ });
this.createNewRecordButton = page.getByTestId('add-button');
this.addToFavoritesButton = page.getByText('Add to favorites');
this.deleteFromFavoritesButton = page.getByText('Delete from favorites');
this.deleteRecordsButton = page.getByText('Delete');
}
async clickTableViews() {
await this.tableViews.click();
}
async clickAddViewButton() {
await this.addViewButton.click();
}
async typeViewName(name: string) {
await this.viewNameInput.clear();
await this.viewNameInput.fill(name);
}
// name can be either be 'Table' or 'Kanban'
async selectViewType(name: string) {
await this.viewTypeSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async createView() {
await this.createViewButton.click();
}
async deleteView() {
await this.deleteViewButton.click();
}
async clickFilterButton() {
await this.filterButton.click();
}
async searchFields(name: string) {
await this.searchFieldInput.clear();
await this.searchFieldInput.fill(name);
}
async clickAdvancedFilterButton() {
await this.advancedFilterButton.click();
}
async addFilter() {
await this.addFilterButton.click();
}
async resetFilter() {
await this.resetFilterButton.click();
}
async saveFilterAsView() {
await this.saveFilterAsViewButton.click();
}
async clickSortButton() {
await this.sortButton.click();
}
//can be Ascending or Descending
async setSortOrder(name: string) {
await this.sortOrderButton.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async clickOptionsButton() {
await this.optionsButton.click();
}
async clickFieldsButton() {
await this.fieldsButton.click();
}
async clickBackButton() {
await this.goBackButton.click();
}
async clickHiddenFieldsButton() {
await this.hiddenFieldsButton.click();
}
async clickEditFieldsButton() {
await this.editFieldsButton.click();
}
async clickImportButton() {
await this.importButton.click();
}
async clickDeletedRecordsButton() {
await this.deletedRecordsButton.click();
}
async clickCreateNewRecordButton() {
await this.createNewRecordButton.click();
}
async clickAddToFavoritesButton() {
await this.addToFavoritesButton.click();
}
async clickDeleteFromFavoritesButton() {
await this.deleteFromFavoritesButton.click();
}
async clickExportBottomBarButton() {
await this.exportBottomBarButton.click();
}
async clickDeleteRecordsButton() {
await this.deleteRecordsButton.click();
}
}

View File

@@ -0,0 +1,150 @@
import { Locator, Page } from '@playwright/test';
export class RecordDetails {
// TODO: add missing components in tasks, notes, files, emails, calendar tabs
private readonly closeRecordButton: Locator;
private readonly previousRecordButton: Locator;
private readonly nextRecordButton: Locator;
private readonly favoriteRecordButton: Locator;
private readonly addShowPageButton: Locator;
private readonly moreOptionsButton: Locator;
private readonly deleteButton: Locator;
private readonly uploadProfileImageButton: Locator;
private readonly timelineTab: Locator;
private readonly tasksTab: Locator;
private readonly notesTab: Locator;
private readonly filesTab: Locator;
private readonly emailsTab: Locator;
private readonly calendarTab: Locator;
private readonly detachRelationButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
}
async clickCloseRecordButton() {
await this.closeRecordButton.click();
}
async clickPreviousRecordButton() {
await this.previousRecordButton.click();
}
async clickNextRecordButton() {
await this.nextRecordButton.click();
}
async clickFavoriteRecordButton() {
await this.favoriteRecordButton.click();
}
async createRelatedNote() {
await this.addShowPageButton.click();
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Note")]')
.click();
}
async createRelatedTask() {
await this.addShowPageButton.click();
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Task")]')
.click();
}
async clickMoreOptionsButton() {
await this.moreOptionsButton.click();
}
async clickDeleteRecordButton() {
await this.deleteButton.click();
}
async clickUploadProfileImageButton() {
await this.uploadProfileImageButton.click();
}
async goToTimelineTab() {
await this.timelineTab.click();
}
async goToTasksTab() {
await this.tasksTab.click();
}
async goToNotesTab() {
await this.notesTab.click();
}
async goToFilesTab() {
await this.filesTab.click();
}
async goToEmailsTab() {
await this.emailsTab.click();
}
async goToCalendarTab() {
await this.calendarTab.click();
}
async clickField(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`,
)
.click();
}
async clickFieldWithButton(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`,
)
.hover();
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div[last()]/div/button`,
)
.click();
}
async clickRelationEditButton(name: string) {
await this.page.getByRole('heading').filter({ hasText: name }).hover();
await this.page
.locator(`//header[contains(., "${name}")]/div[last()]/div/button`)
.click();
}
async detachRelation(name: string) {
await this.page.locator(`//a[contains(., "${name}")]`).hover();
await this.page
.locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`)
.hover();
await this.detachRelationButton.click();
}
async deleteRelationRecord(name: string) {
await this.page.locator(`//a[contains(., "${name}")]`).hover();
await this.page
.locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`)
.hover();
await this.deleteButton.click();
}
async selectRelationRecord(name: string) {
await this.page
.locator(`//div[@data-testid="tooltip" and contains(., "${name}")]`)
.click();
}
async searchRelationRecord(name: string) {
await this.page.getByPlaceholder('Search').fill(name);
}
async createNewRelationRecord() {
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Add New")]')
.click();
}
}

View File

@@ -0,0 +1,62 @@
import { Locator, Page } from '@playwright/test';
export class AccountsSection {
private readonly addAccountButton: Locator;
private readonly deleteAccountButton: Locator;
private readonly addBlocklistField: Locator;
private readonly addBlocklistButton: Locator;
private readonly connectWithGoogleButton: Locator;
private readonly connectWithMicrosoftButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.addAccountButton = page.getByRole('button', { name: 'Add account' });
this.deleteAccountButton = page
.getByTestId('tooltip')
.getByText('Remove account');
this.addBlocklistField = page.getByPlaceholder(
'eddy@gmail.com, @apple.com',
);
this.addBlocklistButton = page.getByRole('button', {
name: 'Add to blocklist',
});
this.connectWithGoogleButton = page.getByRole('button', {
name: 'Connect with Google',
});
this.connectWithMicrosoftButton = page.getByRole('button', {
name: 'Connect with Microsoft',
});
}
async clickAddAccount() {
await this.addAccountButton.click();
}
async deleteAccount(email: string) {
await this.page
.locator(`//span[contains(., "${email}")]/../div/div/div/button`)
.click();
await this.deleteAccountButton.click();
}
async addToBlockList(domain: string) {
await this.addBlocklistField.fill(domain);
await this.addBlocklistButton.click();
}
async removeFromBlocklist(domain: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${domain}')]/../../div[last()]/button`,
)
.click();
}
async linkGoogleAccount() {
await this.connectWithGoogleButton.click();
}
async linkMicrosoftAccount() {
await this.connectWithMicrosoftButton.click();
}
}

View File

@@ -0,0 +1,64 @@
import { Locator, Page } from '@playwright/test';
export class APIsSection {
private readonly createApiKeyButton: Locator;
private readonly regenerateApiKeyButton: Locator;
private readonly nameOfApiKeyInput: Locator;
private readonly expirationDateApiKeySelect: Locator;
private readonly cancelButton: Locator;
private readonly saveButton: Locator;
private readonly deleteButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.createApiKeyButton = page.getByRole('link', {
name: 'Create API Key',
});
this.nameOfApiKeyInput = page
.getByPlaceholder('E.g. backoffice integration')
.first();
this.expirationDateApiKeySelect = page.locator(
'div[aria-controls="object-field-type-select-options"]',
);
this.regenerateApiKeyButton = page.getByRole('button', {
name: 'Regenerate Key',
});
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.saveButton = page.getByRole('button', { name: 'Save' });
this.deleteButton = page.getByRole('button', { name: 'Delete' });
}
async createApiKey() {
await this.createApiKeyButton.click();
}
async typeApiKeyName(name: string) {
await this.nameOfApiKeyInput.clear();
await this.nameOfApiKeyInput.fill(name);
}
async selectApiExpirationDate(date: string) {
await this.expirationDateApiKeySelect.click();
await this.page.getByText(date, { exact: true }).click();
}
async regenerateApiKey() {
await this.regenerateApiKeyButton.click();
}
async deleteApiKey() {
await this.deleteButton.click();
}
async clickCancelButton() {
await this.cancelButton.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
async checkApiKeyDetails(name: string) {
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
}
}

View File

@@ -0,0 +1,30 @@
import { Locator, Page } from '@playwright/test';
export class CalendarSection {
private readonly eventVisibilityEverythingOption: Locator;
private readonly eventVisibilityMetadataOption: Locator;
private readonly contactAutoCreation: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.eventVisibilityEverythingOption = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.eventVisibilityMetadataOption = page.locator(
'input[value="METADATA"]',
);
this.contactAutoCreation = page.getByRole('checkbox').nth(1);
}
async changeVisibilityToEverything() {
await this.eventVisibilityEverythingOption.click();
}
async changeVisibilityToMetadata() {
await this.eventVisibilityMetadataOption.click();
}
async toggleAutoCreation() {
await this.contactAutoCreation.click();
}
}

View File

@@ -0,0 +1,189 @@
import { Locator, Page } from '@playwright/test';
export class DataModelSection {
private readonly searchObjectInput: Locator;
private readonly addObjectButton: Locator;
private readonly objectSingularNameInput: Locator;
private readonly objectPluralNameInput: Locator;
private readonly objectDescription: Locator;
private readonly synchronizeLabelApiToggle: Locator;
private readonly objectApiSingularNameInput: Locator;
private readonly objectApiPluralNameInput: Locator;
private readonly objectMoreOptionsButton: Locator;
private readonly editObjectButton: Locator;
private readonly deleteObjectButton: Locator;
private readonly activeSection: Locator;
private readonly inactiveSection: Locator;
private readonly searchFieldInput: Locator;
private readonly addFieldButton: Locator;
private readonly viewFieldDetailsMoreOptionsButton: Locator;
private readonly nameFieldInput: Locator;
private readonly descriptionFieldInput: Locator;
private readonly deactivateMoreOptionsButton: Locator;
private readonly activateMoreOptionsButton: Locator;
private readonly deactivateButton: Locator; // TODO: add attribute to make it one button
private readonly activateButton: Locator;
private readonly cancelButton: Locator;
private readonly saveButton: Locator;
constructor(public readonly page: Page) {
this.searchObjectInput = page.getByPlaceholder('Search an object...');
this.addObjectButton = page.getByRole('button', { name: 'Add object' });
this.objectSingularNameInput = page.getByPlaceholder('Listing', {
exact: true,
});
this.objectPluralNameInput = page.getByPlaceholder('Listings', {
exact: true,
});
this.objectDescription = page.getByPlaceholder('Write a description');
this.synchronizeLabelApiToggle = page.getByRole('checkbox').nth(1);
this.objectApiSingularNameInput = page.getByPlaceholder('listing', {
exact: true,
});
this.objectApiPluralNameInput = page.getByPlaceholder('listings', {
exact: true,
});
this.objectMoreOptionsButton = page.getByLabel('Object Options');
this.editObjectButton = page.getByTestId('tooltip').getByText('Edit');
this.deactivateMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('Deactivate');
this.activateMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('Activate');
this.deleteObjectButton = page.getByTestId('tooltip').getByText('Delete');
this.activeSection = page.getByText('Active', { exact: true });
this.inactiveSection = page.getByText('Inactive');
this.searchFieldInput = page.getByPlaceholder('Search a field...');
this.addFieldButton = page.getByRole('button', { name: 'Add field' });
this.viewFieldDetailsMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('View');
this.nameFieldInput = page.getByPlaceholder('Employees');
this.descriptionFieldInput = page.getByPlaceholder('Write a description');
this.deactivateButton = page.getByRole('button', { name: 'Deactivate' });
this.activateButton = page.getByRole('button', { name: 'Activate' });
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.saveButton = page.getByRole('button', { name: 'Save' });
}
async searchObject(name: string) {
await this.searchObjectInput.fill(name);
}
async clickAddObjectButton() {
await this.addObjectButton.click();
}
async typeObjectSingularName(name: string) {
await this.objectSingularNameInput.fill(name);
}
async typeObjectPluralName(name: string) {
await this.objectPluralNameInput.fill(name);
}
async typeObjectDescription(name: string) {
await this.objectDescription.fill(name);
}
async toggleSynchronizeLabelApi() {
await this.synchronizeLabelApiToggle.click();
}
async typeObjectSingularApiName(name: string) {
await this.objectApiSingularNameInput.fill(name);
}
async typeObjectPluralApiName(name: string) {
await this.objectApiPluralNameInput.fill(name);
}
async checkObjectDetails(name: string) {
await this.page.getByRole('link').filter({ hasText: name }).click();
}
async activateInactiveObject(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.activateButton.click();
}
// object can be deleted only if is custom and inactive
async deleteInactiveObject(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.deleteObjectButton.click();
}
async editObjectDetails() {
await this.objectMoreOptionsButton.click();
await this.editObjectButton.click();
}
async deactivateObjectWithMoreOptions() {
await this.objectMoreOptionsButton.click();
await this.deactivateButton.click();
}
async searchField(name: string) {
await this.searchFieldInput.fill(name);
}
async checkFieldDetails(name: string) {
await this.page.locator(`//div[@title="${name}"]`).click();
}
async checkFieldDetailsWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.viewFieldDetailsMoreOptionsButton.click();
}
async deactivateFieldWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.deactivateMoreOptionsButton.click();
}
async activateFieldWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.activateMoreOptionsButton.click();
}
async clickAddFieldButton() {
await this.addFieldButton.click();
}
async typeFieldName(name: string) {
await this.nameFieldInput.clear();
await this.nameFieldInput.fill(name);
}
async typeFieldDescription(description: string) {
await this.descriptionFieldInput.clear();
await this.descriptionFieldInput.fill(description);
}
async clickInactiveSection() {
await this.inactiveSection.click();
}
async clickActiveSection() {
await this.activeSection.click();
}
async clickCancelButton() {
await this.cancelButton.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
}

View File

@@ -0,0 +1,61 @@
import { Locator, Page } from '@playwright/test';
export class EmailsSection {
private readonly visibilityEverythingRadio: Locator;
private readonly visibilitySubjectRadio: Locator;
private readonly visibilityMetadataRadio: Locator;
private readonly autoCreationReceivedRadio: Locator;
private readonly autoCreationSentRadio: Locator;
private readonly autoCreationNoneRadio: Locator;
private readonly excludeNonProfessionalToggle: Locator;
private readonly excludeGroupToggle: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.visibilityEverythingRadio = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.visibilitySubjectRadio = page.locator('input[value="SUBJECT"]');
this.visibilityMetadataRadio = page.locator('input[value="METADATA"]');
this.autoCreationReceivedRadio = page.locator(
'input[value="SENT_AND_RECEIVED"]',
);
this.autoCreationSentRadio = page.locator('input[value="SENT"]');
this.autoCreationNoneRadio = page.locator('input[value="NONE"]');
// first checkbox is advanced settings toggle
this.excludeNonProfessionalToggle = page.getByRole('checkbox').nth(1);
this.excludeGroupToggle = page.getByRole('checkbox').nth(2);
}
async changeVisibilityToEverything() {
await this.visibilityEverythingRadio.click();
}
async changeVisibilityToSubject() {
await this.visibilitySubjectRadio.click();
}
async changeVisibilityToMetadata() {
await this.visibilityMetadataRadio.click();
}
async changeAutoCreationToAll() {
await this.autoCreationReceivedRadio.click();
}
async changeAutoCreationToSent() {
await this.autoCreationSentRadio.click();
}
async changeAutoCreationToNone() {
await this.autoCreationNoneRadio.click();
}
async toggleExcludeNonProfessional() {
await this.excludeNonProfessionalToggle.click();
}
async toggleExcludeGroup() {
await this.excludeGroupToggle.click();
}
}

View File

@@ -0,0 +1,64 @@
import { Locator, Page } from '@playwright/test';
export class ExperienceSection {
private readonly lightThemeButton: Locator;
private readonly darkThemeButton: Locator;
private readonly timezoneDropdown: Locator;
private readonly dateFormatDropdown: Locator;
private readonly timeFormatDropdown: Locator;
private readonly searchInput: Locator;
private readonly languageDropdown: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.lightThemeButton = page.locator('div[variant="Light"]').first();
this.darkThemeButton = page.locator('div[variant="Dark"]').first();
this.timezoneDropdown = page.locator(
'//span[contains(., "Time zone")]/../div/div/div',
);
this.dateFormatDropdown = page.locator(
'//span[contains(., "Date format")]/../div/div/div',
);
this.timeFormatDropdown = page.locator(
'//span[contains(., "Time format")]/../div/div/div',
);
this.searchInput = page.getByPlaceholder('Search');
this.languageDropdown = page.locator(
'//h2[contains(., "Language")]/../../../div/div/div',
);
}
async changeThemeToLight() {
await this.lightThemeButton.click();
}
async changeThemeToDark() {
await this.darkThemeButton.click();
}
async selectTimeZone(timezone: string) {
await this.timezoneDropdown.click();
await this.page.getByText(timezone, { exact: true }).click();
}
async selectTimeZoneWithSearch(timezone: string) {
await this.timezoneDropdown.click();
await this.searchInput.fill(timezone);
await this.page.getByText(timezone, { exact: true }).click();
}
async selectDateFormat(dateFormat: string) {
await this.dateFormatDropdown.click();
await this.page.getByText(dateFormat, { exact: true }).click();
}
async selectTimeFormat(timeFormat: string) {
await this.timeFormatDropdown.click();
await this.page.getByText(timeFormat, { exact: true }).click();
}
async selectLanguage(language: string) {
await this.languageDropdown.click();
await this.page.getByText(language, { exact: true }).click();
}
}

View File

@@ -0,0 +1,43 @@
import { Locator, Page } from '@playwright/test';
export class GeneralSection {
private readonly workspaceNameField: Locator;
private readonly supportSwitch: Locator;
private readonly deleteWorkspaceButton: Locator;
private readonly customizeDomainButton: Locator;
private readonly subdomainInput: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.workspaceNameField = page.getByPlaceholder('Apple');
this.supportSwitch = page.getByRole('checkbox').nth(1);
this.deleteWorkspaceButton = page.getByRole('button', {
name: 'Delete workspace',
});
this.customizeDomainButton = page.getByRole('button', {
name: 'Customize domain',
});
this.subdomainInput = page.locator(
'//div[contains(.,".twenty-main.com")]/../input',
);
}
async changeWorkspaceName(workspaceName: string) {
await this.workspaceNameField.clear();
await this.workspaceNameField.fill(workspaceName);
}
async changeSupportSwitchState() {
await this.supportSwitch.click();
}
async clickDeleteWorkSpaceButton() {
await this.deleteWorkspaceButton.click();
}
async changeSubdomain(subdomain: string) {
await this.customizeDomainButton.click();
await this.subdomainInput.fill(subdomain);
await this.page.getByRole('button', { name: 'Save' }).click();
}
}

View File

@@ -0,0 +1,56 @@
import { Locator, Page } from '@playwright/test';
export class MembersSection {
private readonly inviteTab: Locator;
private readonly inviteMembersField: Locator;
private readonly inviteMembersButton: Locator;
private readonly inviteLinkButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.inviteTab = page.getByTestId('tab-invite');
this.inviteMembersField = page.getByPlaceholder(
'tim@apple.com, jony.ive@apple',
);
this.inviteMembersButton = page.getByRole('button', { name: 'Invite' });
this.inviteLinkButton = page.getByRole('button', { name: 'Copy link' });
}
async goToInviteTab() {
await this.inviteTab.click();
}
async copyInviteLink() {
await this.goToInviteTab();
await this.inviteLinkButton.click();
}
async sendInviteEmail(email: string) {
await this.goToInviteTab();
await this.inviteMembersField.click();
await this.inviteMembersField.fill(email);
await this.inviteMembersButton.click();
}
async deleteMember(email: string) {
await this.page
.locator(`//div[contains(., '${email}')]/../../div[last()]/div/button`)
.click();
}
async deleteInviteEmail(email: string) {
await this.page
.locator(
`//div[contains(., '${email}')]/../../div[last()]/div/button[first()]`,
)
.click();
}
async refreshInviteEmail(email: string) {
await this.page
.locator(
`//div[contains(., '${email}')]/../../div[last()]/div/button[last()]`,
)
.click();
}
}

View File

@@ -0,0 +1,256 @@
import { Locator, Page } from '@playwright/test';
export class NewFieldSection {
private readonly searchTypeFieldInput: Locator;
private readonly currencyFieldLink: Locator;
private readonly currencyDefaultUnitSelect: Locator;
private readonly emailsFieldLink: Locator;
private readonly linksFieldLink: Locator;
private readonly phonesFieldLink: Locator;
private readonly addressFieldLink: Locator;
private readonly textFieldLink: Locator;
private readonly numberFieldLink: Locator;
private readonly decreaseDecimalsButton: Locator;
private readonly decimalsNumberInput: Locator;
private readonly increaseDecimalsButton: Locator;
private readonly booleanFieldLink: Locator;
private readonly defaultBooleanSelect: Locator;
private readonly dateTimeFieldLink: Locator;
private readonly dateFieldLink: Locator;
private readonly relativeDateToggle: Locator;
private readonly selectFieldLink: Locator;
private readonly multiSelectFieldLink: Locator;
private readonly setAsDefaultOptionButton: Locator;
private readonly removeOptionButton: Locator;
private readonly addOptionButton: Locator;
private readonly ratingFieldLink: Locator;
private readonly JSONFieldLink: Locator;
private readonly arrayFieldLink: Locator;
private readonly relationFieldLink: Locator;
private readonly relationTypeSelect: Locator;
private readonly objectDestinationSelect: Locator;
private readonly relationIconSelect: Locator;
private readonly relationFieldNameInput: Locator;
private readonly fullNameFieldLink: Locator;
private readonly UUIDFieldLink: Locator;
private readonly nameFieldInput: Locator;
private readonly descriptionFieldInput: Locator;
constructor(public readonly page: Page) {
this.searchTypeFieldInput = page.getByPlaceholder('Search a type');
this.currencyFieldLink = page.getByRole('link', { name: 'Currency' });
this.currencyDefaultUnitSelect = page.locator(
"//span[contains(., 'Default Unit')]/../div",
);
this.emailsFieldLink = page.getByRole('link', { name: 'Emails' }).nth(1);
this.linksFieldLink = page.getByRole('link', { name: 'Links' });
this.phonesFieldLink = page.getByRole('link', { name: 'Phones' });
this.addressFieldLink = page.getByRole('link', { name: 'Address' });
this.textFieldLink = page.getByRole('link', { name: 'Text' });
this.numberFieldLink = page.getByRole('link', { name: 'Number' });
this.decreaseDecimalsButton = page.locator(
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[2]",
);
this.decimalsNumberInput = page.locator(
// would be better if first div was span tag
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/div/div/input[2]",
);
this.increaseDecimalsButton = page.locator(
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[3]",
);
this.booleanFieldLink = page.getByRole('link', { name: 'True/False' });
this.defaultBooleanSelect = page.locator(
"//span[contains(., 'Default Value')]/../div",
);
this.dateTimeFieldLink = page.getByRole('link', { name: 'Date and Time' });
this.dateFieldLink = page.getByRole('link', { name: 'Date' });
this.relativeDateToggle = page.getByRole('checkbox').nth(1);
this.selectFieldLink = page.getByRole('link', { name: 'Select' });
this.multiSelectFieldLink = page.getByRole('link', {
name: 'Multi-select',
});
this.setAsDefaultOptionButton = page
.getByTestId('tooltip')
.getByText('Set as default');
this.removeOptionButton = page
.getByTestId('tooltip')
.getByText('Remove option');
this.addOptionButton = page.getByRole('button', { name: 'Add option' });
this.ratingFieldLink = page.getByRole('link', { name: 'Rating' });
this.JSONFieldLink = page.getByRole('link', { name: 'JSON' });
this.arrayFieldLink = page.getByRole('link', { name: 'Array' });
this.relationFieldLink = page.getByRole('link', { name: 'Relation' });
this.relationTypeSelect = page.locator(
"//span[contains(., 'Relation type')]/../div",
);
this.objectDestinationSelect = page.locator(
"//span[contains(., 'Object destination')]/../div",
);
this.relationIconSelect = page.getByLabel('Click to select icon (').nth(1);
this.relationFieldNameInput = page.getByPlaceholder('Field name');
this.fullNameFieldLink = page.getByRole('link', { name: 'Full Name' });
this.UUIDFieldLink = page.getByRole('link', { name: 'Unique ID' });
this.nameFieldInput = page.getByPlaceholder('Employees');
this.descriptionFieldInput = page.getByPlaceholder('Write a description');
}
async searchTypeField(name: string) {
await this.searchTypeFieldInput.fill(name);
}
async clickCurrencyType() {
await this.currencyFieldLink.click();
}
async selectDefaultUnit(name: string) {
await this.currencyDefaultUnitSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async clickEmailsType() {
await this.emailsFieldLink.click();
}
async clickLinksType() {
await this.linksFieldLink.click();
}
async clickPhonesType() {
await this.phonesFieldLink.click();
}
async clickAddressType() {
await this.addressFieldLink.click();
}
async clickTextType() {
await this.textFieldLink.click();
}
async clickNumberType() {
await this.numberFieldLink.click();
}
async decreaseDecimals() {
await this.decreaseDecimalsButton.click();
}
async typeNumberOfDecimals(amount: number) {
await this.decimalsNumberInput.fill(String(amount));
}
async increaseDecimals() {
await this.increaseDecimalsButton.click();
}
async clickBooleanType() {
await this.booleanFieldLink.click();
}
// either True of False
async selectDefaultBooleanValue(value: string) {
await this.defaultBooleanSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: value }).click();
}
async clickDateTimeType() {
await this.dateTimeFieldLink.click();
}
async clickDateType() {
await this.dateFieldLink.click();
}
async toggleRelativeDate() {
await this.relativeDateToggle.click();
}
async clickSelectType() {
await this.selectFieldLink.click();
}
async clickMultiSelectType() {
await this.multiSelectFieldLink.click();
}
async addSelectOption() {
await this.addOptionButton.click();
}
async setOptionAsDefault() {
// TODO: finish
await this.setAsDefaultOptionButton.click();
}
async deleteSelectOption() {
// TODO: finish
await this.removeOptionButton.click();
}
async changeOptionApiName() {
// TODO: finish
}
async changeOptionColor() {
// TODO: finish
}
async changeOptionName() {
// TODO: finish
}
async clickRatingType() {
await this.ratingFieldLink.click();
}
async clickJSONType() {
await this.JSONFieldLink.click();
}
async clickArrayType() {
await this.arrayFieldLink.click();
}
async clickRelationType() {
await this.relationFieldLink.click();
}
// either 'Has many' or 'Belongs to one'
async selectRelationType(name: string) {
await this.relationTypeSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async selectObjectDestination(name: string) {
await this.objectDestinationSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async selectRelationIcon(name: string) {
await this.relationIconSelect.click();
await this.page.getByTitle(name).click();
}
async typeRelationName(name: string) {
await this.relationFieldNameInput.clear();
await this.relationFieldNameInput.fill(name);
}
async clickFullNameType() {
await this.fullNameFieldLink.click();
}
async clickUUIDType() {
await this.UUIDFieldLink.click();
}
async typeFieldName(name: string) {
await this.nameFieldInput.clear();
await this.nameFieldInput.fill(name);
}
async typeFieldDescription(description: string) {
await this.descriptionFieldInput.clear();
await this.descriptionFieldInput.fill(description);
}
}

View File

@@ -0,0 +1,44 @@
import { Locator, Page } from '@playwright/test';
export class ProfileSection {
private readonly firstNameField: Locator;
private readonly lastNameField: Locator;
private readonly emailField: Locator;
private readonly passwordLinkButton: Locator;
private readonly deleteAccountButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.firstNameField = page.getByPlaceholder('Tim');
this.lastNameField = page.getByPlaceholder('Cook');
this.emailField = page.getByRole('textbox').nth(2);
this.passwordLinkButton = page.getByRole('button', {
name: /Change Password|Set Password/,
});
this.deleteAccountButton = page.getByRole('button', {
name: 'Delete account',
});
}
async changeFirstName(firstName: string) {
await this.firstNameField.clear();
await this.firstNameField.fill(firstName);
}
async changeLastName(lastName: string) {
await this.lastNameField.clear();
await this.lastNameField.fill(lastName);
}
async getEmail() {
await this.emailField.textContent();
}
async sendPasswordSetOrChangeEmail() {
await this.passwordLinkButton.click();
}
async deleteAccount() {
await this.deleteAccountButton.click();
}
}

View File

@@ -0,0 +1,22 @@
import { Locator, Page } from '@playwright/test';
export class RolesSection {
private readonly page: Page;
private readonly createRoleButton: Locator;
private readonly defaultRoleDropdown: Locator;
constructor(page: Page) {
this.page = page;
this.createRoleButton = page.getByRole('button', { name: 'Create Role' });
this.defaultRoleDropdown = page.getByTestId('tooltip');
}
async clickCreateRoleButton() {
await this.createRoleButton.click();
}
async selectDefaultRole(role: string) {
await this.defaultRoleDropdown.click();
await this.page.getByTestId('tooltip').getByText(role).click();
}
}

View File

@@ -0,0 +1,31 @@
import { Locator, Page } from '@playwright/test';
export class SecuritySection {
private readonly googleToggle: Locator;
private readonly microsoftToggle: Locator;
private readonly passwordToggle: Locator;
private readonly inviteByLinkToggle: Locator;
constructor(public readonly page: Page) {
this.googleToggle = page.getByLabel('Google');
this.microsoftToggle = page.getByLabel('Microsoft');
this.passwordToggle = page.getByLabel('Password');
this.inviteByLinkToggle = page.getByLabel('Invite by Link');
}
async toggleGoogle() {
await this.googleToggle.click();
}
async toggleMicrosoft() {
await this.microsoftToggle.click();
}
async togglePassword() {
await this.passwordToggle.click();
}
async toggleInviteByLink() {
await this.inviteByLinkToggle.click();
}
}

View File

@@ -0,0 +1,65 @@
import { Locator, Page } from '@playwright/test';
export class WebhooksSection {
private readonly createWebhookButton: Locator;
private readonly webhookUrlInput: Locator;
private readonly webhookDescription: Locator;
private readonly deleteButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.createWebhookButton = page.getByRole('link', {
name: 'Create Webhook',
});
this.webhookUrlInput = page.getByPlaceholder('URL');
this.webhookDescription = page.getByPlaceholder('Write a description');
this.deleteButton = page.getByRole('button', { name: 'Delete' });
}
async deleteWebhook() {
await this.deleteButton.click();
}
async createWebhook() {
await this.createWebhookButton.click();
}
async typeWebhookUrl(url: string) {
await this.webhookUrlInput.clear();
await this.webhookUrlInput.fill(url);
}
async typeWebhookDescription(description: string) {
await this.webhookDescription.fill(description);
}
async selectWebhookObject(index: number, object: string) {
await this.page
.locator(
`//div[aria-controls="object-webhook-type-select-${index}-options"]`,
)
.click();
await this.page.getByRole('option').getByText(object).click();
}
async selectWebhookAction(index: number, action: string) {
await this.page
.locator(
`//div[aria-controls="operation-webhook-type-select-${index}-options"]`,
)
.click();
await this.page.getByRole('option').getByText(action).click();
}
async deleteWebhookFilter(index: number) {
await this.page
.locator(
`//div[aria-controls="object-webhook-type-select-${index}-options"]/../..//button`,
)
.click();
}
async checkWebhookDetails(name: string) {
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
}
}

View File

@@ -0,0 +1,120 @@
import { Locator, Page } from '@playwright/test';
export class SettingsPage {
private readonly exitSettingsLink: Locator;
private readonly profileLink: Locator;
private readonly experienceLink: Locator;
private readonly accountsLink: Locator;
private readonly emailsLink: Locator;
private readonly calendarsLink: Locator;
private readonly generalLink: Locator;
private readonly membersLink: Locator;
private readonly rolesLink: Locator;
private readonly dataModelLink: Locator;
private readonly integrationsLink: Locator;
private readonly securityLink: Locator;
private readonly apisLink: Locator;
private readonly webhooksLink: Locator;
private readonly adminPanelLink: Locator;
private readonly labLink: Locator;
private readonly releasesLink: Locator;
private readonly advancedToggle: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.exitSettingsLink = page.getByRole('button', { name: 'Exit Settings' });
this.profileLink = page.getByRole('link', { name: 'Profile' });
this.experienceLink = page.getByRole('link', { name: 'Experience' });
this.accountsLink = page.getByRole('link', { name: 'Accounts' });
this.emailsLink = page.getByRole('link', { name: 'Emails', exact: true });
this.calendarsLink = page.getByRole('link', { name: 'Calendars' });
this.generalLink = page.getByRole('link', { name: 'General' });
this.membersLink = page.getByRole('link', { name: 'Members' });
this.rolesLink = page.getByRole('link', { name: 'Roles' });
this.dataModelLink = page.getByRole('link', { name: 'Data model' });
this.integrationsLink = page.getByRole('link', { name: 'Integrations' });
this.securityLink = page.getByRole('link', { name: 'Security' });
this.apisLink = page.getByRole('link', { name: 'APIs' });
this.webhooksLink = page.getByRole('link', { name: 'Webhooks' });
this.adminPanelLink = page.getByRole('link', { name: 'Admin Panel' });
this.labLink = page.getByRole('link', { name: 'Lab' });
this.releasesLink = page.getByRole('link', { name: 'Releases' });
this.advancedToggle = page.locator('input[type="checkbox"]').first();
}
async leaveSettingsPage() {
await this.exitSettingsLink.click();
}
async goToProfileSection() {
await this.profileLink.click();
}
async goToExperienceSection() {
await this.experienceLink.click();
}
async goToAccountsSection() {
await this.accountsLink.click();
}
async goToEmailsSection() {
await this.emailsLink.click();
}
async goToCalendarsSection() {
await this.calendarsLink.click();
}
async goToGeneralSection() {
await this.generalLink.click();
}
async goToMembersSection() {
await this.membersLink.click();
}
async goToRolesSection() {
await this.rolesLink.click();
}
async goToDataModelSection() {
await this.dataModelLink.click();
}
async goToIntegrationsSection() {
await this.integrationsLink.click();
}
async goToSecuritySection() {
await this.securityLink.click();
}
async goToAPIsSection() {
await this.apisLink.click();
}
async goToWebhooksSection() {
await this.webhooksLink.click();
}
async goToAdminPanelSection() {
await this.adminPanelLink.click();
}
async goToLabSection() {
await this.labLink.click();
}
async goToReleasesIntegration() {
await this.releasesLink.click();
}
async logout() {
await this.page.getByText('Logout').click();
}
async toggleAdvancedSettings() {
await this.advancedToggle.click();
}
}

View File

@@ -0,0 +1,9 @@
export const backendGraphQLUrl = new URL(
'/graphql',
process.env.BACKEND_BASE_URL,
).toString();
// Mirrors the baseURL default in playwright.config.ts.
export const frontendOrigin = new URL(
process.env.FRONTEND_BASE_URL || 'http://localhost:3001',
).origin;

View File

@@ -0,0 +1,27 @@
import { type Page } from '@playwright/test';
import { postBackendGraphQL } from './post-backend-graphql';
export const createWorkflow = async ({
page,
workflowId,
workflowName,
}: {
page: Page;
workflowId: string;
workflowName: string;
}) => {
return postBackendGraphQL({
page,
data: {
operationName: 'CreateOneWorkflow',
query:
'mutation CreateOneWorkflow($input: WorkflowCreateInput!) { createWorkflow(data: $input) { __typename id } }',
variables: {
input: {
id: workflowId,
name: workflowName,
},
},
},
});
};

View File

@@ -0,0 +1,20 @@
import { type Page } from '@playwright/test';
import { postBackendGraphQL } from './post-backend-graphql';
export const deleteWorkflow = async ({
page,
workflowId,
}: {
page: Page;
workflowId: string;
}) => {
return postBackendGraphQL({
page,
data: {
operationName: 'DeleteOneWorkflow',
variables: { idToDelete: workflowId },
query:
'mutation DeleteOneWorkflow($idToDelete: UUID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
},
});
};

View File

@@ -0,0 +1,20 @@
import { type Page } from '@playwright/test';
import { postBackendGraphQL } from './post-backend-graphql';
export const destroyWorkflow = async ({
page,
workflowId,
}: {
page: Page;
workflowId: string;
}) => {
return postBackendGraphQL({
page,
data: {
operationName: 'DestroyOneWorkflow',
variables: { idToDestroy: workflowId },
query:
'mutation DestroyOneWorkflow($idToDestroy: UUID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}',
},
});
};

View File

@@ -0,0 +1,20 @@
import { type APIResponse, type Page } from '@playwright/test';
import { backendGraphQLUrl, frontendOrigin } from './backend';
// Authenticates by session cookie: page.request shares the browser context's
// cookie jar, which is the only place an httpOnly session cookie exists.
// CookieSessionCsrfMiddleware fails closed on a missing Origin for a
// cookie-authenticated write, and page.request sends none on its own.
export const postBackendGraphQL = ({
page,
data,
}: {
page: Page;
data: Record<string, unknown>;
}): Promise<APIResponse> =>
page.request.post(backendGraphQLUrl, {
headers: {
Origin: frontendOrigin,
},
data,
});

View File

@@ -0,0 +1,13 @@
export type WorkflowTriggerType =
| 'record-created'
| 'record-updated'
| 'record-deleted'
| 'manual';
export type WorkflowActionType =
| 'create-record'
| 'update-record'
| 'delete-record'
| 'code'
| 'send-email'
| 'form';

View File

@@ -0,0 +1,94 @@
import { Page } from '@playwright/test';
const MAC = process.platform === 'darwin';
async function keyDownCtrlOrMeta(page: Page) {
if (MAC) {
await page.keyboard.down('Meta');
} else {
await page.keyboard.down('Control');
}
}
async function keyUpCtrlOrMeta(page: Page) {
if (MAC) {
await page.keyboard.up('Meta');
} else {
await page.keyboard.up('Control');
}
}
export async function withCtrlOrMeta(page: Page, key: () => Promise<void>) {
await keyDownCtrlOrMeta(page);
await key();
await keyUpCtrlOrMeta(page);
}
export async function selectAllByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('a', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function copyByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('c', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function cutByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('x', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function pasteByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('v', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function companiesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('c');
}
export async function notesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('n');
}
export async function opportunitiesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('o');
}
export async function peopleShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('p');
}
export async function rocketsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('r');
}
export async function tasksShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('t');
}
export async function workflowsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('w');
}
export async function settingsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('s');
}
export async function customShortcut(page: Page, shortcut: string) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press(shortcut);
}

View File

@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
import { selectAllByKeyboard } from './keyboardShortcuts';
// https://github.com/microsoft/playwright/issues/14126
// code must have \n at the end of lines otherwise everything will be in one line
export const pasteCodeToCodeEditor = async (
page: Page,
locator: Locator,
code: string,
) => {
await locator.click();
await selectAllByKeyboard(page);
await page.keyboard.type(code);
};

View File

@@ -0,0 +1,15 @@
import { Page } from '@playwright/test';
import path from 'path';
export const fileUploader = async (
page: Page,
trigger: () => Promise<void>,
filename: string,
) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await trigger();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, '..', 'test_files', filename),
);
};

View File

@@ -0,0 +1,11 @@
{
"name": "twenty-e2e-testing",
"description": "",
"author": "",
"private": true,
"license": "AGPL-3.0",
"devDependencies": {
"@playwright/test": "^1.60.0",
"playwright": "^1.60.0"
}
}

View File

@@ -0,0 +1,83 @@
import { defineConfig, devices } from '@playwright/test';
import { config } from 'dotenv';
import * as path from 'path';
const envResult = config({
path: path.resolve(__dirname, '.env'),
});
if (envResult.error) {
throw new Error('Failed to load .env file');
}
/* === Run your local dev server before starting the tests === */
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
outputDir: 'run_results/', // directory for screenshots and videos
snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', // just in case, do not delete it
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
timeout: process.env.CI ? 60_000 : 30 * 1000, // timeout can be changed
use: {
baseURL: process.env.FRONTEND_BASE_URL || 'http://localhost:3001',
trace: 'retain-on-failure', // trace takes EVERYTHING from page source, records every single step, should be used only when normal debugging won't work
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
},
expect: {
// CI runners are slow enough that post-mutation UI transitions routinely
// exceed 5s; locally keep the tight budget.
timeout: process.env.CI ? 15_000 : 5000,
},
reporter: [
[process.env.CI ? 'github' : 'list'],
['./reporters/log-summary-reporter.ts'],
],
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chrome',
use: {
...devices['Desktop Chrome'],
permissions: ['clipboard-read', 'clipboard-write'],
storageState: path.resolve(__dirname, '.auth', 'user.json'), // takes saved cookies from directory
},
dependencies: ['setup'],
},
//{
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
//},
/* 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' },
//},
],
});

View File

@@ -0,0 +1,53 @@
{
"name": "twenty-e2e-testing",
"projectType": "application",
"tags": ["scope:testing"],
"targets": {
"setup": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-e2e-testing",
"commands": [
"yarn playwright install",
"cp .env.example .env"
]
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-e2e-testing",
"commands": [
"yarn playwright test"
]
}
},
"test:ui": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-e2e-testing",
"commands": [
"yarn playwright test --ui"
]
}
},
"test:debug": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-e2e-testing",
"commands": [
"yarn playwright test --debug"
]
}
},
"test:report": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-e2e-testing",
"commands": [
"yarn playwright show-report"
]
}
}
}
}

View File

@@ -0,0 +1,54 @@
import type {
Reporter,
TestCase,
TestResult,
} from '@playwright/test/reporter';
class LogSummaryReporter implements Reporter {
private passed: string[] = [];
private failed: string[] = [];
onTestEnd(test: TestCase, result: TestResult): void {
const name = test.titlePath().join(' › ');
if (result.status === 'passed') {
this.passed.push(name);
return;
}
if (result.status === 'failed' || result.status === 'timedOut') {
this.failed.push(name);
}
}
onEnd(): void {
const passedSet = new Set(this.passed);
const failedSet = new Set(this.failed);
const flaky: string[] = [];
for (const testName of passedSet) {
if (failedSet.has(testName)) {
flaky.push(testName);
}
}
const uniquePassed = this.passed.filter((testName) => !flaky.includes(testName));
const uniqueFailed = this.failed.filter((testName) => !flaky.includes(testName));
console.log('\n=== Playwright summary ===');
if (uniquePassed.length) {
console.log('Passed:');
uniquePassed.forEach((testName) => console.log(` ✅ ${testName}`));
}
if (uniqueFailed.length) {
console.log('Failed:');
uniqueFailed.forEach((testName) => console.log(` ❌ ${testName}`));
}
if (flaky.length) {
console.log('Flaky:');
flaky.forEach((testName) => console.log(` ⚠️ ${testName}`));
}
}
}
export default LogSummaryReporter;

View File

@@ -0,0 +1,45 @@
import { test as base } from '../../lib/fixtures/screenshot';
import { ConfirmationModal } from '../../lib/pom/helper/confirmationModal';
import { LeftMenu } from '../../lib/pom/leftMenu';
import { LoginPage } from '../../lib/pom/loginPage';
import { MembersSection } from '../../lib/pom/settings/membersSection';
import { ProfileSection } from '../../lib/pom/settings/profileSection';
import { SettingsPage } from '../../lib/pom/settingsPage';
type Fixtures = {
confirmationModal: ConfirmationModal;
loginPage: LoginPage;
leftMenu: LeftMenu;
settingsPage: SettingsPage;
membersSection: MembersSection;
profileSection: ProfileSection;
};
export const test = base.extend<Fixtures>({
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';

View File

@@ -0,0 +1,97 @@
import { randomUUID } from 'crypto';
import { expect, test } from './fixture';
// Signing up on the workspace subdomain the shared fixture points at is
// refused, so create the workspace from the base domain instead.
test.use({
storageState: { cookies: [], origins: [] },
baseURL: process.env.FRONTEND_BASE_URL ?? 'http://localhost:3001',
});
test('New workspace signup goes through every onboarding stage', async ({
page,
loginPage,
}) => {
test.setTimeout(180000);
const suffix = randomUUID().replaceAll('-', '').slice(0, 12);
const email = `test${suffix}@apple.dev`;
const workspaceName = `Test ${suffix}`;
const subdomain = `test-${suffix}`;
await test.step('Create a new account', async () => {
await page.goto('/welcome');
await loginPage.clickLoginWithEmailIfVisible();
await loginPage.typeEmail(email);
await loginPage.clickContinueButton();
await loginPage.typePassword(process.env.DEFAULT_PASSWORD);
await loginPage.clickSignUpButton();
});
await test.step('Create the workspace', async () => {
await expect(page.getByText('Create your workspace')).toBeVisible();
await loginPage.typeWorkspaceName(workspaceName);
await loginPage.typeSubdomain(subdomain);
await loginPage.clickCreateWorkspaceButton();
});
await test.step('Workspace activation stage', async () => {
await page.waitForURL('**/workspace-activation', { timeout: 90000 });
});
const syncEmailsHeading = page.getByText('Import your contacts');
const installAppsHeading = page.getByText('Install your first apps');
const createProfileHeading = page.getByText('Create profile');
// Both stages auto-skip when the instance has no connected-account provider
// and no vetted marketplace app, which is how the e2e server is configured.
await test.step('Sync-email stage (when shown)', async () => {
await expect(
syncEmailsHeading.or(installAppsHeading).or(createProfileHeading),
).toBeVisible({
timeout: 90000,
});
if (!(await syncEmailsHeading.isVisible())) {
return;
}
await loginPage.clickSkipOnboardingStep();
await expect(installAppsHeading).toBeVisible();
await test.step('Goes back to the skipped sync-email stage', async () => {
await page.getByRole('button', { name: 'Go back' }).click();
await expect(syncEmailsHeading).toBeVisible();
await page.reload();
await expect(syncEmailsHeading).toBeVisible({
timeout: 30000,
});
await loginPage.clickSkipOnboardingStep();
await expect(installAppsHeading).toBeVisible();
});
});
await test.step('Install-apps stage (when shown)', async () => {
if (await installAppsHeading.isVisible()) {
await loginPage.clickSkipOnboardingStep();
}
});
await test.step('Create-profile stage', async () => {
await expect(page.getByText('Create profile')).toBeVisible();
await loginPage.typeFirstName('Ada');
await loginPage.typeLastName('Lovelace');
await loginPage.clickContinueButton();
});
await test.step('Invite-team stage', async () => {
await expect(page.getByText('Invite your team')).toBeVisible();
await loginPage.clickSkipOnboardingStep();
});
await test.step('Lands in the app', async () => {
await expect(page.getByTestId('workspace-dropdown')).toBeVisible();
});
});

View File

@@ -0,0 +1,116 @@
import { expect, test as base } from '@playwright/test';
import { LoginPage } from '../../lib/pom/loginPage';
const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
const loginAndSelectWorkspace = async (loginPage: LoginPage, page: any) => {
await page.waitForLoadState('domcontentloaded');
await loginPage.clickLoginWithEmailIfVisible();
await loginPage.typeEmail(process.env.DEFAULT_LOGIN!);
await loginPage.clickContinueButton();
await loginPage.typePassword(process.env.DEFAULT_PASSWORD!);
await page.waitForLoadState('domcontentloaded');
await loginPage.clickSignInButton();
await page.waitForLoadState('domcontentloaded');
const workspaceButton = page.getByText('Apple', { exact: true });
await workspaceButton.waitFor({ state: 'visible', timeout: 30000 }).catch(
() => {
// Single workspace mode — no workspace selection
},
);
if (await workspaceButton.isVisible()) {
await workspaceButton.click();
}
await page.waitForFunction(
() =>
!window.location.href.includes('verify') &&
!window.location.href.includes('welcome'),
{ timeout: 30000 },
);
};
test.describe('Return-to-path after login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should redirect to deep link after login', async ({
page,
loginPage,
}) => {
const deepLink = '/settings/accounts';
await test.step('Navigate to deep link while logged out', async () => {
await page.goto(deepLink);
await page.waitForURL('**/welcome');
await page.waitForLoadState('domcontentloaded');
});
await test.step('Log in and select workspace', async () => {
await loginAndSelectWorkspace(loginPage, page);
});
await test.step(
'Verify redirected to original deep link',
async () => {
await page.waitForURL(`**${deepLink}`, {
timeout: 30000,
waitUntil: 'commit',
});
expect(new URL(page.url()).pathname).toBe(deepLink);
},
);
await test.step(
'Verify return-to-path query param was consumed',
async () => {
const url = new URL(page.url());
expect(url.searchParams.has('returnToPath')).toBe(false);
},
);
});
test('should preserve path with query params across login', async ({
page,
loginPage,
}) => {
const targetPath = '/settings/accounts';
const targetPathWithParams = `${targetPath}?tab=emails&filter=unread`;
await test.step(
'Navigate to path with query params while logged out',
async () => {
await page.goto(targetPathWithParams);
await page.waitForURL('**/welcome');
await page.waitForLoadState('domcontentloaded');
},
);
await test.step('Log in and select workspace', async () => {
await loginAndSelectWorkspace(loginPage, page);
});
await test.step(
'Verify redirected to original path with query params',
async () => {
await page.waitForURL(`**${targetPath}**`, {
timeout: 30000,
waitUntil: 'commit',
});
const url = new URL(page.url());
expect(url.pathname).toBe(targetPath);
expect(url.searchParams.get('tab')).toBe('emails');
expect(url.searchParams.get('filter')).toBe('unread');
},
);
});
});

View File

@@ -0,0 +1,59 @@
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();
// Logging out replaces the document, which would interrupt the goto below.
await page.waitForURL('**/welcome');
await page.goto(inviteLink);
await expect(page.getByText(/Join .+ team/)).toBeVisible();
});
await test.step('Create new account', async () => {
await loginPage.clickLoginWithEmailIfVisible();
await loginPage.typeEmail(email);
await loginPage.clickContinueButton();
await loginPage.typePassword(process.env.DEFAULT_PASSWORD);
await loginPage.clickSignUpButton();
await expect(page.getByText('Create profile')).toBeVisible();
await expect(page.getByPlaceholder('Head of Partnerships')).toBeVisible();
await loginPage.typeFirstName(firstName);
await loginPage.typeLastName(lastName);
await loginPage.clickContinueButton();
});
await test.step('Delete account from workspace', async () => {
await expect(page.getByTestId('workspace-dropdown')).toBeVisible();
await leftMenu.goToSettings();
await settingsPage.goToProfileSection();
await profileSection.deleteAccount();
await expect(page.getByText('Account Deletion')).toBeVisible();
await confirmationModal.typePlaceholderToInput();
await confirmationModal.clickConfirmButton();
await page.waitForURL('**/welcome');
});
});

View File

@@ -0,0 +1,57 @@
import { expect, test } from '../lib/fixtures/screenshot';
test.describe.serial('Create Kanban View', () => {
// Unique per run: retries of this serial group re-run field creation against
// the same database, and a duplicate label makes the form unsubmittable.
const industryLabel = `Industry ${Date.now()}`;
test('Create Industry Select Field', async ({ page }) => {
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByRole('link', { name: 'Data model' }).click();
await page.getByRole('link', { name: 'Opportunities' }).click();
await expect(page.getByRole('button', { name: 'New Field' })).toBeVisible();
await page.getByRole('button', { name: 'New Field' }).click();
await page.getByRole('link', { name: 'Select', exact: true }).click();
await page.getByRole('textbox', { name: 'Employees' }).click();
await page.getByRole('textbox', { name: 'Employees' }).fill(industryLabel);
await page.getByRole('textbox').nth(1).click();
await page.getByRole('textbox').nth(1).press('ControlOrMeta+a');
await page.getByRole('textbox').nth(1).fill('Food');
await page.getByRole('button', { name: 'Add option' }).click();
await page.getByRole('button', { name: 'Option 2' }).getByRole('textbox').fill('Tech');
await page.getByRole('button', { name: 'Add option' }).click();
await page.getByRole('button', { name: 'Option 3' }).getByRole('textbox').fill('Travel');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL('**/objects/opportunities');
await page.waitForSelector(`text=${industryLabel}`);
await expect(page.getByText(industryLabel)).toBeVisible();
});
test('Create Kanban View from Industry Select Field', async ({ page }) => {
await page.getByRole('link', { name: 'Opportunities' }).click();
await page.getByRole('button', { name: /All Opportunities/ }).click();
await page.getByText('Add view').click();
await page.getByRole('textbox').press('ControlOrMeta+a');
await page.getByRole('textbox').fill('By industry');
await page.getByRole('button', { name: 'Table', exact: true }).click();
await page.getByText('Kanban').click();
await page.locator('[aria-controls="view-picker-kanban-field-options"]').click();
await page.getByRole('option', { name: industryLabel }).click();
await page.getByRole('button', { name: 'Create new view' }).click();
await expect(page.getByText('Food')).toBeVisible({ timeout: 30000 });
await expect(page.getByText('Tech')).toBeVisible();
await expect(page.getByText('Travel')).toBeVisible();
await expect(page.getByText('No Value')).toBeVisible();
const byIndustryElements = await page.locator('text=By industry').all();
expect(byIndustryElements.length).toBeGreaterThanOrEqual(1);
for (const element of byIndustryElements) {
await expect(element).toBeVisible();
}
await page.getByText('Options').click();
await page.getByText('Group', { exact: true }).click();
await Promise.all([page.getByTestId('hide-group-').click(),
page.waitForRequest((req) => {
return req.url().includes('/metadata') &&
req.method() === 'POST';
})]);
await expect(page.getByText('No Value')).not.toBeVisible();
});
})

View File

@@ -0,0 +1,164 @@
import { expect, test } from '../lib/fixtures/screenshot';
import { postBackendGraphQL } from '../lib/requests/post-backend-graphql';
const query = `query FindOnePerson($objectRecordId: UUID!) {
person(
filter: {or: [{deletedAt: {is: NULL}}, {deletedAt: {is: NOT_NULL}}], id: {eq: $objectRecordId}}
) {
previousCompanies {
edges {
node {
company {
name
}
}
}
}
emails {
primaryEmail
additionalEmails
__typename
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks {
label
url
}
__typename
}
name {
firstName
lastName
__typename
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones {
number
callingCode
countryCode
}
__typename
}
position
workPreference
updatedAt
}
}`;
test('Create and update record', async ({ page }) => {
await page.goto('/objects/people');
await page.getByRole('button', { name: 'Create new Person' }).click();
// Generate a random email for testing
const randomEmail = `testuser_${Math.random().toString(36).substring(2, 10)}@example.com`;
// Fill first name and last name
const firstNameInput = page.getByRole('textbox', { name: 'F‌‌irst name' });
await expect(firstNameInput).toBeFocused();
await firstNameInput.fill('John');
const lastNameInput = page.getByPlaceholder('L‌‌ast name');
await expect(lastNameInput).toBeVisible();
await lastNameInput.fill('Doe');
await lastNameInput.press('Enter');
// Focus on recordFieldList
const recordFieldList = page.getByTestId('record-fields-widget');
await expect(recordFieldList).toBeVisible();
await recordFieldList.getByText('Emails').first().click();
// Fill email
const emailInput = recordFieldList.getByText('Emails').nth(1);
await expect(emailInput).toBeVisible();
await emailInput.click({ force: true });
await page.getByPlaceholder('Email').fill(randomEmail);
await page.keyboard.press('Enter');
await recordFieldList.getByText('Emails').first().click();
// Fill intro
const introInput = recordFieldList.getByText('Intro').nth(1);
await expect(introInput).toBeVisible();
await introInput.click({ force: true });
await introInput.click({ force: true });
await page.getByPlaceholder('Intro').fill('This is an intro');
await page.getByPlaceholder('Intro').press('Enter');
// Fill URL
await recordFieldList.getByText('Linkedin').first().click();
const urlInput = recordFieldList.getByText('Linkedin').nth(1);
await expect(urlInput).toBeVisible();
await urlInput.click({ force: true });
await page.getByPlaceholder('URL').fill('linkedin.com/johndoe');
await page.getByPlaceholder('URL').press('Enter');
// Click on 4th star to rate
await recordFieldList
.getByText('Performance Rating')
.first()
.click({ force: true });
const ratingContainer = recordFieldList.locator('div[aria-label="Rating"]');
await ratingContainer.locator('svg').nth(3).click({ force: true });
// Fill phone field
await recordFieldList.getByText('Phones').first().click();
const phoneInput = recordFieldList.getByText('Phones').nth(1);
await expect(phoneInput).toBeVisible();
await phoneInput.click({ force: true });
await page.getByPlaceholder('Phone').fill('+336 1 122 3344');
await page.getByPlaceholder('Phone').press('Enter');
// Fill work preference
await recordFieldList
.getByText('Work Preference')
.first()
.click({ force: true });
await recordFieldList
.getByText('Work Preference')
.nth(1)
.click({ force: true });
const options = page.getByRole('listbox');
await options.getByText('Hybrid').first().click({ force: true });
recordFieldList.getByText('Work Preference').first().click({ force: true });
// Open full record page to get person ID
await page.getByRole('button', { name: 'Expand record' }).click();
await page.waitForURL(/\/object\/person\//);
const newPersonId = page.url().match(/\/object\/person\/([a-f0-9-]+)/)?.[1];
// Check data was saved
const findOnePersonResponse = await postBackendGraphQL({
page,
data: {
operationName: 'FindOnePerson',
query,
variables: {
objectRecordId: newPersonId,
},
},
});
const findOnePersonReponseBody = await findOnePersonResponse.json();
expect(findOnePersonReponseBody.data.person.name.firstName).toBe('John');
expect(findOnePersonReponseBody.data.person.name.lastName).toBe('Doe');
expect(findOnePersonReponseBody.data.person.emails.primaryEmail).toBe(
randomEmail,
);
expect(findOnePersonReponseBody.data.person.intro).toBe('This is an intro');
expect(findOnePersonReponseBody.data.person.linkedinLink.primaryLinkUrl).toBe(
'linkedin.com/johndoe',
);
expect(findOnePersonReponseBody.data.person.phones.primaryPhoneNumber).toBe(
'611223344',
);
expect(findOnePersonReponseBody.data.person.workPreference).toEqual([
'HYBRID',
]);
});

View File

@@ -0,0 +1,43 @@
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);
},
});
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');
// Click "Continue with Email" if visible (may be skipped if password is the only auth method)
await loginPage.clickLoginWithEmailIfVisible();
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 page.waitForLoadState('networkidle');
await expect(page.getByText(/Welcome, .+/)).not.toBeVisible();
await expect(page.getByText('Choose a workspace')).toBeVisible();
await page.getByText('Apple', {exact: true}).click();
await page.waitForFunction(() => window.location.href.includes('verify'));
await page.waitForFunction(() => !window.location.href.includes('verify'));
process.env.LINK = page.url();
},
);
await test.step('Saved auth state', async () => {
await page.context().storageState({
path: path.resolve(__dirname, '..', '.auth', 'user.json'),
});
});
});

View File

@@ -0,0 +1,65 @@
import { expect, test } from '../lib/fixtures/screenshot';
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(process.env.LINK);
const workflowsFolder = page.getByRole('button', { name: 'Workflows' });
await workflowsFolder.click();
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
await workflowsLink.click();
const createWorkflowButton = page.getByRole('button', {
name: 'Create new workflow',
});
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';
}),
createWorkflowButton.click()
]);
const recordName = page.getByTestId('top-bar-title').getByPlaceholder('Name');
await expect(recordName).toBeVisible();
await recordName.fill(NEW_WORKFLOW_NAME);
const workflowDiagramContainer = page.locator('.react-flow__renderer');
await workflowDiagramContainer.click();
const body = await createWorkflowResponse.json();
const newWorkflowId = body.data.createWorkflow.id;
try {
const workflowName = page
.getByTestId('top-bar-title')
.getByText(NEW_WORKFLOW_NAME);
// Wait for the name to be visible and not hidden
await workflowName.waitFor({ state: 'visible' });
await expect(workflowName).toBeVisible();
await expect(page).toHaveURL(`/object/workflow/${newWorkflowId}`);
} finally {
await deleteWorkflow({
page,
workflowId: newWorkflowId,
});
await destroyWorkflow({
page,
workflowId: newWorkflowId,
});
}
});

View File

@@ -0,0 +1,150 @@
// import { expect } from '@playwright/test';
// import { test } from '../lib/fixtures/blank-workflow';
// test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
// workflowVisualizer,
// page,
// }) => {
// await workflowVisualizer.createInitialTrigger('manual');
// const manualTriggerAvailabilitySelect = page.getByRole('button', {
// name: 'When record is selected',
// });
// await manualTriggerAvailabilitySelect.click();
// const alwaysAvailableOption = page.getByText('When no record is selected');
// await alwaysAvailableOption.click();
// await workflowVisualizer.closeSidePanel();
// const { createdStepId: firstStepId } =
// await workflowVisualizer.createStep('create-record');
// await workflowVisualizer.closeSidePanel();
// const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
// await launchTestButton.click();
// await workflowVisualizer.closeSidePanel();
// await workflowVisualizer.deleteStep(firstStepId);
// await page.goto('/objects/workflowRuns');
// const recordTableRowForWorkflowRun = page
// .getByRole('row', {
// name: workflowVisualizer.workflowName,
// })
// .first();
// const linkToWorkflowRun = recordTableRowForWorkflowRun
// .getByRole('link', {
// name: workflowVisualizer.workflowName,
// })
// .first();
// await linkToWorkflowRun.click({ force: true });
// const workflowRunNameElement = page
// .getByText(`#1 - ${workflowVisualizer.workflowName}`)
// .nth(1);
// await expect(workflowRunNameElement).toBeVisible();
// const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
// await expect(executedFirstStepNode).toBeVisible();
// await executedFirstStepNode.click();
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
// 'Create Record',
// );
// });
// test('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
// workflowVisualizer,
// page,
// }) => {
// await workflowVisualizer.createInitialTrigger('manual');
// const manualTriggerAvailabilitySelect = page.getByRole('button', {
// name: 'When record is selected',
// });
// await manualTriggerAvailabilitySelect.click();
// const alwaysAvailableOption = page.getByText('When no record is selected');
// await alwaysAvailableOption.click();
// await workflowVisualizer.closeSidePanel();
// const { createdStepId: firstStepId } =
// await workflowVisualizer.createStep('form');
// const addFormFieldButton = page.getByText('Add Field', { exact: true });
// await addFormFieldButton.click();
// await workflowVisualizer.closeSidePanel();
// const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
// await launchTestButton.click();
// const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
// 'Form',
// {
// timeout: 30_000,
// },
// );
// await workflowVisualizer.goBackInCommandMenu.click();
// const workflowRunNameInCommandMenu =
// workflowVisualizer.commandMenu.getByText(workflowRunName);
// await expect(workflowRunNameInCommandMenu).toBeVisible();
// await workflowVisualizer.commandMenu
// .locator(workflowVisualizer.triggerNode)
// .click();
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
// 'Launch manually',
// );
// await workflowVisualizer.goBackInCommandMenu.click();
// const formStep = workflowVisualizer.commandMenu.locator(
// workflowVisualizer.getStepNode(firstStepId),
// );
// await formStep.click();
// await workflowVisualizer.goBackInCommandMenu.click();
// const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
// 'button',
// { name: 'Open' },
// );
// await openInFullScreenButton.click();
// const workflowRunNameInShowPage = page
// .getByText(`#1 - ${workflowVisualizer.workflowName}`)
// .nth(1);
// await expect(workflowRunNameInShowPage).toBeVisible();
// // Expect the side panel to be opened by default on the form.
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
// 'Form',
// );
// });

View File

@@ -0,0 +1,161 @@
// test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
// await workflowVisualizer.createInitialTrigger('record-created');
// await workflowVisualizer.createStep('create-record');
// await workflowVisualizer.background.click();
// await workflowVisualizer.activateWorkflowButton.click();
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
// await workflowVisualizer.createStep('delete-record');
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
// await workflowVisualizer.closeSidePanel();
// await workflowVisualizer.activateWorkflowButton.click();
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// 'Delete Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
// const workflowsLink = page.getByRole('link', { name: 'Workflows' });
// await workflowsLink.click();
// await workflowVisualizer.setWorkflowsOpenInMode('record-page');
// const recordTableRowForWorkflow = page.getByRole('row', {
// name: workflowVisualizer.workflowName,
// });
// const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
// name: workflowVisualizer.workflowName,
// });
// await expect(linkToWorkflow).toBeVisible();
// const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
// 'link',
// {
// name: 'v1',
// },
// );
// await linkToFirstWorkflowVersion.click({ force: true });
// await expect(workflowVisualizer.workflowStatus).toHaveText('Archived');
// await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
// await workflowVisualizer.useAsDraftButton.click();
// await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
// });
// test('Use an old version as draft while having a pending draft version', async ({
// workflowVisualizer,
// page,
// }) => {
// await workflowVisualizer.createInitialTrigger('record-created');
// await workflowVisualizer.createStep('create-record');
// await workflowVisualizer.background.click();
// await workflowVisualizer.activateWorkflowButton.click();
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
// await workflowVisualizer.createStep('delete-record');
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// 'Delete Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
// await workflowVisualizer.closeSidePanel();
// const workflowsLink = page.getByRole('link', { name: 'Workflows' });
// await workflowsLink.click();
// const recordTableRowForWorkflow = page.getByRole('row', {
// name: workflowVisualizer.workflowName,
// });
// const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
// name: workflowVisualizer.workflowName,
// });
// await expect(linkToWorkflow).toBeVisible();
// const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
// 'link',
// {
// name: 'v1',
// },
// );
// await linkToFirstWorkflowVersion.click({ force: true });
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
// await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
// await workflowVisualizer.useAsDraftButton.click();
// await expect(workflowVisualizer.overrideDraftButton).toBeVisible();
// await workflowVisualizer.overrideDraftButton.click();
// await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
// await expect(workflowVisualizer.triggerNode).toContainText(
// 'Record is created',
// );
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
// 'Create Record',
// ]);
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
// await expect(workflowVisualizer.activateWorkflowButton).toBeVisible();
// await expect(workflowVisualizer.discardDraftButton).toBeVisible();
// });

View File

@@ -0,0 +1,218 @@
// 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',
// ]);
// });
// test("Nodes can't be deleted by pressing Backspace or Delete keys", async ({
// workflowVisualizer,
// page,
// }) => {
// await workflowVisualizer.triggerNode.click();
// await page.keyboard.press('Backspace');
// await page.keyboard.press('Delete');
// await expect(workflowVisualizer.triggerNode).toBeVisible();
// const { createdStepId: firstStepId } =
// await workflowVisualizer.createStep('create-record');
// const firstStep = workflowVisualizer.getStepNode(firstStepId);
// await firstStep.click();
// await expect(workflowVisualizer.getDeleteNodeButton(firstStep)).toBeVisible();
// await page.keyboard.press('Backspace');
// await page.keyboard.press('Delete');
// await expect(firstStep).toBeVisible();
// await workflowVisualizer.addStepButton.click();
// await page.keyboard.press('Backspace');
// await page.keyboard.press('Delete');
// await expect(workflowVisualizer.addStepButton).toBeVisible();
// });