Fanout integration UX overhaul

This commit is contained in:
Jack Kingsman
2026-03-06 21:37:11 -08:00
parent 94546f90a4
commit da22eb5c48
8 changed files with 687 additions and 269 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test';
import http from 'http';
export function createCaptureServer(urlFactory: (port: number) => string) {
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
requests.push({ body, headers: req.headers });
res.writeHead(200);
res.end('ok');
});
});
return {
requests,
server,
async listen(): Promise<string> {
return await new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr === 'object' && addr) {
resolve(urlFactory(addr.port));
}
});
});
},
close(): void {
server.close();
},
};
}
export async function openFanoutSettings(page: Page): Promise<void> {
await page.goto('/');
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
}
export function fanoutHeader(page: Page, name: string): Locator {
const nameButton = page.getByRole('button', { name, exact: true });
return page
.locator('div')
.filter({ has: nameButton })
.filter({ has: page.getByRole('button', { name: 'Edit' }) })
.last();
}
+45 -40
View File
@@ -4,9 +4,21 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
test.describe('Apprise integration settings', () => {
let createdAppriseId: string | null = null;
let receiver: ReturnType<typeof createCaptureServer>;
let appriseUrl: string;
test.beforeAll(async () => {
receiver = createCaptureServer((port) => `json://127.0.0.1:${port}`);
appriseUrl = await receiver.listen();
});
test.afterAll(async () => {
receiver.close();
});
test.afterEach(async () => {
if (createdAppriseId) {
@@ -20,22 +32,19 @@ test.describe('Apprise integration settings', () => {
});
test('create apprise via UI, configure URLs, save as enabled', async ({ page }) => {
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
// Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Open add menu and pick Apprise
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Apprise' }).click();
// Click the Apprise add button
await page.getByRole('button', { name: 'Apprise' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.locator('#fanout-edit-name')).toHaveValue('Apprise');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
// Fill in notification URL
const urlsTextarea = page.locator('#fanout-apprise-urls');
await urlsTextarea.fill('json://localhost:9999');
await urlsTextarea.fill(appriseUrl);
// Verify preserve identity checkbox is checked by default
const preserveIdentity = page.getByText('Preserve identity on Discord');
@@ -56,6 +65,7 @@ test.describe('Apprise integration settings', () => {
// Should be back on list view with our apprise config visible
await expect(page.getByText('E2E Apprise')).toBeVisible();
await expect(page.getByText(appriseUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs();
@@ -70,7 +80,7 @@ test.describe('Apprise integration settings', () => {
type: 'apprise',
name: 'API Apprise',
config: {
urls: 'json://localhost:9999\nslack://token_a/token_b/token_c',
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
preserve_identity: false,
include_path: false,
},
@@ -78,19 +88,17 @@ test.describe('Apprise integration settings', () => {
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our apprise config
const row = page.getByText('API Apprise').locator('..');
const row = fanoutHeader(page, 'API Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify the URLs textarea has our content
const urlsTextarea = page.locator('#fanout-apprise-urls');
await expect(urlsTextarea).toHaveValue(/json:\/\/localhost:9999/);
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
// Verify checkboxes reflect our config (both unchecked)
@@ -107,24 +115,24 @@ test.describe('Apprise integration settings', () => {
await expect(pathCheckbox).not.toBeChecked();
// Go back
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('apprise shows scope selector', async ({ page }) => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Scope Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Scope Apprise').locator('..');
const row = fanoutHeader(page, 'Scope Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify scope selector is present
@@ -138,31 +146,31 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Channels (exclude)')).toBeVisible();
// Go back
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('apprise disabled config shows amber dot and can be enabled via save button', async ({
test('apprise disabled config shows disabled status and can be enabled via save button', async ({
page,
}) => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Disabled Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
enabled: false,
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Should show "Disabled" status text
const row = page.getByText('Disabled Apprise').locator('..');
await expect(row.getByText('Disabled', { exact: true })).toBeVisible();
const row = fanoutHeader(page, 'Disabled Apprise');
await expect(row).toContainText('Disabled');
// Edit it
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Save as enabled
@@ -179,27 +187,24 @@ test.describe('Apprise integration settings', () => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Delete Me Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Delete Me Apprise').locator('..');
const row = fanoutHeader(page, 'Delete Me Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Accept the confirmation dialog
page.on('dialog', (dialog) => dialog.accept());
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Integration deleted')).toBeVisible();
// Should be back on list, apprise gone
await expect(page.getByText('Delete Me Apprise')).not.toBeVisible();
await expect(row).not.toBeVisible();
createdAppriseId = null;
});
});
+62 -33
View File
@@ -4,9 +4,21 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
test.describe('Webhook integration settings', () => {
let createdWebhookId: string | null = null;
let receiver: ReturnType<typeof createCaptureServer>;
let webhookUrl: string;
test.beforeAll(async () => {
receiver = createCaptureServer((port) => `http://127.0.0.1:${port}`);
webhookUrl = await receiver.listen();
});
test.afterAll(async () => {
receiver.close();
});
test.afterEach(async () => {
if (createdWebhookId) {
@@ -20,22 +32,19 @@ test.describe('Webhook integration settings', () => {
});
test('create webhook via UI, configure, save as enabled, verify in list', async ({ page }) => {
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
// Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Open add menu and pick Webhook
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
// Click the Webhook add button
await page.getByRole('button', { name: 'Webhook' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.locator('#fanout-edit-name')).toHaveValue('Webhook');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
// Fill in webhook URL
const urlInput = page.locator('#fanout-webhook-url');
await urlInput.fill('https://example.com/e2e-test-hook');
await urlInput.fill(webhookUrl);
// Verify method defaults to POST
await expect(page.locator('#fanout-webhook-method')).toHaveValue('POST');
@@ -51,6 +60,7 @@ test.describe('Webhook integration settings', () => {
// Should be back on list view with our webhook visible
await expect(page.getByText('E2E Webhook')).toBeVisible();
await expect(page.getByText(webhookUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs();
@@ -60,24 +70,46 @@ test.describe('Webhook integration settings', () => {
}
});
test('leaving a new webhook draft does not create a persisted config', async ({ page }) => {
const existingConfigs = await getFanoutConfigs();
const existingIds = new Set(existingConfigs.map((cfg) => cfg.id));
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
await page.locator('#fanout-webhook-url').fill(webhookUrl);
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(page.getByText('Unsaved Webhook Draft')).not.toBeVisible();
const updatedConfigs = await getFanoutConfigs();
const newConfigs = updatedConfigs.filter((cfg) => !existingIds.has(cfg.id));
expect(newConfigs).toHaveLength(0);
expect(updatedConfigs.find((cfg) => cfg.name === 'Unsaved Webhook Draft')).toBeUndefined();
});
test('create webhook via API, edit in UI, save as disabled', async ({ page }) => {
// Create via API
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'API Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
enabled: true,
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our webhook
const row = page.getByText('API Webhook').locator('..');
const row = fanoutHeader(page, 'API Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Should be in edit view
@@ -88,7 +120,8 @@ test.describe('Webhook integration settings', () => {
// Save as disabled
await page.getByRole('button', { name: /Save as Disabled/i }).click();
await expect(page.getByText('Integration saved')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).not.toBeVisible();
await expect(row).toContainText('Disabled');
// Verify it's now disabled in the list
const configs = await getFanoutConfigs();
@@ -101,18 +134,16 @@ test.describe('Webhook integration settings', () => {
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'Scope Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Scope Webhook').locator('..');
const row = fanoutHeader(page, 'Scope Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify scope selector is visible with the three webhook-applicable modes
@@ -128,37 +159,35 @@ test.describe('Webhook integration settings', () => {
await expect(page.getByText('Channels (include)')).toBeVisible();
// Go back without saving
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('delete webhook via UI', async ({ page }) => {
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'Delete Me Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Delete Me Webhook').locator('..');
const row = fanoutHeader(page, 'Delete Me Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Accept the confirmation dialog
page.on('dialog', (dialog) => dialog.accept());
page.once('dialog', (dialog) => dialog.accept());
// Click Delete
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Integration deleted')).toBeVisible();
// Should be back on list, webhook gone
await expect(page.getByText('Delete Me Webhook')).not.toBeVisible();
await expect(row).not.toBeVisible();
// Already deleted, clear the cleanup reference
createdWebhookId = null;