diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 6054e6de..5f197ee3 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -53,7 +53,7 @@ function ConnectionsSection() { const [isCreatingConnection, setIsCreatingConnection] = useState(false); return ( - + Connections @@ -112,6 +112,7 @@ function ConnectionsSection() { {!isCreatingConnection && (IS_LOCAL_MODE ? (connections?.length ?? 0) < 1 : true) && ( ) : ( diff --git a/packages/app/tests/e2e/features/team.spec.ts b/packages/app/tests/e2e/features/team.spec.ts new file mode 100644 index 00000000..0ba02a89 --- /dev/null +++ b/packages/app/tests/e2e/features/team.spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/app/tests/e2e/page-objects/TeamPage.ts b/packages/app/tests/e2e/page-objects/TeamPage.ts new file mode 100644 index 00000000..43262e7c --- /dev/null +++ b/packages/app/tests/e2e/page-objects/TeamPage.ts @@ -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'); + } +}