mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
Improve auto-loop event emission and add ntfy notifications (#821)
This commit is contained in:
@@ -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
|
||||
|
||||
176
apps/ui/tests/features/feature-deep-link.spec.ts
Normal file
176
apps/ui/tests/features/feature-deep-link.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
271
apps/ui/tests/settings/event-hooks-settings.spec.ts
Normal file
271
apps/ui/tests/settings/event-hooks-settings.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user