Compare commits

...

3 Commits

Author SHA1 Message Date
prastoin efc3db8ed6 test: fix css classname locator, company creation edge cases 2026-05-05 18:03:21 +02:00
Charles Bochet 8838aafd18 Merge branch 'main' into rpl-e2e 2026-04-17 19:28:26 +02:00
Devessier d7154df876 test: add e2e for rpl 2026-04-16 18:14:14 +02:00
9 changed files with 508 additions and 2 deletions
@@ -2,3 +2,8 @@ export const backendGraphQLUrl = new URL(
'/graphql',
process.env.BACKEND_BASE_URL,
).toString();
export const metadataGraphQLUrl = new URL(
'/metadata',
process.env.BACKEND_BASE_URL,
).toString();
@@ -0,0 +1,51 @@
import { type Page } from '@playwright/test';
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
import { backendGraphQLUrl } from './backend';
export const createCompany = async ({
page,
companyName,
}: {
page: Page;
companyName: string;
}) => {
const { authToken } = await getAccessAuthToken(page);
const response = await page.request.post(backendGraphQLUrl, {
headers: {
Authorization: `Bearer ${authToken}`,
},
data: {
operationName: 'CreateOneCompany',
query:
'mutation CreateOneCompany($input: CompanyCreateInput!) { createCompany(data: $input) { __typename id } }',
variables: {
input: {
name: companyName,
},
},
},
});
if (!response.ok()) {
throw new Error(
`createCompany request failed with status ${response.status()}`,
);
}
const body = await response.json();
if (body.errors?.length) {
throw new Error(
`createCompany GraphQL errors: ${body.errors.map((e: { message: string }) => e.message).join(', ')}`,
);
}
const id: string | undefined = body.data?.createCompany?.id;
if (!id) {
throw new Error('createCompany returned no id');
}
return id;
};
@@ -0,0 +1,25 @@
import { type Page } from '@playwright/test';
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
import { backendGraphQLUrl } from './backend';
export const deleteCompany = async ({
page,
companyId,
}: {
page: Page;
companyId: string;
}) => {
const { authToken } = await getAccessAuthToken(page);
return page.request.post(backendGraphQLUrl, {
headers: {
Authorization: `Bearer ${authToken}`,
},
data: {
operationName: 'DeleteOneCompany',
variables: { idToDelete: companyId },
query:
'mutation DeleteOneCompany($idToDelete: UUID!) {\n deleteCompany(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
},
});
};
@@ -0,0 +1,25 @@
import { type Page } from '@playwright/test';
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
import { backendGraphQLUrl } from './backend';
export const destroyCompany = async ({
page,
companyId,
}: {
page: Page;
companyId: string;
}) => {
const { authToken } = await getAccessAuthToken(page);
return page.request.post(backendGraphQLUrl, {
headers: {
Authorization: `Bearer ${authToken}`,
},
data: {
operationName: 'DestroyOneCompany',
variables: { idToDestroy: companyId },
query:
'mutation DestroyOneCompany($idToDestroy: UUID!) {\n destroyCompany(id: $idToDestroy) {\n id\n __typename\n }\n}',
},
});
};
@@ -0,0 +1,72 @@
import { type Page } from '@playwright/test';
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
import { metadataGraphQLUrl } from './backend';
export const resetRecordPageLayout = async ({
page,
objectNameSingular,
}: {
page: Page;
objectNameSingular: string;
}) => {
const { authToken } = await getAccessAuthToken(page);
const headers = {
Authorization: `Bearer ${authToken}`,
};
// Step 1: Find the objectMetadataId for the given object name
const objectsResponse = await page.request.post(metadataGraphQLUrl, {
headers,
data: {
operationName: 'ObjectMetadataItems',
query:
'query ObjectMetadataItems { objects(paging: { first: 1000 }) { edges { node { id nameSingular } } } }',
},
});
const objectsBody = await objectsResponse.json();
const objectMetadataId = objectsBody.data.objects.edges.find(
(edge: { node: { nameSingular: string } }) =>
edge.node.nameSingular === objectNameSingular,
)?.node.id as string | undefined;
if (!objectMetadataId) {
throw new Error(
`Object metadata not found for nameSingular: ${objectNameSingular}`,
);
}
// Step 2: Find all record page layouts for this object
const layoutsResponse = await page.request.post(metadataGraphQLUrl, {
headers,
data: {
operationName: 'GetPageLayouts',
query:
'query GetPageLayouts($objectMetadataId: String, $pageLayoutType: PageLayoutType) { getPageLayouts(objectMetadataId: $objectMetadataId, pageLayoutType: $pageLayoutType) { id } }',
variables: {
objectMetadataId,
pageLayoutType: 'RECORD_PAGE',
},
},
});
const layoutsBody = await layoutsResponse.json();
const layouts = layoutsBody.data.getPageLayouts as { id: string }[];
// Step 3: Reset each layout to default
for (const layout of layouts) {
await page.request.post(metadataGraphQLUrl, {
headers,
data: {
operationName: 'ResetPageLayoutToDefault',
query:
'mutation ResetPageLayoutToDefault($id: String!) { resetPageLayoutToDefault(id: $id) { id } }',
variables: {
id: layout.id,
},
},
});
}
};
@@ -0,0 +1,16 @@
import { type Page } from '@playwright/test';
export const enterEditMode = async (page: Page) => {
await page.getByTestId('page-header-side-panel-button').click();
// Type to search for Edit Layout command
const searchInput = page.getByTestId('side-panel-focus');
await searchInput.fill('Edit Layout');
// Click the Edit Layout command
const editLayoutItem = page.getByText('Edit Layout', { exact: true });
await editLayoutItem.click();
// Wait for the layout customization bar to appear
await page.getByText('Layout customization').waitFor({ state: 'visible' });
};
@@ -0,0 +1,305 @@
import { expect, test } from '../lib/fixtures/screenshot';
import { createCompany } from '../lib/requests/create-company';
import { deleteCompany } from '../lib/requests/delete-company';
import { destroyCompany } from '../lib/requests/destroy-company';
import { resetRecordPageLayout } from '../lib/requests/reset-record-page-layout';
import { enterEditMode } from '../lib/utils/enterEditMode';
const DEFAULT_TAB_NAMES = [
'Home',
'Timeline',
'Tasks',
'Notes',
'Files',
'Emails',
'Calendar',
];
test.describe('Record page layout', () => {
let companyId: string | undefined;
test.beforeEach(async ({ page }) => {
companyId = undefined;
await resetRecordPageLayout({ page, objectNameSingular: 'company' });
companyId = await createCompany({
page,
companyName: 'Layout E2E Test Company',
});
await page.goto(`/object/company/${companyId}`);
});
test.afterEach(async ({ page }) => {
if (!companyId) return;
await deleteCompany({ page, companyId });
await destroyCompany({ page, companyId });
});
test('Full layout workflow: view, edit tabs, widgets, save, cancel, reset', async ({
page,
}) => {
const sidePanel = page.locator('[data-side-panel]');
const tabsContainer = page.getByTestId('page-layout-tab-list');
// ===== PHASE 1: Default layout renders correctly =====
for (const tabName of DEFAULT_TAB_NAMES.slice(1)) {
const tab = tabsContainer.getByRole('link', {
name: tabName,
exact: true,
});
await expect(tab.last()).toBeVisible();
}
// ===== PHASE 2: Tab switching works in view mode =====
await tabsContainer
.getByRole('link', { name: 'Emails', exact: true })
.click();
await tabsContainer
.getByRole('link', { name: 'Tasks', exact: true })
.click();
await tabsContainer
.getByRole('link', { name: 'Timeline', exact: true })
.click();
// ===== PHASE 3: Enter edit mode =====
await enterEditMode(page);
// ===== PHASE 4: Create a new tab =====
const defaultMoreTabsCount = 5;
await expect(
page.getByText(`+${defaultMoreTabsCount} More`, { exact: true }),
).toBeVisible();
const newTabButton = tabsContainer.getByRole('button', { name: 'New Tab' });
await newTabButton.click();
const emptyTabInput = sidePanel.getByRole('textbox', { name: 'Tab' });
await expect(emptyTabInput).toHaveValue('Untitled');
await page.keyboard.press('Escape');
await expect(
page.getByText(`+${defaultMoreTabsCount + 1} More`, { exact: true }),
).toBeVisible();
// ===== PHASE 5: Delete the newly created tab =====
const deleteButton = sidePanel.getByText('Delete', { exact: true });
await deleteButton.click();
await expect(
page.getByText(`+${defaultMoreTabsCount} More`, { exact: true }),
).toBeVisible();
// ===== PHASE 6: Rename a tab =====
const timelineTab = tabsContainer.getByText('Timeline', { exact: true });
// Focus
await timelineTab.click();
// Edit
await timelineTab.click();
const titleLabel = sidePanel.getByText('Timeline');
await titleLabel.click();
const titleInput = sidePanel.getByRole('textbox');
await titleInput.clear();
await titleInput.fill('My Custom Timeline');
await page.keyboard.press('Enter');
const renamedTab = tabsContainer.getByText('My Custom Timeline', {
exact: true,
});
await expect(renamedTab).toBeVisible();
// ===== PHASE 7: Create a custom tab for duplication =====
await newTabButton.click();
const customTabInput = sidePanel.getByRole('textbox', { name: 'Tab' });
await customTabInput.clear();
await customTabInput.fill('Custom Tab');
await page.keyboard.press('Enter');
const moreButtonAfterCustomTabCreation = page.getByText(
`+${defaultMoreTabsCount + 1} More`,
{ exact: true },
);
await moreButtonAfterCustomTabCreation.click();
const customTab = page
.getByRole('listbox')
.getByRole('button', { name: 'Custom Tab', exact: true });
await expect(customTab).toBeVisible();
await page.keyboard.press('Escape');
// ===== PHASE 8: Duplicate the custom tab =====
const duplicateButton = sidePanel.getByText('Duplicate', { exact: true });
await duplicateButton.click();
const duplicatedTabInput = sidePanel.getByRole('textbox', { name: 'Tab' });
await expect(duplicatedTabInput).toHaveValue('Custom Tab (Copy)');
await page.keyboard.press('Escape');
const moreButtonAfterTabDuplication = page.getByText(
`+${defaultMoreTabsCount + 2} More`,
{ exact: true },
);
await expect(moreButtonAfterTabDuplication).toBeVisible();
// ===== PHASE 9: Reorder a tab (move left) =====
await moreButtonAfterTabDuplication.click();
const tasksTab = page.getByRole('listbox').getByRole('button', {
name: 'Tasks',
exact: true,
});
await tasksTab.click();
const moveLeftButton = sidePanel.getByText('Move left', { exact: true });
await moveLeftButton.click();
await expect(
tabsContainer.getByRole('button', { name: 'Tasks', exact: true }),
).toBeVisible();
await moreButtonAfterTabDuplication.click();
await expect(
page.getByRole('listbox').getByRole('button', {
name: 'My Custom Timeline',
exact: true,
}),
).toBeVisible();
// ===== PHASE 10: Cancel discards all changes =====
await page.getByRole('button', { name: 'Cancel' }).click();
// After canceling, all changes should be reverted
await expect(
tabsContainer.getByText('My Custom Timeline', { exact: true }),
).not.toBeVisible();
await expect(
tabsContainer.getByText('Custom Tab (Copy)', { exact: true }),
).not.toBeVisible();
await expect(
tabsContainer.getByText('Custom Tab', { exact: true }),
).not.toBeVisible();
await expect(
tabsContainer.getByText('Timeline', { exact: true }),
).toBeVisible();
// ===== PHASE 11: Create a tab, save, and verify persistence =====
await enterEditMode(page);
await newTabButton.click();
const newTabInput = sidePanel.getByRole('textbox', { name: 'Tab' });
await expect(newTabInput).toHaveValue('Untitled');
await newTabInput.clear();
await newTabInput.fill('Persisted Tab');
await page.keyboard.press('Enter');
// Add a widget so the tab is not filtered out as empty
const persistedTabFieldsGroup = page
.getByText('Fields group', {
exact: true,
})
.nth(1);
await persistedTabFieldsGroup.click();
await expect(sidePanel.getByText('Data and display')).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save' });
await saveButton.click();
await expect(saveButton).not.toBeVisible();
// Reload the page to verify persistence
await page.goto(`/object/company/${companyId}`);
const persistedTab = tabsContainer.getByText('Persisted Tab', {
exact: true,
});
await expect(persistedTab).toBeVisible();
// ===== PHASE 12: Clean up - delete the persisted tab =====
await enterEditMode(page);
const moreButtonAfterPersistedTabCreation = page.getByText(
`+${defaultMoreTabsCount + 1} More`,
{ exact: true },
);
await moreButtonAfterPersistedTabCreation.click();
const persistedTabInListBox = page
.getByRole('listbox')
.getByText('Persisted Tab', { exact: true });
await persistedTabInListBox.hover();
await page.getByTestId('tab-list-item-edit-button').click();
const deletePersistedButton = sidePanel.getByText('Delete', {
exact: true,
});
await deletePersistedButton.click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(
tabsContainer.getByText('Persisted Tab', { exact: true }),
).not.toBeVisible();
await expect(
tabsContainer.getByText('Timeline', { exact: true }),
).toBeVisible();
// ===== PHASE 13: Widget management =====
await enterEditMode(page);
// Add a fields group widget on Home tab
const fieldsGroupOption = page.getByText('Field', { exact: true });
await fieldsGroupOption.click();
const createdFieldWidgetTitle = page.getByRole('button', {
name: 'Account Owner',
exact: true,
});
await expect(createdFieldWidgetTitle).toBeVisible();
await createdFieldWidgetTitle.click();
// Delete the widget
const deleteWidgetButton = sidePanel.getByText('Delete widget');
await deleteWidgetButton.click();
await expect(createdFieldWidgetTitle).not.toBeVisible();
// Cancel to discard widget changes
await page.getByRole('button', { name: 'Cancel' }).click();
});
});
@@ -387,7 +387,10 @@ export const PageLayoutTabList = ({
onDragEnd={handleDragEnd}
onDragUpdate={handleDragUpdate}
>
<StyledContainer className={className}>
<StyledContainer
data-testid="page-layout-tab-list"
className={className}
>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
@@ -452,7 +455,10 @@ export const PageLayoutTabList = ({
</StyledContainer>
</DragDropContext>
) : (
<StyledContainer className={className}>
<StyledContainer
data-testid="page-layout-tab-list"
className={className}
>
<PageLayoutTabListVisibleTabs
visibleTabs={tabsWithIcons}
visibleTabCount={visibleTabCount}
@@ -80,6 +80,7 @@ export const PageLayoutTabMenuItemSelectAvatar = ({
Icon={IconPencil}
size="small"
accent="tertiary"
testId="tab-list-item-edit-button"
onClick={() => onEditClick?.(tab.id)}
/>
</div>