Improve auto-loop event emission and add ntfy notifications (#821)

This commit is contained in:
gsxdsm
2026-03-01 00:12:22 -08:00
committed by GitHub
parent 63b0a4fb38
commit 57bcb2802d
53 changed files with 4620 additions and 255 deletions

View File

@@ -85,15 +85,8 @@ test.describe('Agent Chat Session', () => {
const sessionCount = await countSessionItems(page);
expect(sessionCount).toBeGreaterThanOrEqual(1);
// Ensure the new session is selected (click first session item if message list not yet visible)
// Handles race where list updates before selection is applied in CI
// Verify the message list is visible (indicates the newly created session was selected)
const messageList = page.locator('[data-testid="message-list"]');
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
if (!(await messageList.isVisible())) {
await sessionItem.click();
}
// Verify the message list is visible (indicates a session is selected)
await expect(messageList).toBeVisible({ timeout: 10000 });
// Verify the agent input is visible

View File

@@ -0,0 +1,176 @@
/**
* Feature Deep Link E2E Test
*
* Tests that navigating to /board?featureId=xxx opens the board and shows
* the output modal for the specified feature.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
setupRealProject,
waitForNetworkIdle,
clickAddFeature,
fillAddFeatureDialog,
confirmAddFeature,
authenticateForTests,
handleLoginScreenIfPresent,
waitForAgentOutputModal,
getOutputModalDescription,
} from '../utils';
const TEST_TEMP_DIR = createTempDirPath('feature-deep-link-test');
test.describe('Feature Deep Link', () => {
let projectPath: string;
let projectName: string;
test.beforeEach(async ({}, testInfo) => {
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
const automakerDir = path.join(projectPath, '.automaker');
fs.mkdirSync(automakerDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDir, 'categories.json'),
JSON.stringify({ categories: [] }, null, 2)
);
fs.writeFileSync(
path.join(automakerDir, 'app_spec.txt'),
`# ${projectName}\n\nA test project for e2e testing.`
);
});
test.afterEach(async () => {
if (projectPath && fs.existsSync(projectPath)) {
fs.rmSync(projectPath, { recursive: true, force: true });
}
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
test('should open output modal when navigating to /board?featureId=xxx', async ({ page }) => {
const featureDescription = `Deep link test feature ${Date.now()}`;
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk = await authenticateForTests(page);
expect(authOk).toBe(true);
// Create a feature first
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
timeout: 5000,
});
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, featureDescription);
await confirmAddFeature(page);
// Wait for the feature to appear in the backlog
await expect(async () => {
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({
hasText: featureDescription,
});
expect(await featureCard.count()).toBeGreaterThan(0);
}).toPass({ timeout: 20000 });
// Get the feature ID from the card
const featureCard = page
.locator('[data-testid="kanban-column-backlog"]')
.locator('[data-testid^="kanban-card-"]')
.filter({ hasText: featureDescription })
.first();
const cardTestId = await featureCard.getAttribute('data-testid');
const featureId = cardTestId?.replace('kanban-card-', '') || null;
expect(featureId).toBeTruthy();
// Close any open modals first
const modal = page.locator('[data-testid="agent-output-modal"]');
if (await modal.isVisible()) {
await page.keyboard.press('Escape');
await expect(modal).toBeHidden({ timeout: 3000 });
}
// Now navigate to the board with the featureId query parameter
await page.goto(`/board?featureId=${encodeURIComponent(featureId ?? '')}`);
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// The output modal should automatically open
await waitForAgentOutputModal(page, { timeout: 10000 });
const modalVisible = await page.locator('[data-testid="agent-output-modal"]').isVisible();
expect(modalVisible).toBe(true);
// Verify the modal shows the correct feature
const modalDescription = await getOutputModalDescription(page);
expect(modalDescription).toContain(featureDescription);
});
test('should handle invalid featureId gracefully', async ({ page }) => {
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk2 = await authenticateForTests(page);
expect(authOk2).toBe(true);
// Navigate with a non-existent feature ID
const nonExistentId = 'non-existent-feature-id-12345';
await page.goto(`/board?featureId=${nonExistentId}`);
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// Board should still load
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Output modal should NOT appear (feature doesn't exist)
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeHidden({ timeout: 3000 });
});
test('should handle navigation without featureId', async ({ page }) => {
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk3 = await authenticateForTests(page);
expect(authOk3).toBe(true);
// Navigate without featureId
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// Board should load normally
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
timeout: 5000,
});
// Output modal should NOT appear
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeHidden({ timeout: 2000 });
});
});

View File

@@ -0,0 +1,271 @@
/**
* Event Hooks Settings Page Tests
*
* Tests for the event hooks settings section, including:
* - Event hooks management
* - Ntfy endpoint configuration
* - Dialog state management (useEffect hook validation)
*
* This test also serves as a regression test for the bug where
* useEffect was not imported in the event-hooks-section.tsx file,
* causing a runtime error when opening the Ntfy endpoint dialog.
*/
import { test, expect, type Page } from '@playwright/test';
import { authenticateForTests, navigateToSettings } from '../utils';
// Timeout constants for maintainability
const TIMEOUTS = {
sectionVisible: 10000,
dialogVisible: 5000,
dialogHidden: 5000,
endpointVisible: 5000,
} as const;
// Selectors for reuse
const SELECTORS = {
eventHooksButton: 'button:has-text("Event Hooks")',
endpointsTab: 'button[role="tab"]:has-text("Endpoints")',
sectionText: 'text=Run custom commands or send notifications',
addEndpointButton: 'button:has-text("Add Endpoint")',
dialog: '[role="dialog"]',
dialogTitle: 'text=Add Ntfy Endpoint',
} as const;
/**
* Navigate to the Event Hooks Endpoints tab
* This helper reduces code duplication across tests
*/
async function navigateToEndpointsTab(page: Page): Promise<void> {
await navigateToSettings(page);
// Click on the Event Hooks section in the navigation
await page.locator(SELECTORS.eventHooksButton).first().click();
// Wait for the event hooks section to be visible
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
// Switch to Endpoints tab (ntfy endpoints)
await page.locator(SELECTORS.endpointsTab).click();
}
test.describe('Event Hooks Settings', () => {
test.beforeEach(async ({ page }) => {
await authenticateForTests(page);
});
test('should load event hooks settings section without errors', async ({ page }) => {
await navigateToSettings(page);
// Click on the Event Hooks section in the navigation
await page.locator(SELECTORS.eventHooksButton).first().click();
// Wait for the event hooks section to be visible
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
// Verify the tabs are present
await expect(page.locator('button[role="tab"]:has-text("Hooks")')).toBeVisible();
await expect(page.locator(SELECTORS.endpointsTab)).toBeVisible();
await expect(page.locator('button[role="tab"]:has-text("History")')).toBeVisible();
});
test('should open add ntfy endpoint dialog and verify useEffect resets form', async ({
page,
}) => {
// This test specifically validates that the useEffect hook in NtfyEndpointDialog
// works correctly - if useEffect was not imported, the form would not reset
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
// Dialog should be visible
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Dialog title should indicate adding new endpoint
await expect(dialog.locator(SELECTORS.dialogTitle)).toBeVisible();
// Form should have default values (useEffect reset)
// This is the critical test - if useEffect was not imported or not working,
// these assertions would fail because the form state would not be reset
const nameInput = dialog.locator('input#endpoint-name');
const serverUrlInput = dialog.locator('input#endpoint-server');
const topicInput = dialog.locator('input#endpoint-topic');
// Name should be empty (reset by useEffect)
await expect(nameInput).toHaveValue('');
// Server URL should have default value (reset by useEffect)
await expect(serverUrlInput).toHaveValue('https://ntfy.sh');
// Topic should be empty (reset by useEffect)
await expect(topicInput).toHaveValue('');
// Close the dialog
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
});
test('should open and close endpoint dialog without JavaScript errors', async ({ page }) => {
// This test verifies the dialog opens without throwing a "useEffect is not defined" error
// Listen for console errors
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await navigateToEndpointsTab(page);
// Open and close the dialog multiple times to stress test the useEffect
for (let i = 0; i < 3; i++) {
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
}
// Verify no React hook related errors occurred
// This catches "useEffect is not defined", "useState is not defined", etc.
const reactHookError = consoleErrors.find(
(error) =>
(error.includes('useEffect') ||
error.includes('useState') ||
error.includes('useCallback')) &&
error.includes('is not defined')
);
expect(reactHookError).toBeUndefined();
});
test('should have enabled toggle working in endpoint dialog', async ({ page }) => {
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Verify the enabled switch exists and is checked by default (useEffect sets enabled=true)
const enabledSwitch = dialog.locator('#endpoint-enabled');
await expect(enabledSwitch).toBeChecked();
// Click the switch to toggle it off
await enabledSwitch.click();
await expect(enabledSwitch).not.toBeChecked();
// Click it again to toggle it back on
await enabledSwitch.click();
await expect(enabledSwitch).toBeChecked();
// Close the dialog
await page.keyboard.press('Escape');
});
test('should have Add Endpoint button disabled when form is invalid', async ({ page }) => {
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// The Add Endpoint button should be disabled because form is empty (name and topic required)
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
await expect(addButton).toBeDisabled();
// Fill in name but not topic
await dialog.locator('input#endpoint-name').fill('Test Name');
// Button should still be disabled (topic is required)
await expect(addButton).toBeDisabled();
// Fill in topic with invalid value (contains space)
await dialog.locator('input#endpoint-topic').fill('invalid topic');
// Button should still be disabled (topic has space which is invalid)
await expect(addButton).toBeDisabled();
// Fix the topic
await dialog.locator('input#endpoint-topic').fill('valid-topic');
// Now button should be enabled
await expect(addButton).toBeEnabled();
// Close the dialog
await page.keyboard.press('Escape');
});
test('should persist ntfy endpoint after adding and page reload', async ({ page }) => {
// This test verifies that ntfy endpoints are correctly saved to the server
// and restored when the page is reloaded - the core bug fix being tested
await navigateToEndpointsTab(page);
// Add a new endpoint
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Fill in the endpoint form
const uniqueSuffix = Date.now();
await dialog.locator('input#endpoint-name').fill(`Test Endpoint ${uniqueSuffix}`);
await dialog.locator('input#endpoint-server').fill('https://ntfy.sh');
await dialog.locator('input#endpoint-topic').fill(`test-topic-${uniqueSuffix}`);
// Save the endpoint
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
await addButton.click();
// Dialog should close
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
// Wait for the endpoint to appear in the list
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
timeout: TIMEOUTS.endpointVisible,
});
// Reload the page
await page.reload();
// Re-authenticate after reload
await authenticateForTests(page);
// Navigate back to the endpoints tab
await navigateToEndpointsTab(page);
// Verify the endpoint persisted after reload
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
});
test('should display existing endpoints on initial load', async ({ page }) => {
// This test verifies that any existing endpoints are displayed when the page first loads
// Navigate to the page and check if we can see the endpoints section
await navigateToEndpointsTab(page);
// The endpoints tab should show either existing endpoints or the empty state
// The key is that it should NOT show "empty" if there are endpoints on the server
// Either we see "No endpoints configured" OR we see endpoint cards
const emptyState = page.locator('text=No endpoints configured');
const endpointCard = page.locator('[data-testid="endpoint-card"]').first();
// One of these should be visible
await expect(
Promise.race([
emptyState.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'empty'),
endpointCard.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'card'),
])
).resolves.toBeDefined();
});
});