mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
test(e2e): fix failing supersync E2E tests
- cascade-delete: use context menu for project deletion instead of non-existent settings page button - multi-tab: set PLAYWRIGHT user agent on shared context and navigate inactive tabs to about:blank to avoid multi-instance blocker - token-expiry: fix route pattern to match actual API endpoint and re-enter credentials after 401 clears them
This commit is contained in:
parent
612b7b1886
commit
aea9c769c8
3 changed files with 70 additions and 37 deletions
|
|
@ -38,27 +38,29 @@ const navigateToProject = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Delete a project via its settings.
|
||||
* Delete a project via the context menu on the sidebar nav-item.
|
||||
*/
|
||||
const deleteProjectViaSettings = async (
|
||||
const deleteProjectViaContextMenu = async (
|
||||
page: import('@playwright/test').Page,
|
||||
projectName: string,
|
||||
): Promise<void> => {
|
||||
// Navigate to the project first
|
||||
await navigateToProject(page, projectName);
|
||||
// Find the project nav-item in the sidebar
|
||||
const projectNavItem = page.locator(`nav-item:has-text("${projectName}")`).first();
|
||||
await projectNavItem.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Open project settings via the route
|
||||
await page.goto('/#/project-settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
// Hover to reveal the more_vert button (hidden by default, shown on hover)
|
||||
await projectNavItem.hover();
|
||||
const moreBtn = projectNavItem.locator('.additional-btn');
|
||||
await moreBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await moreBtn.click();
|
||||
|
||||
// Find and click Delete Project button
|
||||
const deleteBtn = page.locator('button:has-text("Delete Project")').first();
|
||||
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await deleteBtn.click();
|
||||
// Click "Delete project" in context menu
|
||||
const deleteMenuItem = page.locator('button[mat-menu-item]:has-text("Delete project")');
|
||||
await deleteMenuItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await deleteMenuItem.click();
|
||||
|
||||
// Confirm deletion dialog
|
||||
const confirmBtn = page.locator('mat-dialog-actions button:has-text("Delete")').first();
|
||||
// Confirm deletion dialog (button text is "Ok")
|
||||
const confirmBtn = page.locator('dialog-confirm button[e2e="confirmBtn"]');
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await confirmBtn.click();
|
||||
|
||||
|
|
@ -141,7 +143,7 @@ test.describe('@supersync Cross-Entity Cascade Delete', () => {
|
|||
// ============ PHASE 3: Client A deletes project (without syncing B's tasks first) ============
|
||||
console.log('[CascadeDelete] Phase 3: Client A deletes project');
|
||||
|
||||
await deleteProjectViaSettings(clientA.page, projectName);
|
||||
await deleteProjectViaContextMenu(clientA.page, projectName);
|
||||
console.log('[CascadeDelete] Client A deleted project');
|
||||
|
||||
// Client A syncs (uploads deletion)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ import { waitForAppReady } from '../../utils/waits';
|
|||
* Tests that two browser tabs sharing the same IndexedDB (same BrowserContext)
|
||||
* don't corrupt data when using SuperSync.
|
||||
*
|
||||
* NOTE: This test is inherently fragile. Two Angular instances sharing IndexedDB
|
||||
* may surface real bugs (e.g., transaction conflicts, stale reads). The test uses
|
||||
* reload-based verification to ensure state consistency.
|
||||
* NOTE: The app prevents two tabs from running simultaneously via BroadcastChannel.
|
||||
* This test works around that by navigating the inactive tab to about:blank before
|
||||
* opening the next tab, then reloading it later. This simulates the real-world
|
||||
* scenario of closing and reopening a tab.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - super-sync-server running on localhost:1901 with TEST_MODE=true
|
||||
|
|
@ -36,10 +37,10 @@ test.describe('@supersync Multi-Tab Same Account', () => {
|
|||
* 1. Create a SHARED browser context (same IndexedDB)
|
||||
* 2. Open Tab 1, set up SuperSync, create task
|
||||
* 3. Tab 1 syncs
|
||||
* 4. Open Tab 2 in SAME context
|
||||
* 5. Tab 2 reloads → should see Tab 1's task (from shared IndexedDB)
|
||||
* 6. Tab 2 creates a task
|
||||
* 7. Tab 1 reloads → should see both tasks
|
||||
* 4. Navigate Tab 1 away (avoid multi-instance blocker)
|
||||
* 5. Open Tab 2 → should see Tab 1's task (from shared IndexedDB)
|
||||
* 6. Tab 2 creates a task, syncs
|
||||
* 7. Navigate Tab 2 away, reload Tab 1 → should see both tasks
|
||||
* 8. External Client C (separate context) syncs → should see consistent state
|
||||
*/
|
||||
test('Two tabs sharing IndexedDB converge with external client', async ({
|
||||
|
|
@ -62,6 +63,8 @@ test.describe('@supersync Multi-Tab Same Account', () => {
|
|||
sharedContext = await browser.newContext({
|
||||
storageState: undefined,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
// Set PLAYWRIGHT user agent to skip the Shepherd tour dialog
|
||||
userAgent: 'PLAYWRIGHT MULTI-TAB-TEST',
|
||||
});
|
||||
|
||||
const tab1 = await sharedContext.newPage();
|
||||
|
|
@ -93,6 +96,10 @@ test.describe('@supersync Multi-Tab Same Account', () => {
|
|||
// ============ PHASE 3: Open Tab 2 in same context ============
|
||||
console.log('[MultiTab] Phase 3: Open Tab 2 in same context');
|
||||
|
||||
// Navigate Tab 1 away to avoid multi-instance blocker
|
||||
// (app uses BroadcastChannel to prevent two tabs from running simultaneously)
|
||||
await tab1.goto('about:blank');
|
||||
|
||||
const tab2 = await sharedContext.newPage();
|
||||
tab2.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
|
|
@ -125,7 +132,10 @@ test.describe('@supersync Multi-Tab Same Account', () => {
|
|||
// ============ PHASE 5: Tab 1 reloads and verifies ============
|
||||
console.log('[MultiTab] Phase 5: Tab 1 reloads');
|
||||
|
||||
await tab1.reload();
|
||||
// Navigate Tab 2 away before reloading Tab 1
|
||||
await tab2.goto('about:blank');
|
||||
|
||||
await tab1.goto(appUrl);
|
||||
await waitForAppReady(tab1);
|
||||
await tab1.waitForTimeout(2000);
|
||||
|
||||
|
|
@ -137,6 +147,9 @@ test.describe('@supersync Multi-Tab Same Account', () => {
|
|||
// ============ PHASE 6: External Client C verifies consistent state ============
|
||||
console.log('[MultiTab] Phase 6: External Client C verifies');
|
||||
|
||||
// Navigate Tab 1 away before creating external client
|
||||
await tab1.goto('about:blank');
|
||||
|
||||
clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import {
|
|||
* Tests graceful recovery when auth token returns 401 during sync.
|
||||
* Uses Playwright route interception to simulate expired token responses.
|
||||
*
|
||||
* After a 401, the app clears stored credentials and shows a "Configure" snackbar.
|
||||
* Recovery requires re-entering credentials via the sync config dialog.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - super-sync-server running on localhost:1901 with TEST_MODE=true
|
||||
* - Frontend running on localhost:4242
|
||||
|
|
@ -23,13 +26,13 @@ import {
|
|||
|
||||
test.describe('@supersync Token Expiry Recovery', () => {
|
||||
/**
|
||||
* Scenario: Upload gets 401, retry succeeds after route is restored
|
||||
* Scenario: Upload gets 401, re-authentication and retry succeeds
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client A creates task, sets up sync
|
||||
* 2. Intercept upload to return 401 once
|
||||
* 3. First sync fails with auth error
|
||||
* 4. Remove interception, retry sync
|
||||
* 3. First sync fails with auth error (app clears credentials)
|
||||
* 4. Remove interception, re-enter credentials via setupSuperSync
|
||||
* 5. Client B verifies task synced correctly
|
||||
*/
|
||||
test('recovers from 401 on upload and retries successfully', async ({
|
||||
|
|
@ -57,7 +60,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Intercept upload to return 401 once
|
||||
await clientA.page.route('**/api/sync/ops/**', async (route) => {
|
||||
await clientA.page.route('**/api/sync/ops*', async (route) => {
|
||||
if (state.return401 && route.request().method() === 'POST') {
|
||||
state.return401 = false;
|
||||
console.log('[TokenExpiry] Simulating 401 Unauthorized on upload');
|
||||
|
|
@ -83,10 +86,17 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
expect(state.return401).toBe(false);
|
||||
|
||||
// Remove interception
|
||||
await clientA.page.unroute('**/api/sync/ops/**');
|
||||
await clientA.page.unroute('**/api/sync/ops*');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
|
||||
// Retry sync - should succeed
|
||||
// After 401, the app clears credentials and may show a config dialog.
|
||||
// Close any open dialogs before re-entering credentials.
|
||||
await clientA.sync.ensureOverlaysClosed();
|
||||
|
||||
// Re-enter credentials (app cleared them on auth failure)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync again - should succeed now
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[TokenExpiry] Retry sync succeeded');
|
||||
|
||||
|
|
@ -105,7 +115,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
console.log('[TokenExpiry] ✓ Upload 401 recovery test PASSED');
|
||||
} finally {
|
||||
if (clientA) {
|
||||
await clientA.page.unroute('**/api/sync/ops/**').catch(() => {});
|
||||
await clientA.page.unroute('**/api/sync/ops*').catch(() => {});
|
||||
}
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
|
|
@ -113,13 +123,14 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* Scenario: Download gets 401, retry succeeds
|
||||
* Scenario: Download gets 401, re-authentication and retry succeeds
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client A creates and syncs task
|
||||
* 2. Client B sets up, intercept download to return 401 once
|
||||
* 3. First download fails
|
||||
* 4. Retry succeeds, Client B gets the task
|
||||
* 3. First download fails (app clears credentials)
|
||||
* 4. Remove interception, re-enter credentials
|
||||
* 5. Retry succeeds, Client B gets the task
|
||||
*/
|
||||
test('recovers from 401 on download and retries successfully', async ({
|
||||
browser,
|
||||
|
|
@ -151,7 +162,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Intercept download to return 401 once
|
||||
await clientB.page.route('**/api/sync/ops/**', async (route) => {
|
||||
await clientB.page.route('**/api/sync/ops*', async (route) => {
|
||||
if (state.return401 && route.request().method() === 'GET') {
|
||||
state.return401 = false;
|
||||
console.log('[TokenExpiry] Simulating 401 Unauthorized on download');
|
||||
|
|
@ -177,10 +188,17 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
expect(state.return401).toBe(false);
|
||||
|
||||
// Remove interception
|
||||
await clientB.page.unroute('**/api/sync/ops/**');
|
||||
await clientB.page.unroute('**/api/sync/ops*');
|
||||
await clientB.page.waitForTimeout(500);
|
||||
|
||||
// Retry - should succeed
|
||||
// After 401, the app clears credentials and may show a config dialog.
|
||||
// Close any open dialogs before re-entering credentials.
|
||||
await clientB.sync.ensureOverlaysClosed();
|
||||
|
||||
// Re-enter credentials (app cleared them on auth failure)
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync again - should succeed
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify task received
|
||||
|
|
@ -191,7 +209,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
|
|||
console.log('[TokenExpiry] ✓ Download 401 recovery test PASSED');
|
||||
} finally {
|
||||
if (clientB) {
|
||||
await clientB.page.unroute('**/api/sync/ops/**').catch(() => {});
|
||||
await clientB.page.unroute('**/api/sync/ops*').catch(() => {});
|
||||
}
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue