The TCP monitor creation form showed a placeholder that instructed users to enter a URL with the tcp:// scheme prefix (e.g., tcp://192.168.1.1:8080). Following this guidance caused a silent HTTP 500 error because Go's net.SplitHostPort rejects any input containing a scheme prefix, expecting bare host:port format only. - Corrected the urlPlaceholder translation key to remove the tcp:// prefix - Added per-type dynamic placeholder (urlPlaceholderHttp / urlPlaceholderTcp) so the URL input shows the correct example format as soon as the user selects a monitor type - Added per-type helper text below the URL input explaining the required format, updated in real time when the type selector changes - Added client-side validation: typing a scheme prefix (://) in TCP mode shows an inline error and blocks form submission before the request reaches the backend - Reordered the Create Monitor form so the type selector appears before the URL input, giving users the correct format context before they type - Type selector onChange now clears any stale urlError to prevent incorrect error messages persisting after switching from TCP back to HTTP - Added 5 new i18n keys across all 5 supported locales (en, de, fr, es, zh) - Added 10 RTL unit tests covering all new validation paths including the type-change error-clear scenario - Added 9 Playwright E2E tests covering placeholder variants, helper text, inline error lifecycle, submission blocking, and successful TCP creation Closes #issue-5 (TCP monitor UI cannot add monitor when following placeholder)
201 lines
6.7 KiB
TypeScript
201 lines
6.7 KiB
TypeScript
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
|
import { waitForLoadingComplete } from '../utils/wait-helpers';
|
|
|
|
interface UptimeMonitor {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
url: string;
|
|
interval: number;
|
|
enabled: boolean;
|
|
status: string;
|
|
latency: number;
|
|
max_retries: number;
|
|
last_check?: string | null;
|
|
}
|
|
|
|
const emptyMonitorsRoute = async (page: import('@playwright/test').Page) => {
|
|
await page.route('**/api/v1/uptime/monitors', async (route) => {
|
|
if (route.request().method() === 'GET') {
|
|
await route.fulfill({ status: 200, json: [] });
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
await page.route('**/api/v1/uptime/monitors/*/history*', async (route) => {
|
|
await route.fulfill({ status: 200, json: [] });
|
|
});
|
|
};
|
|
|
|
async function openCreateModal(page: import('@playwright/test').Page) {
|
|
await page.click('[data-testid="add-monitor-button"]');
|
|
await expect(page.getByRole('heading', { name: /create monitor/i })).toBeVisible();
|
|
}
|
|
|
|
test.describe('Create Monitor Modal — TCP UX', () => {
|
|
test.beforeEach(async ({ page, authenticatedUser }) => {
|
|
await loginUser(page, authenticatedUser);
|
|
await emptyMonitorsRoute(page);
|
|
await page.goto('/uptime');
|
|
await waitForLoadingComplete(page);
|
|
});
|
|
|
|
test('HTTP type shows URL placeholder', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
const urlInput = page.locator('#create-monitor-url');
|
|
await expect(urlInput).toHaveAttribute('placeholder', 'https://example.com');
|
|
});
|
|
|
|
test('TCP type shows bare host:port placeholder', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
const typeSelect = page.locator('#create-monitor-type');
|
|
await typeSelect.selectOption('tcp');
|
|
|
|
const urlInput = page.locator('#create-monitor-url');
|
|
await expect(urlInput).toHaveAttribute('placeholder', '192.168.1.1:8080');
|
|
});
|
|
|
|
test('Type selector appears before URL input in tab order', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
const typeSelect = page.locator('#create-monitor-type');
|
|
const urlInput = page.locator('#create-monitor-url');
|
|
|
|
await expect(typeSelect).toBeVisible();
|
|
await expect(urlInput).toBeVisible();
|
|
|
|
// Verify DOM order: type select must appear before URL input
|
|
const typePosition = await typeSelect.evaluate((el) => {
|
|
const all = Array.from(document.querySelectorAll('select, input[type="text"]'));
|
|
return all.indexOf(el as HTMLElement);
|
|
});
|
|
const urlPosition = await urlInput.evaluate((el) => {
|
|
const all = Array.from(document.querySelectorAll('select, input[type="text"]'));
|
|
return all.indexOf(el as HTMLElement);
|
|
});
|
|
|
|
expect(typePosition).toBeLessThan(urlPosition);
|
|
});
|
|
|
|
test('Helper text updates dynamically when type changes', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
const helperText = page.locator('#create-monitor-url-helper');
|
|
|
|
await expect(helperText).toContainText(/scheme/i);
|
|
|
|
await page.locator('#create-monitor-type').selectOption('tcp');
|
|
|
|
await expect(helperText).toContainText(/host:port/i);
|
|
});
|
|
|
|
test('Inline error appears when tcp:// scheme entered for TCP type', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
await page.locator('#create-monitor-type').selectOption('tcp');
|
|
await page.locator('#create-monitor-url').fill('tcp://192.168.1.1:8080');
|
|
|
|
const errorAlert = page.locator('[role="alert"]');
|
|
await expect(errorAlert).toBeVisible();
|
|
await expect(errorAlert).toContainText(/host:port format/i);
|
|
});
|
|
|
|
test('Inline error clears when scheme prefix removed', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
await page.locator('#create-monitor-type').selectOption('tcp');
|
|
const urlInput = page.locator('#create-monitor-url');
|
|
await urlInput.fill('tcp://192.168.1.1:8080');
|
|
|
|
await expect(page.locator('[role="alert"]')).toBeVisible();
|
|
|
|
await urlInput.fill('192.168.1.1:8080');
|
|
|
|
await expect(page.locator('[role="alert"]')).not.toBeVisible();
|
|
});
|
|
|
|
test('Inline error clears when type changed from TCP to HTTP', async ({ page }) => {
|
|
await openCreateModal(page);
|
|
|
|
const typeSelect = page.locator('#create-monitor-type');
|
|
await typeSelect.selectOption('tcp');
|
|
|
|
const urlInput = page.locator('#create-monitor-url');
|
|
await urlInput.fill('tcp://192.168.1.1:8080');
|
|
|
|
await expect(page.locator('[role="alert"]')).toBeVisible();
|
|
|
|
await typeSelect.selectOption('http');
|
|
|
|
await expect(page.locator('[role="alert"]')).not.toBeVisible();
|
|
});
|
|
|
|
test('Submit with tcp:// prefix is prevented client-side', async ({ page }) => {
|
|
let createCalled = false;
|
|
|
|
await page.route('**/api/v1/uptime/monitors', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
createCalled = true;
|
|
await route.continue();
|
|
} else {
|
|
await route.fulfill({ status: 200, json: [] });
|
|
}
|
|
});
|
|
|
|
await openCreateModal(page);
|
|
|
|
await page.locator('#create-monitor-type').selectOption('tcp');
|
|
await page.locator('#create-monitor-name').fill('DB Server');
|
|
await page.locator('#create-monitor-url').fill('tcp://192.168.1.1:8080');
|
|
|
|
await page.getByRole('button', { name: /create/i }).click();
|
|
|
|
await page.waitForTimeout(500);
|
|
expect(createCalled).toBe(false);
|
|
});
|
|
|
|
test('TCP monitor created successfully with bare host:port', async ({ page }) => {
|
|
let capturedPayload: Record<string, unknown> | null = null;
|
|
|
|
const createdMonitor: UptimeMonitor = {
|
|
id: 'm-test',
|
|
name: 'DB Server',
|
|
type: 'tcp',
|
|
url: '192.168.1.1:5432',
|
|
interval: 60,
|
|
enabled: true,
|
|
status: 'pending',
|
|
latency: 0,
|
|
max_retries: 3,
|
|
};
|
|
|
|
await page.route('**/api/v1/uptime/monitors', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
capturedPayload = route.request().postDataJSON() as Record<string, unknown>;
|
|
await route.fulfill({ status: 201, json: createdMonitor });
|
|
} else {
|
|
await route.fulfill({ status: 200, json: [] });
|
|
}
|
|
});
|
|
await page.route(`**/api/v1/uptime/monitors/${createdMonitor.id}/history*`, async (route) => {
|
|
await route.fulfill({ status: 200, json: [] });
|
|
});
|
|
|
|
await openCreateModal(page);
|
|
|
|
await page.locator('#create-monitor-type').selectOption('tcp');
|
|
await page.locator('#create-monitor-name').fill('DB Server');
|
|
await page.locator('#create-monitor-url').fill('192.168.1.1:5432');
|
|
|
|
await page.getByRole('button', { name: /create/i }).click();
|
|
|
|
await expect(page.getByRole('heading', { name: /create monitor/i })).not.toBeVisible({ timeout: 5000 });
|
|
|
|
expect(capturedPayload).not.toBeNull();
|
|
expect(capturedPayload?.url).toBe('192.168.1.1:5432');
|
|
expect(capturedPayload?.type).toBe('tcp');
|
|
});
|
|
});
|