chore: add tests for team page (#1848)

Adds playwright tests for the team page
This commit is contained in:
Tom Alexander 2026-03-04 16:20:45 -05:00 committed by GitHub
parent 6d7327aba0
commit 20561c703a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 582 additions and 10 deletions

View file

@ -53,7 +53,7 @@ function ConnectionsSection() {
const [isCreatingConnection, setIsCreatingConnection] = useState(false);
return (
<Box id="connections">
<Box id="connections" data-testid="connections-section">
<Text size="md">Connections</Text>
<Divider my="md" />
<Card>
@ -112,6 +112,7 @@ function ConnectionsSection() {
{!isCreatingConnection &&
(IS_LOCAL_MODE ? (connections?.length ?? 0) < 1 : true) && (
<Button
data-testid="add-connection-button"
variant="primary"
onClick={() => setIsCreatingConnection(true)}
>
@ -142,7 +143,7 @@ function ConnectionsSection() {
function SourcesSection() {
return (
<Box id="sources">
<Box id="sources" data-testid="sources-section">
<Text size="md">Sources</Text>
<Divider my="md" />
<SourcesList
@ -155,7 +156,7 @@ function SourcesSection() {
}
function IntegrationsSection() {
return (
<Box id="integrations">
<Box id="integrations" data-testid="integrations-section">
<Text size="md">Integrations</Text>
<Divider my="md" />
<Card>
@ -203,7 +204,7 @@ function TeamNameSection() {
[refetchTeam, setTeamName],
);
return (
<Box id="team_name">
<Box id="team_name" data-testid="team-name-section">
<Text size="md">Team Name</Text>
<Divider my="md" />
<Card>
@ -211,6 +212,7 @@ function TeamNameSection() {
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group gap="xs">
<TextInput
data-testid="team-name-input"
size="xs"
placeholder="My Team"
required
@ -227,6 +229,7 @@ function TeamNameSection() {
}}
/>
<Button
data-testid="team-name-save-button"
type="submit"
size="xs"
variant="primary"
@ -235,6 +238,7 @@ function TeamNameSection() {
Save
</Button>
<Button
data-testid="team-name-cancel-button"
type="button"
size="xs"
variant="secondary"
@ -247,9 +251,12 @@ function TeamNameSection() {
</form>
) : (
<Group gap="lg">
<div className="fs-7">{team?.name}</div>
<div className="fs-7" data-testid="team-name-display">
{team?.name}
</div>
{hasAdminAccess && (
<Button
data-testid="team-name-change-button"
size="xs"
variant="secondary"
leftSection={<IconPencil size={16} />}
@ -582,7 +589,7 @@ function ApiKeysSection() {
};
return (
<Box id="api_keys">
<Box id="api_keys" data-testid="api-keys-section">
<Text size="md">API Keys</Text>
<Divider my="md" />
<Card mb="md">
@ -593,6 +600,7 @@ function ApiKeysSection() {
)}
{hasAdminAccess && (
<Button
data-testid="rotate-api-key-button"
variant="danger"
onClick={() => setRotateApiKeyConfirmationModalShow(true)}
>
@ -619,6 +627,7 @@ function ApiKeysSection() {
</Text>
<Group justify="end">
<Button
data-testid="rotate-api-key-cancel"
variant="secondary"
className="mt-2 px-4 ms-2 float-end"
size="sm"
@ -627,6 +636,7 @@ function ApiKeysSection() {
Cancel
</Button>
<Button
data-testid="rotate-api-key-confirm"
variant="danger"
className="mt-2 px-4 float-end"
size="sm"
@ -657,7 +667,7 @@ export default function TeamPage() {
team?.allowedAuthMethods != null && team?.allowedAuthMethods.length > 0;
return (
<div className="TeamPage">
<div className="TeamPage" data-testid="team-page">
<Head>
<title>My Team - {brandName}</title>
</Head>

View file

@ -205,7 +205,7 @@ export default function TeamMembersSection() {
};
return (
<Box id="team_members">
<Box id="team_members" data-testid="team-members-section">
<Text size="md">Team</Text>
<Divider my="md" />
@ -214,6 +214,7 @@ export default function TeamMembersSection() {
<Group align="center" justify="space-between">
<div className="fs-7">Team Members</div>
<Button
data-testid="invite-member-button"
variant="primary"
leftSection={<IconUserPlus size={16} />}
onClick={() => setTeamInviteModalShow(true)}
@ -361,6 +362,7 @@ export default function TeamMembersSection() {
</Text>
<Group justify="flex-end" gap="xs">
<Button
data-testid="cancel-delete-member"
variant="secondary"
onClick={() =>
setDeleteTeamMemberConfirmationModalData({
@ -373,6 +375,7 @@ export default function TeamMembersSection() {
Cancel
</Button>
<Button
data-testid="confirm-delete-member"
variant="danger"
onClick={() =>
deleteTeamMemberConfirmationModalData.id &&
@ -408,6 +411,7 @@ function InviteTeamMemberForm({
>
<Stack>
<TextInput
data-testid="invite-email-input"
label="Email"
name="email"
type="email"
@ -421,6 +425,7 @@ function InviteTeamMemberForm({
The invite link will automatically expire after 30 days.
</div>
<Button
data-testid="send-invite-button"
variant="primary"
type="submit"
disabled={!email || isSubmitting}

View file

@ -104,7 +104,13 @@ export default function WebhooksSection() {
<Stack>
{groupedWebhooks.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
<Text
data-testid="webhooks-empty-state"
size="sm"
c="dimmed"
ta="center"
py="xl"
>
No webhooks configured yet
</Text>
) : (
@ -190,7 +196,11 @@ export default function WebhooksSection() {
</Stack>
{!isAddWebhookModalOpen ? (
<Button variant="secondary" onClick={openWebhookModal}>
<Button
data-testid="add-webhook-section-button"
variant="secondary"
onClick={openWebhookModal}
>
Add Webhook
</Button>
) : (

View file

@ -0,0 +1,224 @@
// seed: packages/app/tests/e2e/features/dashboard.spec.ts
import { TeamPage } from '../page-objects/TeamPage';
import { expect, test } from '../utils/base-test';
test.describe('Team Settings Page', { tag: ['@team', '@full-stack'] }, () => {
let teamPage: TeamPage;
test.beforeEach(async ({ page }) => {
teamPage = new TeamPage(page);
await teamPage.goto();
});
test('should load team page with all sections visible', async () => {
await test.step('Verify page container is visible', async () => {
await expect(teamPage.container).toBeVisible();
});
await test.step('Verify all major sections are visible', async () => {
await expect(teamPage.sources).toBeVisible();
await expect(teamPage.connections).toBeVisible();
await expect(teamPage.integrations).toBeVisible();
await expect(teamPage.teamName).toBeVisible();
await expect(teamPage.apiKeys).toBeVisible();
await expect(teamPage.members).toBeVisible();
});
await test.step('Verify section headings exist', async () => {
await expect(
teamPage.sources.getByText('Sources', { exact: true }),
).toBeVisible();
await expect(
teamPage.connections.getByText('Connections', { exact: true }),
).toBeVisible();
await expect(
teamPage.integrations.getByText('Integrations', { exact: true }),
).toBeVisible();
await expect(
teamPage.teamName.getByText('Team Name', { exact: true }),
).toBeVisible();
await expect(
teamPage.apiKeys.getByText('API Keys', { exact: true }),
).toBeVisible();
await expect(
teamPage.members.getByText('Team', { exact: true }),
).toBeVisible();
});
});
test('should change team name', async () => {
test.setTimeout(90000);
let originalName: string;
await test.step('Read current team name', async () => {
const text = await teamPage.getTeamNameText();
originalName = (text ?? '').trim();
});
await test.step('Start editing and verify edit controls', async () => {
await teamPage.startEditingTeamName();
await expect(teamPage.teamNameSave).toBeVisible();
await expect(teamPage.teamNameCancel).toBeVisible();
});
const newName = `E2E Team ${Date.now()}`;
await test.step('Fill and save new team name', async () => {
await teamPage.fillTeamName(newName);
await teamPage.saveTeamName();
});
await test.step('Verify success notification and new name', async () => {
await expect(teamPage.page.getByText('Updated team name')).toBeVisible();
await expect(teamPage.teamNameValue).toHaveText(newName);
});
await test.step('Restore original team name', async () => {
await teamPage.changeTeamName(originalName!);
await expect(teamPage.page.getByText('Updated team name')).toBeVisible();
});
});
test('should cancel team name editing', async () => {
let originalName: string;
await test.step('Read current team name', async () => {
const text = await teamPage.getTeamNameText();
originalName = (text ?? '').trim();
});
await test.step('Start editing and fill new name', async () => {
await teamPage.startEditingTeamName();
await teamPage.fillTeamName(`E2E Temp ${Date.now()}`);
});
await test.step('Cancel editing', async () => {
await teamPage.cancelEditingTeamName();
});
await test.step('Verify original name is still displayed', async () => {
await expect(teamPage.teamNameValue).toHaveText(originalName!);
await expect(teamPage.teamNameSave).toBeHidden();
});
});
test('should display API keys', async () => {
await test.step('Scroll to API keys section', async () => {
await teamPage.apiKeys.scrollIntoViewIfNeeded();
});
await test.step('Verify API key labels are visible', async () => {
await expect(
teamPage.apiKeys.getByText('Ingestion API Key'),
).toBeVisible();
await expect(
teamPage.apiKeys.getByText('Personal API Access Key'),
).toBeVisible();
});
await test.step('Verify rotate button is visible', async () => {
await expect(teamPage.rotateButton).toBeVisible();
});
});
test('should open and cancel rotate API key modal', async () => {
await test.step('Open rotate API key modal', async () => {
await teamPage.clickRotateApiKey();
});
await test.step('Verify modal shows irreversible warning', async () => {
await expect(teamPage.page.getByText('not reversible')).toBeVisible();
});
await test.step('Cancel and verify modal closes', async () => {
await teamPage.cancelRotateApiKey();
await expect(teamPage.page.getByText('not reversible')).toBeHidden();
});
});
test('should create and delete a webhook', async () => {
test.setTimeout(90000);
const ts = Date.now();
const webhookName = `E2E Webhook ${ts}`;
const webhookUrl = `https://example.com/e2e-webhook-${ts}`;
await test.step('Scroll to integrations and create webhook', async () => {
await teamPage.integrations.scrollIntoViewIfNeeded();
await teamPage.createWebhook({
serviceType: 'Generic',
name: webhookName,
url: webhookUrl,
});
});
await test.step('Verify webhook created successfully', async () => {
await expect(
teamPage.page.getByText('Webhook created successfully'),
).toBeVisible();
await expect(teamPage.integrations.getByText(webhookName)).toBeVisible();
await expect(teamPage.integrations.getByText(webhookUrl)).toBeVisible();
});
await test.step('Delete the webhook', async () => {
await teamPage.deleteWebhookByName(webhookName);
await teamPage.confirmDialog();
});
await test.step('Verify webhook deleted successfully', async () => {
await expect(
teamPage.page.getByText('Webhook deleted successfully'),
).toBeVisible();
await expect(teamPage.integrations.getByText(webhookName)).toBeHidden();
});
});
test('should invite a team member and delete the invitation', async () => {
test.setTimeout(90000);
const email = `e2e-test-${Date.now()}@example.com`;
await test.step('Verify current user is displayed', async () => {
await teamPage.members.scrollIntoViewIfNeeded();
await expect(teamPage.members.getByText('You')).toBeVisible();
});
await test.step('Open invite modal and send invitation', async () => {
await teamPage.clickInviteMember();
await expect(
teamPage.page.getByRole('dialog').getByText('Invite Team Member'),
).toBeVisible();
await teamPage.fillInviteEmail(email);
await teamPage.submitInvite();
});
await test.step('Verify invitation appears with pending badge', async () => {
const row = teamPage.getInvitationRow(email);
await expect(row).toBeVisible({ timeout: 10000 });
await expect(row.getByText(email)).toBeVisible();
await expect(row.getByText('Pending Invite')).toBeVisible();
});
await test.step('Delete the invitation', async () => {
await teamPage.deleteInvitationByEmail(email);
await teamPage.confirmDeleteMember();
});
await test.step('Verify invitation deleted successfully', async () => {
await expect(
teamPage.page.getByText('Deleted team invite'),
).toBeVisible();
await expect(teamPage.members.getByText(email)).toBeHidden();
});
});
test('should display connection information', async () => {
await test.step('Verify connections section is visible', async () => {
await expect(teamPage.connections).toBeVisible();
});
await test.step('Verify connection details are visible', async () => {
await expect(teamPage.connections.getByText('Host:')).toBeVisible();
await expect(teamPage.connections.getByText('Username:')).toBeVisible();
});
});
});

View file

@ -0,0 +1,323 @@
/**
* TeamPage - Page object for the /team settings page
* Encapsulates all interactions with team settings sections:
* Sources, Connections, Integrations/Webhooks, Team Name,
* ClickHouse Settings, API Keys, and Team Members.
*/
import { Locator, Page } from '@playwright/test';
export class TeamPage {
readonly page: Page;
private readonly pageContainer: Locator;
// Section containers
private readonly sourcesSection: Locator;
private readonly connectionsSection: Locator;
private readonly integrationsSection: Locator;
private readonly teamNameSection: Locator;
private readonly apiKeysSection: Locator;
private readonly teamMembersSection: Locator;
// Team name elements
private readonly teamNameDisplay: Locator;
private readonly teamNameChangeButton: Locator;
private readonly teamNameInput: Locator;
private readonly teamNameSaveButton: Locator;
private readonly teamNameCancelButton: Locator;
// API Keys elements
private readonly rotateApiKeyButton: Locator;
private readonly rotateApiKeyConfirm: Locator;
private readonly rotateApiKeyCancel: Locator;
// Connections elements
private readonly addConnectionButton: Locator;
// Webhooks elements
private readonly addWebhookButton: Locator;
private readonly webhooksEmptyState: Locator;
// Team members elements
private readonly inviteMemberButton: Locator;
private readonly inviteEmailInput: Locator;
private readonly sendInviteButton: Locator;
private readonly confirmDeleteMemberButton: Locator;
private readonly cancelDeleteMemberButton: Locator;
private readonly confirmDialogConfirmBtn: Locator;
constructor(page: Page) {
this.page = page;
this.pageContainer = page.getByTestId('team-page');
this.sourcesSection = page.getByTestId('sources-section');
this.connectionsSection = page.getByTestId('connections-section');
this.integrationsSection = page.getByTestId('integrations-section');
this.teamNameSection = page.getByTestId('team-name-section');
this.apiKeysSection = page.getByTestId('api-keys-section');
this.teamMembersSection = page.getByTestId('team-members-section');
this.teamNameDisplay = page.getByTestId('team-name-display');
this.teamNameChangeButton = page.getByTestId('team-name-change-button');
this.teamNameInput = page.getByTestId('team-name-input');
this.teamNameSaveButton = page.getByTestId('team-name-save-button');
this.teamNameCancelButton = page.getByTestId('team-name-cancel-button');
this.rotateApiKeyButton = page.getByTestId('rotate-api-key-button');
this.rotateApiKeyConfirm = page.getByTestId('rotate-api-key-confirm');
this.rotateApiKeyCancel = page.getByTestId('rotate-api-key-cancel');
this.addConnectionButton = page.getByTestId('add-connection-button');
this.addWebhookButton = page.getByTestId('add-webhook-section-button');
this.webhooksEmptyState = page.getByTestId('webhooks-empty-state');
this.inviteMemberButton = page.getByTestId('invite-member-button');
this.inviteEmailInput = page.getByTestId('invite-email-input');
this.sendInviteButton = page.getByTestId('send-invite-button');
this.confirmDeleteMemberButton = page.getByTestId('confirm-delete-member');
this.cancelDeleteMemberButton = page.getByTestId('cancel-delete-member');
this.confirmDialogConfirmBtn = page.getByTestId('confirm-confirm-button');
}
async goto() {
await this.page.goto('/team');
await this.pageContainer.waitFor({ state: 'visible', timeout: 10000 });
}
// --- Team Name ---
async getTeamNameText() {
return this.teamNameDisplay.textContent();
}
async startEditingTeamName() {
await this.teamNameChangeButton.click();
await this.teamNameInput.waitFor({ state: 'visible' });
}
async fillTeamName(name: string) {
await this.teamNameInput.clear();
await this.teamNameInput.fill(name);
}
async saveTeamName() {
await this.teamNameSaveButton.click();
}
async cancelEditingTeamName() {
await this.teamNameCancelButton.click();
}
async changeTeamName(name: string) {
await this.startEditingTeamName();
await this.fillTeamName(name);
await this.saveTeamName();
}
// --- API Keys ---
async clickRotateApiKey() {
await this.rotateApiKeyButton.click();
}
async confirmRotateApiKey() {
await this.rotateApiKeyConfirm.click();
}
async cancelRotateApiKey() {
await this.rotateApiKeyCancel.click();
}
// --- Connections ---
async clickAddConnection() {
await this.addConnectionButton.click();
}
async fillConnectionForm(opts: {
name: string;
host: string;
username: string;
password: string;
}) {
await this.page.getByTestId('connection-name-input').clear();
await this.page.getByTestId('connection-name-input').fill(opts.name);
await this.page.getByTestId('connection-host-input').clear();
await this.page.getByTestId('connection-host-input').fill(opts.host);
await this.page.getByTestId('connection-username-input').clear();
await this.page
.getByTestId('connection-username-input')
.fill(opts.username);
await this.page.getByTestId('update-password-button').click();
await this.page
.getByTestId('connection-password-input')
.fill(opts.password);
}
async saveConnection() {
await this.page.getByTestId('connection-save-button').click();
}
// --- Webhooks ---
async clickAddWebhook() {
await this.addWebhookButton.click();
}
async fillWebhookForm(opts: {
serviceType: 'Slack' | 'incident.io' | 'Generic';
name: string;
url: string;
}) {
await this.page
.getByTestId('service-type-radio-group')
.getByRole('radio', { name: opts.serviceType, exact: true })
.click();
await this.page.getByTestId('webhook-name-input').fill(opts.name);
await this.page.getByTestId('webhook-url-input').fill(opts.url);
}
async submitWebhookForm() {
await this.page.getByTestId('add-webhook-button').click();
}
async createWebhook(opts: {
serviceType: 'Slack' | 'incident.io' | 'Generic';
name: string;
url: string;
}) {
await this.clickAddWebhook();
await this.fillWebhookForm(opts);
await this.submitWebhookForm();
}
// --- Team Members ---
async clickInviteMember() {
await this.inviteMemberButton.click();
}
async fillInviteEmail(email: string) {
await this.inviteEmailInput.fill(email);
}
async submitInvite() {
await this.sendInviteButton.click();
}
async inviteTeamMember(email: string) {
await this.clickInviteMember();
await this.fillInviteEmail(email);
await this.submitInvite();
}
async confirmDeleteMember() {
await this.confirmDeleteMemberButton.click();
}
async cancelDeleteMember() {
await this.cancelDeleteMemberButton.click();
}
getInvitationRow(email: string) {
return this.teamMembersSection.locator('tr').filter({ hasText: email });
}
async deleteInvitationByEmail(email: string) {
await this.getInvitationRow(email)
.getByRole('button', { name: 'Delete' })
.click();
}
async deleteWebhookByName(webhookName: string) {
const webhookItem = this.integrationsSection
.locator('div')
.filter({ hasText: webhookName })
.filter({ has: this.page.getByRole('button', { name: 'Delete' }) })
.last();
await webhookItem.getByRole('button', { name: 'Delete' }).click();
}
async confirmDialog() {
await this.confirmDialogConfirmBtn.click();
}
// --- Getters for assertions ---
get container() {
return this.pageContainer;
}
get sources() {
return this.sourcesSection;
}
get connections() {
return this.connectionsSection;
}
get integrations() {
return this.integrationsSection;
}
get teamName() {
return this.teamNameSection;
}
get teamNameValue() {
return this.teamNameDisplay;
}
get teamNameEditButton() {
return this.teamNameChangeButton;
}
get teamNameSave() {
return this.teamNameSaveButton;
}
get teamNameCancel() {
return this.teamNameCancelButton;
}
get apiKeys() {
return this.apiKeysSection;
}
get rotateButton() {
return this.rotateApiKeyButton;
}
get members() {
return this.teamMembersSection;
}
get inviteButton() {
return this.inviteMemberButton;
}
get webhooksEmpty() {
return this.webhooksEmptyState;
}
get addWebhook() {
return this.addWebhookButton;
}
get addConnection() {
return this.addConnectionButton;
}
get connectionForm() {
return this.page.getByTestId('connection-form');
}
get webhookNameInput() {
return this.page.getByTestId('webhook-name-input');
}
get webhookUrlInput() {
return this.page.getByTestId('webhook-url-input');
}
}