Built-in PagerDuty-equivalent system for on-call scheduling and alert escalation. Users can now manage schedules, rotation layers, overrides, and escalation policies directly in the app alongside PagerDuty. - On-call schedules with rotation layers (daily/weekly/custom), member management, and temporary overrides - Escalation policies with ordered rules, timeout-based escalation, and user/schedule targets - Automatic escalation via Oban worker with configurable repeat count - Email notifications via Swoosh for on-call alerts - Resolver computes who's on-call from layer stacking and overrides - AlertNotificationWorker integration: starts escalation alongside PagerDuty, acknowledges/resolves incidents on alert state changes - Device and organization schemas support escalation_policy_id - Escalation policy picker on device form - Schedules nav item with tabbed index (schedules + escalation policies) - Full CRUD UI for schedules, layers, members, overrides, rules, targets - 62 LiveView tests, 56 context/schema/resolver/escalation tests - 26 E2E Playwright tests for smoke and critical path coverage
526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('On-Call Schedules & Escalation Policies', () => {
|
|
let consoleErrors: string[];
|
|
let pageErrors: Error[];
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
consoleErrors = [];
|
|
pageErrors = [];
|
|
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'error') {
|
|
consoleErrors.push(msg.text());
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', error => {
|
|
pageErrors.push(error);
|
|
});
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
if (consoleErrors.length > 0) {
|
|
console.log('Console errors:', consoleErrors);
|
|
}
|
|
if (pageErrors.length > 0) {
|
|
console.log('Page errors:', pageErrors);
|
|
}
|
|
});
|
|
|
|
test.describe('Smoke Tests', () => {
|
|
test('schedules index loads without errors', async ({ page }) => {
|
|
const response = await page.goto('/schedules');
|
|
expect(response?.status()).toBeLessThan(500);
|
|
|
|
await page.waitForTimeout(1000);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
});
|
|
|
|
test('new schedule form loads without errors', async ({ page }) => {
|
|
const response = await page.goto('/schedules/new');
|
|
expect(response?.status()).toBeLessThan(500);
|
|
|
|
await page.waitForTimeout(1000);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
});
|
|
|
|
test('new escalation policy form loads without errors', async ({ page }) => {
|
|
const response = await page.goto('/schedules/escalation-policies/new');
|
|
expect(response?.status()).toBeLessThan(500);
|
|
|
|
await page.waitForTimeout(1000);
|
|
await expect(page.locator('body')).toBeVisible();
|
|
});
|
|
|
|
test('schedules page maintains socket connection', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(2000);
|
|
|
|
const hasSocketError = consoleErrors.some(err =>
|
|
err.includes('socket') ||
|
|
err.includes('Jason') ||
|
|
err.includes('encode')
|
|
);
|
|
|
|
expect(hasSocketError).toBe(false);
|
|
});
|
|
});
|
|
|
|
test.describe('Navigation', () => {
|
|
test('nav bar shows Schedules link', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.waitForTimeout(500);
|
|
|
|
const schedulesLink = page.locator('a[href="/schedules"]').first();
|
|
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(schedulesLink).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('can navigate to schedules page from nav', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
await page.waitForTimeout(500);
|
|
|
|
const schedulesLink = page.locator('a[href="/schedules"]').first();
|
|
if (await schedulesLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await schedulesLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules/);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Schedules Tab', () => {
|
|
test('schedules page shows tabs for Schedules and Escalation Policies', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const schedulesTab = page.getByText('Schedules', { exact: true }).first();
|
|
const policiesTab = page.getByText('Escalation Policies', { exact: true }).first();
|
|
|
|
await expect(schedulesTab).toBeVisible();
|
|
await expect(policiesTab).toBeVisible();
|
|
});
|
|
|
|
test('schedules tab shows empty state or schedule list', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Either shows empty state or a table of schedules
|
|
const emptyState = page.getByText('No schedules');
|
|
const scheduleTable = page.locator('table');
|
|
|
|
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
|
|
const hasTable = await scheduleTable.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
// One of these should be visible
|
|
expect(hasEmptyState || hasTable).toBe(true);
|
|
});
|
|
|
|
test('can navigate to new schedule form', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const newButton = page.locator('a[href="/schedules/new"]').first();
|
|
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await newButton.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules\/new/);
|
|
}
|
|
});
|
|
|
|
test('new schedule form has required fields', async ({ page }) => {
|
|
await page.goto('/schedules/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Check for name input
|
|
const nameInput = page.locator('input[name*="name"]').first();
|
|
await expect(nameInput).toBeVisible();
|
|
|
|
// Check for timezone input
|
|
const timezoneInput = page.locator('input[name*="timezone"]').first();
|
|
await expect(timezoneInput).toBeVisible();
|
|
|
|
// Check for save button
|
|
const saveButton = page.getByText('Save Schedule');
|
|
await expect(saveButton).toBeVisible();
|
|
});
|
|
|
|
test('can create a new schedule', async ({ page }) => {
|
|
await page.goto('/schedules/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
const nameInput = page.locator('input[name*="name"]').first();
|
|
const timezoneInput = page.locator('input[name*="timezone"]').first();
|
|
|
|
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const scheduleName = `E2E Test Schedule ${Date.now()}`;
|
|
await nameInput.fill(scheduleName);
|
|
await timezoneInput.fill('America/Chicago');
|
|
|
|
const saveButton = page.getByText('Save Schedule');
|
|
if (await saveButton.isVisible()) {
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should redirect to the schedule show page
|
|
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+$/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('new schedule form has back link to index', async ({ page }) => {
|
|
await page.goto('/schedules/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Back arrow link
|
|
const backLink = page.locator('a[href="/schedules"]').first();
|
|
await expect(backLink).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Schedule Show Page', () => {
|
|
test('schedule show page displays schedule details', async ({ page }) => {
|
|
// First, create a schedule or navigate to an existing one
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Look for a schedule row in the table
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show schedule name and on-call status
|
|
const onCallSection = page.getByText('Currently On-Call');
|
|
await expect(onCallSection).toBeVisible();
|
|
|
|
// Should show layers section
|
|
const layersSection = page.getByText('Layers', { exact: true });
|
|
await expect(layersSection).toBeVisible();
|
|
|
|
// Should show overrides section
|
|
const overridesSection = page.getByText('Overrides', { exact: true });
|
|
await expect(overridesSection).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('schedule show page has edit and delete buttons', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const editLink = page.getByText('Edit', { exact: true });
|
|
const deleteButton = page.getByText('Delete', { exact: true });
|
|
|
|
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(editLink).toBeVisible();
|
|
}
|
|
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(deleteButton).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can navigate to edit schedule from show page', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const editLink = page.locator('a[href*="/edit"]').first();
|
|
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await editLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules\/[a-f0-9-]+\/edit/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('back button on show page goes to index', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const backLink = page.locator('a[href="/schedules"]').first();
|
|
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await backLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules$/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can toggle the Add Layer form', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const addLayerButton = page.getByText('Add Layer', { exact: true }).first();
|
|
if (await addLayerButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Click to show the form
|
|
await addLayerButton.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Layer form should now be visible with fields
|
|
const layerNameInput = page.locator('input[name*="layer"][name*="name"]').first();
|
|
const formVisible = await layerNameInput.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
if (formVisible) {
|
|
await expect(layerNameInput).toBeVisible();
|
|
|
|
// Click cancel to hide
|
|
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
|
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await cancelButton.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can toggle the Add Override form', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await scheduleRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const addOverrideButton = page.getByText('Add Override', { exact: true }).first();
|
|
if (await addOverrideButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
// Click to show the form
|
|
await addOverrideButton.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Override form should have datetime fields
|
|
const startTimeInput = page.locator('input[name*="start_time"]').first();
|
|
const formVisible = await startTimeInput.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
if (formVisible) {
|
|
await expect(startTimeInput).toBeVisible();
|
|
|
|
// Click cancel to hide
|
|
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
|
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await cancelButton.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Escalation Policies Tab', () => {
|
|
test('can switch to escalation policies tab', async ({ page }) => {
|
|
await page.goto('/schedules');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policiesTab = page.locator('a[href*="tab=escalation-policies"]').first();
|
|
if (await policiesTab.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policiesTab.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/tab=escalation-policies/);
|
|
}
|
|
});
|
|
|
|
test('escalation policies tab shows empty state or policy list', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const emptyState = page.getByText('No escalation policies');
|
|
const policyTable = page.locator('table');
|
|
|
|
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
|
|
const hasTable = await policyTable.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
expect(hasEmptyState || hasTable).toBe(true);
|
|
});
|
|
|
|
test('can navigate to new escalation policy form', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const newButton = page.locator('a[href*="/schedules/escalation-policies/new"]').first();
|
|
if (await newButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await newButton.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/new/);
|
|
}
|
|
});
|
|
|
|
test('new escalation policy form has required fields', async ({ page }) => {
|
|
await page.goto('/schedules/escalation-policies/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
const nameInput = page.locator('input[name*="name"]').first();
|
|
await expect(nameInput).toBeVisible();
|
|
|
|
const repeatInput = page.locator('input[name*="repeat_count"]').first();
|
|
await expect(repeatInput).toBeVisible();
|
|
|
|
const saveButton = page.getByText('Save Policy');
|
|
await expect(saveButton).toBeVisible();
|
|
});
|
|
|
|
test('can create a new escalation policy', async ({ page }) => {
|
|
await page.goto('/schedules/escalation-policies/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
const nameInput = page.locator('input[name*="name"]').first();
|
|
|
|
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
const policyName = `E2E Test Policy ${Date.now()}`;
|
|
await nameInput.fill(policyName);
|
|
|
|
const saveButton = page.getByText('Save Policy');
|
|
if (await saveButton.isVisible()) {
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should redirect to the policy show page
|
|
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+$/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('new escalation policy form has back link', async ({ page }) => {
|
|
await page.goto('/schedules/escalation-policies/new');
|
|
await page.waitForTimeout(500);
|
|
|
|
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
|
|
await expect(backLink).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Escalation Policy Show Page', () => {
|
|
test('policy show page displays policy details', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policyRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show the policy name
|
|
const heading = page.locator('h1').first();
|
|
await expect(heading).toBeVisible();
|
|
|
|
// Should show escalation rules section
|
|
const rulesSection = page.getByText('Escalation Rules');
|
|
if (await rulesSection.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(rulesSection).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('policy show page has edit and delete buttons', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policyRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const editLink = page.getByText('Edit', { exact: true });
|
|
const deleteButton = page.getByText('Delete', { exact: true });
|
|
|
|
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(editLink).toBeVisible();
|
|
}
|
|
if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await expect(deleteButton).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can navigate to edit policy from show page', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policyRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const editLink = page.locator('a[href*="/edit"]').first();
|
|
if (await editLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await editLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/\/schedules\/escalation-policies\/[a-f0-9-]+\/edit/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('back button on policy show page goes to escalation policies tab', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policyRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const backLink = page.locator('a[href*="tab=escalation-policies"]').first();
|
|
if (await backLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await backLink.click();
|
|
await page.waitForTimeout(500);
|
|
await expect(page).toHaveURL(/tab=escalation-policies/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('can toggle the Add Rule form on policy show page', async ({ page }) => {
|
|
await page.goto('/schedules?tab=escalation-policies');
|
|
await page.waitForTimeout(500);
|
|
|
|
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
|
|
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await policyRow.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const addRuleButton = page.getByText('Add Rule', { exact: true }).first();
|
|
if (await addRuleButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await addRuleButton.click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Rule form should be visible with timeout field
|
|
const timeoutInput = page.locator('input[name="timeout_minutes"]').first();
|
|
const formVisible = await timeoutInput.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
if (formVisible) {
|
|
await expect(timeoutInput).toBeVisible();
|
|
|
|
// Click cancel to hide
|
|
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
|
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await cancelButton.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|