Merge branch 'master' into feat/operation-logs

* master:
  refactor(e2e): improve test infrastructure for easier expansion
  chore(e2e): remove broken/empty skipped tests
  test(e2e): fix flaky plugin and WebDAV sync tests
  refactor(e2e): replace waitForTimeout with condition-based waits
  perf(e2e): remove ineffective waits to speed up test runs
  docs(e2e): add CLAUDE.md reference and barrel export for easier test creation
  build: update dep
  refactor(e2e): simplify waits and fix flaky tests
  feat(e2e): streamline e2e test development with improved infrastructure
  perf(e2e): optimize wait utilities and addTask method for faster test execution
  16.8.1

# Conflicts:
#	e2e/pages/base.page.ts
#	e2e/pages/project.page.ts
#	e2e/tests/reminders/reminders-schedule-page.spec.ts
#	e2e/tests/sync/webdav-sync-advanced.spec.ts
#	e2e/tests/sync/webdav-sync-expansion.spec.ts
#	e2e/tests/sync/webdav-sync-full.spec.ts
#	e2e/utils/waits.ts
This commit is contained in:
Johannes Millan 2026-01-03 18:51:51 +01:00
commit 85fa50974b
62 changed files with 2679 additions and 1849 deletions

View file

@ -1,3 +1,9 @@
## [16.8.1](https://github.com/johannesjo/super-productivity/compare/v16.8.0...v16.8.1) (2026-01-02)
### Bug Fixes
- **e2e:** improve WebDAV sync test reliability ([e6b6468](https://github.com/johannesjo/super-productivity/commit/e6b6468d2aba39149354419b492f860b60bd4fc5))
# [16.8.0](https://github.com/johannesjo/super-productivity/compare/v16.7.3...v16.8.0) (2026-01-02)
### Bug Fixes

View file

@ -20,8 +20,8 @@ android {
minSdkVersion 24
targetSdkVersion 35
compileSdk 35
versionCode 16_08_00_0000
versionName "16.8.0"
versionCode 16_08_01_0000
versionName "16.8.1"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
hostName : "app.super-productivity.com",

View file

@ -0,0 +1,4 @@
### Bug Fixes
* **e2e:** improve WebDAV sync test reliability

162
e2e/CLAUDE.md Normal file
View file

@ -0,0 +1,162 @@
# E2E Test Reference
## Run Tests
```bash
npm run e2e:playwright:file tests/path/to/test.spec.ts # Single test
npm run e2e:playwright # All tests
```
## Test Template
```typescript
// Import path depends on test depth (see Import Paths below)
import { expect, test } from '../../fixtures/test.fixture';
test.describe('Feature', () => {
test('should X when Y', async ({ workViewPage, taskPage }) => {
await workViewPage.waitForTaskList();
await workViewPage.addTask('Task Name');
const task = taskPage.getTaskByText('Task Name');
await expect(task).toBeVisible();
});
});
```
## Import Paths
| Test Location | Import Path |
| -------------------------------- | -------------------------------- |
| `tests/feature/test.spec.ts` | `../../fixtures/test.fixture` |
| `tests/feature/sub/test.spec.ts` | `../../../fixtures/test.fixture` |
## All Fixtures
| Fixture | Use For |
| -------------- | ------------------------------------------------ |
| `workViewPage` | Task list, adding tasks |
| `taskPage` | Task operations (get, edit, mark done) |
| `projectPage` | Project CRUD, navigation |
| `settingsPage` | Settings navigation, plugin management |
| `dialogPage` | Modal/dialog interactions |
| `plannerPage` | Planner view operations |
| `syncPage` | WebDAV sync setup |
| `tagPage` | Tag management |
| `notePage` | Notes functionality |
| `sideNavPage` | Side navigation |
| `testPrefix` | Auto-applied to task/project names for isolation |
## Assertion Helpers
```typescript
import {
expectTaskCount,
expectTaskVisible,
expectTaskDone,
expectDoneTaskCount,
expectDialogVisible,
expectNoGlobalError,
} from '../../utils/assertions';
// Usage:
await expectTaskCount(taskPage, 2);
await expectTaskVisible(taskPage, 'Task Name');
await expectTaskDone(taskPage, 'Task Name');
await expectDialogVisible(dialogPage);
await expectNoGlobalError(page);
```
## Common Patterns
### Create project with tasks
```typescript
await projectPage.createAndGoToTestProject();
await workViewPage.addTask('Task 1');
await workViewPage.addTask('Task 2');
await expectTaskCount(taskPage, 2);
```
### Mark task done and verify
```typescript
await workViewPage.addTask('My Task');
const task = taskPage.getTaskByText('My Task');
await taskPage.markTaskAsDone(task);
await expectDoneTaskCount(taskPage, 1);
```
### Dialog interaction
```typescript
// Trigger dialog via some action, then:
await dialogPage.waitForDialog();
await dialogPage.fillDialogInput('input[name="title"]', 'Value');
await dialogPage.clickSaveButton();
await dialogPage.waitForDialogToClose();
```
### Sync tests (serial execution required)
```typescript
test.describe.configure({ mode: 'serial' });
test.describe('Sync Feature', () => {
test('should sync data', async ({ syncPage, workViewPage }) => {
// Sync tests require special setup - see sync-test-helpers.ts
});
});
```
## Key Methods
### workViewPage
- `waitForTaskList()` - Call first in every test
- `addTask(name)` - Add task via global input
### taskPage
- `getTaskByText(text)` → Locator
- `getTask(index)` → Locator (1-based)
- `getAllTasks()` → Locator
- `markTaskAsDone(task)`
- `getTaskCount()` → number
- `getDoneTasks()` / `getUndoneTasks()` → Locator
- `waitForTaskWithText(text)` → Locator
### projectPage
- `createProject(name)`
- `navigateToProjectByName(name)`
- `createAndGoToTestProject()` - Quick setup
### settingsPage
- `navigateToPluginSettings()`
- `uploadPlugin(path)`, `enablePlugin(name)`, `pluginExists(name)`
### dialogPage
- `waitForDialog()` → Locator
- `clickDialogButton(text)`, `clickSaveButton()`
- `fillDialogInput(selector, value)`
- `waitForDialogToClose()`
For full method list, read the page object file: `e2e/pages/<name>.page.ts`
## Selectors
```typescript
import { cssSelectors } from '../../constants/selectors';
// Available: TASK, FIRST_TASK, TASK_TITLE, TASK_DONE_BTN, ADD_TASK_INPUT, MAT_DIALOG, SIDENAV, etc.
```
## Critical Rules
1. **Always start with** `await workViewPage.waitForTaskList()`
2. **Use page objects** - not raw `page.locator()` for common actions
3. **No `waitForTimeout()`** - use `expect().toBeVisible()` instead
4. **Tests are isolated** - each gets fresh browser context + IndexedDB
5. **Use assertion helpers** - for consistent, readable tests

642
e2e/README.md Normal file
View file

@ -0,0 +1,642 @@
# E2E Testing Guide for Super Productivity
This guide provides comprehensive information for writing and maintaining end-to-end tests for Super Productivity using Playwright.
## Table of Contents
- [Overview](#overview)
- [Running Tests](#running-tests)
- [Test Structure](#test-structure)
- [Page Objects](#page-objects)
- [Common Patterns](#common-patterns)
- [Selectors](#selectors)
- [Wait Utilities](#wait-utilities)
- [Writing New Tests](#writing-new-tests)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
---
## Overview
Our E2E tests are built with Playwright and follow the Page Object Model (POM) pattern for maintainability and reusability. Tests are organized by feature and use shared fixtures for common setup.
### Key Technologies
- **Playwright**: Modern E2E testing framework
- **TypeScript**: Type-safe test code
- **Page Object Model**: Encapsulates page interactions
- **Fixtures**: Shared setup and utilities
---
## Running Tests
### Basic Commands
```bash
# Run all tests
npm run e2e
# Run tests in UI mode (interactive)
npm run e2e:ui
# Run a single test file with detailed output
npm run e2e:file tests/task-basic/task-crud.spec.ts
# Run tests in headed mode (see browser)
npm run e2e:headed
# Run tests in debug mode
npm run e2e:debug
# Show test report
npm run e2e:show-report
```
### WebDAV Sync Tests
```bash
# Run WebDAV tests (starts Docker container)
npm run e2e:webdav
```
---
## Test Structure
### Directory Layout
```
e2e/
├── constants/ # Shared selectors and constants
│ └── selectors.ts # Centralized CSS selectors
├── fixtures/ # Test fixtures and setup
│ └── test.fixture.ts # Custom test fixtures with page objects
├── helpers/ # Test helper functions
│ └── plugin-test.helpers.ts
├── pages/ # Page Object Models
│ ├── base.page.ts # Base page with common methods
│ ├── work-view.page.ts
│ ├── project.page.ts
│ ├── task.page.ts
│ ├── settings.page.ts
│ ├── dialog.page.ts
│ ├── planner.page.ts
│ ├── schedule.page.ts
│ ├── side-nav.page.ts
│ ├── sync.page.ts
│ ├── tag.page.ts
│ └── note.page.ts
├── tests/ # Test specifications
│ ├── task-basic/
│ ├── project/
│ ├── planner/
│ └── ...
├── utils/ # Utility functions
│ ├── waits.ts # Wait helpers
│ └── sync-helpers.ts
├── playwright.config.ts
└── global-setup.ts
```
---
## Page Objects
Page Objects encapsulate interactions with specific pages or components. All page objects extend `BasePage` and receive a `page` and optional `testPrefix`.
### Available Page Objects
#### 1. **BasePage** (`base.page.ts`)
Base class for all page objects. Provides common functionality:
```typescript
class BasePage {
async addTask(taskName: string): Promise<void>;
// Adds a task with automatic test prefix
}
```
**Example:**
```typescript
await workViewPage.addTask('My Task');
// Creates task with name "W0-P0-My Task" (prefixed for isolation)
```
#### 2. **WorkViewPage** (`work-view.page.ts`)
Interactions with the main work view:
```typescript
class WorkViewPage extends BasePage {
async waitForTaskList(): Promise<void>;
async addSubTask(task: Locator, subTaskName: string): Promise<void>;
}
```
**Example:**
```typescript
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent Task');
const task = page.locator('task').first();
await workViewPage.addSubTask(task, 'Child Task');
```
#### 3. **TaskPage** (`task.page.ts`)
Task-specific operations:
```typescript
class TaskPage extends BasePage {
getTask(index: number): Locator;
getTaskByText(text: string): Locator;
async markTaskAsDone(task: Locator): Promise<void>;
async editTaskTitle(task: Locator, newTitle: string): Promise<void>;
async openTaskDetail(task: Locator): Promise<void>;
async getTaskCount(): Promise<number>;
async isTaskDone(task: Locator): Promise<boolean>;
getDoneTasks(): Locator;
getUndoneTasks(): Locator;
async waitForTaskWithText(text: string): Promise<Locator>;
async taskHasTag(task: Locator, tagName: string): Promise<boolean>;
}
```
**Example:**
```typescript
const task = taskPage.getTask(1); // First task
await taskPage.markTaskAsDone(task);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
```
#### 4. **ProjectPage** (`project.page.ts`)
Project management:
```typescript
class ProjectPage extends BasePage {
async createProject(projectName: string): Promise<void>;
async navigateToProjectByName(projectName: string): Promise<void>;
async createAndGoToTestProject(): Promise<void>;
async addNote(noteContent: string): Promise<void>;
async archiveDoneTasks(): Promise<void>;
}
```
**Example:**
```typescript
await projectPage.createProject('My Project');
await projectPage.navigateToProjectByName('My Project');
await projectPage.addNote('Project notes here');
```
#### 5. **SettingsPage** (`settings.page.ts`)
Settings and configuration:
```typescript
class SettingsPage extends BasePage {
async navigateToSettings(): Promise<void>;
async expandSection(sectionSelector: string): Promise<void>;
async expandPluginSection(): Promise<void>;
async navigateToPluginSettings(): Promise<void>;
async enablePlugin(pluginName: string): Promise<boolean>;
async disablePlugin(pluginName: string): Promise<boolean>;
async isPluginEnabled(pluginName: string): Promise<boolean>;
async uploadPlugin(pluginPath: string): Promise<void>;
}
```
**Example:**
```typescript
await settingsPage.navigateToPluginSettings();
await settingsPage.enablePlugin('Test Plugin');
expect(await settingsPage.isPluginEnabled('Test Plugin')).toBeTruthy();
```
#### 6. **DialogPage** (`dialog.page.ts`)
Dialog and modal interactions:
```typescript
class DialogPage extends BasePage {
async waitForDialog(): Promise<Locator>;
async waitForDialogToClose(): Promise<void>;
async clickDialogButton(buttonText: string): Promise<void>;
async clickSaveButton(): Promise<void>;
async fillDialogInput(selector: string, value: string): Promise<void>;
async fillMarkdownDialog(content: string): Promise<void>;
async saveMarkdownDialog(): Promise<void>;
async editDateTime(dateValue?: string, timeValue?: string): Promise<void>;
}
```
**Example:**
```typescript
await dialogPage.waitForDialog();
await dialogPage.fillDialogInput('input[name="title"]', 'New Title');
await dialogPage.clickSaveButton();
await dialogPage.waitForDialogToClose();
```
---
## Common Patterns
### Pattern 1: Basic Task CRUD
```typescript
test('should create and edit task', async ({ page, workViewPage, taskPage }) => {
await workViewPage.waitForTaskList();
// Create
await workViewPage.addTask('Test Task');
await expect(taskPage.getAllTasks()).toHaveCount(1);
// Edit
const task = taskPage.getTask(1);
await taskPage.editTaskTitle(task, 'Updated Task');
await expect(taskPage.getTaskTitle(task)).toContainText('Updated Task');
// Mark as done
await taskPage.markTaskAsDone(task);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
});
```
### Pattern 2: Project Workflow
```typescript
test('should create project and add tasks', async ({ projectPage, workViewPage }) => {
await projectPage.createAndGoToTestProject();
await workViewPage.addTask('Project Task 1');
await workViewPage.addTask('Project Task 2');
await expect(page.locator('task')).toHaveCount(2);
});
```
### Pattern 3: Settings Configuration
```typescript
test('should enable plugin', async ({ settingsPage, waitForNav }) => {
await settingsPage.navigateToPluginSettings();
await settingsPage.enablePlugin('My Plugin');
await waitForNav();
expect(await settingsPage.isPluginEnabled('My Plugin')).toBeTruthy();
});
```
### Pattern 4: Dialog Interactions
```typescript
test('should edit date in dialog', async ({ taskPage, dialogPage }) => {
const task = taskPage.getTask(1);
await taskPage.openTaskDetail(task);
const dateInfo = dialogPage.getDateInfo('Created');
await dateInfo.click();
await dialogPage.editDateTime('12/25/2025', undefined);
await dialogPage.clickSaveButton();
});
```
---
## Selectors
All selectors are centralized in `constants/selectors.ts`. Always use these constants instead of hardcoding selectors in tests.
### Using Selectors
```typescript
import { cssSelectors } from '../constants/selectors';
const { TASK, TASK_TITLE, TASK_DONE_BTN } = cssSelectors;
// In test:
const task = page.locator(TASK).first();
const title = task.locator(TASK_TITLE);
```
### Selector Categories
- **Navigation**: `SIDENAV`, `NAV_ITEM`, `SETTINGS_BTN`
- **Layout**: `ROUTE_WRAPPER`, `BACKDROP`, `PAGE_TITLE`
- **Tasks**: `TASK`, `TASK_TITLE`, `TASK_DONE_BTN`, `SUB_TASK`
- **Add Task**: `ADD_TASK_INPUT`, `ADD_TASK_SUBMIT`
- **Dialogs**: `MAT_DIALOG`, `DIALOG_FULLSCREEN_MARKDOWN`
- **Settings**: `PAGE_SETTINGS`, `PLUGIN_SECTION`, `PLUGIN_MANAGEMENT`
- **Projects**: `PAGE_PROJECT`, `CREATE_PROJECT_BTN`, `WORK_CONTEXT_MENU`
---
## Wait Utilities
Located in `utils/waits.ts`, these utilities help handle Angular's async nature.
### Available Wait Functions
#### `waitForAngularStability(page, timeout?)`
Waits for Angular to finish all async operations.
```typescript
await waitForAngularStability(page);
```
#### `waitForAppReady(page, options?)`
Comprehensive wait for app initialization.
```typescript
await waitForAppReady(page, {
selector: 'task-list',
ensureRoute: true,
routeRegex: /#\/project\/\w+/,
});
```
#### `waitForStatePersistence(page)`
Waits for IndexedDB persistence to complete (important before sync operations).
```typescript
await workViewPage.addTask('Task');
await waitForStatePersistence(page); // Ensure saved to IndexedDB
// Now safe to trigger sync
```
---
## Writing New Tests
### Step 1: Create Test File
```typescript
// e2e/tests/my-feature/my-feature.spec.ts
import { test, expect } from '../../fixtures/test.fixture';
test.describe('My Feature', () => {
test('should do something', async ({ page, workViewPage, taskPage }) => {
// Test code here
});
});
```
### Step 2: Use Page Objects
```typescript
test('my test', async ({ workViewPage, taskPage, dialogPage }) => {
// Wait for page ready
await workViewPage.waitForTaskList();
// Use page objects for interactions
await workViewPage.addTask('Task 1');
const task = taskPage.getTask(1);
await taskPage.markTaskAsDone(task);
// Assertions
await expect(taskPage.getDoneTasks()).toHaveCount(1);
});
```
### Step 3: Handle Waits Properly
```typescript
// GOOD: Use Angular stability waits
await workViewPage.addTask('Task');
await waitForAngularStability(page);
await expect(page.locator('task')).toBeVisible();
// BAD: Arbitrary timeouts
await page.waitForTimeout(5000); // Avoid unless necessary
```
### Step 4: Use Selectors from Constants
```typescript
import { cssSelectors } from '../../constants/selectors';
const { TASK, TASK_TITLE } = cssSelectors;
const title = page.locator(TASK).first().locator(TASK_TITLE);
```
---
## Best Practices
### ✅ DO
1. **Use page objects** for all interactions
2. **Use centralized selectors** from `constants/selectors.ts`
3. **Wait for Angular stability** after state changes
4. **Use test prefixes** (automatic via fixtures) for isolation
5. **Test one thing per test** - keep tests focused
6. **Use descriptive test names** - "should create task and mark as done"
7. **Clean up state** - tests should be independent
8. **Use role-based selectors** when possible (accessibility)
```typescript
// GOOD
await page.getByRole('button', { name: 'Save' }).click();
// LESS GOOD
await page.locator('.save-btn').click();
```
### ❌ DON'T
1. **Don't hardcode selectors** - use `cssSelectors`
2. **Don't use arbitrary waits** - use `waitForAngularStability`
3. **Don't share state between tests** - each test should be independent
4. **Don't access DOM directly** - use page objects
5. **Don't skip error handling** - tests should fail clearly
6. **Don't use `any` types** - maintain type safety
### Test Isolation
Each test gets:
- Isolated browser context (clean storage)
- Unique test prefix (`W0-P0-`, `W1-P0-`, etc.)
- Fresh page instance
This ensures tests don't interfere with each other.
### Handling Flakiness
```typescript
// Use waitFor with explicit conditions
await page.waitForFunction(() => document.querySelectorAll('task').length === 3, {
timeout: 10000,
});
// Use locator assertions (auto-retry)
await expect(page.locator('task')).toHaveCount(3);
// Avoid fixed timeouts
await page.waitForTimeout(1000); // BAD
await waitForAngularStability(page); // GOOD
```
---
## Troubleshooting
### Test Fails with "Element not found"
1. Check if selector is correct in `constants/selectors.ts`
2. Add wait before interaction: `await waitForAngularStability(page)`
3. Use `await element.waitFor({ state: 'visible' })`
4. Check if element is in a different context (iframe, shadow DOM)
### Test Timeout
1. Increase timeout in specific waitFor calls
2. Check if Angular is stuck - look for pending HTTP requests
3. Use `page.pause()` to debug interactively
4. Check network tab for failed requests
### Flaky Tests
1. Add proper waits: `waitForAngularStability`, `waitForAppReady`
2. Avoid `page.waitForTimeout()` - use condition-based waits
3. Check for race conditions - ensure state is persisted
4. Use `waitForStatePersistence` before operations that depend on saved state
### Debugging
```typescript
// Pause execution and open Playwright Inspector
await page.pause();
// Take screenshot
await page.screenshot({ path: 'debug.png' });
// Console log page content
console.log(await page.content());
// Get element text for debugging
const text = await page.locator('task').first().textContent();
console.log('Task text:', text);
```
### Running Single Test
```bash
# Run specific file
npm run e2e:file tests/task-basic/task-crud.spec.ts
# Run in debug mode
npm run e2e:debug
# Run in headed mode to see browser
npm run e2e:headed
```
---
## Examples
### Example 1: Full Task CRUD Test
```typescript
import { test, expect } from '../../fixtures/test.fixture';
test.describe('Task CRUD', () => {
test('should create, edit, and delete tasks', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
// Create
await workViewPage.addTask('Task 1');
await workViewPage.addTask('Task 2');
await expect(taskPage.getAllTasks()).toHaveCount(2);
// Edit
const firstTask = taskPage.getTask(1);
await taskPage.editTaskTitle(firstTask, 'Updated Task');
await expect(taskPage.getTaskTitle(firstTask)).toContainText('Updated Task');
// Mark as done
await taskPage.markTaskAsDone(firstTask);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
await expect(taskPage.getUndoneTasks()).toHaveCount(1);
});
});
```
### Example 2: Project Workflow
```typescript
test('should create project with tasks', async ({
projectPage,
workViewPage,
taskPage,
}) => {
await projectPage.createAndGoToTestProject();
await workViewPage.addTask('Project Task');
await projectPage.addNote('Important notes');
const task = taskPage.getTask(1);
await taskPage.markTaskAsDone(task);
await projectPage.archiveDoneTasks();
await expect(taskPage.getUndoneTasks()).toHaveCount(0);
});
```
### Example 3: Settings Test
```typescript
test('should configure plugin', async ({ settingsPage, page }) => {
await settingsPage.navigateToPluginSettings();
const pluginExists = await settingsPage.pluginExists('Test Plugin');
expect(pluginExists).toBeTruthy();
await settingsPage.enablePlugin('Test Plugin');
expect(await settingsPage.isPluginEnabled('Test Plugin')).toBeTruthy();
await settingsPage.navigateBackToWorkView();
await expect(page).toHaveURL(/tag\/TODAY/);
});
```
---
## Getting Help
- Check existing tests in `e2e/tests/` for examples
- Review page objects in `e2e/pages/` for available methods
- Look at `constants/selectors.ts` for available selectors
- Use Playwright Inspector (`npm run e2e:debug`) for debugging
- Check Playwright docs: https://playwright.dev/
---
## Summary Checklist
When writing a new test:
- [ ] Create test file in appropriate `tests/` subdirectory
- [ ] Import `test` and `expect` from `fixtures/test.fixture.ts`
- [ ] Use page objects for all interactions
- [ ] Use selectors from `constants/selectors.ts`
- [ ] Add proper waits (`waitForAngularStability`, etc.)
- [ ] Use descriptive test names
- [ ] Ensure test is isolated (no shared state)
- [ ] Run test locally before committing
- [ ] Test passes consistently (run 3+ times)

View file

@ -1,5 +1,7 @@
export const cssSelectors = {
// Navigation selectors - Updated for correct structure
// ============================================================================
// NAVIGATION SELECTORS
// ============================================================================
SIDENAV: 'magic-side-nav',
NAV_LIST: 'magic-side-nav .nav-list',
NAV_ITEM: 'magic-side-nav nav-item',
@ -21,4 +23,116 @@ export const cssSelectors = {
// Legacy selectors for backward compatibility
OLD_SIDENAV: 'side-nav',
OLD_NAV_ITEM: 'side-nav-item',
};
// ============================================================================
// LAYOUT & ROUTING SELECTORS
// ============================================================================
ROUTE_WRAPPER: '.route-wrapper',
BACKDROP: '.backdrop',
MAIN: 'main',
PAGE_TITLE: 'main .page-title, .route-wrapper .page-title',
// ============================================================================
// TASK SELECTORS
// ============================================================================
TASK: 'task',
FIRST_TASK: 'task:first-child',
SECOND_TASK: 'task:nth-child(2)',
TASK_TITLE: 'task task-title',
TASK_DONE_BTN: '.task-done-btn',
TASK_LIST: 'task-list',
TASK_TEXTAREA: 'task textarea',
SUB_TASKS_CONTAINER: '.sub-tasks',
SUB_TASK: '.sub-tasks task',
// ============================================================================
// ADD TASK BAR SELECTORS
// ============================================================================
ADD_TASK_INPUT: 'add-task-bar.global input',
ADD_TASK_SUBMIT: '.e2e-add-task-submit',
ADD_BTN: '.tour-addBtn',
SWITCH_ADD_TO_BTN: '.switch-add-to-btn',
// ============================================================================
// DIALOG SELECTORS
// ============================================================================
MAT_DIALOG: 'mat-dialog-container',
DIALOG_FULLSCREEN_MARKDOWN: 'dialog-fullscreen-markdown',
DIALOG_CREATE_PROJECT: 'dialog-create-project',
DIALOG_SCHEDULE_TASK: 'dialog-schedule-task',
DIALOG_ACTIONS: 'mat-dialog-actions',
DIALOG_SUBMIT: 'mat-dialog-actions button:last-child',
// ============================================================================
// REMINDER DIALOG SELECTORS
// ============================================================================
REMINDER_DIALOG: 'dialog-view-task-reminder',
REMINDER_DIALOG_TASKS: 'dialog-view-task-reminder .tasks',
REMINDER_DIALOG_TASK: 'dialog-view-task-reminder .task',
REMINDER_DIALOG_TASK_1: 'dialog-view-task-reminder .task:first-of-type',
REMINDER_DIALOG_TASK_2: 'dialog-view-task-reminder .task:nth-of-type(2)',
REMINDER_DIALOG_TASK_3: 'dialog-view-task-reminder .task:nth-of-type(3)',
// ============================================================================
// SETTINGS PAGE SELECTORS
// ============================================================================
PAGE_SETTINGS: '.page-settings',
PLUGIN_SECTION: '.plugin-section',
PLUGIN_MANAGEMENT: 'plugin-management',
COLLAPSIBLE: 'collapsible',
COLLAPSIBLE_HEADER: '.collapsible-header',
// Plugin selectors
PLUGIN_CARD: 'plugin-management mat-card',
PLUGIN_TOGGLE: 'mat-slide-toggle button[role="switch"]',
PLUGIN_FILE_INPUT: 'input[type="file"][accept=".zip"]',
// ============================================================================
// PROJECT SELECTORS
// ============================================================================
PAGE_PROJECT: '.page-project',
CREATE_PROJECT_BTN:
'button[aria-label="Create New Project"], button:has-text("Create Project")',
PROJECT_NAME_INPUT: '[name="projectName"]',
WORK_CONTEXT_MENU: 'work-context-menu',
MOVE_TO_ARCHIVE_BTN: '.e2e-move-done-to-archive',
// ============================================================================
// NOTES SELECTORS
// ============================================================================
NOTES: 'notes',
TOGGLE_NOTES_BTN: '.e2e-toggle-notes-btn',
ADD_NOTE_BTN: '#add-note-btn',
SAVE_NOTE_BTN: '#T-save-note',
// ============================================================================
// PLANNER SELECTORS
// ============================================================================
PLANNER_VIEW: 'planner',
// ============================================================================
// COMMON UI ELEMENTS
// ============================================================================
GLOBAL_ERROR_ALERT: '.global-error-alert',
MAT_CARD: 'mat-card',
MAT_CARD_TITLE: 'mat-card-title',
// ============================================================================
// DATE/TIME SELECTORS
// ============================================================================
EDIT_DATE_INFO: '.edit-date-info',
TIME_INPUT: 'input[type="time"]',
MAT_TIME_INPUT: 'mat-form-field input[type="time"]',
// ============================================================================
// TASK DETAIL PANEL SELECTORS
// ============================================================================
RIGHT_PANEL: '.right-panel',
DETAIL_PANEL: 'dialog-task-detail-panel, task-detail-panel',
DETAIL_PANEL_BTN: '.show-additional-info-btn',
SCHEDULE_TASK_ITEM:
'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))',
TASK_SCHEDULE_BTN: '.ico-btn.schedule-btn',
} as const;
export type SelectorKey = keyof typeof cssSelectors;

34
e2e/constants/timeouts.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Standardized timeout constants for e2e tests.
* Use these to ensure consistent timeout handling across all tests.
*/
export const TIMEOUTS = {
/** Standard wait for dialogs to appear/disappear */
DIALOG: 5000,
/** Standard wait for navigation changes */
NAVIGATION: 30000,
/** Wait for sync operations to complete */
SYNC: 30000,
/** Maximum wait for scheduled reminders to trigger */
SCHEDULE_MAX: 60000,
/** Wait for tasks to become visible */
TASK_VISIBLE: 10000,
/** Wait for UI animations to complete */
ANIMATION: 500,
/** Wait for Angular stability after state changes */
ANGULAR_STABILITY: 3000,
/** Wait for elements to be enabled/clickable */
ELEMENT_ENABLED: 5000,
/** Extended timeout for complex operations */
EXTENDED: 20000,
} as const;
export type TimeoutKey = keyof typeof TIMEOUTS;

View file

@ -1,11 +1,28 @@
import { BrowserContext, test as base } from '@playwright/test';
import { WorkViewPage } from '../pages/work-view.page';
import { ProjectPage } from '../pages/project.page';
import { TaskPage } from '../pages/task.page';
import { SettingsPage } from '../pages/settings.page';
import { DialogPage } from '../pages/dialog.page';
import { PlannerPage } from '../pages/planner.page';
import { SyncPage } from '../pages/sync.page';
import { TagPage } from '../pages/tag.page';
import { NotePage } from '../pages/note.page';
import { SideNavPage } from '../pages/side-nav.page';
import { waitForAppReady } from '../utils/waits';
import { dismissTourIfVisible } from '../utils/tour-helpers';
type TestFixtures = {
workViewPage: WorkViewPage;
projectPage: ProjectPage;
taskPage: TaskPage;
settingsPage: SettingsPage;
dialogPage: DialogPage;
plannerPage: PlannerPage;
syncPage: SyncPage;
tagPage: TagPage;
notePage: NotePage;
sideNavPage: SideNavPage;
isolatedContext: BrowserContext;
waitForNav: (selector?: string) => Promise<void>;
testPrefix: string;
@ -66,16 +83,8 @@ export const test = base.extend<TestFixtures>({
await waitForAppReady(page);
// Only wait for the global add input if it's already present
const addTaskInput = page.locator('add-task-bar.global input');
try {
const inputCount = await addTaskInput.count();
if (inputCount > 0) {
await addTaskInput.first().waitFor({ state: 'visible', timeout: 3000 });
}
} catch {
// Non-fatal: not all routes show the global add input immediately
}
// Dismiss Shepherd tour if it appears
await dismissTourIfVisible(page);
await use(page);
} finally {
@ -101,6 +110,38 @@ export const test = base.extend<TestFixtures>({
await use(new ProjectPage(page, testPrefix));
},
taskPage: async ({ page, testPrefix }, use) => {
await use(new TaskPage(page, testPrefix));
},
settingsPage: async ({ page, testPrefix }, use) => {
await use(new SettingsPage(page, testPrefix));
},
dialogPage: async ({ page, testPrefix }, use) => {
await use(new DialogPage(page, testPrefix));
},
plannerPage: async ({ page }, use) => {
await use(new PlannerPage(page));
},
syncPage: async ({ page }, use) => {
await use(new SyncPage(page));
},
tagPage: async ({ page, testPrefix }, use) => {
await use(new TagPage(page, testPrefix));
},
notePage: async ({ page, testPrefix }, use) => {
await use(new NotePage(page, testPrefix));
},
sideNavPage: async ({ page, testPrefix }, use) => {
await use(new SideNavPage(page, testPrefix));
},
waitForNav: async ({ page }, use) => {
const waitForNav = async (selector?: string): Promise<void> => {
await waitForAppReady(page, {

View file

@ -322,6 +322,116 @@ export const getCITimeoutMultiplier = (): number => {
return process.env.CI ? 2 : 1;
};
/**
* Disable a plugin with robust verification
*/
export const disablePluginWithVerification = async (
page: Page,
pluginName: string,
timeout: number = 10000,
): Promise<boolean> => {
const startTime = Date.now();
// First, verify the plugin card exists and is enabled
const pluginCardResult = await page
.waitForFunction(
(name) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes(name);
});
return !!targetCard;
},
pluginName,
{ timeout: timeout / 2 },
)
.catch(() => null);
if (!pluginCardResult) {
console.error(`[Plugin Test] Plugin card not found for: ${pluginName}`);
return false;
}
// Check current state and click to disable if needed
const disableResult = await page.evaluate((name) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes(name);
});
if (!targetCard) {
return {
success: false,
error: 'Card not found',
wasEnabled: false,
clicked: false,
};
}
const toggle = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
if (!toggle) {
return {
success: false,
error: 'Toggle not found',
wasEnabled: false,
clicked: false,
};
}
const wasEnabled = toggle.getAttribute('aria-checked') === 'true';
if (wasEnabled) {
toggle.click();
return { success: true, wasEnabled, clicked: true };
}
// Already disabled
return { success: true, wasEnabled: false, clicked: false };
}, pluginName);
if (!disableResult.success) {
console.error(`[Plugin Test] Failed to disable plugin: ${disableResult.error}`);
return false;
}
// If already disabled, no need to wait
if (!disableResult.clicked) {
return true;
}
// Wait for the toggle state to update to disabled
const remainingTimeout = Math.max(5000, timeout - (Date.now() - startTime));
try {
await page.waitForFunction(
(name) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes(name);
});
const toggle = targetCard?.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggle?.getAttribute('aria-checked') === 'false';
},
pluginName,
{ timeout: remainingTimeout },
);
return true;
} catch (error) {
console.error(
`[Plugin Test] Timeout waiting for plugin to disable: ${error.message}`,
);
return false;
}
};
/**
* Robust element clicking with multiple selector fallbacks
*/

View file

@ -1,5 +1,5 @@
import { type Locator, type Page } from '@playwright/test';
import { waitForAngularStability } from '../utils/waits';
import { safeIsVisible } from '../utils/element-helpers';
export abstract class BasePage {
protected page: Page;
@ -15,10 +15,6 @@ export abstract class BasePage {
}
async addTask(taskName: string, skipClose = false): Promise<void> {
// Ensure route is stable before starting
await this.routerWrapper.waitFor({ state: 'visible', timeout: 10000 });
await this.page.waitForLoadState('domcontentloaded');
// Add test prefix to task name for isolation only if not already prefixed
const prefixedTaskName =
this.testPrefix && !taskName.startsWith(this.testPrefix)
@ -37,153 +33,59 @@ export abstract class BasePage {
// Wait for add button with longer timeout - it depends on config loading
await addBtn.waitFor({ state: 'visible', timeout: 20000 });
await addBtn.click();
// Wait for input to appear after clicking
await this.page.waitForTimeout(500);
}
// Ensure input is visible and interactable with longer timeout
await inputEl.first().waitFor({ state: 'visible', timeout: 20000 });
await inputEl.first().waitFor({ state: 'attached', timeout: 5000 });
// Ensure input is visible - Playwright auto-waits for actionability
const input = inputEl.first();
await input.waitFor({ state: 'visible', timeout: 10000 });
// Wait for Angular to stabilize before interacting
await waitForAngularStability(this.page);
// Clear and fill input with retry logic
let filled = false;
for (let attempt = 0; attempt < 3 && !filled; attempt++) {
try {
if (attempt > 0) {
await this.page.waitForTimeout(500);
await waitForAngularStability(this.page);
}
// Focus and select all before clearing to ensure old value is removed
await inputEl.first().click();
await this.page.waitForTimeout(100);
// Try to clear using multiple methods
await inputEl.first().clear();
await this.page.waitForTimeout(50);
// Use keyboard shortcut to ensure clear
await inputEl.first().press('Control+a');
await this.page.waitForTimeout(50);
await inputEl.first().fill(prefixedTaskName);
await this.page.waitForTimeout(100);
// Verify text was filled correctly
const value = await inputEl.first().inputValue();
if (value === prefixedTaskName) {
filled = true;
} else {
// If value doesn't match, try once more with direct keyboard input
await inputEl.first().clear();
await this.page.waitForTimeout(50);
await inputEl.first().pressSequentially(prefixedTaskName, { delay: 20 });
const retryValue = await inputEl.first().inputValue();
if (retryValue === prefixedTaskName) {
filled = true;
}
}
} catch (e) {
if (attempt === 2) throw e;
await this.page.waitForLoadState('networkidle').catch(() => {});
}
}
// Clear and fill input - Playwright handles waiting for interactability
await input.click();
await input.clear();
await input.fill(prefixedTaskName);
// Store the initial count before submission
const initialCount = await this.page.locator('task').count();
const expectedCount = initialCount + 1;
// Wait for submit button and click it
// Click submit button
const submitBtn = this.page.locator('.e2e-add-task-submit');
await submitBtn.waitFor({ state: 'visible', timeout: 5000 });
await submitBtn.click();
// Wait for Angular to process the submission
await waitForAngularStability(this.page);
// Check if a dialog appeared (e.g., create tag dialog)
const dialogExists = await this.page
.locator('mat-dialog-container')
.isVisible()
.catch(() => false);
const dialogExists = await safeIsVisible(this.page.locator('mat-dialog-container'));
if (!dialogExists) {
// Wait for task to be created using multiple strategies
const taskCreated = await Promise.race([
// Strategy 1: Wait for task count to increase
this.page
.waitForFunction(
(args) => {
const currentCount = document.querySelectorAll('task').length;
return currentCount >= args.expectedCount;
},
{ expectedCount },
{ timeout: 12000 },
)
.then(() => true)
.catch(() => false),
// Wait for task to be created - check for the specific task
const taskLocator = this.page.locator(
`task:has-text("${prefixedTaskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}")`,
);
// Strategy 2: Look for the specific task by text content
this.page
.waitForSelector(
`task:has-text("${prefixedTaskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}")`,
{ timeout: 12000 },
)
.then(() => true)
.catch(() => false),
]);
if (!taskCreated) {
// Final attempt: wait for Angular stability and check again
await waitForAngularStability(this.page, 5000);
await this.page.waitForTimeout(1000);
try {
await taskLocator.first().waitFor({ state: 'visible', timeout: 10000 });
} catch (error) {
// If specific task not found, verify count increased
const finalCount = await this.page.locator('task').count();
if (finalCount < expectedCount) {
const tasks = await this.page.locator('task').allTextContents();
throw new Error(
`Task creation failed. Expected ${expectedCount} tasks, but got ${finalCount}.\n` +
`Task name: "${prefixedTaskName}"\n` +
`Existing tasks: ${JSON.stringify(tasks, null, 2)}`,
);
}
}
} else {
// If dialog appeared, wait for it to be fully rendered
await this.page.waitForTimeout(500);
}
// Final verification with detailed error reporting
const finalCount = await this.page.locator('task').count();
if (!dialogExists && finalCount < expectedCount) {
// Gather debug information
const tasks = await this.page.locator('task').all();
const taskTexts = await Promise.all(
tasks.map(async (t) => {
try {
return await t.textContent();
} catch {
return 'error reading text';
}
}),
);
throw new Error(
`Task creation failed. Expected ${expectedCount} tasks, but got ${finalCount}.\n` +
`Task name: "${prefixedTaskName}"\n` +
`Existing tasks: ${JSON.stringify(taskTexts, null, 2)}`,
);
}
if (!skipClose) {
// Close the add task bar if backdrop is visible
const backdropVisible = await this.backdrop.isVisible().catch(() => false);
const backdropVisible = await safeIsVisible(this.backdrop);
if (backdropVisible) {
await this.backdrop.click();
await this.backdrop.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {
// Non-fatal: backdrop might auto-hide
});
}
// Final small wait after closing to ensure DOM is fully settled
await this.page.waitForTimeout(200);
} else {
// If not closing, still wait briefly for the task to be fully persisted
await this.page.waitForTimeout(300);
}
}
}

214
e2e/pages/dialog.page.ts Normal file
View file

@ -0,0 +1,214 @@
import { Locator, Page } from '@playwright/test';
import { BasePage } from './base.page';
import { cssSelectors } from '../constants/selectors';
import { waitForAngularStability } from '../utils/waits';
const { MAT_DIALOG, DIALOG_FULLSCREEN_MARKDOWN, SAVE_NOTE_BTN } = cssSelectors;
export class DialogPage extends BasePage {
constructor(page: Page, testPrefix: string = '') {
super(page, testPrefix);
}
/**
* Wait for any dialog to appear
*/
async waitForDialog(timeout: number = 10000): Promise<Locator> {
const dialog = this.page.locator(MAT_DIALOG).first();
await dialog.waitFor({ state: 'visible', timeout });
return dialog;
}
/**
* Wait for dialog to close
*/
async waitForDialogToClose(timeout: number = 10000): Promise<void> {
await this.page.locator(MAT_DIALOG).waitFor({ state: 'hidden', timeout });
await waitForAngularStability(this.page);
}
/**
* Check if any dialog is open
*/
async isDialogOpen(): Promise<boolean> {
return await this.page.locator(MAT_DIALOG).isVisible();
}
/**
* Get dialog by aria-label
*/
getDialogByLabel(label: string): Locator {
return this.page.locator(`[role="dialog"][aria-label="${label}"]`);
}
/**
* Click dialog button by text
*/
async clickDialogButton(buttonText: string): Promise<void> {
const dialog = this.page.locator(MAT_DIALOG);
const button = dialog.getByRole('button', { name: buttonText });
await button.waitFor({ state: 'visible', timeout: 5000 });
await button.click();
await this.page.waitForTimeout(300);
}
/**
* Click Save button in dialog
*/
async clickSaveButton(): Promise<void> {
await this.clickDialogButton('Save');
}
/**
* Click Cancel button in dialog
*/
async clickCancelButton(): Promise<void> {
await this.clickDialogButton('Cancel');
}
/**
* Fill input field in dialog
*/
async fillDialogInput(
inputSelector: string,
value: string,
clearFirst: boolean = true,
): Promise<void> {
const dialog = this.page.locator(MAT_DIALOG);
const input = dialog.locator(inputSelector);
await input.waitFor({ state: 'visible', timeout: 5000 });
if (clearFirst) {
await input.clear();
await this.page.waitForTimeout(50);
}
await input.fill(value);
await this.page.waitForTimeout(100);
}
/**
* Get dialog title
*/
async getDialogTitle(): Promise<string> {
const dialog = this.page.locator(MAT_DIALOG);
const title = dialog.locator('h2, mat-dialog-title, .mat-dialog-title').first();
return (await title.textContent()) || '';
}
/**
* Wait for fullscreen markdown dialog (for notes)
*/
async waitForMarkdownDialog(timeout: number = 10000): Promise<Locator> {
const dialog = this.page.locator(DIALOG_FULLSCREEN_MARKDOWN);
await dialog.waitFor({ state: 'visible', timeout });
return dialog;
}
/**
* Fill markdown textarea in fullscreen dialog
*/
async fillMarkdownDialog(content: string): Promise<void> {
const dialog = this.page.locator(DIALOG_FULLSCREEN_MARKDOWN);
const textarea = dialog.locator('textarea').first();
await textarea.waitFor({ state: 'visible', timeout: 5000 });
await textarea.fill(content);
}
/**
* Save markdown dialog
*/
async saveMarkdownDialog(): Promise<void> {
const saveBtn = this.page.locator(SAVE_NOTE_BTN);
const saveBtnVisible = await saveBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (saveBtnVisible) {
await saveBtn.click();
} else {
// Fallback: try button with save icon
const saveBtnFallback = this.page.locator('button:has(mat-icon:has-text("save"))');
const fallbackVisible = await saveBtnFallback
.isVisible({ timeout: 2000 })
.catch(() => false);
if (fallbackVisible) {
await saveBtnFallback.click();
} else {
// Last resort: keyboard shortcut
await this.page.keyboard.press('Control+Enter');
}
}
await this.waitForDialogToClose();
}
/**
* Close markdown dialog without saving
*/
async closeMarkdownDialog(): Promise<void> {
await this.page.keyboard.press('Escape');
await this.waitForDialogToClose();
}
/**
* Edit date/time in task detail
*/
async editDateTime(dateValue?: string, timeValue?: string): Promise<void> {
if (dateValue !== undefined) {
const dateInput = this.page.getByRole('textbox', { name: 'Date' });
await dateInput.waitFor({ state: 'visible', timeout: 3000 });
await dateInput.fill(dateValue);
}
if (timeValue !== undefined) {
const timeInput = this.page.getByRole('combobox', { name: 'Time' });
await timeInput.waitFor({ state: 'visible', timeout: 3000 });
await timeInput.fill(timeValue);
}
await this.page.waitForTimeout(200);
}
/**
* Open calendar picker
*/
async openCalendarPicker(): Promise<void> {
const openCalendarBtn = this.page.getByRole('button', { name: 'Open calendar' });
await openCalendarBtn.waitFor({ state: 'visible', timeout: 3000 });
await openCalendarBtn.click();
await this.page.waitForTimeout(300);
}
/**
* Select first day of next month in calendar
*/
async selectFirstDayOfNextMonth(): Promise<void> {
await this.openCalendarPicker();
await this.page.getByRole('button', { name: 'Next month' }).click();
await this.page.locator('mat-month-view button').first().click();
}
/**
* Check if Save button is enabled
*/
async isSaveButtonEnabled(): Promise<boolean> {
const saveBtn = this.page.getByRole('button', { name: 'Save' });
return !(await saveBtn.isDisabled());
}
/**
* Close dialog by clicking backdrop
*/
async closeDialogByBackdrop(): Promise<void> {
await this.backdrop.click();
await this.waitForDialogToClose();
}
/**
* Close dialog by pressing Escape
*/
async closeDialogByEscape(): Promise<void> {
await this.page.keyboard.press('Escape');
await this.waitForDialogToClose();
}
}

11
e2e/pages/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { BasePage } from './base.page';
export { WorkViewPage } from './work-view.page';
export { TaskPage } from './task.page';
export { SettingsPage } from './settings.page';
export { DialogPage } from './dialog.page';
export { ProjectPage } from './project.page';
export { PlannerPage } from './planner.page';
export { SyncPage } from './sync.page';
export { SideNavPage } from './side-nav.page';
export { TagPage } from './tag.page';
export { NotePage } from './note.page';

View file

@ -42,9 +42,6 @@ export class ProjectPage extends BasePage {
: projectName;
try {
// Ensure page is stable before starting
await this.page.waitForLoadState('networkidle');
// Check for empty state first (single "Create Project" button)
const emptyStateBtn = this.page
.locator('nav-item')
@ -135,9 +132,6 @@ export class ProjectPage extends BasePage {
? `${this.testPrefix}-${projectName}`
: projectName;
// Wait for page to be fully loaded before checking
await this.page.waitForLoadState('networkidle');
// Wait for Angular to fully render after any navigation
await this.page.waitForTimeout(2000);
@ -227,8 +221,6 @@ export class ProjectPage extends BasePage {
}
}
await this.page.waitForLoadState('networkidle');
// Final verification - wait for the project to appear in main
// Use a locator-based wait for better reliability
try {
@ -280,9 +272,6 @@ export class ProjectPage extends BasePage {
}
async createAndGoToTestProject(): Promise<void> {
// Ensure the page context is stable before starting
await this.page.waitForLoadState('networkidle');
// Wait for the nav to be fully loaded
await this.sidenav.waitFor({ state: 'visible', timeout: 3000 }); // Reduced from 5s to 3s
@ -417,9 +406,6 @@ export class ProjectPage extends BasePage {
await newProject.click();
// Wait for navigation to complete
await this.page.waitForLoadState('networkidle');
// Verify we're in the project
await expect(this.workCtxTitle).toContainText(projectName);
}
@ -435,19 +421,9 @@ export class ProjectPage extends BasePage {
const routerWrapper = this.page.locator('.route-wrapper');
await routerWrapper.waitFor({ state: 'visible', timeout: 6000 }); // Reduced from 10s to 6s
// Wait for the page to be fully loaded
await this.page.waitForLoadState('networkidle');
// Wait for project view to be present (not necessarily visible immediately)
// Wait for project view to be ready
const workView = this.page.locator('work-view');
await workView.waitFor({ state: 'attached', timeout: 15000 });
const isWorkViewVisible = await workView
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!isWorkViewVisible) {
// Allow a brief moment for the view to finish rendering
await this.page.waitForTimeout(500);
}
await this.page.waitForTimeout(100);
// First ensure notes section is visible by clicking toggle if needed
const toggleNotesBtn = this.page.locator('.e2e-toggle-notes-btn');

231
e2e/pages/settings.page.ts Normal file
View file

@ -0,0 +1,231 @@
import { Locator, Page } from '@playwright/test';
import { BasePage } from './base.page';
import { cssSelectors } from '../constants/selectors';
import { waitForAngularStability } from '../utils/waits';
const {
SETTINGS_BTN,
PAGE_SETTINGS,
PLUGIN_SECTION,
PLUGIN_MANAGEMENT,
PLUGIN_CARD,
PLUGIN_TOGGLE,
PLUGIN_FILE_INPUT,
} = cssSelectors;
export class SettingsPage extends BasePage {
readonly settingsBtn: Locator;
readonly pageSettings: Locator;
readonly pluginSection: Locator;
readonly pluginManagement: Locator;
constructor(page: Page, testPrefix: string = '') {
super(page, testPrefix);
this.settingsBtn = page.locator(SETTINGS_BTN);
this.pageSettings = page.locator(PAGE_SETTINGS);
this.pluginSection = page.locator(PLUGIN_SECTION);
this.pluginManagement = page.locator(PLUGIN_MANAGEMENT);
}
/**
* Navigate to settings page
*/
async navigateToSettings(): Promise<void> {
await this.settingsBtn.waitFor({ state: 'visible', timeout: 10000 });
await this.settingsBtn.click();
await this.pageSettings.waitFor({ state: 'visible', timeout: 10000 });
await waitForAngularStability(this.page);
}
/**
* Expand a collapsible section by scrolling to it and clicking header
*/
async expandSection(sectionSelector: string): Promise<void> {
await this.page.evaluate((selector) => {
const section = document.querySelector(selector);
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
const collapsible = section?.querySelector('collapsible');
if (collapsible) {
const isExpanded = collapsible.classList.contains('isExpanded');
if (!isExpanded) {
const header = collapsible.querySelector('.collapsible-header');
if (header) {
(header as HTMLElement).click();
}
}
}
}, sectionSelector);
await this.page.waitForTimeout(500); // Wait for expansion animation
await waitForAngularStability(this.page);
}
/**
* Expand plugin section
*/
async expandPluginSection(): Promise<void> {
await this.expandSection(PLUGIN_SECTION);
await this.pluginManagement.waitFor({ state: 'visible', timeout: 5000 });
}
/**
* Navigate to plugin settings (settings page + expand plugin section)
*/
async navigateToPluginSettings(): Promise<void> {
const currentUrl = this.page.url();
if (!currentUrl.includes('#/config')) {
await this.navigateToSettings();
}
await this.expandPluginSection();
}
/**
* Get a plugin card by plugin name or ID
*/
async getPluginCard(pluginName: string): Promise<Locator | null> {
const cards = await this.page.locator(PLUGIN_CARD).all();
for (const card of cards) {
const text = await card.textContent();
if (text?.includes(pluginName)) {
return card;
}
}
return null;
}
/**
* Check if a plugin exists
*/
async pluginExists(pluginName: string): Promise<boolean> {
const card = await this.getPluginCard(pluginName);
return card !== null;
}
/**
* Enable a plugin by name
*/
async enablePlugin(pluginName: string): Promise<boolean> {
const card = await this.getPluginCard(pluginName);
if (!card) {
return false;
}
const toggle = card.locator(PLUGIN_TOGGLE);
const isEnabled = (await toggle.getAttribute('aria-checked')) === 'true';
if (!isEnabled) {
await toggle.click();
await this.page.waitForTimeout(500);
await waitForAngularStability(this.page);
}
return true;
}
/**
* Disable a plugin by name
*/
async disablePlugin(pluginName: string): Promise<boolean> {
const card = await this.getPluginCard(pluginName);
if (!card) {
return false;
}
const toggle = card.locator(PLUGIN_TOGGLE);
const isEnabled = (await toggle.getAttribute('aria-checked')) === 'true';
if (isEnabled) {
await toggle.click();
await this.page.waitForTimeout(500);
await waitForAngularStability(this.page);
}
return true;
}
/**
* Check if a plugin is enabled
*/
async isPluginEnabled(pluginName: string): Promise<boolean> {
const card = await this.getPluginCard(pluginName);
if (!card) {
return false;
}
const toggle = card.locator(PLUGIN_TOGGLE);
return (await toggle.getAttribute('aria-checked')) === 'true';
}
/**
* Upload a plugin ZIP file
*/
async uploadPlugin(pluginPath: string): Promise<void> {
// Make file input visible
await this.page.evaluate(() => {
const input = document.querySelector(
'input[type="file"][accept=".zip"]',
) as HTMLElement;
if (input) {
input.style.display = 'block';
input.style.position = 'relative';
input.style.opacity = '1';
}
});
await this.page.locator(PLUGIN_FILE_INPUT).setInputFiles(pluginPath);
await this.page.waitForTimeout(1000);
await waitForAngularStability(this.page);
}
/**
* Get all plugin names
*/
async getAllPluginNames(): Promise<string[]> {
const cards = await this.page.locator(PLUGIN_CARD).all();
const names: string[] = [];
for (const card of cards) {
const titleEl = card.locator('mat-card-title');
const title = await titleEl.textContent();
if (title) {
names.push(title.trim());
}
}
return names;
}
/**
* Scroll to a specific section
*/
async scrollToSection(sectionSelector: string): Promise<void> {
await this.page.evaluate((selector) => {
const section = document.querySelector(selector);
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, sectionSelector);
await this.page.waitForTimeout(500);
}
/**
* Check if on settings page
*/
async isOnSettingsPage(): Promise<boolean> {
return await this.pageSettings.isVisible();
}
/**
* Navigate back to work view
*/
async navigateBackToWorkView(): Promise<void> {
await this.page.goto('/#/tag/TODAY');
await this.page.waitForLoadState('networkidle');
await waitForAngularStability(this.page);
}
}

238
e2e/pages/task.page.ts Normal file
View file

@ -0,0 +1,238 @@
import { Locator, Page } from '@playwright/test';
import { BasePage } from './base.page';
import { cssSelectors } from '../constants/selectors';
import { waitForAngularStability } from '../utils/waits';
const { TASK, FIRST_TASK, TASK_DONE_BTN, TASK_TEXTAREA, SUB_TASK } = cssSelectors;
export class TaskPage extends BasePage {
constructor(page: Page, testPrefix: string = '') {
super(page, testPrefix);
}
/**
* Get a task by index (1-based)
*/
getTask(index: number = 1): Locator {
if (index === 1) {
return this.page.locator(FIRST_TASK);
}
return this.page.locator(TASK).nth(index - 1);
}
/**
* Get a task by text content
*/
getTaskByText(text: string): Locator {
return this.page.locator(TASK).filter({ hasText: text });
}
/**
* Get all tasks
*/
getAllTasks(): Locator {
return this.page.locator(TASK);
}
/**
* Get task count
*/
async getTaskCount(): Promise<number> {
return await this.page.locator(TASK).count();
}
/**
* Get task title element for a specific task
*/
getTaskTitle(task: Locator): Locator {
return task.locator('task-title');
}
/**
* Mark a task as done
*/
async markTaskAsDone(task: Locator): Promise<void> {
await task.waitFor({ state: 'visible' });
await task.hover();
const doneBtn = task.locator(TASK_DONE_BTN);
await doneBtn.waitFor({ state: 'visible', timeout: 5000 });
await doneBtn.click();
await waitForAngularStability(this.page);
}
/**
* Mark the first task as done
*/
async markFirstTaskAsDone(): Promise<void> {
const firstTask = this.getTask(1);
await this.markTaskAsDone(firstTask);
}
/**
* Edit task title
*/
async editTaskTitle(task: Locator, newTitle: string): Promise<void> {
const titleElement = this.getTaskTitle(task);
await titleElement.waitFor({ state: 'visible' });
await titleElement.click();
const textarea = task.locator(TASK_TEXTAREA);
await textarea.waitFor({ state: 'visible', timeout: 3000 });
await textarea.clear();
await this.page.waitForTimeout(50);
await textarea.fill(newTitle);
await this.page.keyboard.press('Tab'); // Blur to save
await waitForAngularStability(this.page);
}
/**
* Open task detail panel (additional info)
*/
async openTaskDetail(task: Locator): Promise<void> {
await task.waitFor({ state: 'visible' });
await task.hover();
const showDetailBtn = this.page.getByRole('button', {
name: 'Show/Hide additional info',
});
await showDetailBtn.waitFor({ state: 'visible', timeout: 3000 });
await showDetailBtn.click();
await this.page.waitForTimeout(300);
}
/**
* Open detail panel for the first task
*/
async openFirstTaskDetail(): Promise<void> {
const firstTask = this.getTask(1);
await this.openTaskDetail(firstTask);
}
/**
* Get subtasks for a task
*/
getSubTasks(task: Locator): Locator {
return task.locator(SUB_TASK);
}
/**
* Get subtask count for a task
*/
async getSubTaskCount(task: Locator): Promise<number> {
return await this.getSubTasks(task).count();
}
/**
* Check if a task is marked as done
*/
async isTaskDone(task: Locator): Promise<boolean> {
const classes = await task.getAttribute('class');
return classes?.includes('isDone') || false;
}
/**
* Get done tasks
*/
getDoneTasks(): Locator {
return this.page.locator(`${TASK}.isDone`);
}
/**
* Get undone tasks
*/
getUndoneTasks(): Locator {
return this.page.locator(`${TASK}:not(.isDone)`);
}
/**
* Get done task count
*/
async getDoneTaskCount(): Promise<number> {
return await this.getDoneTasks().count();
}
/**
* Get undone task count
*/
async getUndoneTaskCount(): Promise<number> {
return await this.getUndoneTasks().count();
}
/**
* Wait for a task to appear with specific text
*/
async waitForTaskWithText(text: string, timeout: number = 10000): Promise<Locator> {
const task = this.getTaskByText(text);
await task.waitFor({ state: 'visible', timeout });
return task;
}
/**
* Wait for task count to match expected count
*/
async waitForTaskCount(expectedCount: number, timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
(args) => {
const currentCount = document.querySelectorAll('task').length;
return currentCount === args.expectedCount;
},
{ expectedCount },
{ timeout },
);
}
/**
* Get task tags
*/
getTaskTags(task: Locator): Locator {
return task.locator('tag');
}
/**
* Check if task has a specific tag
*/
async taskHasTag(task: Locator, tagName: string): Promise<boolean> {
const tags = this.getTaskTags(task);
const tagCount = await tags.count();
for (let i = 0; i < tagCount; i++) {
const tagText = await tags.nth(i).textContent();
if (tagText?.includes(tagName)) {
return true;
}
}
return false;
}
/**
* Toggle task detail panel
*/
async toggleTaskDetail(task: Locator): Promise<void> {
await task.hover();
const toggleBtn = this.page.getByRole('button', {
name: 'Show/Hide additional info',
});
await toggleBtn.click();
await this.page.waitForTimeout(300);
}
/**
* Start/Stop task time tracking
*/
async toggleTaskTimeTracking(task: Locator): Promise<void> {
await task.waitFor({ state: 'visible' });
await task.hover();
const playBtn = task.locator('.play-btn, .pause-btn').first();
await playBtn.waitFor({ state: 'visible', timeout: 3000 });
await playBtn.click();
await waitForAngularStability(this.page);
}
/**
* Get the date info element (created/completed) in task detail
*/
getDateInfo(infoPrefix: string): Locator {
return this.page
.locator('.edit-date-info')
.filter({ hasText: new RegExp(infoPrefix) });
}
}

View file

@ -20,37 +20,29 @@ export class WorkViewPage extends BasePage {
}
async waitForTaskList(): Promise<void> {
// Wait for the loading screen to disappear first (if visible).
// The app shows `.loading-full-page-wrapper` while syncing/importing data.
const loadingWrapper = this.page.locator('.loading-full-page-wrapper');
try {
const isLoadingVisible = await loadingWrapper.isVisible().catch(() => false);
if (isLoadingVisible) {
await loadingWrapper.waitFor({ state: 'hidden', timeout: 30000 });
}
} catch {
// Loading screen might not appear at all - that's fine
}
// Wait for task list to be visible
await this.page.waitForSelector('task-list', {
state: 'visible',
timeout: 10000,
timeout: 15000,
});
// Ensure route wrapper is fully loaded
await this.routerWrapper.waitFor({ state: 'visible', timeout: 10000 });
// Wait for network to settle with timeout
await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {
// Non-fatal: proceed even if network doesn't fully idle
});
// Wait for Angular to stabilize using shared helper
await waitForAngularStability(this.page);
// If the global add-task bar is already open, wait for its input
try {
const inputCount = await this.addTaskGlobalInput.count();
if (inputCount > 0) {
await this.addTaskGlobalInput
.first()
.waitFor({ state: 'visible', timeout: 3000 });
}
} catch {
// Non-fatal: some routes/tests don't show the global add bar immediately
}
// Final small wait to ensure UI is fully settled
await this.page.waitForTimeout(200);
}
async addSubTask(task: Locator, subTaskName: string): Promise<void> {
@ -70,13 +62,10 @@ export class WorkViewPage extends BasePage {
// Ensure the field is properly focused and cleared before filling
await textarea.click();
await this.page.waitForTimeout(100);
await textarea.fill('');
await this.page.waitForTimeout(50);
// Use fill() instead of type() for more reliable text input
await textarea.fill(subTaskName);
await this.page.waitForTimeout(100);
await this.page.keyboard.press('Enter');
}
}

View file

@ -72,8 +72,8 @@ export default defineConfig({
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:4242',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Collect trace on failure for better debugging. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
/* Take screenshot on failure */
screenshot: 'only-on-failure',

View file

@ -10,38 +10,31 @@ test.describe('All Basic Routes Without Error', () => {
// Wait for magic-side-nav to be fully loaded
await page.locator('magic-side-nav').waitFor({ state: 'visible' });
await page.waitForTimeout(1000); // Give extra time for navigation items to load
// Helper to navigate and wait for route to load
const navigateAndWait = async (route: string): Promise<void> => {
await page.goto(route);
await page.locator('.route-wrapper').waitFor({ state: 'visible', timeout: 10000 });
};
// Navigate to schedule
await page.goto('/#/tag/TODAY/schedule');
await navigateAndWait('/#/tag/TODAY/schedule');
// Test that key navigation elements are visible and functional
// Wait for navigation to be fully loaded
await page.waitForSelector('magic-side-nav', { state: 'visible' });
// Test navigation to different routes by URL (the main goal of this test)
await page.goto('/#/schedule');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/tasks');
await page.waitForTimeout(500);
await page.goto('/#/config');
await page.waitForTimeout(500);
await navigateAndWait('/#/schedule');
await navigateAndWait('/#/tag/TODAY/tasks');
await navigateAndWait('/#/config');
// Navigate to different routes
await page.goto('/#/tag/TODAY/quick-history');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/worklog');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/metrics');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/planner');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/daily-summary');
await page.waitForTimeout(500);
await page.goto('/#/tag/TODAY/settings');
await page.waitForTimeout(500);
await navigateAndWait('/#/tag/TODAY/quick-history');
await navigateAndWait('/#/tag/TODAY/worklog');
await navigateAndWait('/#/tag/TODAY/metrics');
await navigateAndWait('/#/tag/TODAY/planner');
await navigateAndWait('/#/tag/TODAY/daily-summary');
await navigateAndWait('/#/tag/TODAY/settings');
// Send 'n' key to open notes dialog
await page.keyboard.press('n');

View file

@ -75,9 +75,6 @@ test.describe('App Features', () => {
await appFeaturesSection.click();
await expect(featureSwitch).toBeVisible();
// Wait a moment for the toggle to be fully interactive after expansion animation
await page.waitForTimeout(100);
// Click toggle button to enable and verify state change
await featureSwitch.click();
await expect(featureSwitch).toBeChecked();

View file

@ -52,8 +52,8 @@ test.describe('App Features - Focus Mode', () => {
// send shortcut for focus mode, ensure that focus overlay is not showing
await page.keyboard.press('F');
await page.waitForTimeout(500);
expect(focusModeOverlay).not.toBeAttached();
// Verify overlay doesn't appear after a brief moment
await expect(focusModeOverlay).not.toBeAttached({ timeout: 1000 });
// Re-enable the feature
await page.goto('/#/config');

View file

@ -40,16 +40,17 @@ test.describe('App Features - Time Tracking', () => {
// Navigate to main view
await page.goto('/#/tag/TODAY');
// Play button in main button bar should not be present when feature is disabled
expect(mainPlayButton).not.toBeAttached();
await expect(mainPlayButton).not.toBeAttached();
// Play button in the task hover menu should not be visible
await firstTask.hover();
expect(taskPlayButton).not.toBeAttached();
await expect(taskPlayButton).not.toBeAttached();
// select task and send PlayPause shortcut, ensure tracking is not started
await firstTaskHandle.click();
expect(firstTask).toBeFocused();
page.keyboard.press('Y');
await page.waitForTimeout(200);
expect(firstTask).not.toContainClass('isCurrent');
await expect(firstTask).toBeFocused();
await page.keyboard.press('Y');
// With feature disabled, pressing Y should NOT start tracking (no isCurrent class)
// Use a short timeout since we're testing that nothing happens
await expect(firstTask).not.toHaveClass(/isCurrent/, { timeout: 1000 });
// Re-enable the feature
await page.goto('/#/config');
@ -69,9 +70,9 @@ test.describe('App Features - Time Tracking', () => {
await expect(taskPlayButton).toBeAttached();
// select task and send PlayPause shortcut, ensure tracking is started
await firstTaskHandle.click();
expect(firstTask).toBeFocused();
page.keyboard.press('Y');
await page.waitForTimeout(200);
expect(firstTask).toContainClass('isCurrent');
await expect(firstTask).toBeFocused();
await page.keyboard.press('Y');
// With feature enabled, pressing Y should start tracking (adds isCurrent class)
await expect(firstTask).toHaveClass(/isCurrent/);
});
});

View file

@ -12,9 +12,6 @@ test.describe('Autocomplete Dropdown', () => {
// Use sd:today to set dueDay so task appears in TODAY view
await workViewPage.addTask('some task <3 #basicTag sd:today', true);
// Small delay to let the tag creation dialog appear
await page.waitForTimeout(500);
// Wait for and click the confirm create tag button with increased timeout
await page.waitForSelector(CONFIRM_CREATE_TAG_BTN, {
state: 'visible',

View file

@ -24,8 +24,8 @@ test.describe('Daily Summary', () => {
const taskName = 'test task hohoho 1h/1h';
await workViewPage.addTask(taskName);
// Wait a moment for task to be saved
await page.waitForTimeout(500);
// Wait for task to appear
await expect(page.locator('task')).toHaveCount(1, { timeout: 5000 });
// Navigate to daily summary
await page.goto('/#/tag/TODAY/daily-summary');

View file

@ -84,18 +84,14 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
const taskSelectorOverlay = page.locator('.task-selector-overlay');
await expect(taskSelectorOverlay).toBeVisible({ timeout: 3000 });
// Wait a bit for the autocomplete to show suggestions
await page.waitForTimeout(500);
// Click on the first suggested task (mat-option is in CDK overlay panel)
const suggestedTask = page.locator('mat-option, .mat-mdc-option').first();
await expect(suggestedTask).toBeVisible({ timeout: 5000 });
await suggestedTask.click();
await page.waitForTimeout(500);
}
// Wait for focus mode main component to be ready (after task selection)
await page.waitForTimeout(500);
// Wait for task selector overlay to close
await expect(taskSelectorOverlay).not.toBeVisible({ timeout: 5000 });
}
// Step 2: Switch to Countdown mode
await countdownButton.click();
@ -116,10 +112,7 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
await expect(durationSlider).not.toBeVisible({ timeout: 2000 });
// Verify clock shows 0:00 (Flowtime starts at 0 and counts up)
await page.waitForTimeout(300);
const clockText = await clockTime.textContent();
console.log('Clock text in Flowtime mode:', clockText);
expect(clockText?.trim()).toBe('0:00');
await expect(clockTime).toHaveText('0:00', { timeout: 3000 });
// Step 5: Start the focus session by clicking play button
await expect(playButton).toBeVisible({ timeout: 2000 });
@ -218,15 +211,13 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
const taskSelectorOverlay = page.locator('.task-selector-overlay');
await expect(taskSelectorOverlay).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(500);
const suggestedTask = page.locator('mat-option, .mat-mdc-option').first();
await expect(suggestedTask).toBeVisible({ timeout: 5000 });
await suggestedTask.click();
await page.waitForTimeout(500);
}
await page.waitForTimeout(500);
// Wait for task selector overlay to close
await expect(taskSelectorOverlay).not.toBeVisible({ timeout: 5000 });
}
// Step 1: Switch to Countdown mode
await countdownButton.click();
@ -242,10 +233,7 @@ test.describe('Bug #5117: Flowtime timer stops at Countdown duration', () => {
await expect(flowtimeButton).toHaveClass(/is-active/, { timeout: 2000 });
// Clock should show 0:00 for Flowtime
await page.waitForTimeout(300);
const flowTimeDisplay = await clockTime.textContent();
console.log('Flowtime initial display:', flowTimeDisplay);
expect(flowTimeDisplay?.trim()).toBe('0:00');
await expect(clockTime).toHaveText('0:00', { timeout: 3000 });
// Step 4: Start the Flowtime session
await expect(playButton).toBeVisible({ timeout: 2000 });

View file

@ -15,8 +15,11 @@ test.describe('Issue Provider Panel', () => {
await page.click('mat-tab-group .mat-mdc-tab:last-child');
await page.waitForSelector('issue-provider-setup-overview', { state: 'visible' });
// Wait for the setup overview to be fully loaded
await page.waitForTimeout(1000);
// Wait for buttons to be ready
await page
.locator('issue-provider-setup-overview button')
.first()
.waitFor({ state: 'visible', timeout: 5000 });
// Get all buttons in the issue provider setup overview
const setupButtons = page.locator('issue-provider-setup-overview button');

View file

@ -76,29 +76,4 @@ test.describe('Planner Navigation', () => {
await expect(page).toHaveURL(/\/(planner|tasks)/);
await expect(plannerPage.routerWrapper).toBeVisible();
});
test.skip('should navigate to project planner', async ({
page,
projectPage,
workViewPage,
}) => {
// Create and navigate to a test project
await projectPage.createAndGoToTestProject();
// Wait for project to be fully loaded
await page.waitForTimeout(1000);
// Add a task with schedule to ensure planner has content
await workViewPage.addTask('Scheduled task for planner');
await page.waitForTimeout(500);
// Navigate to planner using the button
await plannerPage.navigateToPlanner();
await plannerPage.waitForPlannerView();
// Should be on planner or tasks view - the app may redirect based on content
// We just verify that navigation to planner works, regardless of final URL
await expect(page).toHaveURL(/\/(planner|tasks)/);
await expect(plannerPage.routerWrapper).toBeVisible();
});
});

View file

@ -34,7 +34,10 @@ test.describe('Enable Plugin Test', () => {
// Navigate to plugin settings
await page.click(SETTINGS_BTN);
await page.waitForTimeout(1000);
await page
.locator('.page-settings')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
await page.evaluate(() => {
const configPage = document.querySelector('.page-settings');
@ -70,10 +73,12 @@ test.describe('Enable Plugin Test', () => {
}
});
await page.waitForTimeout(1000);
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 5000 });
await page.waitForTimeout(2000);
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 10000 });
// Wait for plugin cards to be loaded
await page
.locator('plugin-management mat-card')
.first()
.waitFor({ state: 'attached', timeout: 10000 });
// Check if plugin-management has any content
await page.evaluate(() => {
@ -95,8 +100,6 @@ test.describe('Enable Plugin Test', () => {
};
});
await page.waitForTimeout(1000);
// Try to find and enable the API Test Plugin (which exists by default)
const enableResult = await page.evaluate(() => {
const pluginCards = document.querySelectorAll('plugin-management mat-card');
@ -128,7 +131,25 @@ test.describe('Enable Plugin Test', () => {
// console.log('Plugin enablement result:', enableResult);
expect(enableResult.foundApiTestPlugin).toBe(true);
await page.waitForTimeout(3000); // Wait for plugin to initialize
// Wait for toggle state to change to enabled
if (enableResult.toggleClicked) {
await page.waitForFunction(
() => {
const cards = Array.from(
document.querySelectorAll('plugin-management mat-card'),
);
const apiTestCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes('API Test Plugin');
});
const toggle = apiTestCard?.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggle?.getAttribute('aria-checked') === 'true';
},
{ timeout: 10000 },
);
}
// Now check if plugin menu has buttons
await page.evaluate(() => {

View file

@ -39,14 +39,11 @@ test.describe('Plugin Lifecycle', () => {
const settingsBtn = page.locator(SETTINGS_BTN);
await settingsBtn.waitFor({ state: 'visible' });
await settingsBtn.click();
// Wait for navigation to settings page
await page.waitForTimeout(500); // Give time for navigation
// Wait for settings page to be fully visible - use first() to avoid multiple matches
await page.locator('.page-settings').first().waitFor({ state: 'visible' });
await page.waitForTimeout(50); // Small delay for UI settling
// Wait for page to stabilize
await page.waitForTimeout(300);
await page
.locator('.page-settings')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
await page.evaluate(() => {
const configPage = document.querySelector('.page-settings');
@ -71,9 +68,6 @@ test.describe('Plugin Lifecycle', () => {
}
});
// Wait for expansion animation
await page.waitForTimeout(300);
// Scroll plugin-management into view
await page.evaluate(() => {
const pluginMgmt = document.querySelector('plugin-management');
@ -84,7 +78,6 @@ test.describe('Plugin Lifecycle', () => {
// Wait for plugin management section to be attached
await page.locator('plugin-management').waitFor({ state: 'attached', timeout: 5000 });
await page.waitForTimeout(50); // Small delay for UI settling
// Enable the plugin
const enableResult = await page.evaluate((pluginName: string) => {
@ -117,15 +110,10 @@ test.describe('Plugin Lifecycle', () => {
expect(enableResult.found).toBe(true);
// Wait for plugin to initialize
await page.waitForTimeout(100); // Small delay for plugin initialization
// Go back to work view
await page.goto('/#/tag/TODAY');
// Wait for navigation and work view to be ready
await page.waitForTimeout(500); // Give time for navigation
await page.locator('.route-wrapper').waitFor({ state: 'visible' });
await page.waitForTimeout(50); // Small delay for UI settling
await page.locator('.route-wrapper').waitFor({ state: 'visible', timeout: 10000 });
// Wait for task list to be visible
await page.waitForSelector('task-list', { state: 'visible', timeout: 10000 });
@ -135,7 +123,6 @@ test.describe('Plugin Lifecycle', () => {
test.setTimeout(20000); // Increase timeout
// Wait for magic-side-nav to be ready
await page.locator(SIDENAV).waitFor({ state: 'visible' });
await page.waitForTimeout(50); // Small delay for plugins to initialize
// Plugin doesn't show snack bar on load, check plugin nav item instead
await expect(page.locator(API_TEST_PLUGIN_NAV_ITEM)).toBeVisible({ timeout: 10000 });
@ -148,13 +135,10 @@ test.describe('Plugin Lifecycle', () => {
// Click on the plugin nav item to navigate to plugin
await expect(page.locator(API_TEST_PLUGIN_NAV_ITEM)).toBeVisible();
await page.click(API_TEST_PLUGIN_NAV_ITEM);
// Wait for navigation to plugin page
await page.waitForTimeout(500); // Give time for navigation
await page.waitForTimeout(50); // Small delay for UI settling
// Verify we navigated to the plugin page
await expect(page).toHaveURL(/\/plugins\/api-test-plugin\/index/);
await expect(page.locator('iframe')).toBeVisible();
await expect(page).toHaveURL(/\/plugins\/api-test-plugin\/index/, { timeout: 10000 });
await expect(page.locator('iframe')).toBeVisible({ timeout: 10000 });
// Go back to work view
await page.goto('/#/tag/TODAY');
@ -165,14 +149,11 @@ test.describe('Plugin Lifecycle', () => {
// Navigate to settings
await page.click(SETTINGS_BTN);
// Wait for navigation to settings page
await page.waitForTimeout(500); // Give time for navigation
// Wait for settings page to be visible - use first() to avoid multiple matches
await page.locator('.page-settings').first().waitFor({ state: 'visible' });
await page.waitForTimeout(200); // Small delay for UI settling
// Wait for page to stabilize
await page.waitForTimeout(300);
await page
.locator('.page-settings')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
// Expand plugin section
await page.evaluate(() => {
@ -190,9 +171,6 @@ test.describe('Plugin Lifecycle', () => {
}
});
// Wait for expansion animation
await page.waitForTimeout(300);
// Scroll plugin-management into view
await page.evaluate(() => {
const pluginMgmt = document.querySelector('plugin-management');
@ -205,7 +183,11 @@ test.describe('Plugin Lifecycle', () => {
await page
.locator('plugin-management')
.waitFor({ state: 'attached', timeout: 10000 });
await page.waitForTimeout(500); // Give time for plugins to load
// Wait for plugin cards to be available
await page
.locator('plugin-management mat-card')
.first()
.waitFor({ state: 'attached', timeout: 10000 });
// Check current state of the plugin and enable if needed
const currentState = await page.evaluate((pluginName: string) => {
@ -250,7 +232,6 @@ test.describe('Plugin Lifecycle', () => {
'API Test Plugin',
{ timeout: 5000 },
);
await page.waitForTimeout(1000); // Wait for plugin to fully initialize
}
// Now disable the plugin
@ -290,14 +271,11 @@ test.describe('Plugin Lifecycle', () => {
'API Test Plugin',
{ timeout: 5000 },
);
await page.waitForTimeout(1000); // Wait for plugin to fully disable
// Go back to work view
await page.goto('/#/tag/TODAY');
// Wait for navigation and work view to be ready
await page.waitForTimeout(500); // Give time for navigation
await page.locator('.route-wrapper').waitFor({ state: 'visible' });
await page.waitForTimeout(500); // Small delay for UI settling
await page.locator('.route-wrapper').waitFor({ state: 'visible', timeout: 10000 });
// Check if the magic-side-nav exists and verify the API Test Plugin is not in it
const sideNavExists = (await page.locator(SIDENAV).count()) > 0;

View file

@ -4,6 +4,8 @@ import {
waitForPluginAssets,
waitForPluginManagementInit,
getCITimeoutMultiplier,
enablePluginWithVerification,
disablePluginWithVerification,
} from '../../helpers/plugin-test.helpers';
const { SIDENAV } = cssSelectors;
@ -71,7 +73,26 @@ test.describe.serial('Plugin Loading', () => {
expect(enableResult.found).toBe(true);
await page.waitForTimeout(2000); // Wait for plugin to initialize
// Wait for toggle state to change to enabled
if (enableResult.clicked) {
await page.waitForFunction(
(name) => {
const cards = Array.from(
document.querySelectorAll('plugin-management mat-card'),
);
const targetCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes(name);
});
const toggle = targetCard?.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggle?.getAttribute('aria-checked') === 'true';
},
'API Test Plugin',
{ timeout: 10000 },
);
}
// Ensure plugin management is visible in viewport
await page.evaluate(() => {
@ -83,7 +104,6 @@ test.describe.serial('Plugin Loading', () => {
// Navigate to plugin management - check for attachment first
await expect(page.locator(PLUGIN_CARD).first()).toBeAttached({ timeout: 20000 });
await page.waitForTimeout(500);
// Check example plugin is loaded and enabled
const pluginCardsResult = await page.evaluate(() => {
@ -122,13 +142,12 @@ test.describe.serial('Plugin Loading', () => {
// Try to open plugin iframe view if menu is available
if (pluginMenuVisible) {
await pluginNavItem.click();
await expect(page.locator(PLUGIN_IFRAME)).toBeVisible();
await expect(page.locator(PLUGIN_IFRAME)).toBeVisible({ timeout: 10000 });
await expect(page).toHaveURL(/\/plugins\/api-test-plugin\/index/);
await page.waitForTimeout(1000); // Wait for iframe to load
// Switch to iframe context and verify content
const frame = page.frameLocator(PLUGIN_IFRAME);
await expect(frame.locator('h1')).toBeVisible();
await expect(frame.locator('h1')).toBeVisible({ timeout: 10000 });
await expect(frame.locator('h1')).toContainText('API Test Plugin');
} else {
console.log('Skipping iframe test - plugin menu not available');
@ -157,113 +176,36 @@ test.describe.serial('Plugin Loading', () => {
throw new Error('Plugin management could not be initialized');
}
// Enable the plugin first
await page.evaluate((pluginName: string) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes(pluginName);
});
if (targetCard) {
const toggleButton = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
if (toggleButton) {
const wasChecked = toggleButton.getAttribute('aria-checked') === 'true';
if (!wasChecked) {
toggleButton.click();
}
}
}
}, 'API Test Plugin');
await page.waitForTimeout(2000); // Wait for plugin to initialize
// Ensure plugin management is visible in viewport
await page.evaluate(() => {
const pluginMgmt = document.querySelector('plugin-management');
if (pluginMgmt) {
pluginMgmt.scrollIntoView({ behavior: 'instant', block: 'center' });
}
});
// Enable the plugin first using the helper function
const enableResult = await enablePluginWithVerification(
page,
'API Test Plugin',
15000,
);
expect(enableResult).toBe(true);
// Navigate to plugin management - check for attachment
await expect(page.locator(PLUGIN_ITEM).first()).toBeAttached({ timeout: 10000 });
// Find the toggle for API Test Plugin and disable it
await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const apiTestCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes('API Test Plugin');
});
const toggle = apiTestCard?.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
// Disable the plugin using the helper function
const disableResult = await disablePluginWithVerification(
page,
'API Test Plugin',
15000,
);
expect(disableResult).toBe(true);
const result = {
found: !!apiTestCard,
hasToggle: !!toggle,
wasChecked: toggle?.getAttribute('aria-checked') === 'true',
clicked: false,
};
if (toggle && toggle.getAttribute('aria-checked') === 'true') {
toggle.click();
result.clicked = true;
}
return result;
});
await page.waitForTimeout(2000); // Give more time for plugin to unload
// Stay on the settings page, just wait for state to update
await page.waitForTimeout(2000);
// Re-enable the plugin - we should still be on settings page
// Just make sure plugin section is visible
await page.evaluate(() => {
const pluginSection = document.querySelector('.plugin-section');
if (pluginSection) {
pluginSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
await page.waitForTimeout(1000);
await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const apiTestCard = cards.find((card) => {
const title = card.querySelector('mat-card-title')?.textContent || '';
return title.includes('API Test Plugin');
});
const toggle = apiTestCard?.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
const result = {
found: !!apiTestCard,
hasToggle: !!toggle,
wasChecked: toggle?.getAttribute('aria-checked') === 'true',
clicked: false,
};
if (toggle && toggle.getAttribute('aria-checked') !== 'true') {
toggle.click();
result.clicked = true;
}
return result;
});
await page.waitForTimeout(2000); // Give time for plugin to reload
// Re-enable the plugin using the helper function
const reEnableResult = await enablePluginWithVerification(
page,
'API Test Plugin',
15000,
);
expect(reEnableResult).toBe(true);
// Navigate back to main view
await page.click('text=Today'); // Click on Today navigation
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/#\/tag\/TODAY/, { timeout: 10000 });
// Check if menu entry is back (gracefully handle if not visible)
const pluginNavItemReEnabled = page

View file

@ -1,103 +1,29 @@
import { expect, test } from '../../fixtures/test.fixture';
import { cssSelectors } from '../../constants/selectors';
import * as path from 'path';
const { SETTINGS_BTN } = cssSelectors;
const FILE_INPUT = 'input[type="file"][accept=".zip"]';
const TEST_PLUGIN_ID = 'test-upload-plugin';
test.describe('Plugin Simple Enable', () => {
test('upload and enable test plugin', async ({ page, workViewPage, waitForNav }) => {
test('upload and enable test plugin', async ({ workViewPage, settingsPage }) => {
await workViewPage.waitForTaskList();
// Navigate to plugin settings
await page.click(SETTINGS_BTN);
await waitForNav();
await page.evaluate(() => {
const configPage = document.querySelector('.page-settings');
if (!configPage) {
throw new Error('Not on config page');
}
const pluginSection = document.querySelector('.plugin-section');
if (pluginSection) {
pluginSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
const collapsible = document.querySelector('.plugin-section collapsible');
if (collapsible) {
const isExpanded = collapsible.classList.contains('isExpanded');
if (!isExpanded) {
const header = collapsible.querySelector('.collapsible-header');
if (header) {
(header as HTMLElement).click();
}
}
}
});
await waitForNav();
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 5000 });
// Navigate to plugin settings using page object
await settingsPage.navigateToPluginSettings();
// Upload plugin ZIP file
const testPluginPath = path.resolve(__dirname, '../../../src/assets/test-plugin.zip');
await settingsPage.uploadPlugin(testPluginPath);
// Make file input visible for testing
await page.evaluate(() => {
const input = document.querySelector(
'input[type="file"][accept=".zip"]',
) as HTMLElement;
if (input) {
input.style.display = 'block';
input.style.position = 'relative';
input.style.opacity = '1';
}
});
await page.locator(FILE_INPUT).setInputFiles(testPluginPath);
await waitForNav();
// Check if plugin was uploaded
const pluginExists = await page.evaluate((pluginId: string) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
return cards.some((card) => card.textContent?.includes(pluginId));
}, TEST_PLUGIN_ID);
// Check if plugin was uploaded using Playwright's built-in waiting
const pluginExists = await settingsPage.pluginExists(TEST_PLUGIN_ID);
expect(pluginExists).toBeTruthy();
// Enable the plugin
const enableResult = await page.evaluate((pluginId: string) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => card.textContent?.includes(pluginId));
if (targetCard) {
const toggle = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
if (toggle && toggle.getAttribute('aria-checked') !== 'true') {
toggle.click();
return true;
}
}
return false;
}, TEST_PLUGIN_ID);
// Enable the plugin using page object
const enableResult = await settingsPage.enablePlugin(TEST_PLUGIN_ID);
expect(enableResult).toBeTruthy();
await waitForNav();
// Verify plugin is enabled
const isEnabled = await page.evaluate((pluginId: string) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => card.textContent?.includes(pluginId));
if (targetCard) {
const toggle = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggle?.getAttribute('aria-checked') === 'true';
}
return false;
}, TEST_PLUGIN_ID);
const isEnabled = await settingsPage.isPluginEnabled(TEST_PLUGIN_ID);
expect(isEnabled).toBeTruthy();
// The test plugin has isSkipMenuEntry: true, so no menu entry should appear

View file

@ -34,7 +34,10 @@ test.describe.serial('Plugin Structure Test', () => {
// Navigate to plugin settings (implementing navigateToPluginSettings inline)
await page.click(SETTINGS_BTN);
await page.waitForTimeout(1000);
await page
.locator('.page-settings')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
// Execute script to navigate to plugin section
await page.evaluate(() => {
@ -75,8 +78,7 @@ test.describe.serial('Plugin Structure Test', () => {
}
});
await page.waitForTimeout(1000);
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 5000 });
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 10000 });
// Check plugin card structure
await page.evaluate(() => {

View file

@ -22,7 +22,10 @@ test.describe.serial('Plugin Upload', () => {
test.setTimeout(process.env.CI ? 90000 : 60000);
// Navigate to plugin management
await page.click(SETTINGS_BTN);
await page.waitForTimeout(1000);
await page
.locator('.page-settings')
.first()
.waitFor({ state: 'visible', timeout: 10000 });
await page.evaluate(() => {
const configPage = document.querySelector('.page-settings');
@ -48,8 +51,7 @@ test.describe.serial('Plugin Upload', () => {
}
});
await page.waitForTimeout(1000);
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 5000 });
await expect(page.locator('plugin-management')).toBeVisible({ timeout: 10000 });
// Upload plugin ZIP file
const testPluginPath = path.resolve(__dirname, '../../../src/assets/test-plugin.zip');
@ -69,11 +71,19 @@ test.describe.serial('Plugin Upload', () => {
});
await page.locator(FILE_INPUT).setInputFiles(testPluginPath);
await page.waitForTimeout(3000); // Wait for file processing
// Wait for uploaded plugin to appear in list
await page.waitForFunction(
(pluginId) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
return cards.some((card) => card.textContent?.includes(pluginId));
},
TEST_PLUGIN_ID,
{ timeout: 15000 },
);
// Verify uploaded plugin appears in list (there are multiple cards, so check first)
await expect(page.locator(PLUGIN_CARD).first()).toBeVisible();
await page.waitForTimeout(1000);
const pluginExists = await page.evaluate((pluginName: string) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
@ -114,7 +124,23 @@ test.describe.serial('Plugin Upload', () => {
}, TEST_PLUGIN_ID);
expect(enableResult).toBeTruthy();
await page.waitForTimeout(2000); // Longer pause to ensure DOM update completes
// Wait for toggle state to change to enabled
await page.waitForFunction(
(pluginId) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => card.textContent?.includes(pluginId));
if (targetCard) {
const toggleButton = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggleButton?.getAttribute('aria-checked') === 'true';
}
return false;
},
TEST_PLUGIN_ID,
{ timeout: 10000 },
);
// Verify plugin is now enabled
const enabledStatus = await page.evaluate((pluginId: string) => {
@ -148,7 +174,23 @@ test.describe.serial('Plugin Upload', () => {
}, TEST_PLUGIN_ID);
expect(disableResult).toBeTruthy();
await page.waitForTimeout(1000);
// Wait for toggle state to change to disabled
await page.waitForFunction(
(pluginId) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => card.textContent?.includes(pluginId));
if (targetCard) {
const toggleButton = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggleButton?.getAttribute('aria-checked') === 'false';
}
return false;
},
TEST_PLUGIN_ID,
{ timeout: 10000 },
);
// Verify plugin is now disabled
const disabledStatus = await page.evaluate((pluginId: string) => {
@ -182,7 +224,23 @@ test.describe.serial('Plugin Upload', () => {
}, TEST_PLUGIN_ID);
expect(reEnableResult).toBeTruthy();
await page.waitForTimeout(1000);
// Wait for toggle state to change to enabled again
await page.waitForFunction(
(pluginId) => {
const cards = Array.from(document.querySelectorAll('plugin-management mat-card'));
const targetCard = cards.find((card) => card.textContent?.includes(pluginId));
if (targetCard) {
const toggleButton = targetCard.querySelector(
'mat-slide-toggle button[role="switch"]',
) as HTMLButtonElement;
return toggleButton?.getAttribute('aria-checked') === 'true';
}
return false;
},
TEST_PLUGIN_ID,
{ timeout: 10000 },
);
// Verify plugin is enabled again
const reEnabledStatus = await page.evaluate((pluginId: string) => {
@ -218,9 +276,15 @@ test.describe.serial('Plugin Upload', () => {
return false;
}, TEST_PLUGIN_ID);
await page.waitForTimeout(500);
await page.waitForTimeout(3000); // Longer pause for removal to complete
// Wait for plugin to be removed from the list
await page.waitForFunction(
(pluginId) => {
const items = Array.from(document.querySelectorAll('plugin-management mat-card'));
return !items.some((item) => item.textContent?.includes(pluginId));
},
TEST_PLUGIN_ID,
{ timeout: 15000 },
);
// Verify plugin is removed
const removalResult = await page.evaluate((pluginId: string) => {

View file

@ -1,57 +0,0 @@
import { test, expect } from '../../fixtures/test.fixture';
const NOTES_WRAPPER = 'notes';
const NOTE = 'notes note';
const FIRST_NOTE = `${NOTE}:first-of-type`;
const TOGGLE_NOTES_BTN = '.e2e-toggle-notes-btn';
test.describe('Project Note', () => {
test.skip('create a note', async ({ page, projectPage }) => {
// Create and navigate to default project
await projectPage.createAndGoToTestProject();
// Add a note
await projectPage.addNote('Some new Note');
// Move to notes wrapper area and verify note is visible
const notesWrapper = page.locator(NOTES_WRAPPER);
await notesWrapper.hover({ position: { x: 10, y: 50 } });
const firstNote = page.locator(FIRST_NOTE);
await firstNote.waitFor({ state: 'visible' });
await expect(firstNote).toContainText('Some new Note');
});
test.skip('new note should be still available after reload', async ({
page,
projectPage,
}) => {
// Create and navigate to default project
await projectPage.createAndGoToTestProject();
// Add a note
await projectPage.addNote('Some new Note');
// Wait for save
await page.waitForLoadState('networkidle');
// Reload the page
await page.reload();
// Click toggle notes button
const toggleNotesBtn = page.locator(TOGGLE_NOTES_BTN);
await toggleNotesBtn.waitFor({ state: 'visible' });
await toggleNotesBtn.click();
// Verify notes wrapper is present
const notesWrapper = page.locator(NOTES_WRAPPER);
await notesWrapper.waitFor({ state: 'visible' });
await notesWrapper.hover({ position: { x: 10, y: 50 } });
// Verify note is still there
const firstNote = page.locator(FIRST_NOTE);
await firstNote.waitFor({ state: 'visible' });
await expect(firstNote).toBeVisible();
await expect(firstNote).toContainText('Some new Note');
});
});

View file

@ -12,8 +12,6 @@ test.describe('Project', () => {
// Wait for app to be ready
await workViewPage.waitForTaskList();
// Additional wait for stability in parallel execution
await page.waitForTimeout(50);
});
test('move done tasks to archive without error', async ({ page }) => {
@ -43,147 +41,6 @@ test.describe('Project', () => {
await expect(projectPage.globalErrorAlert).not.toBeVisible();
});
test.skip('create second project', async ({ page, testPrefix }) => {
// Handle empty state vs existing projects scenario
const addProjectBtn = page
.locator('nav-item')
.filter({ hasText: 'Create Project' })
.locator('button');
const projectsGroupBtn = page
.locator('nav-list-tree')
.filter({ hasText: 'Projects' })
.locator('nav-item button')
.first();
// Check if we're in empty state (no projects yet) or if projects group exists
try {
await addProjectBtn.waitFor({ state: 'visible', timeout: 1000 });
// Empty state: project will be created via the empty state button
} catch {
// Normal state: expand projects group first
await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 });
const isExpanded = await projectsGroupBtn.getAttribute('aria-expanded');
if (isExpanded !== 'true') {
await projectsGroupBtn.click();
await page.waitForTimeout(500); // Wait for expansion animation
}
}
// Create a new project
await projectPage.createProject('Cool Test Project');
// Wait for project creation to complete and navigation to update
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); // Increased wait time for DOM updates
// After creating, ensure Projects section exists and is expanded
await projectsGroupBtn.waitFor({ state: 'visible', timeout: 5000 });
// Check if Projects section needs to be expanded
let isExpanded = await projectsGroupBtn.getAttribute('aria-expanded');
if (isExpanded !== 'true') {
// Multiple approaches to expand the Projects section
// First: Try clicking the expand icon within the Projects button
const expandIcon = projectsGroupBtn
.locator('mat-icon, .expand-icon, [class*="expand"]')
.first();
if (await expandIcon.isVisible({ timeout: 1000 }).catch(() => false)) {
await expandIcon.click();
await page.waitForTimeout(1500);
isExpanded = await projectsGroupBtn.getAttribute('aria-expanded');
}
// If still not expanded, try clicking the main button
if (isExpanded !== 'true') {
await projectsGroupBtn.click();
await page.waitForTimeout(1500);
isExpanded = await projectsGroupBtn.getAttribute('aria-expanded');
}
// If still not expanded, try double-clicking as last resort
if (isExpanded !== 'true') {
await projectsGroupBtn.dblclick();
await page.waitForTimeout(1500);
}
}
// Find the newly created project directly (with test prefix)
const expectedProjectName = testPrefix
? `${testPrefix}-Cool Test Project`
: 'Cool Test Project';
// Check if .nav-children container is visible after expansion attempts
const navChildren = page.locator('.nav-children');
const navChildrenExists = await navChildren.count();
if (navChildrenExists > 0) {
await navChildren.waitFor({ state: 'visible', timeout: 5000 });
} else {
// Projects section might not have expanded properly - continue with fallback approaches
}
// Look for the newly created project
// Wait a moment for the project to fully appear in the list
await page.waitForTimeout(1000);
let newProject;
let projectFound = false;
// If .nav-children exists, use structured approach
if (navChildrenExists > 0) {
try {
// Primary approach: nav-child-item structure with nav-item button
newProject = page
.locator('.nav-children .nav-child-item nav-item button')
.filter({ hasText: expectedProjectName });
await newProject.waitFor({ state: 'visible', timeout: 3000 });
projectFound = true;
} catch {
try {
// Second approach: any nav-child-item with the project name
newProject = page
.locator('.nav-child-item')
.filter({ hasText: expectedProjectName })
.locator('button');
await newProject.waitFor({ state: 'visible', timeout: 3000 });
projectFound = true;
} catch {
// Continue to fallback approaches
}
}
}
// Fallback approaches if structured approach didn't work
if (!projectFound) {
try {
// Fallback: find any button with project name in the nav area
newProject = page
.locator('magic-side-nav button')
.filter({ hasText: expectedProjectName });
await newProject.waitFor({ state: 'visible', timeout: 3000 });
projectFound = true;
} catch {
// Ultimate fallback: search entire page for project button
newProject = page.locator('button').filter({ hasText: expectedProjectName });
await newProject.waitFor({ state: 'visible', timeout: 3000 });
projectFound = true;
}
}
// Verify the project is found and visible
await expect(newProject).toBeVisible({ timeout: 3000 });
// Click on the new project
await newProject.click();
// Wait for navigation to complete
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500); // Brief wait for any animations
// Verify we're in the new project
await expect(projectPage.workCtxTitle).toContainText(expectedProjectName);
});
test('navigate to project settings', async ({ page }) => {
// Navigate to Inbox project
const inboxMenuItem = page.locator('magic-side-nav button:has-text("Inbox")');

View file

@ -45,7 +45,6 @@ test.describe('Default task reminder option', () => {
// Scroll into view and hover over the task to reveal action buttons
await task.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await task.hover({ force: true });
// Open the detail panel to access the schedule action
@ -74,8 +73,7 @@ test.describe('Default task reminder option', () => {
await timeInput.click();
// Wait for the reminder dropdown to appear and check the default option
await page.waitForTimeout(500);
await expect(page.getByText(changedOptionText)).toBeVisible();
await expect(page.getByText(changedOptionText)).toBeVisible({ timeout: 5000 });
});
test('should apply when scheduling a task using short syntax', async ({
@ -96,7 +94,6 @@ test.describe('Default task reminder option', () => {
const addBtn = page.locator('.tour-addBtn');
await addBtn.waitFor({ state: 'visible', timeout: 10000 });
await addBtn.click();
await page.waitForTimeout(300);
}
// Wait for the global add-task input to be available
@ -113,7 +110,6 @@ test.describe('Default task reminder option', () => {
}
// Wait for task to be created and reschedule button to appear
await page.waitForTimeout(500);
const rescheduleBtn = page.getByTitle('Reschedule').first();
await rescheduleBtn.waitFor({ state: 'visible', timeout: 10000 });
await rescheduleBtn.click();

View file

@ -1,11 +1,7 @@
import type { Locator, Page } from '@playwright/test';
import { test, expect } from '../../fixtures/test.fixture';
import { scheduleTaskViaDetailPanel } from '../../utils/schedule-task-helper';
const TASK = 'task';
const SCHEDULE_DIALOG = 'dialog-schedule-task';
const SCHEDULE_DIALOG_TIME_INPUT = 'dialog-schedule-task input[type="time"]';
const SCHEDULE_DIALOG_CONFIRM = 'mat-dialog-actions button:last-child';
const SCHEDULE_ROUTE_BTN = 'magic-side-nav a[href="#/scheduled-list"]';
const SCHEDULE_PAGE_CMP = 'scheduled-list-page';
// Target only the first .tasks container (scheduled with time, not planned for days)
@ -13,66 +9,6 @@ const SCHEDULE_PAGE_FIRST_TASKS_CONTAINER = `${SCHEDULE_PAGE_CMP} .component-wra
const SCHEDULE_PAGE_TASKS = `${SCHEDULE_PAGE_FIRST_TASKS_CONTAINER} planner-task`;
const SCHEDULE_PAGE_TASK_1 = `${SCHEDULE_PAGE_TASKS}:first-of-type`;
const SCHEDULE_PAGE_TASK_1_TITLE_EL = `${SCHEDULE_PAGE_TASK_1} .title`;
const DETAIL_PANEL_BTN = '.show-additional-info-btn';
const DETAIL_PANEL_SELECTOR = 'dialog-task-detail-panel, task-detail-panel';
const DETAIL_PANEL_SCHEDULE_ITEM =
'task-detail-item:has(mat-icon:text("alarm")), ' +
'task-detail-item:has(mat-icon:text("today")), ' +
'task-detail-item:has(mat-icon:text("schedule"))';
const fillScheduleDialogTime = async (
page: Page,
scheduleTime: number,
): Promise<void> => {
const dialog = page.locator(SCHEDULE_DIALOG);
await dialog.waitFor({ state: 'visible', timeout: 10000 });
const timeInput = page.locator(SCHEDULE_DIALOG_TIME_INPUT);
await timeInput.waitFor({ state: 'visible', timeout: 10000 });
const date = new Date(scheduleTime);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
await timeInput.fill('');
await timeInput.fill(`${hours}:${minutes}`);
const confirmBtn = page.locator(SCHEDULE_DIALOG_CONFIRM);
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 });
await confirmBtn.click();
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
};
const closeDetailPanelIfOpen = async (page: Page): Promise<void> => {
const detailPanel = page.locator(DETAIL_PANEL_SELECTOR).first();
if (await detailPanel.isVisible()) {
await page.keyboard.press('Escape');
await detailPanel.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
};
const scheduleTaskViaDetailPanel = async (
page: Page,
task: Locator,
scheduleTime: number,
): Promise<void> => {
await task.waitFor({ state: 'visible' });
await task.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await task.hover({ force: true });
const detailBtn = task.locator(DETAIL_PANEL_BTN).first();
await detailBtn.waitFor({ state: 'visible', timeout: 5000 });
await detailBtn.click();
const scheduleItem = page.locator(DETAIL_PANEL_SCHEDULE_ITEM).first();
await scheduleItem.waitFor({ state: 'visible', timeout: 5000 });
await scheduleItem.click();
await fillScheduleDialogTime(page, scheduleTime);
await closeDetailPanelIfOpen(page);
};
test.describe('Reminders Schedule Page', () => {
test('should add a scheduled tasks', async ({ page, workViewPage, testPrefix }) => {
@ -89,9 +25,6 @@ test.describe('Reminders Schedule Page', () => {
const targetTask = page.locator(TASK).filter({ hasText: title }).first();
await targetTask.waitFor({ state: 'visible' });
// Hover to reveal schedule button
await targetTask.hover();
// Open detail panel to access schedule action
await scheduleTaskViaDetailPanel(page, targetTask, scheduleTime);
@ -125,9 +58,6 @@ test.describe('Reminders Schedule Page', () => {
test.setTimeout(90000); // Increase timeout for multiple operations
await workViewPage.waitForTaskList();
// Wait a bit for the page to stabilize
await page.waitForTimeout(1000);
// Helper function to schedule a task
const scheduleTask = async (
taskTitle: string,
@ -150,13 +80,12 @@ test.describe('Reminders Schedule Page', () => {
await workViewPage.addTask(title1);
// Wait for first task to be visible and stable
// Wait for first task to be visible
await page
.locator(TASK)
.filter({ hasText: title1 })
.first()
.waitFor({ state: 'visible' });
await page.waitForTimeout(500); // Let the task fully render
await scheduleTask(title1, scheduleTime1);
@ -166,13 +95,12 @@ test.describe('Reminders Schedule Page', () => {
await workViewPage.addTask(title2);
// Wait for second task to be visible and stable
// Wait for second task to be visible
await page
.locator(TASK)
.filter({ hasText: title2 })
.first()
.waitFor({ state: 'visible' });
await page.waitForTimeout(500); // Let the task fully render
await scheduleTask(title2, scheduleTime2);
@ -193,9 +121,6 @@ test.describe('Reminders Schedule Page', () => {
// Wait for scheduled page to load
await page.waitForSelector(SCHEDULE_PAGE_CMP, { state: 'visible', timeout: 10000 });
// Wait for the scheduled tasks to render
await page.waitForTimeout(1000);
// Verify both tasks appear in scheduled list with retry
await expect(async () => {
const scheduledTasks = page.locator(SCHEDULE_PAGE_TASKS);

View file

@ -1,24 +1,10 @@
import { expect, test } from '../../fixtures/test.fixture';
import { addTaskWithReminder } from '../../utils/schedule-task-helper';
const DIALOG = 'dialog-view-task-reminder';
const DIALOG_TASK = `${DIALOG} .task`;
const DIALOG_TASK1 = `${DIALOG_TASK}:first-of-type`;
const SCHEDULE_MAX_WAIT_TIME = 60000; // Reduced from 180s to 60s
// Helper selectors from addTaskWithReminder
const TASK = 'task';
const SCHEDULE_TASK_ITEM =
'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))';
const DIALOG_CONTAINER = 'mat-dialog-container';
const DIALOG_SUBMIT = `${DIALOG_CONTAINER} mat-dialog-actions button:last-of-type`;
const TIME_INP = 'input[type="time"]';
const getTimeVal = (d: Date): string => {
const hours = d.getHours().toString().padStart(2, '0');
const minutes = d.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
const SCHEDULE_MAX_WAIT_TIME = 60000;
test.describe('Reminders View Task', () => {
test('should display a modal with a scheduled task if due', async ({
@ -26,81 +12,16 @@ test.describe('Reminders View Task', () => {
workViewPage,
testPrefix,
}) => {
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 20000); // Add extra time for test setup
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 20000);
// Wait for work view to be ready
await workViewPage.waitForTaskList();
const taskTitle = `${testPrefix}-0 A task`;
const scheduleTime = Date.now() + 10000; // Add 10 seconds buffer
const d = new Date(scheduleTime);
const timeValue = getTimeVal(d);
// Add task
await workViewPage.addTask(taskTitle);
// Open panel for task
const taskEl = page.locator(TASK).first();
await taskEl.hover();
const detailPanelBtn = page.locator('.show-additional-info-btn').first();
await detailPanelBtn.waitFor({ state: 'visible' });
await detailPanelBtn.click();
// Wait for and click schedule task item with better error handling
const scheduleItem = page.locator(SCHEDULE_TASK_ITEM);
await scheduleItem.waitFor({ state: 'visible', timeout: 5000 });
await scheduleItem.click();
// Wait for dialog with improved timeout
const dialogContainer = page.locator(DIALOG_CONTAINER);
await dialogContainer.waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(200); // Allow dialog animation to complete
// Set time - use more robust selector and approach
const timeInput = page
.locator('mat-form-field input[type="time"]')
.or(page.locator(TIME_INP));
await timeInput.waitFor({ state: 'visible', timeout: 10000 });
// Multiple approaches to ensure the time input is ready
await timeInput.click();
await page.waitForTimeout(100);
// Clear existing value if any
await timeInput.fill('');
await page.waitForTimeout(100);
// Set the time value
await timeInput.fill(timeValue);
await page.waitForTimeout(100);
// Verify the value was set
const inputValue = await timeInput.inputValue();
if (inputValue !== timeValue) {
// Fallback: use evaluate to set value directly
await page.evaluate(
({ value }) => {
const timeInputEl = document.querySelector(
'mat-form-field input[type="time"]',
) as HTMLInputElement;
if (timeInputEl) {
timeInputEl.value = value;
timeInputEl.dispatchEvent(new Event('input', { bubbles: true }));
timeInputEl.dispatchEvent(new Event('change', { bubbles: true }));
}
},
{ value: timeValue },
);
}
// Ensure focus moves away to commit the value
await page.keyboard.press('Tab');
await page.waitForTimeout(100);
// Submit dialog
await page.waitForSelector(DIALOG_SUBMIT, { state: 'visible' });
await page.click(DIALOG_SUBMIT);
await page.waitForSelector(DIALOG_CONTAINER, { state: 'hidden' });
// Add task with reminder using shared helper
await addTaskWithReminder(page, workViewPage, taskTitle, scheduleTime);
// Wait for reminder dialog to appear
await page.waitForSelector(DIALOG, {

View file

@ -1,105 +1,20 @@
import { expect, test } from '../../fixtures/test.fixture';
import { addTaskWithReminder } from '../../utils/schedule-task-helper';
const DIALOG = 'dialog-view-task-reminder';
const DIALOG_TASKS_WRAPPER = `${DIALOG} .tasks`;
const DIALOG_TASK = `${DIALOG} .task`;
const DIALOG_TASK1 = `${DIALOG_TASK}:first-of-type`;
const DIALOG_TASK2 = `${DIALOG_TASK}:nth-of-type(2)`;
const SCHEDULE_MAX_WAIT_TIME = 60000; // Reduced from 180s to 60s
// Helper selectors for task scheduling
const TASK = 'task';
const SCHEDULE_TASK_ITEM =
'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))';
const SCHEDULE_DIALOG = 'mat-dialog-container';
const DIALOG_SUBMIT = `${SCHEDULE_DIALOG} mat-dialog-actions button:last-of-type`;
const TIME_INP = 'input[type="time"]';
const SIDE_INNER = '.right-panel';
const DEFAULT_DELTA = 5000; // 5 seconds instead of 1.2 minutes
const SCHEDULE_MAX_WAIT_TIME = 60000;
test.describe.serial('Reminders View Task 2', () => {
const addTaskWithReminder = async (
page: any,
workViewPage: any,
title: string,
scheduleTime: number = Date.now() + DEFAULT_DELTA,
): Promise<void> => {
// Add task
await workViewPage.addTask(title);
// Open task panel by hovering and clicking the detail button
const taskSel = page.locator(TASK).first();
await taskSel.waitFor({ state: 'visible' });
await taskSel.hover();
const detailPanelBtn = page.locator('.show-additional-info-btn').first();
await detailPanelBtn.waitFor({ state: 'visible' });
await detailPanelBtn.click();
await page.waitForSelector(SIDE_INNER, { state: 'visible' });
// Click schedule item with better error handling
const scheduleItem = page.locator(SCHEDULE_TASK_ITEM);
await scheduleItem.waitFor({ state: 'visible', timeout: 5000 });
await scheduleItem.click();
const scheduleDialog = page.locator(SCHEDULE_DIALOG);
await scheduleDialog.waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(200); // Allow dialog animation
// Set time with improved robustness
const d = new Date(scheduleTime);
const timeValue = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
// Use more robust selector and multiple fallback approaches
const timeInput = page
.locator('mat-form-field input[type="time"]')
.or(page.locator(TIME_INP));
await timeInput.waitFor({ state: 'visible', timeout: 10000 });
await timeInput.click();
await page.waitForTimeout(100);
// Clear and set value
await timeInput.fill('');
await page.waitForTimeout(100);
await timeInput.fill(timeValue);
await page.waitForTimeout(100);
// Verify the value was set
const inputValue = await timeInput.inputValue();
if (inputValue !== timeValue) {
// Fallback: use evaluate to set value directly
await page.evaluate(
({ value }) => {
const timeInputEl = document.querySelector(
'mat-form-field input[type="time"]',
) as HTMLInputElement;
if (timeInputEl) {
timeInputEl.value = value;
timeInputEl.dispatchEvent(new Event('input', { bubbles: true }));
timeInputEl.dispatchEvent(new Event('change', { bubbles: true }));
}
},
{ value: timeValue },
);
}
await page.keyboard.press('Tab');
await page.waitForTimeout(100);
// Submit with better handling
const submitBtn = page.locator(DIALOG_SUBMIT);
await submitBtn.waitFor({ state: 'visible', timeout: 5000 });
await submitBtn.click();
await scheduleDialog.waitFor({ state: 'hidden', timeout: 10000 });
};
test('should display a modal with 2 scheduled task if due', async ({
page,
workViewPage,
testPrefix,
}) => {
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 30000); // Add extra buffer
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 30000);
await workViewPage.waitForTaskList();

View file

@ -1,4 +1,5 @@
import { expect, test } from '../../fixtures/test.fixture';
import { addTaskWithReminder } from '../../utils/schedule-task-helper';
const DIALOG = 'dialog-view-task-reminder';
const DIALOG_TASKS_WRAPPER = `${DIALOG} .tasks`;
@ -7,147 +8,34 @@ const DIALOG_TASK1 = `${DIALOG_TASK}:first-of-type`;
const DIALOG_TASK2 = `${DIALOG_TASK}:nth-of-type(2)`;
const DIALOG_TASK3 = `${DIALOG_TASK}:nth-of-type(3)`;
const TO_TODAY_SUF = ' .actions button:last-of-type';
const SCHEDULE_MAX_WAIT_TIME = 60000; // Reduced from 180s to 60s
// Helper selectors for task scheduling
const SCHEDULE_TASK_ITEM =
'task-detail-item:has(mat-icon:text("alarm")), task-detail-item:has(mat-icon:text("today")), task-detail-item:has(mat-icon:text("schedule"))';
const DIALOG_CONTAINER = 'mat-dialog-container';
const DIALOG_SUBMIT = `${DIALOG_CONTAINER} mat-dialog-actions button:last-of-type`;
const TIME_INP = 'input[type="time"]';
const RIGHT_PANEL = '.right-panel';
const DEFAULT_DELTA = 5000; // 5 seconds instead of 1.2 minutes
const SCHEDULE_MAX_WAIT_TIME = 60000;
test.describe.serial('Reminders View Task 4', () => {
const addTaskWithReminder = async (
page: any,
workViewPage: any,
title: string,
scheduleTime: number = Date.now() + DEFAULT_DELTA,
): Promise<void> => {
// Add task (title should already include test prefix)
await workViewPage.addTask(title);
// Wait for task to be fully rendered before proceeding
await page.waitForTimeout(800);
// Open task panel by hovering and clicking the detail button
// Find the specific task by title to ensure we're working with the right one
const specificTaskSelector =
`task:has-text("${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}")`.substring(
0,
200,
); // Limit selector length
const taskSel = page.locator(specificTaskSelector).first();
await taskSel.waitFor({ state: 'visible', timeout: 10000 });
// Ensure task is fully loaded by checking for task content and that it's not moving
await page.waitForTimeout(500);
await taskSel.scrollIntoViewIfNeeded();
await taskSel.hover();
const detailPanelBtn = taskSel.locator('.show-additional-info-btn').first();
await detailPanelBtn.waitFor({ state: 'visible', timeout: 5000 });
await detailPanelBtn.click();
await page.waitForSelector(RIGHT_PANEL, { state: 'visible', timeout: 10000 });
// Wait for and click schedule task item with better error handling
const scheduleItem = page.locator(SCHEDULE_TASK_ITEM);
await scheduleItem.waitFor({ state: 'visible', timeout: 10000 });
// Ensure the schedule item is clickable
await scheduleItem.waitFor({ state: 'attached' });
await page.waitForTimeout(200);
await scheduleItem.click();
// Wait for dialog with improved timeout
const dialogContainer = page.locator(DIALOG_CONTAINER);
await dialogContainer.waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(200); // Allow dialog animation to complete
// Set time - use more robust selector and approach
const d = new Date(scheduleTime);
const timeValue = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
const timeInput = page
.locator('mat-form-field input[type="time"]')
.or(page.locator(TIME_INP));
await timeInput.waitFor({ state: 'visible', timeout: 10000 });
// Multiple approaches to ensure the time input is ready
await timeInput.click();
await page.waitForTimeout(100);
// Clear existing value if any
await timeInput.fill('');
await page.waitForTimeout(100);
// Set the time value
await timeInput.fill(timeValue);
await page.waitForTimeout(100);
// Verify the value was set
const inputValue = await timeInput.inputValue();
if (inputValue !== timeValue) {
// Fallback: use evaluate to set value directly
await page.evaluate(
({ value }: { value: string }) => {
const timeInputEl = document.querySelector(
'mat-form-field input[type="time"]',
) as HTMLInputElement;
if (timeInputEl) {
timeInputEl.value = value;
timeInputEl.dispatchEvent(new Event('input', { bubbles: true }));
timeInputEl.dispatchEvent(new Event('change', { bubbles: true }));
}
},
{ value: timeValue },
);
}
// Ensure focus moves away to commit the value
await page.keyboard.press('Tab');
await page.waitForTimeout(100);
// Submit dialog
await page.waitForSelector(DIALOG_SUBMIT, { state: 'visible' });
await page.click(DIALOG_SUBMIT);
await page.waitForSelector(DIALOG_CONTAINER, { state: 'hidden' });
// Wait for UI to fully settle after dialog closes
await page.waitForTimeout(500);
};
test('should manually empty list via add to today', async ({
page,
workViewPage,
testPrefix,
}) => {
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 60000); // Reduced extra time
test.setTimeout(SCHEDULE_MAX_WAIT_TIME + 60000);
await workViewPage.waitForTaskList();
const start = Date.now() + 10000; // Reduce from 100 seconds to 10 seconds
const start = Date.now() + 10000;
// Add three tasks with reminders using test prefix
const task1Name = `${testPrefix}-0 D task xyz`;
const task2Name = `${testPrefix}-1 D task xyz`;
const task3Name = `${testPrefix}-2 D task xyz`;
// Add tasks with proper spacing to avoid interference
// Add tasks - the helper now handles all the complexity
await addTaskWithReminder(page, workViewPage, task1Name, start);
await page.waitForTimeout(2000); // Ensure first task is fully processed
await addTaskWithReminder(page, workViewPage, task2Name, start);
await page.waitForTimeout(2000); // Ensure second task is fully processed
await addTaskWithReminder(page, workViewPage, task3Name, Date.now() + 5000);
await page.waitForTimeout(2000); // Ensure third task is fully processed
// Wait for reminder dialog
await page.waitForSelector(DIALOG, {
state: 'visible',
timeout: SCHEDULE_MAX_WAIT_TIME + 60000, // Reduced timeout
timeout: SCHEDULE_MAX_WAIT_TIME + 60000,
});
// Wait for all tasks to be present
@ -160,19 +48,28 @@ test.describe.serial('Reminders View Task 4', () => {
await expect(page.locator(DIALOG_TASKS_WRAPPER)).toContainText(task2Name);
await expect(page.locator(DIALOG_TASKS_WRAPPER)).toContainText(task3Name);
// Click "add to today" buttons with proper waits
// Click "add to today" buttons - wait for each to process before next
const button1 = page.locator(DIALOG_TASK1 + TO_TODAY_SUF);
await button1.waitFor({ state: 'visible', timeout: 5000 });
await button1.click();
await page.waitForTimeout(500); // Allow first click to process
// Wait for task count to reduce before clicking next
await expect(async () => {
const count = await page.locator(DIALOG_TASK).count();
expect(count).toBeLessThanOrEqual(3);
}).toPass({ timeout: 5000 });
const button2 = page.locator(DIALOG_TASK2 + TO_TODAY_SUF);
await button2.waitFor({ state: 'visible', timeout: 5000 });
await button2.click();
await page.waitForTimeout(500); // Allow second click to process
// Wait for task count to reduce
await expect(async () => {
const count = await page.locator(DIALOG_TASK).count();
expect(count).toBeLessThanOrEqual(2);
}).toPass({ timeout: 5000 });
// Verify remaining task contains 'D task xyz'
await page.waitForTimeout(1000); // Allow dialog state to update
await expect(page.locator(DIALOG_TASK1)).toContainText('D task xyz');
});
});

View file

@ -11,9 +11,6 @@ test.describe('Short Syntax', () => {
// Add a task with project short syntax
await workViewPage.addTask('0 test task koko +i');
// Wait a moment for the task to be processed
await page.waitForTimeout(500);
// Verify task is visible
const task = page.locator('task').first();
await expect(task).toBeVisible({ timeout: 10000 });

View file

@ -1,20 +1,19 @@
import { test, expect } from '../../fixtures/test.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForAppReady } from '../../utils/waits';
import { type Browser, type Page } from '@playwright/test';
import { isWebDavServerUp } from '../../utils/check-webdav';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
} from '../../utils/sync-test-helpers';
test.describe('WebDAV Sync Advanced Features', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
const WEBDAV_CONFIG_TEMPLATE = {
baseUrl: 'http://127.0.0.1:2345/',
username: 'admin',
password: 'admin',
};
test.beforeAll(async () => {
const isUp = await isWebDavServerUp(WEBDAV_CONFIG_TEMPLATE.baseUrl);
if (!isUp) {
@ -23,101 +22,8 @@ test.describe('WebDAV Sync Advanced Features', () => {
}
});
const createSyncFolder = async (request: any, folderName: string): Promise<void> => {
const mkcolUrl = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${folderName}`;
console.log(`Creating WebDAV folder: ${mkcolUrl}`);
try {
const response = await request.fetch(mkcolUrl, {
method: 'MKCOL',
headers: {
Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'),
},
});
if (!response.ok() && response.status() !== 405) {
console.warn(
`Failed to create WebDAV folder: ${response.status()} ${response.statusText()}`,
);
}
} catch (e) {
console.warn('Error creating WebDAV folder:', e);
}
};
const setupClient = async (
browser: Browser,
baseURL: string | undefined,
): Promise<{ context: any; page: Page }> => {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/');
await waitForAppReady(page);
// Dismiss Shepherd Tour if present
try {
const tourElement = page.locator('.shepherd-element').first();
// Short wait to see if it appears
await tourElement.waitFor({ state: 'visible', timeout: 4000 });
const cancelIcon = page.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
} else {
await page.keyboard.press('Escape');
}
await tourElement.waitFor({ state: 'hidden', timeout: 3000 });
} catch (e) {
// Tour didn't appear or wasn't dismissable, ignore
}
return { context, page };
};
const waitForSync = async (
page: Page,
syncPage: SyncPage,
): Promise<'success' | 'conflict' | void> => {
// Poll for success icon, error snackbar, or conflict dialog
const startTime = Date.now();
let stableCount = 0;
while (Date.now() - startTime < 30000) {
// 30s timeout
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
const snackBars = page.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
// Check for keywords indicating failure
if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) {
throw new Error(`Sync failed with error: ${text}`);
}
}
// Check if sync is in progress (spinner visible)
const isSpinning = await syncPage.syncSpinner.isVisible();
if (!isSpinning) {
// Check for success icon
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) return 'success';
// No spinner, no error, no check icon - use stable count fallback
stableCount++;
if (stableCount >= 3) {
return 'success'; // Consider sync complete after 3 stable checks
}
} else {
stableCount = 0; // Reset if still spinning
}
await page.waitForTimeout(500);
}
throw new Error('Sync timeout: Sync did not complete');
};
test('should sync sub-tasks correctly', async ({ browser, baseURL, request }) => {
const SYNC_FOLDER_NAME = `e2e-advanced-sub-${Date.now()}`;
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-advanced-sub');
await createSyncFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
@ -127,7 +33,7 @@ test.describe('WebDAV Sync Advanced Features', () => {
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
@ -149,10 +55,10 @@ test.describe('WebDAV Sync Advanced Features', () => {
// Sync A
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
@ -163,7 +69,7 @@ test.describe('WebDAV Sync Advanced Features', () => {
// Sync B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Verify structure on B
const parentTaskB = pageB.locator('task', { hasText: parentTaskName }).first();
@ -184,7 +90,7 @@ test.describe('WebDAV Sync Advanced Features', () => {
test('should sync task attachments', async ({ browser, baseURL, request }) => {
test.slow();
const SYNC_FOLDER_NAME = `e2e-advanced-att-${Date.now()}`;
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-advanced-att');
await createSyncFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
@ -194,7 +100,7 @@ test.describe('WebDAV Sync Advanced Features', () => {
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
@ -246,10 +152,10 @@ test.describe('WebDAV Sync Advanced Features', () => {
// Sync A
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
@ -259,7 +165,7 @@ test.describe('WebDAV Sync Advanced Features', () => {
// Sync B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Verify Attachment on B
const taskB = pageB.locator('task', { hasText: taskName }).first();

View file

@ -3,8 +3,15 @@ import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { ProjectPage } from '../../pages/project.page';
import { waitForAppReady, waitForStatePersistence } from '../../utils/waits';
import { type Browser, type Page } from '@playwright/test';
import { isWebDavServerUp } from '../../utils/check-webdav';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
dismissTourIfVisible,
} from '../../utils/sync-test-helpers';
// Timing constants for sync detection
const SYNC_TIMEOUT_MS = 60000;
@ -19,12 +26,6 @@ test.describe('WebDAV Sync Expansion', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
const WEBDAV_CONFIG_TEMPLATE = {
baseUrl: 'http://127.0.0.1:2345/',
username: 'admin',
password: 'admin',
};
test.beforeAll(async () => {
const isUp = await isWebDavServerUp(WEBDAV_CONFIG_TEMPLATE.baseUrl);
if (!isUp) {
@ -33,131 +34,9 @@ test.describe('WebDAV Sync Expansion', () => {
}
});
const createSyncFolder = async (request: any, folderName: string): Promise<void> => {
const mkcolUrl = `${WEBDAV_CONFIG_TEMPLATE.baseUrl}${folderName}`;
console.log(`Creating WebDAV folder: ${mkcolUrl}`);
try {
const response = await request.fetch(mkcolUrl, {
method: 'MKCOL',
headers: {
Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'),
},
});
if (!response.ok() && response.status() !== 405) {
console.warn(
`Failed to create WebDAV folder: ${response.status()} ${response.statusText()}`,
);
}
} catch (e) {
console.warn('Error creating WebDAV folder:', e);
}
};
const dismissTour = async (page: Page): Promise<void> => {
try {
const tourElement = page.locator('.shepherd-element').first();
await tourElement.waitFor({ state: 'visible', timeout: 4000 });
const cancelIcon = page.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
} else {
await page.keyboard.press('Escape');
}
await tourElement.waitFor({ state: 'hidden', timeout: 3000 });
} catch (e) {
// Ignore
}
};
const setupClient = async (
browser: Browser,
baseURL: string | undefined,
): Promise<{ context: any; page: Page }> => {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/');
await waitForAppReady(page);
await dismissTour(page);
return { context, page };
};
const waitForSync = async (
page: Page,
syncPage: SyncPage,
): Promise<'success' | 'conflict' | void> => {
const startTime = Date.now();
let sawSpinner = false;
let stableCount = 0;
await expect(syncPage.syncBtn).toBeVisible({ timeout: 10000 });
// First, wait for sync to START (spinner appears) or immediately complete
const spinnerStartWait = Date.now();
while (Date.now() - spinnerStartWait < SPINNER_START_WAIT_MS) {
const isSpinning = await syncPage.syncSpinner.isVisible();
if (isSpinning) {
sawSpinner = true;
break;
}
await page.waitForTimeout(SPINNER_POLL_INTERVAL_MS);
}
while (Date.now() - startTime < SYNC_TIMEOUT_MS) {
// Check for conflict dialog
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
// Check for error snackbar
const snackBars = page.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) {
throw new Error(`Sync failed with error: ${text}`);
}
}
// Check if sync is in progress (spinner visible)
const isSpinning = await syncPage.syncSpinner.isVisible();
if (isSpinning) {
sawSpinner = true;
stableCount = 0; // Reset stable count while spinning
} else if (sawSpinner) {
// Spinner was visible before, now it's gone - sync completed
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) {
return 'success';
}
// No check icon but spinner stopped - wait a bit more
stableCount++;
if (stableCount >= STABLE_COUNT_WITH_SPINNER) {
return 'success';
}
} else {
// Never saw spinner - might have completed instantly
// Check for snackbar indicating sync result
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
if (
text.toLowerCase().includes('sync') ||
text.toLowerCase().includes('already in sync')
) {
return 'success';
}
}
stableCount++;
if (stableCount >= STABLE_COUNT_WITHOUT_SPINNER) {
return 'success';
}
}
await page.waitForTimeout(SYNC_POLL_INTERVAL_MS);
}
throw new Error('Sync timeout: Sync did not complete');
};
test('should sync projects', async ({ browser, baseURL, request }) => {
test.slow();
const SYNC_FOLDER_NAME = `e2e-expansion-proj-${Date.now()}`;
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-expansion-proj');
await createSyncFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
@ -167,7 +46,7 @@ test.describe('WebDAV Sync Expansion', () => {
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
const projectPageA = new ProjectPage(pageA);
@ -188,10 +67,10 @@ test.describe('WebDAV Sync Expansion', () => {
// Sync A
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
const projectPageB = new ProjectPage(pageB);
@ -200,16 +79,11 @@ test.describe('WebDAV Sync Expansion', () => {
// Configure Sync B
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Wait for state persistence to complete after sync
await waitForStatePersistence(pageB);
// Note: We DON'T reload here because:
// 1. The sync already updates NgRx store via reInitFromRemoteSync()
// 2. Reload would require re-initializing the sync provider which has timing issues
// 3. Without reload, the sync button remains functional
// Wait for the synced project to appear in the sidebar
// First ensure Projects group is expanded
const projectsTree = pageB.locator('nav-list-tree').filter({ hasText: 'Projects' });
@ -239,7 +113,7 @@ test.describe('WebDAV Sync Expansion', () => {
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Wait for server to process and ensure Last-Modified timestamp differs
// WebDAV servers often have second-level timestamp precision
@ -247,7 +121,7 @@ test.describe('WebDAV Sync Expansion', () => {
// Sync A - trigger sync to download changes from B
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Wait for state persistence to complete after sync
await waitForStatePersistence(pageA);
@ -266,7 +140,7 @@ test.describe('WebDAV Sync Expansion', () => {
test('should sync task done state', async ({ browser, baseURL, request }) => {
test.slow();
const SYNC_FOLDER_NAME = `e2e-expansion-done-${Date.now()}`;
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-expansion-done');
await createSyncFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
@ -276,7 +150,7 @@ test.describe('WebDAV Sync Expansion', () => {
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
@ -287,10 +161,10 @@ test.describe('WebDAV Sync Expansion', () => {
await workViewPageA.addTask(taskName);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
@ -298,7 +172,7 @@ test.describe('WebDAV Sync Expansion', () => {
// Configure Sync B
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Wait for state persistence to complete after sync
await waitForStatePersistence(pageB);
@ -318,11 +192,11 @@ test.describe('WebDAV Sync Expansion', () => {
await waitForStatePersistence(pageB);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Sync A to get done state from B
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Wait for state persistence to complete after sync
await waitForStatePersistence(pageA);

View file

@ -2,140 +2,46 @@ import { test, expect } from '../../fixtures/test.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { waitForAppReady, waitForStatePersistence } from '../../utils/waits';
import { type Browser, type Page } from '@playwright/test';
import { isWebDavServerUp } from '../../utils/check-webdav';
import {
WEBDAV_CONFIG_TEMPLATE,
setupSyncClient,
createSyncFolder,
waitForSyncComplete,
generateSyncFolderName,
dismissTourIfVisible,
} from '../../utils/sync-test-helpers';
test.describe('WebDAV Sync Full Flow', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
// Use a unique folder for each test run to avoid collisions
const SYNC_FOLDER_NAME = `e2e-test-${Date.now()}`;
const SYNC_FOLDER_NAME = generateSyncFolderName('e2e-full');
const WEBDAV_CONFIG = {
baseUrl: 'http://127.0.0.1:2345/',
username: 'admin',
password: 'admin',
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
test.beforeAll(async () => {
const isUp = await isWebDavServerUp(WEBDAV_CONFIG.baseUrl);
const isUp = await isWebDavServerUp(WEBDAV_CONFIG_TEMPLATE.baseUrl);
if (!isUp) {
console.warn('WebDAV server not reachable. Skipping WebDAV tests.');
test.skip(true, 'WebDAV server not reachable');
}
});
const setupClient = async (
browser: Browser,
baseURL: string | undefined,
): Promise<{ context: any; page: Page }> => {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/');
await waitForAppReady(page);
// Dismiss Shepherd Tour if present
try {
const tourElement = page.locator('.shepherd-element').first();
// Short wait to see if it appears
await tourElement.waitFor({ state: 'visible', timeout: 4000 });
const cancelIcon = page.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
} else {
await page.keyboard.press('Escape');
}
await tourElement.waitFor({ state: 'hidden', timeout: 3000 });
} catch (e) {
// Tour didn't appear or wasn't dismissable, ignore
}
return { context, page };
};
const waitForSync = async (
page: Page,
syncPage: SyncPage,
): Promise<'success' | 'conflict' | void> => {
// Poll for success icon, error snackbar, or conflict dialog
const startTime = Date.now();
let stableCount = 0;
while (Date.now() - startTime < 30000) {
// 30s timeout
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
const snackBars = page.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
// Check for keywords indicating failure
if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) {
throw new Error(`Sync failed with error: ${text}`);
}
}
// Check if sync is in progress (spinner visible)
const isSpinning = await syncPage.syncSpinner.isVisible();
if (!isSpinning) {
// Check for success icon
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) return 'success';
// No spinner, no error, no check icon - use stable count fallback
stableCount++;
if (stableCount >= 3) {
return 'success'; // Consider sync complete after 3 stable checks
}
} else {
stableCount = 0; // Reset if still spinning
}
await page.waitForTimeout(500);
}
throw new Error('Sync timeout: Sync did not complete');
};
test('should sync data between two clients', async ({ browser, baseURL, request }) => {
test.slow(); // Sync tests might take longer
console.log('Using baseURL:', baseURL);
const url = baseURL || 'http://localhost:4242';
// Create the sync folder on WebDAV server to avoid 409 Conflict (parent missing)
// The app adds /DEV suffix in dev mode, so we need to ensure the base folder exists.
const mkcolUrl = `${WEBDAV_CONFIG.baseUrl}${SYNC_FOLDER_NAME}`;
console.log(`Creating WebDAV folder: ${mkcolUrl}`);
try {
const response = await request.fetch(mkcolUrl, {
method: 'MKCOL',
headers: {
Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'),
},
});
if (!response.ok() && response.status() !== 405) {
console.warn(
`Failed to create WebDAV folder: ${response.status()} ${response.statusText()}`,
);
}
} catch (e) {
console.warn('Error creating WebDAV folder:', e);
}
await createSyncFolder(request, SYNC_FOLDER_NAME);
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const { context: contextA, page: pageA } = await setupSyncClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
@ -151,10 +57,10 @@ test.describe('WebDAV Sync Full Flow', () => {
// Sync Client A (Upload)
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const { context: contextB, page: pageB } = await setupSyncClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
@ -165,7 +71,7 @@ test.describe('WebDAV Sync Full Flow', () => {
// Sync Client B (Download)
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Verify Task appears on Client B
await expect(pageB.locator('task')).toHaveCount(1);
@ -177,11 +83,11 @@ test.describe('WebDAV Sync Full Flow', () => {
await workViewPageA.addTask(taskName2);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Sync Client B
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
await expect(pageB.locator('task')).toHaveCount(2);
await expect(pageB.locator('task').first()).toContainText(taskName2);
@ -201,7 +107,7 @@ test.describe('WebDAV Sync Full Flow', () => {
await pageA.waitForTimeout(1000);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Retry sync on B up to 3 times to handle eventual consistency
let taskCountOnB = 2;
@ -212,7 +118,7 @@ test.describe('WebDAV Sync Full Flow', () => {
await pageB.waitForTimeout(500);
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
await waitForSyncComplete(pageB, syncPageB);
// Wait for sync state to persist
await waitForStatePersistence(pageB);
@ -221,18 +127,7 @@ test.describe('WebDAV Sync Full Flow', () => {
// Reload to ensure UI reflects synced state
await pageB.reload();
await waitForAppReady(pageB);
// Dismiss tour if it appears
try {
const tourElement = pageB.locator('.shepherd-element').first();
await tourElement.waitFor({ state: 'visible', timeout: 2000 });
const cancelIcon = pageB.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
}
} catch {
// Tour didn't appear
}
await dismissTourIfVisible(pageB);
await workViewPageB.waitForTaskList();
taskCountOnB = await pageB.locator('task').count();
@ -254,31 +149,14 @@ test.describe('WebDAV Sync Full Flow', () => {
await waitForStatePersistence(pageA);
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Wait for WebDAV server to process A's upload
await pageA.waitForTimeout(2000);
// Create a fresh Client B for conflict test
console.log('Creating fresh Client B for conflict test...');
const contextB2 = await browser.newContext({ baseURL: url });
const pageB2 = await contextB2.newPage();
await pageB2.goto('/');
await waitForAppReady(pageB2);
// Dismiss tour
try {
const tourElement = pageB2.locator('.shepherd-element').first();
await tourElement.waitFor({ state: 'visible', timeout: 4000 });
const cancelIcon = pageB2.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
} else {
await pageB2.keyboard.press('Escape');
}
} catch {
// Tour didn't appear
}
const { context: contextB2, page: pageB2 } = await setupSyncClient(browser, url);
const syncPageB2 = new SyncPage(pageB2);
const workViewPageB2 = new WorkViewPage(pageB2);
await workViewPageB2.waitForTaskList();
@ -286,7 +164,7 @@ test.describe('WebDAV Sync Full Flow', () => {
// Setup sync on fresh Client B
await syncPageB2.setupWebdavSync(WEBDAV_CONFIG);
await syncPageB2.triggerSync();
await waitForSync(pageB2, syncPageB2);
await waitForSyncComplete(pageB2, syncPageB2);
// Wait for state persistence
await waitForStatePersistence(pageB2);
@ -294,16 +172,7 @@ test.describe('WebDAV Sync Full Flow', () => {
// Reload to ensure UI reflects synced state
await pageB2.reload();
await waitForAppReady(pageB2);
try {
const tourElement = pageB2.locator('.shepherd-element').first();
await tourElement.waitFor({ state: 'visible', timeout: 2000 });
const cancelIcon = pageB2.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
}
} catch {
// Tour didn't appear
}
await dismissTourIfVisible(pageB2);
await workViewPageB2.waitForTaskList();
// Final assertion - should have 2 tasks now
@ -337,11 +206,11 @@ test.describe('WebDAV Sync Full Flow', () => {
// Sync A (Uploads "A")
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
await waitForSyncComplete(pageA, syncPageA);
// Sync B2 (Downloads "A" but has "B") -> Conflict
await syncPageB2.triggerSync();
const result = await waitForSync(pageB2, syncPageB2);
const result = await waitForSyncComplete(pageB2, syncPageB2);
if (result === 'success') {
console.log(
@ -371,7 +240,7 @@ test.describe('WebDAV Sync Full Flow', () => {
// Confirmation might not appear
}
await waitForSync(pageB2, syncPageB2);
await waitForSyncComplete(pageB2, syncPageB2);
await expect(pageB2.locator('task', { hasText: 'Conflict Task A' })).toBeVisible();
await expect(

View file

@ -1,6 +0,0 @@
// Recurring task tests removed - feature too complex for reliable e2e testing
import { test } from '../../fixtures/test.fixture';
test.describe('WebDAV Sync Recurring Tasks', () => {
test.skip('removed - feature too complex for reliable e2e testing', () => {});
});

View file

@ -1,6 +0,0 @@
// Reminder/schedule tests removed - feature too complex for reliable e2e testing
import { test } from '../../fixtures/test.fixture';
test.describe('WebDAV Sync Scheduled Tasks', () => {
test.skip('removed - feature too complex for reliable e2e testing', () => {});
});

View file

@ -1,139 +0,0 @@
import { test, expect } from '../../fixtures/test.fixture';
import { SyncPage } from '../../pages/sync.page';
import { WorkViewPage } from '../../pages/work-view.page';
import { isWebDavServerUp } from '../../utils/check-webdav';
import {
WEBDAV_CONFIG_TEMPLATE,
createUniqueSyncFolder,
createWebDavFolder,
setupClient,
waitForSync,
} from '../../utils/sync-helpers';
test.describe('WebDAV Sync Time Tracking', () => {
// Run sync tests serially to avoid WebDAV server contention
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
const isUp = await isWebDavServerUp(WEBDAV_CONFIG_TEMPLATE.baseUrl);
if (!isUp) {
console.warn('WebDAV server not reachable. Skipping WebDAV tests.');
test.skip(true, 'WebDAV server not reachable');
}
});
// Skip: Time tracking data persistence is complex and was redesigned in feat/operation-log.
// The timer UI works (isCurrent class toggles) but timeSpent value storage varies by context.
// This test should be revisited after operation-log merge to verify the new time tracking sync.
test.skip('should sync time spent on task between clients', async ({
browser,
baseURL,
request,
}) => {
test.slow();
const SYNC_FOLDER_NAME = createUniqueSyncFolder('time-tracking');
await createWebDavFolder(request, SYNC_FOLDER_NAME);
const WEBDAV_CONFIG = {
...WEBDAV_CONFIG_TEMPLATE,
syncFolderPath: `/${SYNC_FOLDER_NAME}`,
};
const url = baseURL || 'http://localhost:4242';
// --- Client A ---
const { context: contextA, page: pageA } = await setupClient(browser, url);
const syncPageA = new SyncPage(pageA);
const workViewPageA = new WorkViewPage(pageA);
await workViewPageA.waitForTaskList();
// Configure Sync on Client A
await syncPageA.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageA.syncBtn).toBeVisible();
// Create a task
const taskName = `Time Track Test ${Date.now()}`;
await workViewPageA.addTask(taskName);
const taskA = pageA.locator('task', { hasText: taskName }).first();
await expect(taskA).toBeVisible();
// Click the task to select/focus it
await taskA.click();
await pageA.waitForTimeout(200);
// Start timer using header play button (starts tracking for selected task)
const playBtn = pageA.locator('.play-btn.tour-playBtn').first();
await playBtn.waitFor({ state: 'visible' });
await playBtn.click();
// Wait for the class to be applied
await pageA.waitForTimeout(500);
// Verify task is being tracked (has isCurrent class)
await expect(taskA).toHaveClass(/isCurrent/);
// Wait for time to accumulate (3 seconds)
await pageA.waitForTimeout(3000);
// Stop timer by clicking play button again
await playBtn.click();
// Wait for the class to be removed
await pageA.waitForTimeout(500);
// Verify tracking stopped
await expect(taskA).not.toHaveClass(/isCurrent/);
// Wait for state to persist and reload to ensure time display is updated
await pageA.waitForTimeout(1000);
await pageA.reload();
await workViewPageA.waitForTaskList();
// Refetch the task after reload
const taskAAfterReload = pageA.locator('task', { hasText: taskName }).first();
await expect(taskAAfterReload).toBeVisible();
// Verify time spent is visible on Client A before syncing
const timeDisplayA = taskAAfterReload.locator('.time-wrapper .time-val').first();
await expect(timeDisplayA).toBeVisible({ timeout: 5000 });
const timeTextA = await timeDisplayA.textContent();
console.log('Time spent on Client A:', timeTextA);
// Time should show something like "3s" not "-"
expect(timeTextA).not.toBe('-');
// Sync Client A (Upload)
await syncPageA.triggerSync();
await waitForSync(pageA, syncPageA);
// --- Client B ---
const { context: contextB, page: pageB } = await setupClient(browser, url);
const syncPageB = new SyncPage(pageB);
const workViewPageB = new WorkViewPage(pageB);
await workViewPageB.waitForTaskList();
// Configure Sync on Client B
await syncPageB.setupWebdavSync(WEBDAV_CONFIG);
await expect(syncPageB.syncBtn).toBeVisible();
// Sync Client B (Download)
await syncPageB.triggerSync();
await waitForSync(pageB, syncPageB);
// Verify task appears on Client B
const taskB = pageB.locator('task', { hasText: taskName }).first();
await expect(taskB).toBeVisible();
// Verify time spent is visible on Client B (time-wrapper contains time value)
const timeDisplayB = taskB.locator('.time-wrapper .time-val').first();
await expect(timeDisplayB).toBeVisible({ timeout: 5000 });
const timeTextB = await timeDisplayB.textContent();
console.log('Time spent on Client B:', timeTextB);
// Time should be synced and show same value (not "-")
expect(timeTextB).not.toBe('-');
expect(timeTextB).toBeTruthy();
// Cleanup
await contextA.close();
await contextB.close();
});
});

View file

@ -10,8 +10,6 @@ test.describe('Drag Task to change project and labels', () => {
// Wait for app to be ready
await workViewPage.waitForTaskList();
// Additional wait for stability in parallel execution
await page.waitForTimeout(50);
});
test('should be able to move task to project by dragging to project link in magic-side-nav', async ({
@ -54,32 +52,28 @@ test.describe('Drag Task to change project and labels', () => {
// find drag handle of task
const firstTask = page.locator('task').first();
const dragHandle = firstTask.locator('.drag-handle');
const tagList = firstTask.locator('tag-list');
// Drag and drop to first project
// Drag and drop to first project - wait for tag to appear
await dragHandle.dragTo(project1NavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).toContainText(
`${testPrefix}-TestProject 1`,
);
await expect(tagList).toContainText(`${testPrefix}-TestProject 1`, {
timeout: 5000,
});
// Drag and drop to second project
// Drag and drop to second project - wait for tag change
await dragHandle.dragTo(project2NavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).not.toContainText(
`${testPrefix}-TestProject 1`,
);
await expect(firstTask.locator('tag-list')).toContainText(
`${testPrefix}-TestProject 2`,
);
await expect(tagList).not.toContainText(`${testPrefix}-TestProject 1`, {
timeout: 5000,
});
await expect(tagList).toContainText(`${testPrefix}-TestProject 2`);
// Drag and drop back to inbox
// Drag and drop back to inbox - wait for tag change
const inboxNavItem = page.getByRole('menuitem').filter({ hasText: 'Inbox' });
await dragHandle.dragTo(inboxNavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).not.toContainText(
`${testPrefix}-TestProject 2`,
);
await expect(firstTask.locator('tag-list')).toContainText('Inbox');
await expect(tagList).not.toContainText(`${testPrefix}-TestProject 2`, {
timeout: 5000,
});
await expect(tagList).toContainText('Inbox');
});
test('should be able to add and remove tags by dragging task to the tag link in magic-side-nav', async ({
@ -132,22 +126,20 @@ test.describe('Drag Task to change project and labels', () => {
// find drag handle of task
const firstTask = page.locator('task').first();
const dragHandle = firstTask.locator('.drag-handle');
const tagList = firstTask.locator('tag-list');
// Drag and drop to first tag
// Drag and drop to first tag - wait for tag to appear
await dragHandle.dragTo(tag1NavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).toContainText(`${testPrefix}-Tag1`);
await expect(tagList).toContainText(`${testPrefix}-Tag1`, { timeout: 5000 });
// Drag and drop to second tag
// Drag and drop to second tag - wait for tag to appear
await dragHandle.dragTo(tag2NavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).toContainText(`${testPrefix}-Tag1`);
await expect(firstTask.locator('tag-list')).toContainText(`${testPrefix}-Tag2`);
await expect(tagList).toContainText(`${testPrefix}-Tag1`);
await expect(tagList).toContainText(`${testPrefix}-Tag2`, { timeout: 5000 });
// Drag and drop again to first tag to remove it
// Drag and drop again to first tag to remove it - wait for tag to disappear
await dragHandle.dragTo(tag1NavItem);
await page.waitForTimeout(500); // Wait for drag animation and state update
await expect(firstTask.locator('tag-list')).not.toContainText(`${testPrefix}-Tag1`);
await expect(firstTask.locator('tag-list')).toContainText(`${testPrefix}-Tag2`);
await expect(tagList).not.toContainText(`${testPrefix}-Tag1`, { timeout: 5000 });
await expect(tagList).toContainText(`${testPrefix}-Tag2`);
});
});

View file

@ -19,11 +19,8 @@ test.describe('Task List - Start/Stop', () => {
await playBtn.waitFor({ state: 'visible' });
await playBtn.click();
// Wait a moment for the class to be applied
await page.waitForTimeout(200);
// Verify the task has the 'isCurrent' class
await expect(firstTask).toHaveClass(/isCurrent/);
// Verify the task has the 'isCurrent' class (auto-waits)
await expect(firstTask).toHaveClass(/isCurrent/, { timeout: 5000 });
// Hover again to ensure button is visible
await firstTask.hover();
@ -31,10 +28,7 @@ test.describe('Task List - Start/Stop', () => {
// Click the play button again to stop the task
await playBtn.click();
// Wait a moment for the class to be removed
await page.waitForTimeout(200);
// Verify the task no longer has the 'isCurrent' class
await expect(firstTask).not.toHaveClass(/isCurrent/);
// Verify the task no longer has the 'isCurrent' class (auto-waits)
await expect(firstTask).not.toHaveClass(/isCurrent/, { timeout: 5000 });
});
});

View file

@ -14,27 +14,20 @@ test.describe('Work View Features', () => {
workViewPage,
testPrefix,
}) => {
test.setTimeout(30000); // Increase timeout
test.setTimeout(30000);
// Wait for work view to be ready
await workViewPage.waitForTaskList();
// Wait for any dialogs to be dismissed
await page.waitForTimeout(2000);
// Verify undone task list is visible
await expect(page.locator(UNDONE_TASK_LIST)).toBeVisible({ timeout: 8000 }); // Reduced from 10s to 8s
await expect(page.locator(UNDONE_TASK_LIST)).toBeVisible({ timeout: 8000 });
// Create tasks
await workViewPage.addTask('Task 1');
await page.waitForSelector(TASK, { state: 'visible', timeout: 4000 }); // Reduced from 5s to 4s
await page.waitForTimeout(500);
await page.locator(TASK).first().waitFor({ state: 'visible', timeout: 5000 });
await workViewPage.addTask('Task 2');
await page.waitForTimeout(1000);
// Verify we have 2 tasks
await expect(page.locator(TASK)).toHaveCount(2);
await expect(page.locator(TASK)).toHaveCount(2, { timeout: 5000 });
// Mark first task as done
const firstTask = page.locator(FIRST_TASK);
@ -48,10 +41,12 @@ test.describe('Work View Features', () => {
await doneBtn.waitFor({ state: 'visible' });
await doneBtn.click();
// Wait a bit for the transition
await page.waitForTimeout(2000);
// Wait for task count in undone list to decrease
await expect(page.locator(`${UNDONE_TASK_LIST} ${TASK}`)).toHaveCount(1, {
timeout: 5000,
});
// Check if done section exists (it might not show if there are no done tasks)
// Check if done section exists
const doneSectionExists = await page
.locator(DONE_TASKS_SECTION)
.isVisible({ timeout: 5000 })
@ -62,7 +57,7 @@ test.describe('Work View Features', () => {
const toggleBtn = page.locator(TOGGLE_DONE_TASKS_BTN);
if (await toggleBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await toggleBtn.click();
await page.waitForTimeout(1000);
await expect(page.locator(DONE_TASK_LIST)).toBeVisible({ timeout: 5000 });
}
// Verify done task list is visible
@ -82,17 +77,19 @@ test.describe('Work View Features', () => {
// Wait for work view to be ready
await workViewPage.waitForTaskList();
await page.waitForTimeout(1000);
// Create multiple tasks
// Create multiple tasks - wait for each to appear before adding next
await workViewPage.addTask('First created');
await page.waitForTimeout(500);
await expect(page.locator(TASK)).toHaveCount(1, { timeout: 5000 });
await workViewPage.addTask('Second created');
await page.waitForTimeout(500);
await expect(page.locator(TASK)).toHaveCount(2, { timeout: 5000 });
await workViewPage.addTask('Third created');
await page.waitForTimeout(500);
await expect(page.locator(TASK)).toHaveCount(3, { timeout: 5000 });
await workViewPage.addTask('Fourth created');
await page.waitForTimeout(500);
await expect(page.locator(TASK)).toHaveCount(4, { timeout: 5000 });
// Verify order (newest first)
await expect(page.locator('task:nth-of-type(1) task-title')).toContainText(

View file

@ -92,10 +92,7 @@ test.describe('Work View', () => {
await page.keyboard.press('Enter');
// Wait for first task to be created
await page.waitForFunction(() => document.querySelectorAll('task').length >= 1, {
timeout: 10000,
});
await page.waitForTimeout(300);
await page.locator('task').first().waitFor({ state: 'visible', timeout: 10000 });
// Add second task
await workViewPage.addTaskGlobalInput.clear();
@ -137,8 +134,8 @@ test.describe('Work View', () => {
// Add two tasks - the addTask method now properly waits for each one
await workViewPage.addTask('test task hihi');
// Wait a bit between tasks to ensure proper state update
await page.waitForTimeout(500);
// Wait for first task to be visible before adding second
await page.locator('task').first().waitFor({ state: 'visible', timeout: 10000 });
await workViewPage.addTask('some other task here');

59
e2e/utils/assertions.ts Normal file
View file

@ -0,0 +1,59 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { TaskPage } from '../pages/task.page';
import type { DialogPage } from '../pages/dialog.page';
/**
* Assert that the task list has the expected number of tasks.
*/
export const expectTaskCount = async (
taskPage: TaskPage,
count: number,
): Promise<void> => {
await expect(taskPage.getAllTasks()).toHaveCount(count);
};
/**
* Assert that a task with the given text is visible.
*/
export const expectTaskVisible = async (
taskPage: TaskPage,
text: string,
): Promise<void> => {
const task = taskPage.getTaskByText(text);
await expect(task).toBeVisible();
};
/**
* Assert that a dialog is currently visible.
*/
export const expectDialogVisible = async (dialogPage: DialogPage): Promise<void> => {
const dialog = await dialogPage.waitForDialog();
await expect(dialog).toBeVisible();
};
/**
* Assert that no global error alert is displayed.
*/
export const expectNoGlobalError = async (page: Page): Promise<void> => {
const error = page.locator('.global-error-alert');
await expect(error).not.toBeVisible();
};
/**
* Assert that a task is marked as done.
*/
export const expectTaskDone = async (taskPage: TaskPage, text: string): Promise<void> => {
const task = taskPage.getTaskByText(text);
await expect(task).toHaveClass(/isDone/);
};
/**
* Assert that the done task count matches expected.
*/
export const expectDoneTaskCount = async (
taskPage: TaskPage,
count: number,
): Promise<void> => {
await expect(taskPage.getDoneTasks()).toHaveCount(count);
};

View file

@ -0,0 +1,35 @@
import type { Locator } from '@playwright/test';
/**
* Safely checks if an element is visible, returning false on any error.
* Use this instead of `.isVisible().catch(() => false)` pattern.
*
* @param locator - Playwright locator to check
* @param timeout - Optional timeout in ms (default uses Playwright's default)
* @returns Promise<boolean> - true if visible, false otherwise
*/
export const safeIsVisible = async (
locator: Locator,
timeout?: number,
): Promise<boolean> => {
try {
return await locator.isVisible({ timeout });
} catch {
return false;
}
};
/**
* Safely checks if an element is enabled, returning false on any error.
* Use this instead of `.isEnabled().catch(() => false)` pattern.
*
* @param locator - Playwright locator to check
* @returns Promise<boolean> - true if enabled, false otherwise
*/
export const safeIsEnabled = async (locator: Locator): Promise<boolean> => {
try {
return await locator.isEnabled();
} catch {
return false;
}
};

View file

@ -0,0 +1,117 @@
import type { Locator, Page } from '@playwright/test';
import type { WorkViewPage } from '../pages/work-view.page';
import { fillTimeInput } from './time-input-helper';
// Selectors for scheduling
const DETAIL_PANEL_BTN = '.show-additional-info-btn';
const DETAIL_PANEL_SELECTOR = 'dialog-task-detail-panel, task-detail-panel';
const DETAIL_PANEL_SCHEDULE_ITEM =
'task-detail-item:has(mat-icon:text("alarm")), ' +
'task-detail-item:has(mat-icon:text("today")), ' +
'task-detail-item:has(mat-icon:text("schedule"))';
const RIGHT_PANEL = '.right-panel';
const DIALOG_CONTAINER = 'mat-dialog-container';
const DIALOG_SUBMIT = 'mat-dialog-actions button:last-child';
/**
* Closes the task detail panel if it's currently open.
*/
export const closeDetailPanelIfOpen = async (page: Page): Promise<void> => {
const detailPanel = page.locator(DETAIL_PANEL_SELECTOR).first();
const isVisible = await detailPanel.isVisible().catch(() => false);
if (isVisible) {
await page.keyboard.press('Escape');
await detailPanel.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
};
/**
* Opens the detail panel for a task by hovering and clicking the detail button.
*
* @param page - Playwright page object
* @param task - Locator for the task element
*/
export const openTaskDetailPanel = async (page: Page, task: Locator): Promise<void> => {
await task.waitFor({ state: 'visible' });
await task.scrollIntoViewIfNeeded();
await task.hover();
const detailBtn = task.locator(DETAIL_PANEL_BTN).first();
await detailBtn.waitFor({ state: 'visible', timeout: 5000 });
await detailBtn.click();
// Wait for detail panel to be visible
await page
.locator(RIGHT_PANEL)
.or(page.locator(DETAIL_PANEL_SELECTOR))
.first()
.waitFor({ state: 'visible', timeout: 10000 });
};
/**
* Schedules a task via the detail panel.
*
* @param page - Playwright page object
* @param task - Locator for the task element
* @param scheduleTime - Date object or timestamp for when to schedule
*/
export const scheduleTaskViaDetailPanel = async (
page: Page,
task: Locator,
scheduleTime: Date | number,
): Promise<void> => {
await openTaskDetailPanel(page, task);
// Click the schedule item
const scheduleItem = page.locator(DETAIL_PANEL_SCHEDULE_ITEM).first();
await scheduleItem.waitFor({ state: 'visible', timeout: 5000 });
await scheduleItem.click();
// Wait for schedule dialog
const dialogContainer = page.locator(DIALOG_CONTAINER);
await dialogContainer.waitFor({ state: 'visible', timeout: 10000 });
// Fill time input
await fillTimeInput(page, scheduleTime);
// Submit dialog
const submitBtn = page.locator(DIALOG_SUBMIT);
await submitBtn.waitFor({ state: 'visible', timeout: 5000 });
await submitBtn.click();
// Wait for dialog to close
await dialogContainer.waitFor({ state: 'hidden', timeout: 10000 });
// Close detail panel if open
await closeDetailPanelIfOpen(page);
};
// Default schedule delta: 5 seconds from now
const DEFAULT_SCHEDULE_DELTA = 5000;
/**
* Adds a task and schedules it with a reminder.
* This is a convenience function combining task creation and scheduling.
*
* @param page - Playwright page object
* @param workViewPage - WorkViewPage instance
* @param taskTitle - Title for the new task
* @param scheduleTime - Date object or timestamp for when to schedule (defaults to 5s from now)
*/
export const addTaskWithReminder = async (
page: Page,
workViewPage: WorkViewPage,
taskTitle: string,
scheduleTime: Date | number = Date.now() + DEFAULT_SCHEDULE_DELTA,
): Promise<void> => {
// Add the task
await workViewPage.addTask(taskTitle);
// Find the task by title
const escapedTitle = taskTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const task = page.locator(`task:has-text("${escapedTitle}")`).first();
await task.waitFor({ state: 'visible', timeout: 10000 });
// Schedule it
await scheduleTaskViaDetailPanel(page, task, scheduleTime);
};

View file

@ -0,0 +1,113 @@
import type { Browser, Page, APIRequestContext } from '@playwright/test';
import { expect } from '@playwright/test';
import type { SyncPage } from '../pages/sync.page';
import { waitForAppReady } from './waits';
import { dismissTourIfVisible } from './tour-helpers';
// Re-export for convenience
export { dismissTourIfVisible };
/**
* Default WebDAV configuration template for sync tests
*/
export const WEBDAV_CONFIG_TEMPLATE = {
baseUrl: 'http://127.0.0.1:2345/',
username: 'admin',
password: 'admin',
};
/**
* Creates a new browser context and page for sync testing.
* Handles app initialization and tour dismissal.
*/
export const setupSyncClient = async (
browser: Browser,
baseURL: string | undefined,
): Promise<{ context: Awaited<ReturnType<Browser['newContext']>>; page: Page }> => {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/');
await waitForAppReady(page);
await dismissTourIfVisible(page);
return { context, page };
};
/**
* Creates a WebDAV folder on the server.
* Used to set up sync folder before tests.
*/
export const createSyncFolder = async (
request: APIRequestContext,
folderName: string,
baseUrl: string = WEBDAV_CONFIG_TEMPLATE.baseUrl,
): Promise<void> => {
const mkcolUrl = `${baseUrl}${folderName}`;
console.log(`Creating WebDAV folder: ${mkcolUrl}`);
try {
const response = await request.fetch(mkcolUrl, {
method: 'MKCOL',
headers: {
Authorization:
'Basic ' +
Buffer.from(
`${WEBDAV_CONFIG_TEMPLATE.username}:${WEBDAV_CONFIG_TEMPLATE.password}`,
).toString('base64'),
},
});
if (!response.ok() && response.status() !== 405) {
console.warn(
`Failed to create WebDAV folder: ${response.status()} ${response.statusText()}`,
);
}
} catch (e) {
console.warn('Error creating WebDAV folder:', e);
}
};
/**
* Waits for sync to complete by polling for success icon or conflict dialog.
* Throws on error snackbar or timeout.
*
* @param page - Playwright page
* @param syncPage - SyncPage instance
* @param timeout - Maximum wait time in ms (default 30000)
* @returns 'success' | 'conflict' | void
*/
export const waitForSyncComplete = async (
page: Page,
syncPage: SyncPage,
timeout: number = 30000,
): Promise<'success' | 'conflict' | void> => {
const startTime = Date.now();
// Ensure sync button is visible first
await expect(syncPage.syncBtn).toBeVisible({ timeout: 10000 });
while (Date.now() - startTime < timeout) {
const successVisible = await syncPage.syncCheckIcon.isVisible();
if (successVisible) return 'success';
const conflictDialog = page.locator('dialog-sync-conflict');
if (await conflictDialog.isVisible()) return 'conflict';
const snackBars = page.locator('.mat-mdc-snack-bar-container');
const count = await snackBars.count();
for (let i = 0; i < count; ++i) {
const text = await snackBars.nth(i).innerText();
if (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail')) {
throw new Error(`Sync failed with error: ${text}`);
}
}
await page.waitForTimeout(500);
}
throw new Error(`Sync timeout after ${timeout}ms: Success icon did not appear`);
};
/**
* Generates a unique sync folder name for test isolation.
*/
export const generateSyncFolderName = (prefix: string = 'e2e-test'): string => {
return `${prefix}-${Date.now()}`;
};

View file

@ -0,0 +1,59 @@
import { type Page, expect } from '@playwright/test';
/**
* Fills a time input field with the specified time.
* Handles both mat-form-field wrapped inputs and plain time inputs.
* Uses retry logic to ensure the value is properly set.
*
* @param page - Playwright page object
* @param scheduleTime - Date object or timestamp for the desired time
*/
export const fillTimeInput = async (
page: Page,
scheduleTime: Date | number,
): Promise<void> => {
const d = new Date(scheduleTime);
const timeValue = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
// Try multiple selectors for the time input
const timeInput = page
.locator('mat-dialog-container input[type="time"]')
.or(page.locator('mat-form-field input[type="time"]'))
.or(page.locator('input[type="time"]'))
.first();
await timeInput.waitFor({ state: 'visible', timeout: 10000 });
// Click and focus the input
await timeInput.click();
// Clear and fill with small delays for stability
await timeInput.clear();
await timeInput.fill(timeValue);
// Verify with retry - if fill() didn't work, use evaluate fallback
const inputValue = await timeInput.inputValue();
if (inputValue !== timeValue) {
await page.evaluate(
({ value }: { value: string }) => {
const timeInputEl = document.querySelector(
'mat-form-field input[type="time"]',
) as HTMLInputElement;
if (timeInputEl) {
timeInputEl.value = value;
timeInputEl.dispatchEvent(new Event('input', { bubbles: true }));
timeInputEl.dispatchEvent(new Event('change', { bubbles: true }));
}
},
{ value: timeValue },
);
}
// Verify the value was set
await expect(async () => {
const value = await timeInput.inputValue();
expect(value).toBe(timeValue);
}).toPass({ timeout: 5000 });
// Tab out to commit the value
await page.keyboard.press('Tab');
};

23
e2e/utils/tour-helpers.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Page } from '@playwright/test';
/**
* Dismisses the Shepherd tour if it appears on the page.
* Silently ignores if tour doesn't appear.
*/
export const dismissTourIfVisible = async (page: Page): Promise<void> => {
try {
const tourElement = page.locator('.shepherd-element').first();
await tourElement.waitFor({ state: 'visible', timeout: 4000 });
const cancelIcon = page.locator('.shepherd-cancel-icon').first();
if (await cancelIcon.isVisible()) {
await cancelIcon.click();
} else {
await page.keyboard.press('Escape');
}
await tourElement.waitFor({ state: 'hidden', timeout: 3000 });
} catch {
// Tour didn't appear or wasn't dismissable, ignore
}
};

View file

@ -18,57 +18,30 @@ type WaitForAppReadyOptions = {
};
/**
* Wait until Angular reports stability or fall back to a DOM based heuristic.
* Works for both dev and prod builds (where window.ng may be stripped).
* Simplified wait that relies on Playwright's auto-waiting.
* Previously used Angular testability API to check Zone.js stability.
* Now just checks DOM readiness - Playwright handles element actionability.
*
* Experiment showed: Angular stability checks not needed for most UI tests.
* Playwright's auto-waiting (before click, fill, assertions) is sufficient.
*/
export const waitForAngularStability = async (
page: Page,
timeout = 5000,
timeout = 3000,
): Promise<void> => {
await page
.waitForFunction(
() => {
const win = window as unknown as {
getAllAngularTestabilities?: () => Array<{ isStable: () => boolean }>;
ng?: any;
};
const testabilities = win.getAllAngularTestabilities?.();
if (testabilities && testabilities.length) {
return testabilities.every((testability) => {
try {
return testability.isStable();
} catch {
return false;
}
});
}
const ng = win.ng;
const appRef = ng
?.getComponent?.(document.body)
?.injector?.get?.(ng.core?.ApplicationRef);
const manualStableFlag = appRef?.isStable;
if (typeof manualStableFlag === 'boolean') {
return manualStableFlag;
}
// As a final fallback, ensure the main shell exists & DOM settled.
return (
document.readyState === 'complete' &&
!!document.querySelector('magic-side-nav') &&
!!document.querySelector('.route-wrapper')
);
},
{ timeout },
)
.catch(() => {
// Non-fatal: fall back to next waits
});
await page.waitForFunction(
() =>
document.readyState === 'complete' && !!document.querySelector('.route-wrapper'),
{ timeout },
);
};
/**
* Shared helper to wait until the application shell and Angular are ready.
* Optimized for speed - removed networkidle wait and redundant checks.
*
* Note: The app shows a loading screen while initial sync and data load completes.
* This screen hides the .route-wrapper, so we must wait for loading to complete first.
*/
export const waitForAppReady = async (
page: Page,
@ -76,10 +49,8 @@ export const waitForAppReady = async (
): Promise<void> => {
const { selector, ensureRoute = true, routeRegex = DEFAULT_ROUTE_REGEX } = options;
// Wait for initial page load
await page.waitForLoadState('domcontentloaded');
await page
.waitForSelector('body', { state: 'visible', timeout: 10000 })
.catch(() => {});
// Handle any blocking dialogs (pre-migration, confirmation, etc.)
// These dialogs block app until dismissed
@ -95,55 +66,32 @@ export const waitForAppReady = async (
}
}
// Wait for the main navigation to appear - this is critical for the app to be usable
// Use retry loop with longer total timeout to handle slow loads
let sideNavVisible = false;
const sideNavTimeout = 30000; // 30s total timeout
const sideNavStartTime = Date.now();
while (Date.now() - sideNavStartTime < sideNavTimeout) {
try {
await page.waitForSelector('magic-side-nav', { state: 'visible', timeout: 5000 });
sideNavVisible = true;
break;
} catch {
// Check for any blocking dialogs that might have appeared
try {
const dialogConfirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
if (await dialogConfirmBtn.isVisible()) {
await dialogConfirmBtn.click();
await page.waitForTimeout(500);
}
} catch {
// No dialog
}
// Continue waiting
// Wait for the loading screen to disappear (if visible).
// The app shows `.loading-full-page-wrapper` while syncing/importing data.
const loadingWrapper = page.locator('.loading-full-page-wrapper');
try {
const isLoadingVisible = await loadingWrapper.isVisible().catch(() => false);
if (isLoadingVisible) {
await loadingWrapper.waitFor({ state: 'hidden', timeout: 30000 });
}
} catch {
// Loading screen might not appear at all - that's fine
}
if (!sideNavVisible) {
throw new Error(
'App failed to load: magic-side-nav not visible after 30s. ' +
'The app may be stuck on the splash screen or failed to initialize.',
);
}
// Wait for route to match (if required)
if (ensureRoute) {
await page.waitForURL(routeRegex, { timeout: 15000 }).catch(() => {});
await page.waitForURL(routeRegex, { timeout: 15000 });
}
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
// Wait for main route wrapper to be visible (indicates app shell loaded)
await page
.locator('.route-wrapper')
.first()
.waitFor({ state: 'visible', timeout: 10000 })
.catch(() => {});
.waitFor({ state: 'visible', timeout: 15000 });
// Wait for optional selector
if (selector) {
await page
.waitForSelector(selector, { state: 'visible', timeout: 10000 })
.catch(() => {});
await page.locator(selector).first().waitFor({ state: 'visible', timeout: 8000 });
}
await waitForAngularStability(page).catch(() => {});

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "superProductivity",
"version": "16.8.0",
"version": "16.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "superProductivity",
"version": "16.8.0",
"version": "16.8.1",
"license": "MIT",
"workspaces": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"name": "superProductivity",
"version": "16.8.0",
"version": "16.8.1",
"description": "ToDo list and Time Tracking",
"keywords": [
"ToDo",

View file

@ -72,7 +72,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -1084,7 +1083,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz",
"integrity": "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -1275,7 +1273,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@ -1964,7 +1961,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2125,7 +2121,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
}
@ -2180,7 +2175,6 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz",
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==",
"dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@ -2432,7 +2426,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View file

@ -96,7 +96,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -605,7 +604,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@ -628,7 +626,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2305,7 +2302,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@ -3483,7 +3479,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-30.0.4.tgz",
"integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==",
"dev": true,
"peer": true,
"dependencies": {
"@jest/core": "30.0.4",
"@jest/types": "30.0.1",
@ -4093,7 +4088,6 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -5351,7 +5345,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -1,6 +1,6 @@
// this file is automatically generated by git.version.ts script
export const versions = {
version: '16.8.0',
version: '16.8.1',
revision: 'NO_REV',
branch: 'NO_BRANCH',
};