mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
fix(e2e): improve test stability for parallel execution
SuperSync page object improvements: - Add networkidle wait before interacting with sync dialog - Add explicit mat-dialog-container wait before form interaction - Add toBeAttached() assertions for element stability - Use toPass() with progressive backoff for dropdown interactions - Dismiss existing dropdown overlays before retrying - Add blur() calls in password change dialog for Angular validation - Add try-catch for fresh client dialog race condition Task detail tests: - Add blur() after time input fill to ensure Angular registers the change before clicking Save These changes fix intermittent failures when running E2E tests with multiple workers in parallel.
This commit is contained in:
parent
7436c20167
commit
f37110bbb5
32 changed files with 9056 additions and 8985 deletions
140
e2e/fixtures/supersync.fixture.ts
Normal file
140
e2e/fixtures/supersync.fixture.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { test as base } from '@playwright/test';
|
||||
import {
|
||||
isServerHealthy,
|
||||
generateTestRunId,
|
||||
type SimulatedE2EClient,
|
||||
} from '../utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* Extended test fixture for SuperSync E2E tests.
|
||||
*
|
||||
* Provides:
|
||||
* - Automatic server health check (skips tests if server unavailable)
|
||||
* - Unique testRunId per test for data isolation
|
||||
* - Client tracking for automatic cleanup
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
*
|
||||
* test.describe('@supersync My Tests', () => {
|
||||
* test('should sync', async ({ browser, baseURL, testRunId }) => {
|
||||
* // testRunId is automatically generated and unique per test
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface SuperSyncFixtures {
|
||||
/** Unique test run ID for data isolation (e.g., "1704067200000-0") */
|
||||
testRunId: string;
|
||||
/** Whether the SuperSync server is healthy and available */
|
||||
serverHealthy: boolean;
|
||||
}
|
||||
|
||||
// Cache server health check result per worker to avoid repeated checks
|
||||
let serverHealthyCache: boolean | null = null;
|
||||
|
||||
export const test = base.extend<SuperSyncFixtures>({
|
||||
/**
|
||||
* Generate a unique test run ID for this test.
|
||||
* Used to isolate test data between parallel test runs.
|
||||
*/
|
||||
testRunId: async ({}, use, testInfo) => {
|
||||
const id = generateTestRunId(testInfo.workerIndex);
|
||||
await use(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check server health once per worker and cache the result.
|
||||
* Tests are automatically skipped if the server is not healthy.
|
||||
*/
|
||||
serverHealthy: async ({}, use, testInfo) => {
|
||||
// Only check once per worker
|
||||
if (serverHealthyCache === null) {
|
||||
serverHealthyCache = await isServerHealthy();
|
||||
if (!serverHealthyCache) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the test if server is not healthy
|
||||
testInfo.skip(!serverHealthyCache, 'SuperSync server not running');
|
||||
|
||||
await use(serverHealthyCache);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a describe block that auto-checks server health.
|
||||
* Use this instead of manually adding beforeEach health checks.
|
||||
*
|
||||
* @param title - Test suite title (will be prefixed with @supersync)
|
||||
* @param fn - Test suite function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* supersyncDescribe('Basic Sync', () => {
|
||||
* test('should create and sync task', async ({ browser, baseURL, testRunId }) => {
|
||||
* // Server health already checked, testRunId ready
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const supersyncDescribe = (title: string, fn: () => void): void => {
|
||||
test.describe(`@supersync ${title}`, () => {
|
||||
// The serverHealthy fixture will auto-skip if server unavailable
|
||||
test.beforeEach(async ({ serverHealthy }) => {
|
||||
// This line ensures the fixture is evaluated and test is skipped if needed
|
||||
void serverHealthy;
|
||||
});
|
||||
fn();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track clients for automatic cleanup in afterEach.
|
||||
* Use with `trackClient` and `cleanupTrackedClients`.
|
||||
*/
|
||||
const trackedClients = new Map<string, SimulatedE2EClient[]>();
|
||||
|
||||
/**
|
||||
* Track a client for automatic cleanup.
|
||||
* Call this when creating clients so they're cleaned up even if the test fails.
|
||||
*
|
||||
* @param testId - A unique ID for this test (use testInfo.testId)
|
||||
* @param client - The client to track
|
||||
*/
|
||||
export const trackClient = (testId: string, client: SimulatedE2EClient): void => {
|
||||
if (!trackedClients.has(testId)) {
|
||||
trackedClients.set(testId, []);
|
||||
}
|
||||
trackedClients.get(testId)!.push(client);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up all tracked clients for a test.
|
||||
* Call this in afterEach or finally blocks.
|
||||
*
|
||||
* @param testId - The test ID used when tracking clients
|
||||
*/
|
||||
export const cleanupTrackedClients = async (testId: string): Promise<void> => {
|
||||
const clients = trackedClients.get(testId);
|
||||
if (clients) {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
if (!client.page.isClosed()) {
|
||||
await client.context.close();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
trackedClients.delete(testId);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-export expect for convenience
|
||||
export { expect } from '@playwright/test';
|
||||
|
|
@ -80,6 +80,11 @@ export class SuperSyncPage extends BasePage {
|
|||
await this.syncBtn.waitFor({ state: 'visible', timeout: syncBtnTimeout });
|
||||
}
|
||||
|
||||
// Wait for network to be idle - helps ensure Angular has finished loading
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {
|
||||
console.log('[SuperSyncPage] Network idle timeout (non-fatal)');
|
||||
});
|
||||
|
||||
// Retry loop for opening the sync settings dialog via right-click
|
||||
// Sometimes the right-click doesn't register, especially under load
|
||||
let dialogOpened = false;
|
||||
|
|
@ -97,8 +102,17 @@ export class SuperSyncPage extends BasePage {
|
|||
await this.syncBtn.click({ button: 'right' });
|
||||
|
||||
try {
|
||||
// Wait for dialog to be fully loaded - use shorter timeout to retry faster
|
||||
// Wait for dialog container first
|
||||
const dialogContainer = this.page.locator('mat-dialog-container');
|
||||
await dialogContainer.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Wait for the formly form to be rendered inside the dialog
|
||||
// Under heavy load, the dialog may appear but form rendering is delayed
|
||||
await this.providerSelect.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Ensure the element is actually attached and stable
|
||||
await expect(this.providerSelect).toBeAttached({ timeout: 2000 });
|
||||
|
||||
dialogOpened = true;
|
||||
console.log('[SuperSyncPage] Sync settings dialog opened successfully');
|
||||
break;
|
||||
|
|
@ -116,69 +130,69 @@ export class SuperSyncPage extends BasePage {
|
|||
// Last attempt with longer timeout
|
||||
console.log('[SuperSyncPage] Final attempt to open sync settings dialog...');
|
||||
await this.syncBtn.click({ button: 'right', force: true });
|
||||
const dialogContainer = this.page.locator('mat-dialog-container');
|
||||
await dialogContainer.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(this.providerSelect).toBeAttached({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// Additional wait for the element to be stable/interactive
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Retry loop for opening the dropdown
|
||||
let dropdownOpen = false;
|
||||
// Retry loop for opening the dropdown - use toPass() for more robust retries
|
||||
const superSyncOption = this.page
|
||||
.locator('mat-option')
|
||||
.filter({ hasText: 'SuperSync' });
|
||||
const dropdownPanel = this.page.locator('.mat-mdc-select-panel');
|
||||
const dropdownBackdrop = this.page.locator(
|
||||
'.cdk-overlay-backdrop.cdk-overlay-transparent-backdrop',
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Check if page is still open before each attempt
|
||||
if (this.page.isClosed()) {
|
||||
throw new Error('Page was closed during SuperSync setup');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use shorter timeout for click to fail fast and retry
|
||||
await this.providerSelect.click({ timeout: 5000 });
|
||||
// Wait for dropdown animation
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
if (await superSyncOption.isVisible()) {
|
||||
dropdownOpen = true;
|
||||
break;
|
||||
} else {
|
||||
console.log(`[SuperSyncPage] Dropdown not open attempt ${i + 1}, retrying...`);
|
||||
// If not visible, close any partial dropdown and wait before retry
|
||||
if (!this.page.isClosed()) {
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Check if page is closed before trying to recover
|
||||
if (this.page.isClosed()) {
|
||||
throw new Error('Page was closed during SuperSync setup');
|
||||
}
|
||||
console.log(`[SuperSyncPage] Error opening dropdown attempt ${i + 1}: ${e}`);
|
||||
// On click timeout, try to dismiss any blocking overlays
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dropdownOpen) {
|
||||
await expect(async () => {
|
||||
// Check if page is still open
|
||||
if (this.page.isClosed()) {
|
||||
throw new Error('Page was closed during SuperSync setup');
|
||||
}
|
||||
// Last ditch effort - force click
|
||||
console.log('[SuperSyncPage] Last attempt - force clicking provider select');
|
||||
await this.providerSelect.click({ force: true, timeout: 10000 });
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await superSyncOption.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await superSyncOption.click();
|
||||
// If a dropdown backdrop is showing, dismiss it first
|
||||
if (await dropdownBackdrop.isVisible()) {
|
||||
console.log('[SuperSyncPage] Dismissing existing dropdown overlay...');
|
||||
await this.page.keyboard.press('Escape');
|
||||
await dropdownBackdrop
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {});
|
||||
await this.page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Wait for the dropdown overlay to close
|
||||
await this.page.locator('.mat-mdc-select-panel').waitFor({ state: 'detached' });
|
||||
// Ensure the select is still attached (may have been re-rendered)
|
||||
await expect(this.providerSelect).toBeAttached({ timeout: 2000 });
|
||||
|
||||
// Click to open dropdown - use force to bypass any lingering overlays
|
||||
await this.providerSelect.click({ timeout: 3000, force: true });
|
||||
|
||||
// Wait for dropdown panel to appear
|
||||
await dropdownPanel.waitFor({ state: 'visible', timeout: 3000 });
|
||||
|
||||
// Verify the option is visible
|
||||
await expect(superSyncOption).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({
|
||||
timeout: 30000,
|
||||
intervals: [500, 1000, 1500, 2000, 2500, 3000],
|
||||
});
|
||||
|
||||
// Click the SuperSync option with retry to handle dropdown closing issues
|
||||
await expect(async () => {
|
||||
// Check if option is visible - if not, dropdown may have closed unexpectedly
|
||||
if (await superSyncOption.isVisible()) {
|
||||
await superSyncOption.click({ timeout: 2000 });
|
||||
}
|
||||
|
||||
// Wait for dropdown panel to close
|
||||
await dropdownPanel.waitFor({ state: 'detached', timeout: 3000 });
|
||||
}).toPass({
|
||||
timeout: 15000,
|
||||
intervals: [500, 1000, 1500, 2000],
|
||||
});
|
||||
|
||||
// Fill Access Token first (it's outside the collapsible)
|
||||
await this.accessTokenInput.waitFor({ state: 'visible' });
|
||||
|
|
@ -327,8 +341,15 @@ export class SuperSyncPage extends BasePage {
|
|||
// Check if fresh client confirmation dialog appeared
|
||||
if (await this.freshClientDialog.isVisible()) {
|
||||
console.log('[SuperSyncPage] Fresh client dialog detected, confirming...');
|
||||
await this.freshClientConfirmBtn.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
try {
|
||||
await this.freshClientConfirmBtn.click({ timeout: 2000 });
|
||||
await this.page.waitForTimeout(500);
|
||||
} catch (e) {
|
||||
// Dialog may have auto-closed or been detached - that's OK
|
||||
console.log(
|
||||
'[SuperSyncPage] Fresh client dialog closed before click completed',
|
||||
);
|
||||
}
|
||||
stableCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -490,10 +511,16 @@ export class SuperSyncPage extends BasePage {
|
|||
);
|
||||
|
||||
await newPasswordInput.fill(newPassword);
|
||||
await newPasswordInput.blur(); // Trigger ngModel update
|
||||
await confirmPasswordInput.fill(newPassword);
|
||||
await confirmPasswordInput.blur(); // Trigger ngModel update
|
||||
|
||||
// Click the confirm button
|
||||
// Wait for Angular to process form validation
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Click the confirm button - wait for it to be enabled first
|
||||
const confirmBtn = changePasswordDialog.locator('button[color="warn"]');
|
||||
await expect(confirmBtn).toBeEnabled({ timeout: 5000 });
|
||||
await confirmBtn.click();
|
||||
|
||||
// Wait for the dialog to close (password change complete)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
|
|
@ -6,7 +6,6 @@ import {
|
|||
closeClient,
|
||||
deleteTestUser,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -24,25 +23,7 @@ import {
|
|||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-account-deletion.spec.ts
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Account Deletion', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Account Deletion', () => {
|
||||
/**
|
||||
* Scenario: Client detects auth error after account deletion
|
||||
*
|
||||
|
|
@ -55,58 +36,57 @@ base.describe('@supersync SuperSync Account Deletion', () => {
|
|||
* 3. Client tries to sync again
|
||||
* 4. Verify client shows error (snackbar or error icon)
|
||||
*/
|
||||
base(
|
||||
'Client shows error after account deletion',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
test('Client shows error after account deletion', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
// 1. Create user and set up client
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
console.log(`[Account-Deletion] Created user ${user.userId}`);
|
||||
try {
|
||||
// 1. Create user and set up client
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
console.log(`[Account-Deletion] Created user ${user.userId}`);
|
||||
|
||||
client = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
client = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create a task and sync to verify everything works
|
||||
const taskName = `Pre-Delete-Task-${testRunId}`;
|
||||
await client.workView.addTask(taskName);
|
||||
await client.sync.syncAndWait();
|
||||
await waitForTask(client.page, taskName);
|
||||
console.log('[Account-Deletion] ✓ Initial sync successful');
|
||||
// Create a task and sync to verify everything works
|
||||
const taskName = `Pre-Delete-Task-${testRunId}`;
|
||||
await client.workView.addTask(taskName);
|
||||
await client.sync.syncAndWait();
|
||||
await waitForTask(client.page, taskName);
|
||||
console.log('[Account-Deletion] ✓ Initial sync successful');
|
||||
|
||||
// 2. Delete the user account on the server
|
||||
await deleteTestUser(user.userId);
|
||||
console.log(`[Account-Deletion] ✓ Deleted user ${user.userId}`);
|
||||
// 2. Delete the user account on the server
|
||||
await deleteTestUser(user.userId);
|
||||
console.log(`[Account-Deletion] ✓ Deleted user ${user.userId}`);
|
||||
|
||||
// 3. Try to sync again - should fail with auth error
|
||||
await client.sync.triggerSync();
|
||||
// 3. Try to sync again - should fail with auth error
|
||||
await client.sync.triggerSync();
|
||||
|
||||
// 4. Wait for error indicator to appear
|
||||
// The sync button should show error state (sync_problem icon),
|
||||
// or a snackbar with error message should appear
|
||||
const errorIndicator = client.page.locator(
|
||||
// Error snackbar with any of these indicators
|
||||
'simple-snack-bar:has-text("Configure"), ' +
|
||||
'simple-snack-bar:has-text("error"), ' +
|
||||
'simple-snack-bar:has-text("401"), ' +
|
||||
'simple-snack-bar:has-text("403"), ' +
|
||||
'simple-snack-bar:has-text("Authentication"), ' +
|
||||
// Or sync button showing error icon
|
||||
'.sync-btn mat-icon:has-text("sync_problem")',
|
||||
);
|
||||
// 4. Wait for error indicator to appear
|
||||
// The sync button should show error state (sync_problem icon),
|
||||
// or a snackbar with error message should appear
|
||||
const errorIndicator = client.page.locator(
|
||||
// Error snackbar with any of these indicators
|
||||
'simple-snack-bar:has-text("Configure"), ' +
|
||||
'simple-snack-bar:has-text("error"), ' +
|
||||
'simple-snack-bar:has-text("401"), ' +
|
||||
'simple-snack-bar:has-text("403"), ' +
|
||||
'simple-snack-bar:has-text("Authentication"), ' +
|
||||
// Or sync button showing error icon
|
||||
'.sync-btn mat-icon:has-text("sync_problem")',
|
||||
);
|
||||
|
||||
await expect(errorIndicator.first()).toBeVisible({ timeout: 20000 });
|
||||
console.log('[Account-Deletion] ✓ Client detected auth failure');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
await expect(errorIndicator.first()).toBeVisible({ timeout: 20000 });
|
||||
console.log('[Account-Deletion] ✓ Client detected auth failure');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Client can reconfigure after account deletion and re-registration
|
||||
|
|
@ -121,70 +101,69 @@ base.describe('@supersync SuperSync Account Deletion', () => {
|
|||
* 4. Reconfigure client with user B credentials
|
||||
* 5. Verify sync works with new account
|
||||
*/
|
||||
base(
|
||||
'Client can reconfigure with new account after deletion',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
test('Client can reconfigure with new account after deletion', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
// 1. Create first user and sync
|
||||
const userA = await createTestUser(`${testRunId}-A`);
|
||||
const configA = getSuperSyncConfig(userA);
|
||||
console.log(`[Reconfigure] Created user A: ${userA.userId}`);
|
||||
try {
|
||||
// 1. Create first user and sync
|
||||
const userA = await createTestUser(`${testRunId}-A`);
|
||||
const configA = getSuperSyncConfig(userA);
|
||||
console.log(`[Reconfigure] Created user A: ${userA.userId}`);
|
||||
|
||||
client = await createSimulatedClient(browser, appUrl, 'Client', testRunId);
|
||||
await client.sync.setupSuperSync(configA);
|
||||
client = await createSimulatedClient(browser, appUrl, 'Client', testRunId);
|
||||
await client.sync.setupSuperSync(configA);
|
||||
|
||||
const task1 = `UserA-Task-${testRunId}`;
|
||||
await client.workView.addTask(task1);
|
||||
await client.sync.syncAndWait();
|
||||
console.log('[Reconfigure] ✓ User A sync successful');
|
||||
const task1 = `UserA-Task-${testRunId}`;
|
||||
await client.workView.addTask(task1);
|
||||
await client.sync.syncAndWait();
|
||||
console.log('[Reconfigure] ✓ User A sync successful');
|
||||
|
||||
// 2. Delete user A account
|
||||
await deleteTestUser(userA.userId);
|
||||
console.log('[Reconfigure] ✓ Deleted user A');
|
||||
// 2. Delete user A account
|
||||
await deleteTestUser(userA.userId);
|
||||
console.log('[Reconfigure] ✓ Deleted user A');
|
||||
|
||||
// 3. Create user B (new account)
|
||||
const userB = await createTestUser(`${testRunId}-B`);
|
||||
const configB = getSuperSyncConfig(userB);
|
||||
console.log(`[Reconfigure] ✓ Created user B: ${userB.userId}`);
|
||||
// 3. Create user B (new account)
|
||||
const userB = await createTestUser(`${testRunId}-B`);
|
||||
const configB = getSuperSyncConfig(userB);
|
||||
console.log(`[Reconfigure] ✓ Created user B: ${userB.userId}`);
|
||||
|
||||
// 4. Reconfigure client with user B credentials
|
||||
// Open sync settings via right-click
|
||||
await client.sync.syncBtn.click({ button: 'right' });
|
||||
await client.sync.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
|
||||
// 4. Reconfigure client with user B credentials
|
||||
// Open sync settings via right-click
|
||||
await client.sync.syncBtn.click({ button: 'right' });
|
||||
await client.sync.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Update access token
|
||||
await client.sync.accessTokenInput.clear();
|
||||
await client.sync.accessTokenInput.fill(configB.accessToken);
|
||||
// Update access token
|
||||
await client.sync.accessTokenInput.clear();
|
||||
await client.sync.accessTokenInput.fill(configB.accessToken);
|
||||
|
||||
// Save configuration
|
||||
await client.sync.saveBtn.click();
|
||||
await client.page
|
||||
.locator('mat-dialog-container')
|
||||
.waitFor({ state: 'detached', timeout: 5000 })
|
||||
.catch(() => {});
|
||||
// Save configuration
|
||||
await client.sync.saveBtn.click();
|
||||
await client.page
|
||||
.locator('mat-dialog-container')
|
||||
.waitFor({ state: 'detached', timeout: 5000 })
|
||||
.catch(() => {});
|
||||
|
||||
// Wait for any dialogs to settle
|
||||
await client.page.waitForTimeout(1000);
|
||||
// Wait for any dialogs to settle
|
||||
await client.page.waitForTimeout(1000);
|
||||
|
||||
// 5. Sync and verify it works with user B
|
||||
await client.sync.syncAndWait();
|
||||
// 5. Sync and verify it works with user B
|
||||
await client.sync.syncAndWait();
|
||||
|
||||
// Create a new task with user B
|
||||
const task2 = `UserB-Task-${testRunId}`;
|
||||
await client.workView.addTask(task2);
|
||||
await client.sync.syncAndWait();
|
||||
// Create a new task with user B
|
||||
const task2 = `UserB-Task-${testRunId}`;
|
||||
await client.workView.addTask(task2);
|
||||
await client.sync.syncAndWait();
|
||||
|
||||
// Verify task exists
|
||||
await waitForTask(client.page, task2);
|
||||
console.log('[Reconfigure] ✓ User B sync successful - reconfiguration complete');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Verify task exists
|
||||
await waitForTask(client.page, task2);
|
||||
console.log('[Reconfigure] ✓ User B sync successful - reconfiguration complete');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -21,35 +20,14 @@ import {
|
|||
* as E2E tests for these scenarios are too fragile due to UI timing issues.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Advanced Edge Cases', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Advanced Edge Cases', () => {
|
||||
/**
|
||||
* Bulk Operations: Creating many tasks at once
|
||||
*
|
||||
* Verifies that creating multiple tasks in quick succession
|
||||
* syncs correctly without data loss.
|
||||
*/
|
||||
base('Bulk task creation syncs correctly', async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
test('Bulk task creation syncs correctly', async ({ browser, baseURL, testRunId }) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
|
|
@ -57,10 +35,10 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => {
|
|||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create 10 tasks in rapid succession
|
||||
|
|
@ -99,86 +77,80 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => {
|
|||
* Simulates a client that was offline for a period while
|
||||
* other clients made many changes, then reconnects.
|
||||
*/
|
||||
base(
|
||||
'Stale client reconnection after many changes',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Stale client reconnection after many changes', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Client A starts syncing
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Client A starts syncing
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Client B joins initially
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
// Client B joins initially
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// Initial sync
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Create initial task
|
||||
const initialTask = `Initial-${testRunId}`;
|
||||
await clientA.workView.addTask(initialTask);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// Create initial task
|
||||
const initialTask = `Initial-${testRunId}`;
|
||||
await clientA.workView.addTask(initialTask);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify both have it
|
||||
await waitForTask(clientA.page, initialTask);
|
||||
await waitForTask(clientB.page, initialTask);
|
||||
// Verify both have it
|
||||
await waitForTask(clientA.page, initialTask);
|
||||
await waitForTask(clientB.page, initialTask);
|
||||
|
||||
// Now Client B goes "offline" (we just don't sync)
|
||||
// Client A makes many changes
|
||||
const offlineTask1 = `WhileOffline1-${testRunId}`;
|
||||
const offlineTask2 = `WhileOffline2-${testRunId}`;
|
||||
const offlineTask3 = `WhileOffline3-${testRunId}`;
|
||||
// Now Client B goes "offline" (we just don't sync)
|
||||
// Client A makes many changes
|
||||
const offlineTask1 = `WhileOffline1-${testRunId}`;
|
||||
const offlineTask2 = `WhileOffline2-${testRunId}`;
|
||||
const offlineTask3 = `WhileOffline3-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(offlineTask1);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.workView.addTask(offlineTask1);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientA.workView.addTask(offlineTask2);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.workView.addTask(offlineTask2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientA.workView.addTask(offlineTask3);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.workView.addTask(offlineTask3);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Mark initial task as done
|
||||
const initialTaskLocator = clientA.page.locator(
|
||||
`task:has-text("${initialTask}")`,
|
||||
);
|
||||
await initialTaskLocator.hover();
|
||||
await initialTaskLocator.locator('.task-done-btn').click();
|
||||
await clientA.sync.syncAndWait();
|
||||
// Mark initial task as done
|
||||
const initialTaskLocator = clientA.page.locator(`task:has-text("${initialTask}")`);
|
||||
await initialTaskLocator.hover();
|
||||
await initialTaskLocator.locator('.task-done-btn').click();
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client B "reconnects" (syncs after missing many updates)
|
||||
await clientB.sync.syncAndWait();
|
||||
// Client B "reconnects" (syncs after missing many updates)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has all the changes
|
||||
await waitForTask(clientB.page, offlineTask1);
|
||||
await waitForTask(clientB.page, offlineTask2);
|
||||
await waitForTask(clientB.page, offlineTask3);
|
||||
// Verify B has all the changes
|
||||
await waitForTask(clientB.page, offlineTask1);
|
||||
await waitForTask(clientB.page, offlineTask2);
|
||||
await waitForTask(clientB.page, offlineTask3);
|
||||
|
||||
// Initial task should be marked as done
|
||||
// Use more specific locator to target the done task (avoids matching both backlog and done section)
|
||||
const initialTaskB = clientB.page.locator(
|
||||
`task.isDone:has-text("${initialTask}")`,
|
||||
);
|
||||
await expect(initialTaskB).toBeVisible();
|
||||
// Initial task should be marked as done
|
||||
// Use more specific locator to target the done task (avoids matching both backlog and done section)
|
||||
const initialTaskB = clientB.page.locator(`task.isDone:has-text("${initialTask}")`);
|
||||
await expect(initialTaskB).toBeVisible();
|
||||
|
||||
console.log('[Stale] Stale client reconnected and received all changes');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Stale] Stale client reconnected and received all changes');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Data Integrity: Special characters in task names
|
||||
|
|
@ -186,52 +158,51 @@ base.describe('@supersync SuperSync Advanced Edge Cases', () => {
|
|||
* Verifies that tasks with quotes and special characters
|
||||
* sync correctly without data corruption.
|
||||
*/
|
||||
base(
|
||||
'Special characters in task names sync correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Special characters in task names sync correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create tasks with special characters (avoiding complex unicode that may render differently)
|
||||
const task1 = `Task-quotes-${testRunId}`;
|
||||
const task2 = `Task-ampersand-${testRunId}`;
|
||||
const task3 = `Task-numbers-123-${testRunId}`;
|
||||
// Create tasks with special characters (avoiding complex unicode that may render differently)
|
||||
const task1 = `Task-quotes-${testRunId}`;
|
||||
const task2 = `Task-ampersand-${testRunId}`;
|
||||
const task3 = `Task-numbers-123-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.workView.addTask(task3);
|
||||
|
||||
// Sync A -> B
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// Sync A -> B
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify all tasks synced correctly
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
// Verify all tasks synced correctly
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
// Verify count
|
||||
const taskLocator = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const count = await taskLocator.count();
|
||||
expect(count).toBe(3);
|
||||
// Verify count
|
||||
const taskLocator = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const count = await taskLocator.count();
|
||||
expect(count).toBe(3);
|
||||
|
||||
console.log('[SpecialChars] Special characters synced correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[SpecialChars] Special characters synced correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
getTaskElement,
|
||||
markTaskDone,
|
||||
deleteTask,
|
||||
countTasks,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskNotVisible } from '../../utils/supersync-assertions';
|
||||
|
||||
/**
|
||||
* SuperSync Advanced E2E Tests
|
||||
|
|
@ -17,25 +20,7 @@ import {
|
|||
* and error conditions.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Advanced', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Advanced', () => {
|
||||
/**
|
||||
* Scenario: Large Dataset Sync
|
||||
*
|
||||
|
|
@ -47,11 +32,13 @@ base.describe('@supersync SuperSync Advanced', () => {
|
|||
* 3. Client B syncs (download)
|
||||
* 4. Verify all 50 tasks exist on Client B
|
||||
*/
|
||||
base('Large dataset sync (50 tasks)', async ({ browser, baseURL }, testInfo) => {
|
||||
test('Large dataset sync (50 tasks)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
// Increase timeout for this test as creating/syncing 50 tasks takes time
|
||||
testInfo.setTimeout(120000);
|
||||
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const TASK_COUNT = 50;
|
||||
|
|
@ -129,109 +116,105 @@ base.describe('@supersync SuperSync Advanced', () => {
|
|||
* 7. Client A syncs (download)
|
||||
* 8. Verify Client A sees "Tag A" removed
|
||||
*/
|
||||
base(
|
||||
'Tag Management: Add/Remove tags syncs correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const taskName = `Task-Tag-${testRunId}`;
|
||||
const tagName = `TagA-${testRunId}`;
|
||||
test('Tag Management: Add/Remove tags syncs correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const taskName = `Task-Tag-${testRunId}`;
|
||||
const tagName = `TagA-${testRunId}`;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Client A creates task with tag
|
||||
// We type "#TagName" to trigger tag creation
|
||||
// skipClose=true because we expect a dialog (create tag) which blocks closing
|
||||
await clientA.workView.addTask(`${taskName} #${tagName}`, true);
|
||||
// Client A creates task with tag
|
||||
// We type "#TagName" to trigger tag creation
|
||||
// skipClose=true because we expect a dialog (create tag) which blocks closing
|
||||
await clientA.workView.addTask(`${taskName} #${tagName}`, true);
|
||||
|
||||
// Handle tag creation confirmation dialog if it appears
|
||||
const confirmBtn = clientA.page.locator('button[e2e="confirmBtn"]');
|
||||
if (await confirmBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
|
||||
// Close the add task bar manually if it's still open
|
||||
if (await clientA.workView.backdrop.isVisible().catch(() => false)) {
|
||||
await clientA.workView.backdrop.click();
|
||||
}
|
||||
|
||||
// Wait for task
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Verify tag is present on Client A
|
||||
await expect(
|
||||
clientA.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).toBeVisible();
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Setup Client B
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B (Download)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has task and tag
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(
|
||||
clientB.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).toBeVisible();
|
||||
|
||||
// Client B removes tag
|
||||
// Right click task -> Toggle Tags -> Click Tag
|
||||
const taskB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskB.click({ button: 'right' });
|
||||
|
||||
// Click "Toggle Tags" in context menu (using text match or class if available)
|
||||
// Based on i18n: T.F.TASK.CMP.TOGGLE_TAGS -> 'Toggle Tags' (en)
|
||||
const toggleTagsItem = clientB.page
|
||||
.locator('.mat-mdc-menu-item')
|
||||
.filter({ hasText: 'Toggle Tags' });
|
||||
await toggleTagsItem.click();
|
||||
|
||||
// Wait for tag list submenu
|
||||
// Exclude nav-link items (which might be "Go to Project" links) to avoid strict mode violation
|
||||
const tagItem = clientB.page.locator(
|
||||
`.mat-mdc-menu-item:not(.nav-link):has-text("${tagName}")`,
|
||||
);
|
||||
await tagItem.waitFor({ state: 'visible' });
|
||||
await tagItem.click();
|
||||
|
||||
// Close menu (press Escape)
|
||||
await clientB.page.keyboard.press('Escape');
|
||||
|
||||
// Verify tag is gone on B
|
||||
await expect(
|
||||
clientB.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Sync B (Upload removal)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Sync A (Download removal)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify tag is gone on A
|
||||
await expect(
|
||||
clientA.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
console.log('[TagTest] ✓ Tag added and removed successfully across clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Handle tag creation confirmation dialog if it appears
|
||||
const confirmBtn = clientA.page.locator('button[e2e="confirmBtn"]');
|
||||
if (await confirmBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Close the add task bar manually if it's still open
|
||||
if (await clientA.workView.backdrop.isVisible().catch(() => false)) {
|
||||
await clientA.workView.backdrop.click();
|
||||
}
|
||||
|
||||
// Wait for task
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Verify tag is present on Client A
|
||||
await expect(clientA.page.locator(`task tag:has-text("${tagName}")`)).toBeVisible();
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Setup Client B
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B (Download)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has task and tag
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task tag:has-text("${tagName}")`)).toBeVisible();
|
||||
|
||||
// Client B removes tag
|
||||
// Right click task -> Toggle Tags -> Click Tag
|
||||
const taskB = getTaskElement(clientB, taskName);
|
||||
await taskB.click({ button: 'right' });
|
||||
|
||||
// Click "Toggle Tags" in context menu (using text match or class if available)
|
||||
// Based on i18n: T.F.TASK.CMP.TOGGLE_TAGS -> 'Toggle Tags' (en)
|
||||
const toggleTagsItem = clientB.page
|
||||
.locator('.mat-mdc-menu-item')
|
||||
.filter({ hasText: 'Toggle Tags' });
|
||||
await toggleTagsItem.click();
|
||||
|
||||
// Wait for tag list submenu
|
||||
// Exclude nav-link items (which might be "Go to Project" links) to avoid strict mode violation
|
||||
const tagItem = clientB.page.locator(
|
||||
`.mat-mdc-menu-item:not(.nav-link):has-text("${tagName}")`,
|
||||
);
|
||||
await tagItem.waitFor({ state: 'visible' });
|
||||
await tagItem.click();
|
||||
|
||||
// Close menu (press Escape)
|
||||
await clientB.page.keyboard.press('Escape');
|
||||
|
||||
// Verify tag is gone on B
|
||||
await expect(
|
||||
clientB.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Sync B (Upload removal)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Sync A (Download removal)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify tag is gone on A
|
||||
await expect(
|
||||
clientA.page.locator(`task tag:has-text("${tagName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
console.log('[TagTest] ✓ Tag added and removed successfully across clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Concurrent Delete vs. Update
|
||||
|
|
@ -250,81 +233,68 @@ base.describe('@supersync SuperSync Advanced', () => {
|
|||
* 5. Client B syncs (update conflicts with deletion)
|
||||
* 6. Verify final state is consistent (both clients agree)
|
||||
*/
|
||||
base(
|
||||
'Concurrent Delete vs. Update (Conflict Handling)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const taskName = `Task-Conflict-${testRunId}`;
|
||||
test('Concurrent Delete vs. Update (Conflict Handling)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const taskName = `Task-Conflict-${testRunId}`;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create initial task on A and sync to B
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
// Create initial task on A and sync to B
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
// Client A deletes the task
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocatorA.click({ button: 'right' });
|
||||
await clientA.page
|
||||
.locator('.mat-mdc-menu-item')
|
||||
.filter({ hasText: 'Delete' })
|
||||
.click();
|
||||
// Client A deletes the task
|
||||
await deleteTask(clientA, taskName);
|
||||
await expectTaskNotVisible(clientA, taskName);
|
||||
|
||||
// Handle confirmation dialog if present
|
||||
const dialogA = clientA.page.locator('dialog-confirm');
|
||||
if (await dialogA.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await dialogA.locator('button[type=submit]').click();
|
||||
}
|
||||
await expect(taskLocatorA).not.toBeVisible();
|
||||
// Client B updates the task (marks as done - reliable operation)
|
||||
await markTaskDone(clientB, taskName);
|
||||
|
||||
// Client B updates the task (marks as done - reliable operation)
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.task-done-btn').click();
|
||||
// A syncs first (uploads DELETE)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// A syncs first (uploads DELETE)
|
||||
await clientA.sync.syncAndWait();
|
||||
// B syncs (downloads DELETE, has local UPDATE) -> potential conflict
|
||||
// The conflict resolution may show a dialog or auto-resolve
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// B syncs (downloads DELETE, has local UPDATE) -> potential conflict
|
||||
// The conflict resolution may show a dialog or auto-resolve
|
||||
await clientB.sync.syncAndWait();
|
||||
// Final sync to converge
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Final sync to converge
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// Verify consistent state
|
||||
// Both clients should have the same view (either both have task or neither)
|
||||
const hasTaskA =
|
||||
(await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0;
|
||||
const hasTaskB =
|
||||
(await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0;
|
||||
|
||||
// Verify consistent state
|
||||
// Both clients should have the same view (either both have task or neither)
|
||||
const hasTaskA =
|
||||
(await clientA.page.locator(`task:has-text("${taskName}")`).count()) > 0;
|
||||
const hasTaskB =
|
||||
(await clientB.page.locator(`task:has-text("${taskName}")`).count()) > 0;
|
||||
// State should be consistent (doesn't matter which wins, just that they agree)
|
||||
expect(hasTaskA).toBe(hasTaskB);
|
||||
|
||||
// State should be consistent (doesn't matter which wins, just that they agree)
|
||||
expect(hasTaskA).toBe(hasTaskB);
|
||||
|
||||
console.log(
|
||||
`[ConflictTest] ✓ Concurrent Delete/Update resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`,
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`[ConflictTest] ✓ Concurrent Delete/Update resolved consistently (task ${hasTaskA ? 'restored' : 'deleted'})`,
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -30,10 +29,6 @@ import {
|
|||
* parentId, not just the subTaskIds from the operation payload.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to add a subtask to a task using keyboard shortcut
|
||||
* This is more reliable than using the context menu
|
||||
|
|
@ -130,21 +125,7 @@ const checkForOrphanSubtaskError = (consoleMessages: string[]): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
base.describe('@supersync Archive Subtasks Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Archive Subtasks Sync', () => {
|
||||
/**
|
||||
* Scenario: Archive parent with subtasks syncs correctly
|
||||
*
|
||||
|
|
@ -160,91 +141,89 @@ base.describe('@supersync Archive Subtasks Sync', () => {
|
|||
* 7. Verify Client B has no tasks in Today view (all archived)
|
||||
* 8. Verify no orphan subtask errors in console
|
||||
*/
|
||||
base(
|
||||
'Archive parent task with subtasks syncs without leaving orphans',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrors: string[] = [];
|
||||
test('Archive parent task with subtasks syncs without leaving orphans', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Setup Client B with console monitoring
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client B with console monitoring
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Client A creates parent task
|
||||
const parentName = `Parent-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await waitForTask(clientA.page, parentName);
|
||||
console.log('[ArchiveSubtasks] Created parent task');
|
||||
// 1. Client A creates parent task
|
||||
const parentName = `Parent-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await waitForTask(clientA.page, parentName);
|
||||
console.log('[ArchiveSubtasks] Created parent task');
|
||||
|
||||
// 2. Client A adds subtasks
|
||||
const subtask1Name = `Sub1-${testRunId}`;
|
||||
const subtask2Name = `Sub2-${testRunId}`;
|
||||
await addSubtask(clientA.page, parentName, subtask1Name);
|
||||
console.log('[ArchiveSubtasks] Added subtask 1');
|
||||
await addSubtask(clientA.page, parentName, subtask2Name);
|
||||
console.log('[ArchiveSubtasks] Added subtask 2');
|
||||
// 2. Client A adds subtasks
|
||||
const subtask1Name = `Sub1-${testRunId}`;
|
||||
const subtask2Name = `Sub2-${testRunId}`;
|
||||
await addSubtask(clientA.page, parentName, subtask1Name);
|
||||
console.log('[ArchiveSubtasks] Added subtask 1');
|
||||
await addSubtask(clientA.page, parentName, subtask2Name);
|
||||
console.log('[ArchiveSubtasks] Added subtask 2');
|
||||
|
||||
// 3. Mark subtasks as done first (parent requires all subtasks done)
|
||||
await markTaskDone(clientA.page, subtask1Name, true);
|
||||
console.log('[ArchiveSubtasks] Marked subtask 1 as done');
|
||||
await markTaskDone(clientA.page, subtask2Name, true);
|
||||
console.log('[ArchiveSubtasks] Marked subtask 2 as done');
|
||||
// 3. Mark subtasks as done first (parent requires all subtasks done)
|
||||
await markTaskDone(clientA.page, subtask1Name, true);
|
||||
console.log('[ArchiveSubtasks] Marked subtask 1 as done');
|
||||
await markTaskDone(clientA.page, subtask2Name, true);
|
||||
console.log('[ArchiveSubtasks] Marked subtask 2 as done');
|
||||
|
||||
// 4. Now mark parent as done
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
console.log('[ArchiveSubtasks] Marked parent as done');
|
||||
// 4. Now mark parent as done
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
console.log('[ArchiveSubtasks] Marked parent as done');
|
||||
|
||||
// 4. Archive via Daily Summary
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[ArchiveSubtasks] Archived tasks');
|
||||
// 4. Archive via Daily Summary
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[ArchiveSubtasks] Archived tasks');
|
||||
|
||||
// 5. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ArchiveSubtasks] Client A synced');
|
||||
// 5. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ArchiveSubtasks] Client A synced');
|
||||
|
||||
// 6. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[ArchiveSubtasks] Client B synced');
|
||||
// 6. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[ArchiveSubtasks] Client B synced');
|
||||
|
||||
// Wait for UI to settle
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Wait for UI to settle
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// 7. Verify Client B has no tasks with testRunId in Today view
|
||||
// (they should all be archived)
|
||||
const tasksOnB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCount = await tasksOnB.count();
|
||||
// 7. Verify Client B has no tasks with testRunId in Today view
|
||||
// (they should all be archived)
|
||||
const tasksOnB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCount = await tasksOnB.count();
|
||||
|
||||
expect(taskCount).toBe(0);
|
||||
console.log('[ArchiveSubtasks] Verified no tasks on Client B (all archived)');
|
||||
expect(taskCount).toBe(0);
|
||||
console.log('[ArchiveSubtasks] Verified no tasks on Client B (all archived)');
|
||||
|
||||
// 8. Verify no orphan subtask errors
|
||||
const hasOrphanError = checkForOrphanSubtaskError(consoleErrors);
|
||||
expect(hasOrphanError).toBe(false);
|
||||
// 8. Verify no orphan subtask errors
|
||||
const hasOrphanError = checkForOrphanSubtaskError(consoleErrors);
|
||||
expect(hasOrphanError).toBe(false);
|
||||
|
||||
console.log('[ArchiveSubtasks] ✓ Archive with subtasks synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[ArchiveSubtasks] ✓ Archive with subtasks synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Multiple subtask levels archive correctly
|
||||
|
|
@ -260,91 +239,89 @@ base.describe('@supersync Archive Subtasks Sync', () => {
|
|||
* 6. Both clients sync
|
||||
* 7. Verify both clients have no orphan tasks
|
||||
*/
|
||||
base(
|
||||
'Multiple subtasks archive and sync without orphans',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrorsA: string[] = [];
|
||||
const consoleErrorsB: string[] = [];
|
||||
test('Multiple subtasks archive and sync without orphans', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrorsA: string[] = [];
|
||||
const consoleErrorsB: string[] = [];
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup clients with console monitoring
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
clientA.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsA.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsB.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Create parent task
|
||||
const parentName = `MultiSub-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await waitForTask(clientA.page, parentName);
|
||||
|
||||
// 2. Add multiple subtasks
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await addSubtask(clientA.page, parentName, `Sub${i}-${testRunId}`);
|
||||
// Setup clients with console monitoring
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
clientA.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsA.push(msg.text());
|
||||
}
|
||||
console.log('[MultiSubtask] Created parent with 3 subtasks');
|
||||
});
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 3. Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 4. Sync B and verify
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, parentName);
|
||||
console.log('[MultiSubtask] Client B received tasks');
|
||||
|
||||
// 5. Client A marks all subtasks and parent done
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await markTaskDone(clientA.page, `Sub${i}-${testRunId}`, true);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsB.push(msg.text());
|
||||
}
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[MultiSubtask] Client A archived all');
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 6. Both clients sync
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// 1. Create parent task
|
||||
const parentName = `MultiSub-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await waitForTask(clientA.page, parentName);
|
||||
|
||||
// 7. Verify no orphan tasks
|
||||
const tasksOnA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const tasksOnB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
|
||||
expect(tasksOnA).toBe(0);
|
||||
expect(tasksOnB).toBe(0);
|
||||
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsA)).toBe(false);
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false);
|
||||
|
||||
console.log('[MultiSubtask] ✓ Multiple subtasks archived without orphans');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// 2. Add multiple subtasks
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await addSubtask(clientA.page, parentName, `Sub${i}-${testRunId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[MultiSubtask] Created parent with 3 subtasks');
|
||||
|
||||
// 3. Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 4. Sync B and verify
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, parentName);
|
||||
console.log('[MultiSubtask] Client B received tasks');
|
||||
|
||||
// 5. Client A marks all subtasks and parent done
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await markTaskDone(clientA.page, `Sub${i}-${testRunId}`, true);
|
||||
}
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[MultiSubtask] Client A archived all');
|
||||
|
||||
// 6. Both clients sync
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// 7. Verify no orphan tasks
|
||||
const tasksOnA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const tasksOnB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
|
||||
expect(tasksOnA).toBe(0);
|
||||
expect(tasksOnB).toBe(0);
|
||||
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsA)).toBe(false);
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false);
|
||||
|
||||
console.log('[MultiSubtask] ✓ Multiple subtasks archived without orphans');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Add subtask then immediately archive syncs correctly
|
||||
|
|
@ -360,72 +337,70 @@ base.describe('@supersync Archive Subtasks Sync', () => {
|
|||
* 5. Client B syncs
|
||||
* 6. Verify no orphan subtasks
|
||||
*/
|
||||
base(
|
||||
'Add subtask then immediately archive syncs correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrorsB: string[] = [];
|
||||
test('Add subtask then immediately archive syncs correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
const consoleErrorsB: string[] = [];
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsB.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
clientB.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrorsB.push(msg.text());
|
||||
}
|
||||
});
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create parent and sync both clients
|
||||
const parentName = `RaceTest-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[RaceTest] Both clients synced with parent task');
|
||||
// Create parent and sync both clients
|
||||
const parentName = `RaceTest-${testRunId}`;
|
||||
await clientA.workView.addTask(parentName);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[RaceTest] Both clients synced with parent task');
|
||||
|
||||
// 2. Client A adds subtask
|
||||
const subtaskName = `NewSub-${testRunId}`;
|
||||
await addSubtask(clientA.page, parentName, subtaskName);
|
||||
console.log('[RaceTest] Added subtask (no sync yet)');
|
||||
// 2. Client A adds subtask
|
||||
const subtaskName = `NewSub-${testRunId}`;
|
||||
await addSubtask(clientA.page, parentName, subtaskName);
|
||||
console.log('[RaceTest] Added subtask (no sync yet)');
|
||||
|
||||
// 3. Immediately mark subtask and parent done, then archive (NO SYNC BETWEEN)
|
||||
await markTaskDone(clientA.page, subtaskName, true);
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[RaceTest] Archived (without intermediate sync)');
|
||||
// 3. Immediately mark subtask and parent done, then archive (NO SYNC BETWEEN)
|
||||
await markTaskDone(clientA.page, subtaskName, true);
|
||||
await markTaskDone(clientA.page, parentName, false);
|
||||
await archiveDoneTasks(clientA.page);
|
||||
console.log('[RaceTest] Archived (without intermediate sync)');
|
||||
|
||||
// 4. Client A syncs (sends both add subtask + archive)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[RaceTest] Client A synced');
|
||||
// 4. Client A syncs (sends both add subtask + archive)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[RaceTest] Client A synced');
|
||||
|
||||
// 5. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
console.log('[RaceTest] Client B synced');
|
||||
// 5. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
console.log('[RaceTest] Client B synced');
|
||||
|
||||
// 6. Verify no orphan subtasks
|
||||
const tasksOnB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(tasksOnB).toBe(0);
|
||||
// 6. Verify no orphan subtasks
|
||||
const tasksOnB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(tasksOnB).toBe(0);
|
||||
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false);
|
||||
expect(checkForOrphanSubtaskError(consoleErrorsB)).toBe(false);
|
||||
|
||||
console.log('[RaceTest] ✓ Add subtask + immediate archive synced correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[RaceTest] ✓ Add subtask + immediate archive synced correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { waitForUISettle } from '../../utils/waits';
|
||||
|
|
@ -19,24 +18,7 @@ import { waitForUISettle } from '../../utils/waits';
|
|||
* - Marking tasks done syncs across clients
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `cross-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Cross-Entity Operations Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Cross-Entity Operations Sync', () => {
|
||||
/**
|
||||
* Test: Multiple tasks sync as a batch
|
||||
*
|
||||
|
|
@ -46,8 +28,7 @@ base.describe('@supersync Cross-Entity Operations Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify all 5 tasks appear on Client B
|
||||
*/
|
||||
base('Multiple tasks sync together', async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Multiple tasks sync together', async ({ browser, baseURL, testRunId }) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
|
@ -110,8 +91,7 @@ base.describe('@supersync Cross-Entity Operations Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify parent and all subtasks on Client B
|
||||
*/
|
||||
base('Task with subtasks syncs correctly', async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Task with subtasks syncs correctly', async ({ browser, baseURL, testRunId }) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { test as base, expect, type ConsoleMessage } from '@playwright/test';
|
||||
import { type ConsoleMessage } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -12,10 +12,6 @@ import {
|
|||
* SuperSync Daily Summary E2E Tests
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a console message indicates a DB lock error.
|
||||
* These errors would indicate the fix for synchronous flush is broken.
|
||||
|
|
@ -25,21 +21,7 @@ const isDbLockError = (msg: ConsoleMessage): boolean => {
|
|||
return text.includes('Attempting to write DB') && text.includes('while locked');
|
||||
};
|
||||
|
||||
base.describe('@supersync Daily Summary Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Daily Summary Sync', () => {
|
||||
/**
|
||||
* Scenario: Archived tasks and time tracking appear on Daily Summary
|
||||
*
|
||||
|
|
@ -53,137 +35,137 @@ base.describe('@supersync Daily Summary Sync', () => {
|
|||
* 7. Client B navigates to Daily Summary.
|
||||
* 8. Verify Client B sees both tasks and correct time.
|
||||
*/
|
||||
base(
|
||||
'Archived tasks and time tracking appear on Daily Summary',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Archived tasks and time tracking appear on Daily Summary', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Setup & Work ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Setup & Work ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create Task A and track time
|
||||
const taskAName = `TaskA-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskAName);
|
||||
const taskALocator = clientA.page.locator(`task:has-text("${taskAName}")`);
|
||||
// Create Task A and track time
|
||||
const taskAName = `TaskA-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskAName);
|
||||
const taskALocator = clientA.page.locator(`task:has-text("${taskAName}")`);
|
||||
|
||||
// Manually set time via Detail Panel -> Time Estimate Dialog
|
||||
// 1. Open detail panel
|
||||
await taskALocator.hover();
|
||||
const detailBtn = taskALocator.locator('.show-additional-info-btn');
|
||||
await detailBtn.click();
|
||||
// Manually set time via Detail Panel -> Time Estimate Dialog
|
||||
// 1. Open detail panel
|
||||
await taskALocator.hover();
|
||||
const detailBtn = taskALocator.locator('.show-additional-info-btn');
|
||||
await detailBtn.click();
|
||||
|
||||
const panel = clientA.page.locator('task-detail-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
const panel = clientA.page.locator('task-detail-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// 2. Click time item to open dialog
|
||||
// Look for the item with the timer icon
|
||||
const timeItem = panel.locator('task-detail-item:has(mat-icon:text("timer"))');
|
||||
await timeItem.click();
|
||||
// 2. Click time item to open dialog
|
||||
// Look for the item with the timer icon
|
||||
const timeItem = panel.locator('task-detail-item:has(mat-icon:text("timer"))');
|
||||
await timeItem.click();
|
||||
|
||||
// 3. Wait for dialog
|
||||
const dialog = clientA.page.locator('dialog-time-estimate');
|
||||
await expect(dialog).toBeVisible();
|
||||
// 3. Wait for dialog
|
||||
const dialog = clientA.page.locator('dialog-time-estimate');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// 4. Fill time spent (assuming first input is time spent or allow flexible input)
|
||||
// Usually the dialog focuses the relevant input or has labeled inputs.
|
||||
// We'll try filling the first input found in the dialog.
|
||||
const timeInput = dialog.locator('input').first();
|
||||
await timeInput.fill('10m');
|
||||
await clientA.page.keyboard.press('Enter');
|
||||
// 4. Fill time spent (assuming first input is time spent or allow flexible input)
|
||||
// Usually the dialog focuses the relevant input or has labeled inputs.
|
||||
// We'll try filling the first input found in the dialog.
|
||||
const timeInput = dialog.locator('input').first();
|
||||
await timeInput.fill('10m');
|
||||
await clientA.page.keyboard.press('Enter');
|
||||
|
||||
// 5. Verify update in panel
|
||||
// The time item should now show 10m
|
||||
await expect(timeItem).toContainText('10m');
|
||||
console.log('Client A manually set time to: 10m');
|
||||
// 5. Verify update in panel
|
||||
// The time item should now show 10m
|
||||
await expect(timeItem).toContainText('10m');
|
||||
console.log('Client A manually set time to: 10m');
|
||||
|
||||
// Create Task B (no time)
|
||||
const taskBName = `TaskB-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskBName);
|
||||
const taskBLocator = clientA.page.locator(`task:has-text("${taskBName}")`);
|
||||
// Create Task B (no time)
|
||||
const taskBName = `TaskB-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskBName);
|
||||
const taskBLocator = clientA.page.locator(`task:has-text("${taskBName}")`);
|
||||
|
||||
// Mark both done
|
||||
await taskALocator.hover();
|
||||
await taskALocator.locator('.task-done-btn').click();
|
||||
// Mark both done
|
||||
await taskALocator.hover();
|
||||
await taskALocator.locator('.task-done-btn').click();
|
||||
|
||||
await taskBLocator.hover();
|
||||
await taskBLocator.locator('.task-done-btn').click();
|
||||
await taskBLocator.hover();
|
||||
await taskBLocator.locator('.task-done-btn').click();
|
||||
|
||||
// Archive Tasks (Finish Day)
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.click();
|
||||
// Archive Tasks (Finish Day)
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.click();
|
||||
|
||||
// Wait for Daily Summary
|
||||
await clientA.page.waitForURL(/daily-summary/);
|
||||
// Wait for Daily Summary
|
||||
await clientA.page.waitForURL(/daily-summary/);
|
||||
|
||||
// Click "Save and go home" to archive
|
||||
const saveAndGoHomeBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveAndGoHomeBtn.waitFor({ state: 'visible' });
|
||||
await saveAndGoHomeBtn.click();
|
||||
// Click "Save and go home" to archive
|
||||
const saveAndGoHomeBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveAndGoHomeBtn.waitFor({ state: 'visible' });
|
||||
await saveAndGoHomeBtn.click();
|
||||
|
||||
// Wait for Work View (Archived)
|
||||
// Accept either active/tasks or tag/TODAY (with or without /tasks suffix)
|
||||
await clientA.page.waitForURL(/(active\/tasks|tag\/TODAY)/);
|
||||
console.log('Client A archived tasks.');
|
||||
// Wait for Work View (Archived)
|
||||
// Accept either active/tasks or tag/TODAY (with or without /tasks suffix)
|
||||
await clientA.page.waitForURL(/(active\/tasks|tag\/TODAY)/);
|
||||
console.log('Client A archived tasks.');
|
||||
|
||||
// Sync A (upload archive)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
// Sync A (upload archive)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
|
||||
// ============ PHASE 2: Client B Sync & Verify ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 2: Client B Sync & Verify ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B (download archive)
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
// Sync B (download archive)
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
|
||||
// Navigate B to Daily Summary
|
||||
// We manually go there because there might be no active tasks to trigger "Finish Day" button
|
||||
await clientB.page.goto('/#/tag/TODAY/daily-summary');
|
||||
// Navigate B to Daily Summary
|
||||
// We manually go there because there might be no active tasks to trigger "Finish Day" button
|
||||
await clientB.page.goto('/#/tag/TODAY/daily-summary');
|
||||
|
||||
// Wait for table
|
||||
await clientB.page.waitForSelector('task-summary-tables', { state: 'visible' });
|
||||
console.log('Client B on Daily Summary.');
|
||||
// Wait for table
|
||||
await clientB.page.waitForSelector('task-summary-tables', { state: 'visible' });
|
||||
console.log('Client B on Daily Summary.');
|
||||
|
||||
// Verify Content
|
||||
// Check Task A Name
|
||||
// Use specific selector for summary table cells
|
||||
const rowA = clientB.page.locator('tr', { hasText: taskAName });
|
||||
await expect(rowA).toBeVisible({ timeout: 10000 });
|
||||
// Verify Content
|
||||
// Check Task A Name
|
||||
// Use specific selector for summary table cells
|
||||
const rowA = clientB.page.locator('tr', { hasText: taskAName });
|
||||
await expect(rowA).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check Task A Time
|
||||
const rowAText = await rowA.textContent();
|
||||
console.log(`Row A Content: ${rowAText}`);
|
||||
// Expect "10m" or "00:10" or "0:10"
|
||||
expect(rowAText).toMatch(/10m|00:10|0:10/);
|
||||
// Check Task A Time
|
||||
const rowAText = await rowA.textContent();
|
||||
console.log(`Row A Content: ${rowAText}`);
|
||||
// Expect "10m" or "00:10" or "0:10"
|
||||
expect(rowAText).toMatch(/10m|00:10|0:10/);
|
||||
|
||||
// Check Task B Name
|
||||
const rowB = clientB.page.locator('tr', { hasText: taskBName });
|
||||
await expect(rowB).toBeVisible({ timeout: 10000 });
|
||||
// Check Task B Name
|
||||
const rowB = clientB.page.locator('tr', { hasText: taskBName });
|
||||
await expect(rowB).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check Task B Time (should be 0 or empty)
|
||||
// It likely shows "-" or "0s"
|
||||
const rowBText = await rowB.textContent();
|
||||
console.log(`Row B Content: ${rowBText}`);
|
||||
// Check Task B Time (should be 0 or empty)
|
||||
// It likely shows "-" or "0s"
|
||||
const rowBText = await rowB.textContent();
|
||||
console.log(`Row B Content: ${rowBText}`);
|
||||
|
||||
console.log('✓ Daily Summary verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('Daily Summary verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Finish day completes without DB lock errors
|
||||
|
|
@ -199,71 +181,71 @@ base.describe('@supersync Daily Summary Sync', () => {
|
|||
* 3. Client completes daily summary (archives tasks, triggers sync).
|
||||
* 4. Verify NO "DB lock" console errors occurred.
|
||||
*/
|
||||
base(
|
||||
'Finish day completes without DB lock errors',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(60000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
const dbLockErrors: string[] = [];
|
||||
test('Finish day completes without DB lock errors', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
const uniqueId = Date.now();
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
const dbLockErrors: string[] = [];
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Create client and set up sync
|
||||
client = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
// Create client and set up sync
|
||||
client = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Monitor for DB lock errors
|
||||
client.page.on('console', (msg) => {
|
||||
if (isDbLockError(msg)) {
|
||||
dbLockErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Create multiple tasks to increase chance of triggering flush
|
||||
const tasks = [`Task1-${uniqueId}`, `Task2-${uniqueId}`, `Task3-${uniqueId}`];
|
||||
|
||||
for (const taskName of tasks) {
|
||||
await client.workView.addTask(taskName);
|
||||
// Monitor for DB lock errors
|
||||
client.page.on('console', (msg) => {
|
||||
if (isDbLockError(msg)) {
|
||||
dbLockErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all tasks as done
|
||||
for (const taskName of tasks) {
|
||||
const taskLocator = client.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
}
|
||||
// Create multiple tasks to increase chance of triggering flush
|
||||
const tasks = [`Task1-${uniqueId}`, `Task2-${uniqueId}`, `Task3-${uniqueId}`];
|
||||
|
||||
// Click finish day - wait for button to be visible and stable
|
||||
const finishDayBtn = client.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
|
||||
// Wait for Daily Summary
|
||||
await client.page.waitForURL(/daily-summary/);
|
||||
|
||||
// Click "Save and go home" to archive and trigger sync
|
||||
const saveAndGoHomeBtn = client.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveAndGoHomeBtn.waitFor({ state: 'visible' });
|
||||
await saveAndGoHomeBtn.click();
|
||||
|
||||
// Wait for navigation back to work view
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY)/);
|
||||
|
||||
// Wait a moment for any async effects to complete
|
||||
await client.page.waitForTimeout(1000);
|
||||
|
||||
// Verify no DB lock errors occurred
|
||||
expect(dbLockErrors).toEqual([]);
|
||||
console.log('✓ Finish day completed without DB lock errors');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
for (const taskName of tasks) {
|
||||
await client.workView.addTask(taskName);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Mark all tasks as done
|
||||
for (const taskName of tasks) {
|
||||
const taskLocator = client.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
}
|
||||
|
||||
// Click finish day - wait for button to be visible and stable
|
||||
const finishDayBtn = client.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
|
||||
// Wait for Daily Summary
|
||||
await client.page.waitForURL(/daily-summary/);
|
||||
|
||||
// Click "Save and go home" to archive and trigger sync
|
||||
const saveAndGoHomeBtn = client.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveAndGoHomeBtn.waitFor({ state: 'visible' });
|
||||
await saveAndGoHomeBtn.click();
|
||||
|
||||
// Wait for navigation back to work view
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY)/);
|
||||
|
||||
// Wait a moment for any async effects to complete
|
||||
await client.page.waitForTimeout(1000);
|
||||
|
||||
// Verify no DB lock errors occurred
|
||||
expect(dbLockErrors).toEqual([]);
|
||||
console.log('Finish day completed without DB lock errors');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskVisible } from '../../utils/supersync-assertions';
|
||||
|
||||
/**
|
||||
* SuperSync Encryption Password Change E2E Tests
|
||||
|
|
@ -21,337 +21,319 @@ import {
|
|||
* Run with E2E_VERBOSE=1 to see browser console logs for debugging.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
test.describe('@supersync SuperSync Encryption Password Change', () => {
|
||||
test('Password change preserves existing data', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
|
||||
base.describe('@supersync SuperSync Encryption Password Change', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
// --- Setup with initial password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
// Create tasks (names must be distinct - no substring overlap)
|
||||
const task1 = `TaskA-${testRunId}`;
|
||||
const task2 = `TaskB-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
|
||||
// Sync with old password
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify tasks exist
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
|
||||
// --- Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Verify tasks still exist after password change ---
|
||||
await expectTaskVisible(clientA, task1);
|
||||
await expectTaskVisible(clientA, task2);
|
||||
|
||||
// Trigger another sync to verify everything works
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Tasks should still be there
|
||||
await expectTaskVisible(clientA, task1);
|
||||
await expectTaskVisible(clientA, task2);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
base(
|
||||
'Password change preserves existing data',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
test('New client can sync with new password after password change', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Client A: Setup and create data ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `BeforeChange-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client A: Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client B: Setup with NEW password ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
// Sync should succeed with new password
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify task synced to Client B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expectTaskVisible(clientB, taskName);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
test('Old password fails after password change', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Client A: Setup, create data, and change password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Change password
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client C: Try to sync with OLD password ---
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword, // Using OLD password!
|
||||
});
|
||||
|
||||
// Try to sync - should fail or not get the data
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Setup with initial password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
// Create tasks (names must be distinct - no substring overlap)
|
||||
const task1 = `TaskA-${testRunId}`;
|
||||
const task2 = `TaskB-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
|
||||
// Sync with old password
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify tasks exist
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
|
||||
// --- Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Verify tasks still exist after password change ---
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
|
||||
// Trigger another sync to verify everything works
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Tasks should still be there
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
await clientC.sync.triggerSync();
|
||||
await clientC.sync.waitForSyncComplete();
|
||||
} catch (e) {
|
||||
// Expected - sync may throw an error
|
||||
console.log('Sync with old password failed as expected:', e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'New client can sync with new password after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
// Verify Client C does NOT have the task
|
||||
await expect(
|
||||
clientC.page.locator(`task:has-text("${taskName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
// Check for error state
|
||||
const hasError = await clientC.sync.hasSyncError();
|
||||
const snackbar = clientC.page.locator('simple-snack-bar');
|
||||
const snackbarVisible = await snackbar.isVisible().catch(() => false);
|
||||
|
||||
// --- Client A: Setup and create data ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `BeforeChange-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client A: Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client B: Setup with NEW password ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
// Sync should succeed with new password
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify task synced to Client B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Either error icon or error snackbar should be visible
|
||||
if (!hasError && !snackbarVisible) {
|
||||
// If no visible error, at least verify no data was synced
|
||||
const taskCount = await clientC.page.locator('task').count();
|
||||
console.log(`Client C has ${taskCount} tasks (should be 0 real tasks)`);
|
||||
}
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
});
|
||||
|
||||
base(
|
||||
'Old password fails after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
test('Bidirectional sync works after password change', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Client A: Setup, create data, and change password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
// --- Setup both clients with old password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
// Change password
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
// --- Create initial tasks and sync ---
|
||||
const taskFromA = `FromA-${testRunId}`;
|
||||
await clientA.workView.addTask(taskFromA);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client C: Try to sync with OLD password ---
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword, // Using OLD password!
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
|
||||
// Try to sync - should fail or not get the data
|
||||
try {
|
||||
await clientC.sync.triggerSync();
|
||||
await clientC.sync.waitForSyncComplete();
|
||||
} catch (e) {
|
||||
// Expected - sync may throw an error
|
||||
console.log('Sync with old password failed as expected:', e);
|
||||
}
|
||||
// --- Client A changes password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// Verify Client C does NOT have the task
|
||||
await expect(
|
||||
clientC.page.locator(`task:has-text("${taskName}")`),
|
||||
).not.toBeVisible();
|
||||
// --- Client B must reconfigure with new password ---
|
||||
// Close and recreate with new password (simulating user entering new password)
|
||||
await closeClient(clientB);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B2', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Check for error state
|
||||
const hasError = await clientC.sync.hasSyncError();
|
||||
const snackbar = clientC.page.locator('simple-snack-bar');
|
||||
const snackbarVisible = await snackbar.isVisible().catch(() => false);
|
||||
// Verify B has the task
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
|
||||
// Either error icon or error snackbar should be visible
|
||||
if (!hasError && !snackbarVisible) {
|
||||
// If no visible error, at least verify no data was synced
|
||||
const taskCount = await clientC.page.locator('task').count();
|
||||
console.log(`Client C has ${taskCount} tasks (should be 0 real tasks)`);
|
||||
}
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
},
|
||||
);
|
||||
// --- Client B creates a new task ---
|
||||
const taskFromB = `FromB-${testRunId}`;
|
||||
await clientB.workView.addTask(taskFromB);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
base(
|
||||
'Bidirectional sync works after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
// --- Client A syncs and should get B's task ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskFromB);
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
// Verify both clients have both tasks
|
||||
await expectTaskVisible(clientA, taskFromA);
|
||||
await expectTaskVisible(clientA, taskFromB);
|
||||
await expectTaskVisible(clientB, taskFromA);
|
||||
await expectTaskVisible(clientB, taskFromB);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Setup both clients with old password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
test('Multiple password changes work correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const password1 = `pass1-${testRunId}`;
|
||||
const password2 = `pass2-${testRunId}`;
|
||||
const password3 = `pass3-${testRunId}`;
|
||||
|
||||
// --- Create initial tasks and sync ---
|
||||
const taskFromA = `FromA-${testRunId}`;
|
||||
await clientA.workView.addTask(taskFromA);
|
||||
await clientA.sync.syncAndWait();
|
||||
// --- Setup with password1 ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password1,
|
||||
});
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
// Create and sync task1
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client A changes password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
// --- Change to password2 ---
|
||||
await clientA.sync.changeEncryptionPassword(password2);
|
||||
|
||||
// --- Client B must reconfigure with new password ---
|
||||
// Close and recreate with new password (simulating user entering new password)
|
||||
await closeClient(clientB);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B2', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
// Create and sync task2
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify B has the task
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
// --- Change to password3 ---
|
||||
await clientA.sync.changeEncryptionPassword(password3);
|
||||
|
||||
// --- Client B creates a new task ---
|
||||
const taskFromB = `FromB-${testRunId}`;
|
||||
await clientB.workView.addTask(taskFromB);
|
||||
await clientB.sync.syncAndWait();
|
||||
// Create and sync task3
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client A syncs and should get B's task ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskFromB);
|
||||
// --- Verify all tasks still exist ---
|
||||
await expectTaskVisible(clientA, task1);
|
||||
await expectTaskVisible(clientA, task2);
|
||||
await expectTaskVisible(clientA, task3);
|
||||
|
||||
// Verify both clients have both tasks
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// --- New client with password3 should see all tasks ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password3,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
base(
|
||||
'Multiple password changes work correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const password1 = `pass1-${testRunId}`;
|
||||
const password2 = `pass2-${testRunId}`;
|
||||
const password3 = `pass3-${testRunId}`;
|
||||
|
||||
// --- Setup with password1 ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password1,
|
||||
});
|
||||
|
||||
// Create and sync task1
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Change to password2 ---
|
||||
await clientA.sync.changeEncryptionPassword(password2);
|
||||
|
||||
// Create and sync task2
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Change to password3 ---
|
||||
await clientA.sync.changeEncryptionPassword(password3);
|
||||
|
||||
// Create and sync task3
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Verify all tasks still exist ---
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
|
||||
// --- New client with password3 should see all tasks ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password3,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
await expectTaskVisible(clientB, task1);
|
||||
await expectTaskVisible(clientB, task2);
|
||||
await expectTaskVisible(clientB, task3);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -23,341 +22,337 @@ import {
|
|||
* Run with E2E_VERBOSE=1 to see browser console logs for debugging.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
test.describe('@supersync SuperSync Encryption', () => {
|
||||
// Server health check is handled automatically by the supersync fixture
|
||||
|
||||
base.describe('@supersync SuperSync Encryption', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
test('Encrypted data syncs correctly with valid password', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `pass-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// --- Client A: Encrypt & Upload ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const secretTaskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(secretTaskName);
|
||||
|
||||
// Sync A (Encrypts and uploads)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B: Download & Decrypt ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
// Use SAME config (same password)
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B (Downloads and decrypts)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has the task
|
||||
await waitForTask(clientB.page, secretTaskName);
|
||||
await expect(
|
||||
clientB.page.locator(`task:has-text("${secretTaskName}")`),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
base(
|
||||
'Encrypted data syncs correctly with valid password',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Encrypted data fails to sync with wrong password', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const correctPassword = `correct-${testRunId}`;
|
||||
const wrongPassword = `wrong-${testRunId}`;
|
||||
|
||||
// --- Client A: Encrypt & Upload (Correct Password) ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: correctPassword,
|
||||
});
|
||||
|
||||
const secretTaskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(secretTaskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client C: Attempt Download (Wrong Password) ---
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
|
||||
// Setup with WRONG password
|
||||
await clientC.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: wrongPassword,
|
||||
});
|
||||
|
||||
// Try to sync - expectation is that it completes (download happens) but processing fails
|
||||
// The SyncPage helper might throw if it detects an error icon, or we check for error manually
|
||||
|
||||
// We expect the sync to technically "fail" or show an error state because decryption failed
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `pass-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// --- Client A: Encrypt & Upload ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const secretTaskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(secretTaskName);
|
||||
|
||||
// Sync A (Encrypts and uploads)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B: Download & Decrypt ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
// Use SAME config (same password)
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B (Downloads and decrypts)
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has the task
|
||||
await waitForTask(clientB.page, secretTaskName);
|
||||
await expect(
|
||||
clientB.page.locator(`task:has-text("${secretTaskName}")`),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
await clientC.sync.triggerSync();
|
||||
// It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it
|
||||
await clientC.sync.waitForSyncComplete();
|
||||
} catch (e) {
|
||||
// Expected error or timeout due to failure
|
||||
console.log('Sync failed as expected:', e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'Encrypted data fails to sync with wrong password',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
// Verify Client C DOES NOT have the task
|
||||
await expect(
|
||||
clientC.page.locator(`task:has-text("${secretTaskName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const correctPassword = `correct-${testRunId}`;
|
||||
const wrongPassword = `wrong-${testRunId}`;
|
||||
|
||||
// --- Client A: Encrypt & Upload (Correct Password) ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: correctPassword,
|
||||
});
|
||||
|
||||
const secretTaskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(secretTaskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client C: Attempt Download (Wrong Password) ---
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
|
||||
// Setup with WRONG password
|
||||
await clientC.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: wrongPassword,
|
||||
});
|
||||
|
||||
// Try to sync - expectation is that it completes (download happens) but processing fails
|
||||
// The SyncPage helper might throw if it detects an error icon, or we check for error manually
|
||||
|
||||
// We expect the sync to technically "fail" or show an error state because decryption failed
|
||||
try {
|
||||
await clientC.sync.triggerSync();
|
||||
// It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it
|
||||
await clientC.sync.waitForSyncComplete();
|
||||
} catch (e) {
|
||||
// Expected error or timeout due to failure
|
||||
console.log('Sync failed as expected:', e);
|
||||
}
|
||||
|
||||
// Verify Client C DOES NOT have the task
|
||||
await expect(
|
||||
clientC.page.locator(`task:has-text("${secretTaskName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Verify Error UI
|
||||
// Check for error icon or snackbar
|
||||
const hasError = await clientC.sync.hasSyncError();
|
||||
const snackbar = clientC.page.locator('simple-snack-bar');
|
||||
// "Decryption failed" or "Wrong encryption password"
|
||||
if (!hasError && !(await snackbar.isVisible())) {
|
||||
// If sync didn't report error, maybe it just silently ignored ops (shouldn't happen with current logic)
|
||||
// But let's check if we at least DON'T have the data.
|
||||
} else {
|
||||
expect(hasError || (await snackbar.isVisible())).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientC) await closeClient(clientC);
|
||||
// Verify Error UI
|
||||
// Check for error icon or snackbar
|
||||
const hasError = await clientC.sync.hasSyncError();
|
||||
const snackbar = clientC.page.locator('simple-snack-bar');
|
||||
// "Decryption failed" or "Wrong encryption password"
|
||||
if (!hasError && !(await snackbar.isVisible())) {
|
||||
// If sync didn't report error, maybe it just silently ignored ops (shouldn't happen with current logic)
|
||||
// But let's check if we at least DON'T have the data.
|
||||
} else {
|
||||
expect(hasError || (await snackbar.isVisible())).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
});
|
||||
|
||||
base(
|
||||
'Multiple tasks sync correctly with encryption',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Multiple tasks sync correctly with encryption', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `multi-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `multi-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// --- Client A: Create multiple tasks ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// --- Client A: Create multiple tasks ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task3);
|
||||
|
||||
// Sync all tasks
|
||||
await clientA.sync.syncAndWait();
|
||||
// Sync all tasks
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B: Verify all tasks arrive ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// --- Client B: Verify all tasks arrive ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify all 3 tasks exist
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
// Verify all 3 tasks exist
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
base(
|
||||
'Bidirectional sync works with encryption',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Bidirectional sync works with encryption', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `bidi-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `bidi-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// --- Setup both clients ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// --- Setup both clients ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// --- Client A creates a task ---
|
||||
const taskFromA = `FromA-${testRunId}`;
|
||||
await clientA.workView.addTask(taskFromA);
|
||||
await clientA.sync.syncAndWait();
|
||||
// --- Client A creates a task ---
|
||||
const taskFromA = `FromA-${testRunId}`;
|
||||
await clientA.workView.addTask(taskFromA);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B syncs and creates a task ---
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
// --- Client B syncs and creates a task ---
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
|
||||
const taskFromB = `FromB-${testRunId}`;
|
||||
await clientB.workView.addTask(taskFromB);
|
||||
await clientB.sync.syncAndWait();
|
||||
const taskFromB = `FromB-${testRunId}`;
|
||||
await clientB.workView.addTask(taskFromB);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// --- Client A syncs again and should have both tasks ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskFromB);
|
||||
// --- Client A syncs again and should have both tasks ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskFromB);
|
||||
|
||||
// Verify both clients have both tasks
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Verify both clients have both tasks
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
base(
|
||||
'Task update syncs correctly with encryption',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task update syncs correctly with encryption', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `update-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `update-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// --- Client A: Create a task ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// --- Client A: Create a task ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `UpdatableTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.page.waitForTimeout(300);
|
||||
await clientA.sync.syncAndWait();
|
||||
const taskName = `UpdatableTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.page.waitForTimeout(300);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B: Sync and verify task exists ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// --- Client B: Sync and verify task exists ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
|
||||
// --- Client B: Create another task ---
|
||||
const task2Name = `UpdatedByB-${testRunId}`;
|
||||
await clientB.workView.addTask(task2Name);
|
||||
await clientB.page.waitForTimeout(300);
|
||||
await clientB.sync.syncAndWait();
|
||||
// --- Client B: Create another task ---
|
||||
const task2Name = `UpdatedByB-${testRunId}`;
|
||||
await clientB.workView.addTask(task2Name);
|
||||
await clientB.page.waitForTimeout(300);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// --- Client A: Sync and verify both tasks exist ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, task2Name);
|
||||
// --- Client A: Sync and verify both tasks exist ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, task2Name);
|
||||
|
||||
await expect(clientA.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2Name}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
await expect(clientA.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2Name}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
base(
|
||||
'Long encryption password works correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Long encryption password works correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
// Use a very long password with special characters
|
||||
const longPassword = `This-Is-A-Very-Long-Password-With-Special-Chars!@#$%^&*()-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: longPassword,
|
||||
};
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
// Use a very long password with special characters
|
||||
const longPassword = `This-Is-A-Very-Long-Password-With-Special-Chars!@#$%^&*()-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: longPassword,
|
||||
};
|
||||
|
||||
// --- Client A: Create task with long password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// --- Client A: Create task with long password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `LongPassTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
const taskName = `LongPassTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client B: Sync with same long password ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// --- Client B: Sync with same long password ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify task synced correctly
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Verify task synced correctly
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -18,25 +17,7 @@ import {
|
|||
* - Error recovery after sync failures
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Error Handling', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Error Handling', () => {
|
||||
/**
|
||||
* Scenario: Concurrent Modification Creates Conflict
|
||||
*
|
||||
|
|
@ -54,99 +35,98 @@ base.describe('@supersync SuperSync Error Handling', () => {
|
|||
* 7. Client B syncs (gets CONFLICT_CONCURRENT, auto-resolves via LWW)
|
||||
* 8. Verify both clients converge to same state
|
||||
*/
|
||||
base(
|
||||
'Concurrent modification triggers LWW conflict resolution',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Concurrent modification triggers LWW conflict resolution', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Client A creates task
|
||||
const taskName = `Conflict-Test-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
// 1. Client A creates task
|
||||
const taskName = `Conflict-Test-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 2. Client B downloads the task
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// 2. Client B downloads the task
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
// 3-5. Both clients modify the task while "offline"
|
||||
// Client A changes title first using inline editing (dblclick)
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorA.dblclick();
|
||||
const editInputA = clientA.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputA.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputA.fill(`${taskName}-ModifiedByA`);
|
||||
await editInputA.press('Enter');
|
||||
await clientA.page.waitForTimeout(300);
|
||||
// 3-5. Both clients modify the task while "offline"
|
||||
// Client A changes title first using inline editing (dblclick)
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorA.dblclick();
|
||||
const editInputA = clientA.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputA.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputA.fill(`${taskName}-ModifiedByA`);
|
||||
await editInputA.press('Enter');
|
||||
await clientA.page.waitForTimeout(300);
|
||||
|
||||
// Client B modifies the same task with a different value using inline editing
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorB.dblclick();
|
||||
const editInputB = clientB.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputB.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputB.fill(`${taskName}-ModifiedByB`);
|
||||
await editInputB.press('Enter');
|
||||
await clientB.page.waitForTimeout(300);
|
||||
// Client B modifies the same task with a different value using inline editing
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorB.dblclick();
|
||||
const editInputB = clientB.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputB.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputB.fill(`${taskName}-ModifiedByB`);
|
||||
await editInputB.press('Enter');
|
||||
await clientB.page.waitForTimeout(300);
|
||||
|
||||
// 6. Client A syncs first (succeeds)
|
||||
await clientA.sync.syncAndWait();
|
||||
// 6. Client A syncs first (succeeds)
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 7. Client B syncs (gets conflict, should auto-resolve via LWW)
|
||||
// The newer timestamp wins, so B's change might win or A's depending on timing
|
||||
await clientB.sync.syncAndWait();
|
||||
// 7. Client B syncs (gets conflict, should auto-resolve via LWW)
|
||||
// The newer timestamp wins, so B's change might win or A's depending on timing
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Give time for any conflict resolution UI
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Give time for any conflict resolution UI
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// 8. Final sync to ensure convergence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// 8. Final sync to ensure convergence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify both clients have converged - they should show the same task title
|
||||
// The exact title depends on LWW resolution (whichever timestamp was later)
|
||||
const finalTaskA = await clientA.page
|
||||
.locator('task:not(.ng-animating)')
|
||||
.first()
|
||||
.textContent();
|
||||
const finalTaskB = await clientB.page
|
||||
.locator('task:not(.ng-animating)')
|
||||
.first()
|
||||
.textContent();
|
||||
// Verify both clients have converged - they should show the same task title
|
||||
// The exact title depends on LWW resolution (whichever timestamp was later)
|
||||
const finalTaskA = await clientA.page
|
||||
.locator('task:not(.ng-animating)')
|
||||
.first()
|
||||
.textContent();
|
||||
const finalTaskB = await clientB.page
|
||||
.locator('task:not(.ng-animating)')
|
||||
.first()
|
||||
.textContent();
|
||||
|
||||
// Both clients should show the same content (convergence)
|
||||
expect(finalTaskA).toBe(finalTaskB);
|
||||
// Both clients should show the same content (convergence)
|
||||
expect(finalTaskA).toBe(finalTaskB);
|
||||
|
||||
console.log(
|
||||
'[Conflict-Resolution] ✓ Concurrent modification handled via LWW - clients converged',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
'[Conflict-Resolution] ✓ Concurrent modification handled via LWW - clients converged',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Sync Recovery After Network Failure
|
||||
|
|
@ -162,46 +142,45 @@ base.describe('@supersync SuperSync Error Handling', () => {
|
|||
* 5. Client syncs again (should succeed)
|
||||
* 6. Verify all tasks are synced
|
||||
*/
|
||||
base(
|
||||
'Sync recovers after initial connection',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(60000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
test('Sync recovers after initial connection', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup client
|
||||
client = await createSimulatedClient(browser, appUrl, 'Recovery', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
// Setup client
|
||||
client = await createSimulatedClient(browser, appUrl, 'Recovery', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Create first task
|
||||
const task1 = `Recovery-Task1-${testRunId}`;
|
||||
await client.workView.addTask(task1);
|
||||
// 1. Create first task
|
||||
const task1 = `Recovery-Task1-${testRunId}`;
|
||||
await client.workView.addTask(task1);
|
||||
|
||||
// 2. Sync successfully
|
||||
await client.sync.syncAndWait();
|
||||
// 2. Sync successfully
|
||||
await client.sync.syncAndWait();
|
||||
|
||||
// 3. Create second task
|
||||
const task2 = `Recovery-Task2-${testRunId}`;
|
||||
await client.workView.addTask(task2);
|
||||
// 3. Create second task
|
||||
const task2 = `Recovery-Task2-${testRunId}`;
|
||||
await client.workView.addTask(task2);
|
||||
|
||||
// 4-5. Sync again
|
||||
await client.sync.syncAndWait();
|
||||
// 4-5. Sync again
|
||||
await client.sync.syncAndWait();
|
||||
|
||||
// 6. Verify both tasks exist
|
||||
await waitForTask(client.page, task1);
|
||||
await waitForTask(client.page, task2);
|
||||
// 6. Verify both tasks exist
|
||||
await waitForTask(client.page, task1);
|
||||
await waitForTask(client.page, task2);
|
||||
|
||||
console.log('[Sync-Recovery] ✓ Multiple syncs completed successfully');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Sync-Recovery] ✓ Multiple syncs completed successfully');
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Duplicate Operation Handling (Idempotency)
|
||||
|
|
@ -216,45 +195,44 @@ base.describe('@supersync SuperSync Error Handling', () => {
|
|||
* 3. Client syncs again immediately (same ops might be in queue)
|
||||
* 4. Verify no duplicate tasks created
|
||||
*/
|
||||
base(
|
||||
'Duplicate sync attempts are handled gracefully',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(60000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
test('Duplicate sync attempts are handled gracefully', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let client: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup client
|
||||
client = await createSimulatedClient(browser, appUrl, 'Idempotent', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
// Setup client
|
||||
client = await createSimulatedClient(browser, appUrl, 'Idempotent', testRunId);
|
||||
await client.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Create task
|
||||
const taskName = `Idempotent-${testRunId}`;
|
||||
await client.workView.addTask(taskName);
|
||||
// 1. Create task
|
||||
const taskName = `Idempotent-${testRunId}`;
|
||||
await client.workView.addTask(taskName);
|
||||
|
||||
// 2-3. Sync multiple times rapidly
|
||||
await client.sync.syncAndWait();
|
||||
await client.sync.syncAndWait();
|
||||
await client.sync.syncAndWait();
|
||||
// 2-3. Sync multiple times rapidly
|
||||
await client.sync.syncAndWait();
|
||||
await client.sync.syncAndWait();
|
||||
await client.sync.syncAndWait();
|
||||
|
||||
// 4. Verify exactly one task with this name exists
|
||||
const matchingTasks = client.page.locator(`task:has-text("${taskName}")`);
|
||||
const count = await matchingTasks.count();
|
||||
// 4. Verify exactly one task with this name exists
|
||||
const matchingTasks = client.page.locator(`task:has-text("${taskName}")`);
|
||||
const count = await matchingTasks.count();
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(count).toBe(1);
|
||||
|
||||
console.log(
|
||||
'[Idempotency] ✓ Multiple sync attempts handled correctly - no duplicates',
|
||||
);
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
'[Idempotency] ✓ Multiple sync attempts handled correctly - no duplicates',
|
||||
);
|
||||
} finally {
|
||||
if (client) await closeClient(client);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Three-Client Convergence
|
||||
|
|
@ -271,9 +249,11 @@ base.describe('@supersync SuperSync Error Handling', () => {
|
|||
* 6. All clients sync
|
||||
* 7. Verify all clients have same state
|
||||
*/
|
||||
base('Three clients converge to same state', async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Three clients converge to same state', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { ImportPage } from '../../pages/import.page';
|
||||
|
|
@ -24,25 +23,7 @@ import { ImportPage } from '../../pages/import.page';
|
|||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-import-clean-slate.spec.ts
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
|
||||
/**
|
||||
* Scenario: Import drops all concurrent work from both clients
|
||||
*
|
||||
|
|
@ -71,149 +52,149 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
|
|||
* fresh start. All operations created before it (even if already synced)
|
||||
* are dropped because they reference state that no longer exists.
|
||||
*/
|
||||
base(
|
||||
'Import drops ALL concurrent work from both clients (clean slate)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Import drops ALL concurrent work from both clients (clean slate)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
console.log('[Clean Slate] Phase 1: Setting up both clients');
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
console.log('[Clean Slate] Phase 1: Setting up both clients');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============
|
||||
console.log('[Clean Slate] Phase 2: Creating pre-import tasks');
|
||||
// ============ PHASE 2: Create Pre-Import Tasks on Both Clients ============
|
||||
console.log('[Clean Slate] Phase 2: Creating pre-import tasks');
|
||||
|
||||
// Client A creates and syncs first task
|
||||
const taskABefore = `Task-A-Before-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskABefore);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Clean Slate] Client A created: ${taskABefore}`);
|
||||
// Client A creates and syncs first task
|
||||
const taskABefore = `Task-A-Before-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskABefore);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Clean Slate] Client A created: ${taskABefore}`);
|
||||
|
||||
// Client B syncs to get A's task
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskABefore);
|
||||
console.log('[Clean Slate] Client B received Task-A-Before');
|
||||
// Client B syncs to get A's task
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskABefore);
|
||||
console.log('[Clean Slate] Client B received Task-A-Before');
|
||||
|
||||
// Client B creates and syncs a concurrent task
|
||||
const taskBConcurrent = `Task-B-Concurrent-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBConcurrent);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log(`[Clean Slate] Client B created: ${taskBConcurrent}`);
|
||||
// Client B creates and syncs a concurrent task
|
||||
const taskBConcurrent = `Task-B-Concurrent-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBConcurrent);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log(`[Clean Slate] Client B created: ${taskBConcurrent}`);
|
||||
|
||||
// Client A creates another task (will NOT sync before import)
|
||||
const taskAConcurrent = `Task-A-Concurrent-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskAConcurrent);
|
||||
console.log(`[Clean Slate] Client A created: ${taskAConcurrent}`);
|
||||
// Client A creates another task (will NOT sync before import)
|
||||
const taskAConcurrent = `Task-A-Concurrent-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskAConcurrent);
|
||||
console.log(`[Clean Slate] Client A created: ${taskAConcurrent}`);
|
||||
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Clean Slate] Phase 3: Client A importing backup');
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Clean Slate] Phase 3: Client A importing backup');
|
||||
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
|
||||
// Import the backup file (contains "E2E Import Test" tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Clean Slate] Client A imported backup');
|
||||
// Import the backup file (contains "E2E Import Test" tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Clean Slate] Client A imported backup');
|
||||
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Clean Slate] Client A has imported tasks');
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Clean Slate] Client A has imported tasks');
|
||||
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Clean Slate] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Clean Slate] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Clean Slate] Client A synced (SYNC_IMPORT uploaded)');
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Clean Slate] Client A synced (SYNC_IMPORT uploaded)');
|
||||
|
||||
// Client B syncs (receives SYNC_IMPORT - should drop all concurrent work)
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Clean Slate] Client B synced (received SYNC_IMPORT)');
|
||||
// Client B syncs (receives SYNC_IMPORT - should drop all concurrent work)
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Clean Slate] Client B synced (received SYNC_IMPORT)');
|
||||
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// ============ PHASE 5: Verify Clean Slate on Both Clients ============
|
||||
console.log('[Clean Slate] Phase 5: Verifying clean slate');
|
||||
// ============ PHASE 5: Verify Clean Slate on Both Clients ============
|
||||
console.log('[Clean Slate] Phase 5: Verifying clean slate');
|
||||
|
||||
// Navigate back to work view to see tasks
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
// Navigate back to work view to see tasks
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
// CRITICAL VERIFICATION: Pre-import tasks should be GONE
|
||||
console.log('[Clean Slate] Verifying pre-import tasks are gone...');
|
||||
// CRITICAL VERIFICATION: Pre-import tasks should be GONE
|
||||
console.log('[Clean Slate] Verifying pre-import tasks are gone...');
|
||||
|
||||
// Check Client A - should NOT have pre-import tasks
|
||||
const taskABeforeOnA = clientA.page.locator(`task:has-text("${taskABefore}")`);
|
||||
const taskAConcurrentOnA = clientA.page.locator(
|
||||
`task:has-text("${taskAConcurrent}")`,
|
||||
);
|
||||
const taskBConcurrentOnA = clientA.page.locator(
|
||||
`task:has-text("${taskBConcurrent}")`,
|
||||
);
|
||||
// Check Client A - should NOT have pre-import tasks
|
||||
const taskABeforeOnA = clientA.page.locator(`task:has-text("${taskABefore}")`);
|
||||
const taskAConcurrentOnA = clientA.page.locator(
|
||||
`task:has-text("${taskAConcurrent}")`,
|
||||
);
|
||||
const taskBConcurrentOnA = clientA.page.locator(
|
||||
`task:has-text("${taskBConcurrent}")`,
|
||||
);
|
||||
|
||||
await expect(taskABeforeOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskAConcurrentOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskBConcurrentOnA).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Client A: All pre-import tasks are GONE');
|
||||
await expect(taskABeforeOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskAConcurrentOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskBConcurrentOnA).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Client A: All pre-import tasks are GONE');
|
||||
|
||||
// Check Client B - should NOT have pre-import tasks
|
||||
const taskABeforeOnB = clientB.page.locator(`task:has-text("${taskABefore}")`);
|
||||
const taskAConcurrentOnB = clientB.page.locator(
|
||||
`task:has-text("${taskAConcurrent}")`,
|
||||
);
|
||||
const taskBConcurrentOnB = clientB.page.locator(
|
||||
`task:has-text("${taskBConcurrent}")`,
|
||||
);
|
||||
// Check Client B - should NOT have pre-import tasks
|
||||
const taskABeforeOnB = clientB.page.locator(`task:has-text("${taskABefore}")`);
|
||||
const taskAConcurrentOnB = clientB.page.locator(
|
||||
`task:has-text("${taskAConcurrent}")`,
|
||||
);
|
||||
const taskBConcurrentOnB = clientB.page.locator(
|
||||
`task:has-text("${taskBConcurrent}")`,
|
||||
);
|
||||
|
||||
await expect(taskABeforeOnB).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskAConcurrentOnB).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskBConcurrentOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Client B: All pre-import tasks are GONE');
|
||||
await expect(taskABeforeOnB).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskAConcurrentOnB).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(taskBConcurrentOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Client B: All pre-import tasks are GONE');
|
||||
|
||||
// Verify both clients have imported tasks
|
||||
const importedTask1OnA = clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
const importedTask1OnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
// Verify both clients have imported tasks
|
||||
const importedTask1OnA = clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
const importedTask1OnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
|
||||
await expect(importedTask1OnA).toBeVisible({ timeout: 5000 });
|
||||
await expect(importedTask1OnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Both clients have imported tasks');
|
||||
await expect(importedTask1OnA).toBeVisible({ timeout: 5000 });
|
||||
await expect(importedTask1OnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Clean Slate] ✓ Both clients have imported tasks');
|
||||
|
||||
console.log('[Clean Slate] ✓ Clean slate test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Clean Slate] ✓ Clean slate test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Late joiner with synced ops - ops are dropped after import
|
||||
|
|
@ -238,98 +219,98 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
|
|||
* represents a complete state reset. Client B's ops are not replayed because
|
||||
* they reference state that no longer exists after the import.
|
||||
*/
|
||||
base(
|
||||
'Late joiner synced ops are dropped after import (no replay)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Late joiner synced ops are dropped after import (no replay)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client B Creates and Syncs ============
|
||||
console.log('[Late Joiner] Phase 1: Client B creates and syncs');
|
||||
// ============ PHASE 1: Client B Creates and Syncs ============
|
||||
console.log('[Late Joiner] Phase 1: Client B creates and syncs');
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Client B creates and syncs a task
|
||||
const taskBLocal = `Task-B-Local-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBLocal);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log(`[Late Joiner] Client B created and synced: ${taskBLocal}`);
|
||||
// Client B creates and syncs a task
|
||||
const taskBLocal = `Task-B-Local-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBLocal);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log(`[Late Joiner] Client B created and synced: ${taskBLocal}`);
|
||||
|
||||
// Verify task exists on B
|
||||
await waitForTask(clientB.page, taskBLocal);
|
||||
console.log('[Late Joiner] Task-B-Local confirmed on Client B');
|
||||
// Verify task exists on B
|
||||
await waitForTask(clientB.page, taskBLocal);
|
||||
console.log('[Late Joiner] Task-B-Local confirmed on Client B');
|
||||
|
||||
// ============ PHASE 2: Client A Imports (Without Syncing First) ============
|
||||
console.log('[Late Joiner] Phase 2: Client A imports backup');
|
||||
// ============ PHASE 2: Client A Imports (Without Syncing First) ============
|
||||
console.log('[Late Joiner] Phase 2: Client A imports backup');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// DO NOT sync before import - A doesn't know about B's task
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// DO NOT sync before import - A doesn't know about B's task
|
||||
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
|
||||
// Import backup
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Late Joiner] Client A imported backup');
|
||||
// Import backup
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Late Joiner] Client A imported backup');
|
||||
|
||||
// Re-enable sync after import
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Re-enable sync after import
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// ============ PHASE 3: Both Clients Sync ============
|
||||
console.log('[Late Joiner] Phase 3: Both clients sync');
|
||||
// ============ PHASE 3: Both Clients Sync ============
|
||||
console.log('[Late Joiner] Phase 3: Both clients sync');
|
||||
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Late Joiner] Client A synced');
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Late Joiner] Client A synced');
|
||||
|
||||
// Client B syncs (receives SYNC_IMPORT)
|
||||
// This is where the old code would replay Task-B-Local
|
||||
// With clean slate semantics, Task-B-Local should be dropped
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Late Joiner] Client B synced');
|
||||
// Client B syncs (receives SYNC_IMPORT)
|
||||
// This is where the old code would replay Task-B-Local
|
||||
// With clean slate semantics, Task-B-Local should be dropped
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Late Joiner] Client B synced');
|
||||
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// ============ PHASE 4: Verify Clean Slate on Client B ============
|
||||
console.log('[Late Joiner] Phase 4: Verifying clean slate on Client B');
|
||||
// ============ PHASE 4: Verify Clean Slate on Client B ============
|
||||
console.log('[Late Joiner] Phase 4: Verifying clean slate on Client B');
|
||||
|
||||
// Navigate back to work view to see tasks
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
// Navigate back to work view to see tasks
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for imported task
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
// Wait for imported task
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
// CRITICAL: Task-B-Local should be GONE (not replayed)
|
||||
const taskBLocalOnB = clientB.page.locator(`task:has-text("${taskBLocal}")`);
|
||||
await expect(taskBLocalOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Late Joiner] ✓ Task-B-Local is GONE (not replayed)');
|
||||
// CRITICAL: Task-B-Local should be GONE (not replayed)
|
||||
const taskBLocalOnB = clientB.page.locator(`task:has-text("${taskBLocal}")`);
|
||||
await expect(taskBLocalOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Late Joiner] ✓ Task-B-Local is GONE (not replayed)');
|
||||
|
||||
// Verify imported task is present
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Late Joiner] ✓ Client B has imported tasks');
|
||||
// Verify imported task is present
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Late Joiner] ✓ Client B has imported tasks');
|
||||
|
||||
console.log('[Late Joiner] ✓ Late joiner test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Late Joiner] ✓ Late joiner test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Import invalidates PENDING (unsynced) operations from other clients
|
||||
|
|
@ -357,132 +338,132 @@ base.describe('@supersync @cleanslate Import Clean Slate Semantics', () => {
|
|||
* have a vector clock that is CONCURRENT with the import (created without
|
||||
* knowledge of the import). These must be dropped per clean slate semantics.
|
||||
*/
|
||||
base(
|
||||
'Import invalidates PENDING (unsynced) operations from other clients',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Import invalidates PENDING (unsynced) operations from other clients', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients with Initial Sync ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 1: Setting up both clients with initial sync',
|
||||
);
|
||||
// ============ PHASE 1: Setup Both Clients with Initial Sync ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 1: Setting up both clients with initial sync',
|
||||
);
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait(); // Initial sync to establish vector clock
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait(); // Initial sync to establish vector clock
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait(); // Initial sync to establish vector clock
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait(); // Initial sync to establish vector clock
|
||||
|
||||
console.log('[Pending Invalidation] Both clients synced initially');
|
||||
console.log('[Pending Invalidation] Both clients synced initially');
|
||||
|
||||
// ============ PHASE 2: Client B Creates Task but Does NOT Sync ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 2: Client B creates task (pending, not synced)',
|
||||
);
|
||||
// ============ PHASE 2: Client B Creates Task but Does NOT Sync ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 2: Client B creates task (pending, not synced)',
|
||||
);
|
||||
|
||||
const taskBPending = `Task-B-Pending-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBPending);
|
||||
await waitForTask(clientB.page, taskBPending);
|
||||
console.log(
|
||||
`[Pending Invalidation] Client B created: ${taskBPending} (NOT synced)`,
|
||||
);
|
||||
const taskBPending = `Task-B-Pending-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskBPending);
|
||||
await waitForTask(clientB.page, taskBPending);
|
||||
console.log(
|
||||
`[Pending Invalidation] Client B created: ${taskBPending} (NOT synced)`,
|
||||
);
|
||||
|
||||
// Verify task exists locally on B
|
||||
const taskBLocator = clientB.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBLocator).toBeVisible();
|
||||
// Verify task exists locally on B
|
||||
const taskBLocator = clientB.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBLocator).toBeVisible();
|
||||
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Pending Invalidation] Phase 3: Client A importing backup');
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Pending Invalidation] Phase 3: Client A importing backup');
|
||||
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
// Navigate to import page
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
|
||||
// Import the backup file (contains "E2E Import Test" tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Pending Invalidation] Client A imported backup');
|
||||
// Import the backup file (contains "E2E Import Test" tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Pending Invalidation] Client A imported backup');
|
||||
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Pending Invalidation] Client A has imported tasks');
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Pending Invalidation] Client A has imported tasks');
|
||||
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Pending Invalidation] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Pending Invalidation] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Pending Invalidation] Client A synced (SYNC_IMPORT uploaded)');
|
||||
// Client A syncs (uploads SYNC_IMPORT)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Pending Invalidation] Client A synced (SYNC_IMPORT uploaded)');
|
||||
|
||||
// ============ PHASE 5: Client B Syncs (Pending Ops Should Be Invalidated) ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 5: Client B syncs (pending ops invalidated)',
|
||||
);
|
||||
// ============ PHASE 5: Client B Syncs (Pending Ops Should Be Invalidated) ============
|
||||
console.log(
|
||||
'[Pending Invalidation] Phase 5: Client B syncs (pending ops invalidated)',
|
||||
);
|
||||
|
||||
// This is the CRITICAL sync - B's pending task was created BEFORE the import
|
||||
// but B hasn't synced it yet. When B syncs now:
|
||||
// 1. B tries to upload Task-B-Pending (has old vector clock)
|
||||
// 2. B receives SYNC_IMPORT
|
||||
// 3. B's pending ops are CONCURRENT to SYNC_IMPORT
|
||||
// 4. Clean slate semantics: B's pending ops are filtered/dropped
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Pending Invalidation] Client B synced (received SYNC_IMPORT)');
|
||||
// This is the CRITICAL sync - B's pending task was created BEFORE the import
|
||||
// but B hasn't synced it yet. When B syncs now:
|
||||
// 1. B tries to upload Task-B-Pending (has old vector clock)
|
||||
// 2. B receives SYNC_IMPORT
|
||||
// 3. B's pending ops are CONCURRENT to SYNC_IMPORT
|
||||
// 4. Clean slate semantics: B's pending ops are filtered/dropped
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Pending Invalidation] Client B synced (received SYNC_IMPORT)');
|
||||
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Wait for state to settle - allow UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// ============ PHASE 6: Verify Clean Slate on Client B ============
|
||||
console.log('[Pending Invalidation] Phase 6: Verifying clean slate');
|
||||
// ============ PHASE 6: Verify Clean Slate on Client B ============
|
||||
console.log('[Pending Invalidation] Phase 6: Verifying clean slate');
|
||||
|
||||
// Navigate back to work view to see tasks
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
// Navigate back to work view to see tasks
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
// CRITICAL VERIFICATION: B's pending task should be GONE
|
||||
console.log('[Pending Invalidation] Verifying pending task is gone...');
|
||||
// CRITICAL VERIFICATION: B's pending task should be GONE
|
||||
console.log('[Pending Invalidation] Verifying pending task is gone...');
|
||||
|
||||
const taskBPendingOnB = clientB.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBPendingOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log(
|
||||
'[Pending Invalidation] ✓ Task-B-Pending is GONE (correctly invalidated)',
|
||||
);
|
||||
const taskBPendingOnB = clientB.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBPendingOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log(
|
||||
'[Pending Invalidation] ✓ Task-B-Pending is GONE (correctly invalidated)',
|
||||
);
|
||||
|
||||
// Verify imported task is present on B
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Pending Invalidation] ✓ Client B has imported tasks');
|
||||
// Verify imported task is present on B
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Pending Invalidation] ✓ Client B has imported tasks');
|
||||
|
||||
// Also verify on Client A - should NOT have B's pending task
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
const taskBPendingOnA = clientA.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBPendingOnA).not.toBeVisible({ timeout: 5000 });
|
||||
console.log(
|
||||
"[Pending Invalidation] ✓ Client A also does not have B's pending task",
|
||||
);
|
||||
// Also verify on Client A - should NOT have B's pending task
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
const taskBPendingOnA = clientA.page.locator(`task:has-text("${taskBPending}")`);
|
||||
await expect(taskBPendingOnA).not.toBeVisible({ timeout: 5000 });
|
||||
console.log(
|
||||
"[Pending Invalidation] ✓ Client A also does not have B's pending task",
|
||||
);
|
||||
|
||||
console.log('[Pending Invalidation] ✓ Pending invalidation test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Pending Invalidation] ✓ Pending invalidation test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskVisible } from '../../utils/supersync-assertions';
|
||||
|
||||
/**
|
||||
* SuperSync Late Join E2E Tests
|
||||
|
|
@ -15,144 +15,126 @@ import {
|
|||
* Scenarios where a client works locally for a while before enabling sync.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
test.describe('@supersync SuperSync Late Join', () => {
|
||||
test('Late joiner merges correctly with existing server data', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
base.describe('@supersync SuperSync Late Join', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
// Client A: The "Server" Client (syncs immediately)
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
base(
|
||||
'Late joiner merges correctly with existing server data',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
// A creates initial data
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
// A Syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced initial tasks');
|
||||
|
||||
// Client A: The "Server" Client (syncs immediately)
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Client B: The "Late Joiner" (works locally first)
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
// DO NOT SETUP SYNC YET
|
||||
|
||||
// A creates initial data
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
// B creates local data
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
console.log('Client B created local tasks');
|
||||
|
||||
// A Syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced initial tasks');
|
||||
// A creates more data (server moves ahead)
|
||||
const taskA3 = `A3-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA3);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A added more tasks and synced');
|
||||
|
||||
// Client B: The "Late Joiner" (works locally first)
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
// DO NOT SETUP SYNC YET
|
||||
// B creates more local data
|
||||
const taskB3 = `B3-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB3);
|
||||
console.log('Client B added more local tasks');
|
||||
|
||||
// B creates local data
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
console.log('Client B created local tasks');
|
||||
// NOW B Enables Sync
|
||||
console.log('Client B enabling sync...');
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// A creates more data (server moves ahead)
|
||||
const taskA3 = `A3-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA3);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A added more tasks and synced');
|
||||
// Initial sync happens automatically on setup usually, but let's trigger to be sure
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// B creates more local data
|
||||
const taskB3 = `B3-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB3);
|
||||
console.log('Client B added more local tasks');
|
||||
|
||||
// NOW B Enables Sync
|
||||
console.log('Client B enabling sync...');
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync happens automatically on setup usually, but let's trigger to be sure
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Handle Potential Conflict Dialog
|
||||
// Since B has local data and server has remote data, and they might touch global settings or similar,
|
||||
// a conflict might occur. However, tasks are distinct IDs, so they should merge cleanly.
|
||||
// If a conflict dialog appears for Global Config or similar, we should handle it.
|
||||
// Check multiple times as dialog might appear with slight delay
|
||||
const conflictDialog = clientB.page.locator('dialog-conflict-resolution');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await conflictDialog.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('Conflict dialog detected on B, resolving...');
|
||||
// Pick Remote for singleton models (like Global Config)
|
||||
const useRemoteBtn = conflictDialog
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
// Wait for dialog to close
|
||||
await conflictDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
} else {
|
||||
// Fallback: dismiss dialog
|
||||
await clientB.page.keyboard.press('Escape');
|
||||
}
|
||||
// Brief wait for any subsequent dialogs
|
||||
await clientB.page.waitForTimeout(500);
|
||||
} catch {
|
||||
// No conflict dialog visible, proceed
|
||||
break;
|
||||
// Handle Potential Conflict Dialog
|
||||
// Since B has local data and server has remote data, and they might touch global settings or similar,
|
||||
// a conflict might occur. However, tasks are distinct IDs, so they should merge cleanly.
|
||||
// If a conflict dialog appears for Global Config or similar, we should handle it.
|
||||
// Check multiple times as dialog might appear with slight delay
|
||||
const conflictDialog = clientB.page.locator('dialog-conflict-resolution');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await conflictDialog.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('Conflict dialog detected on B, resolving...');
|
||||
// Pick Remote for singleton models (like Global Config)
|
||||
const useRemoteBtn = conflictDialog
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
// Wait for dialog to close
|
||||
await conflictDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
} else {
|
||||
// Fallback: dismiss dialog
|
||||
await clientB.page.keyboard.press('Escape');
|
||||
}
|
||||
// Brief wait for any subsequent dialogs
|
||||
await clientB.page.waitForTimeout(500);
|
||||
} catch {
|
||||
// No conflict dialog visible, proceed
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for sync to fully settle after conflict resolution
|
||||
await clientB.page.waitForTimeout(2000);
|
||||
|
||||
// Trigger another sync to ensure all data is propagated
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// A Syncs to get B's data
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Brief wait for state to propagate
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// VERIFICATION
|
||||
// Both clients should have A1, A2, A3, B1, B2, B3
|
||||
|
||||
const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskB3];
|
||||
|
||||
console.log('Verifying all tasks on Client A');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await expect(clientA.page.locator(`task:has-text("${task}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
console.log('Verifying all tasks on Client B');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientB.page, task);
|
||||
await expect(clientB.page.locator(`task:has-text("${task}")`)).toBeVisible();
|
||||
}
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for sync to fully settle after conflict resolution
|
||||
await clientB.page.waitForTimeout(2000);
|
||||
|
||||
// Trigger another sync to ensure all data is propagated
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// A Syncs to get B's data
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Brief wait for state to propagate
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// VERIFICATION
|
||||
// Both clients should have A1, A2, A3, B1, B2, B3
|
||||
|
||||
const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskB3];
|
||||
|
||||
console.log('Verifying all tasks on Client A');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await expectTaskVisible(clientA, task);
|
||||
}
|
||||
|
||||
console.log('Verifying all tasks on Client B');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientB.page, task);
|
||||
await expectTaskVisible(clientB, task);
|
||||
}
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Multiple clients with existing data joining SuperSync
|
||||
|
|
@ -172,160 +154,160 @@ base.describe('@supersync SuperSync Late Join', () => {
|
|||
* - Client 3 enables sync → same as Client 2
|
||||
* - All clients end up with ALL data from all three clients
|
||||
*/
|
||||
base(
|
||||
'Multiple clients with existing data all merge correctly when joining',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
test('Multiple clients with existing data all merge correctly when joining', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// === PHASE 1: Each client creates local data WITHOUT syncing ===
|
||||
console.log('[Test] Phase 1: Creating clients with local data (no sync yet)');
|
||||
|
||||
// Client A creates local tasks
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
console.log('[Test] Client A created 2 local tasks');
|
||||
|
||||
// Client B creates local tasks
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await waitForTask(clientB.page, taskB2);
|
||||
console.log('[Test] Client B created 2 local tasks');
|
||||
|
||||
// Client C creates local tasks
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
const taskC2 = `C2-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await clientC.workView.addTask(taskC2);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await waitForTask(clientC.page, taskC2);
|
||||
console.log('[Test] Client C created 2 local tasks');
|
||||
|
||||
// === PHASE 2: Clients enable sync one by one ===
|
||||
console.log('[Test] Phase 2: Enabling sync on each client');
|
||||
|
||||
// Client A enables sync first (should create SYNC_IMPORT since server is empty)
|
||||
console.log('[Test] Client A enabling sync (first to sync)...');
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced');
|
||||
|
||||
// Client B enables sync second (should download A's SYNC_IMPORT, NOT create new one)
|
||||
console.log('[Test] Client B enabling sync (second to sync)...');
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// Handle potential conflict dialogs
|
||||
const conflictDialogB = clientB.page.locator('dialog-conflict-resolution');
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// === PHASE 1: Each client creates local data WITHOUT syncing ===
|
||||
console.log('[Test] Phase 1: Creating clients with local data (no sync yet)');
|
||||
|
||||
// Client A creates local tasks
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
console.log('[Test] Client A created 2 local tasks');
|
||||
|
||||
// Client B creates local tasks
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await waitForTask(clientB.page, taskB2);
|
||||
console.log('[Test] Client B created 2 local tasks');
|
||||
|
||||
// Client C creates local tasks
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
const taskC2 = `C2-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await clientC.workView.addTask(taskC2);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await waitForTask(clientC.page, taskC2);
|
||||
console.log('[Test] Client C created 2 local tasks');
|
||||
|
||||
// === PHASE 2: Clients enable sync one by one ===
|
||||
console.log('[Test] Phase 2: Enabling sync on each client');
|
||||
|
||||
// Client A enables sync first (should create SYNC_IMPORT since server is empty)
|
||||
console.log('[Test] Client A enabling sync (first to sync)...');
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced');
|
||||
|
||||
// Client B enables sync second (should download A's SYNC_IMPORT, NOT create new one)
|
||||
console.log('[Test] Client B enabling sync (second to sync)...');
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// Handle potential conflict dialogs
|
||||
const conflictDialogB = clientB.page.locator('dialog-conflict-resolution');
|
||||
try {
|
||||
await conflictDialogB.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('[Test] Conflict dialog on B, resolving...');
|
||||
const useRemoteBtn = conflictDialogB
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
await conflictDialogB.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
} catch {
|
||||
// No conflict dialog, proceed
|
||||
await conflictDialogB.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('[Test] Conflict dialog on B, resolving...');
|
||||
const useRemoteBtn = conflictDialogB
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
await conflictDialogB.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Test] Client B synced');
|
||||
|
||||
// Client C enables sync third (should also merge, NOT create SYNC_IMPORT)
|
||||
console.log('[Test] Client C enabling sync (third to sync)...');
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
// Handle potential conflict dialogs
|
||||
const conflictDialogC = clientC.page.locator('dialog-conflict-resolution');
|
||||
try {
|
||||
await conflictDialogC.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('[Test] Conflict dialog on C, resolving...');
|
||||
const useRemoteBtn = conflictDialogC
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
await conflictDialogC.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
} catch {
|
||||
// No conflict dialog, proceed
|
||||
}
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('[Test] Client C synced');
|
||||
|
||||
// === PHASE 3: All clients sync to get each other's data ===
|
||||
console.log('[Test] Phase 3: Final sync round');
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Brief wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// === PHASE 4: Verification ===
|
||||
console.log('[Test] Phase 4: Verifying all clients have all data');
|
||||
|
||||
const allTasks = [taskA1, taskA2, taskB1, taskB2, taskC1, taskC2];
|
||||
|
||||
// Verify Client A has all 6 tasks
|
||||
console.log('[Test] Verifying Client A has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await expect(clientA.page.locator(`task:has-text("${task}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify Client B has all 6 tasks
|
||||
console.log('[Test] Verifying Client B has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientB.page, task);
|
||||
await expect(clientB.page.locator(`task:has-text("${task}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify Client C has all 6 tasks
|
||||
console.log('[Test] Verifying Client C has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientC.page, task);
|
||||
await expect(clientC.page.locator(`task:has-text("${task}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Additional verification - count tasks to ensure no duplicates or missing
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountA = await taskLocatorA.count();
|
||||
expect(taskCountA).toBe(6);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(6);
|
||||
|
||||
const taskLocatorC = clientC.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountC = await taskLocatorC.count();
|
||||
expect(taskCountC).toBe(6);
|
||||
|
||||
console.log('[Test] SUCCESS: All 3 clients with existing data merged correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
if (clientC) await closeClient(clientC).catch(() => {});
|
||||
} catch {
|
||||
// No conflict dialog, proceed
|
||||
}
|
||||
},
|
||||
);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Test] Client B synced');
|
||||
|
||||
// Client C enables sync third (should also merge, NOT create SYNC_IMPORT)
|
||||
console.log('[Test] Client C enabling sync (third to sync)...');
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
// Handle potential conflict dialogs
|
||||
const conflictDialogC = clientC.page.locator('dialog-conflict-resolution');
|
||||
try {
|
||||
await conflictDialogC.waitFor({ state: 'visible', timeout: 2000 });
|
||||
console.log('[Test] Conflict dialog on C, resolving...');
|
||||
const useRemoteBtn = conflictDialogC
|
||||
.locator('button')
|
||||
.filter({ hasText: 'Remote' })
|
||||
.first();
|
||||
if (await useRemoteBtn.isVisible()) {
|
||||
await useRemoteBtn.click();
|
||||
await conflictDialogC.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
} catch {
|
||||
// No conflict dialog, proceed
|
||||
}
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('[Test] Client C synced');
|
||||
|
||||
// === PHASE 3: All clients sync to get each other's data ===
|
||||
console.log('[Test] Phase 3: Final sync round');
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Brief wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// === PHASE 4: Verification ===
|
||||
console.log('[Test] Phase 4: Verifying all clients have all data');
|
||||
|
||||
const allTasks = [taskA1, taskA2, taskB1, taskB2, taskC1, taskC2];
|
||||
|
||||
// Verify Client A has all 6 tasks
|
||||
console.log('[Test] Verifying Client A has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await expectTaskVisible(clientA, task);
|
||||
}
|
||||
|
||||
// Verify Client B has all 6 tasks
|
||||
console.log('[Test] Verifying Client B has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientB.page, task);
|
||||
await expectTaskVisible(clientB, task);
|
||||
}
|
||||
|
||||
// Verify Client C has all 6 tasks
|
||||
console.log('[Test] Verifying Client C has all tasks...');
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientC.page, task);
|
||||
await expectTaskVisible(clientC, task);
|
||||
}
|
||||
|
||||
// Additional verification - count tasks to ensure no duplicates or missing
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountA = await taskLocatorA.count();
|
||||
expect(taskCountA).toBe(6);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(6);
|
||||
|
||||
const taskLocatorC = clientC.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountC = await taskLocatorC.count();
|
||||
expect(taskCountC).toBe(6);
|
||||
|
||||
console.log('[Test] SUCCESS: All 3 clients with existing data merged correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
if (clientC) await closeClient(clientC).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,15 @@
|
|||
import { test as base, expect, Page } from '@playwright/test';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { ProjectPage } from '../../pages/project.page';
|
||||
|
||||
/**
|
||||
* SuperSync Models E2E Tests
|
||||
*
|
||||
* Verifies synchronization of non-task models:
|
||||
* - Projects
|
||||
* - Tags (including creation and assignment)
|
||||
* - Project Notes
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
// Robust helper to create a project
|
||||
const createProjectReliably = async (page: Page, projectName: string): Promise<void> => {
|
||||
await page.goto('/#/tag/TODAY/work');
|
||||
|
|
@ -147,95 +134,84 @@ const createTagReliably = async (page: Page, tagName: string): Promise<void> =>
|
|||
await dialog.waitFor({ state: 'hidden' });
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Models', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
test.describe('@supersync SuperSync Models', () => {
|
||||
test('Projects and their tasks sync correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Client A setup
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create Project on Client A
|
||||
const projectName = `Proj-${testRunId}`;
|
||||
await createProjectReliably(clientA.page, projectName);
|
||||
|
||||
// Go to project
|
||||
const projectBtnA = clientA.page.getByText(projectName).first();
|
||||
await projectBtnA.waitFor({ state: 'visible' });
|
||||
await projectBtnA.click({ force: true });
|
||||
|
||||
// Add task to project
|
||||
const taskName = `TaskInProject-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client B setup
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify Project exists on B
|
||||
const projectsTreeB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Projects' })
|
||||
.first();
|
||||
await projectsTreeB.waitFor({ state: 'visible' });
|
||||
|
||||
const groupNavItemB = projectsTreeB.locator('nav-item').first();
|
||||
// Ensure expanded
|
||||
const expandBtnB = groupNavItemB
|
||||
.locator('button.expand-btn, button.arrow-btn')
|
||||
.first();
|
||||
if (await expandBtnB.isVisible()) {
|
||||
const isExpanded = await expandBtnB.getAttribute('aria-expanded');
|
||||
if (isExpanded !== 'true') {
|
||||
await groupNavItemB.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
const projectBtnB = clientB.page.getByText(projectName).first();
|
||||
await expect(projectBtnB).toBeVisible();
|
||||
|
||||
// Click project on B
|
||||
await projectBtnB.click({ force: true });
|
||||
|
||||
// Verify Task exists in project on B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
base(
|
||||
'Projects and their tasks sync correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Client A setup
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create Project on Client A
|
||||
const projectName = `Proj-${testRunId}`;
|
||||
await createProjectReliably(clientA.page, projectName);
|
||||
|
||||
// Go to project
|
||||
const projectBtnA = clientA.page.getByText(projectName).first();
|
||||
await projectBtnA.waitFor({ state: 'visible' });
|
||||
await projectBtnA.click({ force: true });
|
||||
|
||||
// Add task to project
|
||||
const taskName = `TaskInProject-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client B setup
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify Project exists on B
|
||||
const projectsTreeB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Projects' })
|
||||
.first();
|
||||
await projectsTreeB.waitFor({ state: 'visible' });
|
||||
|
||||
const groupNavItemB = projectsTreeB.locator('nav-item').first();
|
||||
// Ensure expanded
|
||||
const expandBtnB = groupNavItemB
|
||||
.locator('button.expand-btn, button.arrow-btn')
|
||||
.first();
|
||||
if (await expandBtnB.isVisible()) {
|
||||
const isExpanded = await expandBtnB.getAttribute('aria-expanded');
|
||||
if (isExpanded !== 'true') {
|
||||
await groupNavItemB.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
const projectBtnB = clientB.page.getByText(projectName).first();
|
||||
await expect(projectBtnB).toBeVisible();
|
||||
|
||||
// Click project on B
|
||||
await projectBtnB.click({ force: true });
|
||||
|
||||
// Verify Task exists in project on B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base('Tags and tagged tasks sync correctly', async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Tags and tagged tasks sync correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
|
|
@ -390,8 +366,7 @@ base.describe('@supersync SuperSync Models', () => {
|
|||
}
|
||||
});
|
||||
|
||||
base('Project notes sync correctly', async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Project notes sync correctly', async ({ browser, baseURL, testRunId }) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
|
|
@ -519,212 +494,202 @@ base.describe('@supersync SuperSync Models', () => {
|
|||
* - All tasks exist but NO task has the deleted tag
|
||||
* - Changes were atomic (single operation in sync)
|
||||
*/
|
||||
base(
|
||||
'Tag deletion atomically removes tag from many tasks',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(180000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Tag deletion atomically removes tag from many tasks', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(180000);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const tagName = `BulkTag-${testRunId}`;
|
||||
const taskCount = 10;
|
||||
const taskNames: string[] = [];
|
||||
const tagName = `BulkTag-${testRunId}`;
|
||||
const taskCount = 10;
|
||||
const taskNames: string[] = [];
|
||||
|
||||
// 1. Create tag first
|
||||
await createTagReliably(clientA.page, tagName);
|
||||
console.log(`[BulkTag] Created tag: ${tagName}`);
|
||||
// 1. Create tag first
|
||||
await createTagReliably(clientA.page, tagName);
|
||||
console.log(`[BulkTag] Created tag: ${tagName}`);
|
||||
|
||||
// Go back to Today view
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
// Go back to Today view
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
|
||||
// 2. Create tasks with the tag using short syntax: "task name #tagname"
|
||||
// This is much faster and more reliable than using the context menu
|
||||
for (let i = 0; i < taskCount; i++) {
|
||||
const taskName = `TaggedTask-${testRunId}-${i.toString().padStart(2, '0')}`;
|
||||
taskNames.push(taskName);
|
||||
// 2. Create tasks with the tag using short syntax: "task name #tagname"
|
||||
// This is much faster and more reliable than using the context menu
|
||||
for (let i = 0; i < taskCount; i++) {
|
||||
const taskName = `TaggedTask-${testRunId}-${i.toString().padStart(2, '0')}`;
|
||||
taskNames.push(taskName);
|
||||
|
||||
// Create task with tag using short syntax
|
||||
await clientA.workView.addTask(`${taskName} #${tagName}`);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
// Create task with tag using short syntax
|
||||
await clientA.workView.addTask(`${taskName} #${tagName}`);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Verify tag is on task
|
||||
const taskLocator = clientA.page
|
||||
.locator(`task:has-text("${taskName}")`)
|
||||
.first();
|
||||
await expect(taskLocator).toContainText(tagName);
|
||||
// Verify tag is on task
|
||||
const taskLocator = clientA.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskLocator).toContainText(tagName);
|
||||
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`[BulkTag] Created and tagged ${i + 1}/${taskCount} tasks`);
|
||||
}
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`[BulkTag] Created and tagged ${i + 1}/${taskCount} tasks`);
|
||||
}
|
||||
console.log(`[BulkTag] Created ${taskCount} tasks with tag`);
|
||||
|
||||
// 3. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client A synced');
|
||||
|
||||
// 4. Client B syncs
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client B synced');
|
||||
|
||||
// Verify B has the tag in sidebar
|
||||
const tagsTreeB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.first();
|
||||
await tagsTreeB.waitFor({ state: 'visible' });
|
||||
await expect(
|
||||
clientB.page.locator(`nav-list-tree:has-text("${tagName}")`),
|
||||
).toBeVisible();
|
||||
console.log('[BulkTag] Client B sees the tag');
|
||||
|
||||
// 5. Client A deletes the tag
|
||||
// Navigate to tag settings to delete it
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
|
||||
const tagsTreeA = clientA.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.first();
|
||||
await tagsTreeA.waitFor({ state: 'visible' });
|
||||
|
||||
// Find the tag nav item and right-click to get context menu
|
||||
const groupNavItemA = tagsTreeA.locator('nav-item').first();
|
||||
const expandBtnA = groupNavItemA
|
||||
.locator('button.expand-btn, button.arrow-btn')
|
||||
.first();
|
||||
if (await expandBtnA.isVisible()) {
|
||||
const isExpanded = await expandBtnA.getAttribute('aria-expanded');
|
||||
if (isExpanded !== 'true') {
|
||||
await groupNavItemA.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
const tagNavItem = tagsTreeA
|
||||
.locator('nav-item')
|
||||
.filter({ hasText: tagName })
|
||||
.first();
|
||||
await tagNavItem.waitFor({ state: 'visible' });
|
||||
|
||||
// Right-click on tag to get context menu
|
||||
await tagNavItem.click({ button: 'right' });
|
||||
await clientA.page.waitForTimeout(300);
|
||||
|
||||
// Find and click delete option
|
||||
const deleteBtn = clientA.page
|
||||
.locator('.mat-mdc-menu-panel button')
|
||||
.filter({ hasText: /delete/i })
|
||||
.first();
|
||||
await deleteBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await deleteBtn.click();
|
||||
|
||||
// Confirm deletion if there's a dialog
|
||||
const confirmBtn = clientA.page
|
||||
.locator('mat-dialog-container button')
|
||||
.filter({ hasText: /delete|confirm|yes/i })
|
||||
.first();
|
||||
try {
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await confirmBtn.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
} catch {
|
||||
// No confirmation dialog, that's fine
|
||||
}
|
||||
|
||||
console.log('[BulkTag] Tag deleted on Client A');
|
||||
|
||||
// 6. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client A synced after tag deletion');
|
||||
|
||||
// 7. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client B synced');
|
||||
|
||||
// Wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Navigate both clients to Today view
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/tag/TODAY/work');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// VERIFICATION: Tag should be gone on both clients
|
||||
console.log('[BulkTag] Verifying tag is gone...');
|
||||
|
||||
// Check that the tag name doesn't appear in the nav (it should be deleted)
|
||||
// We check in the Tags section specifically
|
||||
const tagItemA = clientA.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.locator(`nav-item:has-text("${tagName}")`);
|
||||
const tagItemB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.locator(`nav-item:has-text("${tagName}")`);
|
||||
|
||||
await expect(tagItemA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(tagItemB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[BulkTag] ✓ Tag is gone on both clients');
|
||||
|
||||
// VERIFICATION: All tasks still exist but WITHOUT the tag
|
||||
console.log('[BulkTag] Verifying tasks exist without the tag...');
|
||||
|
||||
// Check first few tasks on Client A
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const taskName = taskNames[i];
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:has-text("${taskName}")`)
|
||||
.first();
|
||||
await expect(taskLocatorA).toBeVisible({ timeout: 5000 });
|
||||
// Task should NOT contain the tag name
|
||||
await expect(taskLocatorA).not.toContainText(tagName);
|
||||
}
|
||||
console.log('[BulkTag] ✓ Tasks on Client A exist without tag');
|
||||
|
||||
// Check first few tasks on Client B
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const taskName = taskNames[i];
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:has-text("${taskName}")`)
|
||||
.first();
|
||||
await expect(taskLocatorB).toBeVisible({ timeout: 5000 });
|
||||
// Task should NOT contain the tag name
|
||||
await expect(taskLocatorB).not.toContainText(tagName);
|
||||
}
|
||||
console.log('[BulkTag] ✓ Tasks on Client B exist without tag');
|
||||
|
||||
// Verify task count matches
|
||||
const countA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(countA).toBe(countB);
|
||||
expect(countA).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[BulkTag] ✓ Both clients have ${countA} tasks`);
|
||||
|
||||
console.log('[BulkTag] ✓ Tag deletion atomic cleanup test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(`[BulkTag] Created ${taskCount} tasks with tag`);
|
||||
|
||||
// 3. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client A synced');
|
||||
|
||||
// 4. Client B syncs
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client B synced');
|
||||
|
||||
// Verify B has the tag in sidebar
|
||||
const tagsTreeB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.first();
|
||||
await tagsTreeB.waitFor({ state: 'visible' });
|
||||
await expect(
|
||||
clientB.page.locator(`nav-list-tree:has-text("${tagName}")`),
|
||||
).toBeVisible();
|
||||
console.log('[BulkTag] Client B sees the tag');
|
||||
|
||||
// 5. Client A deletes the tag
|
||||
// Navigate to tag settings to delete it
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
|
||||
const tagsTreeA = clientA.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.first();
|
||||
await tagsTreeA.waitFor({ state: 'visible' });
|
||||
|
||||
// Find the tag nav item and right-click to get context menu
|
||||
const groupNavItemA = tagsTreeA.locator('nav-item').first();
|
||||
const expandBtnA = groupNavItemA
|
||||
.locator('button.expand-btn, button.arrow-btn')
|
||||
.first();
|
||||
if (await expandBtnA.isVisible()) {
|
||||
const isExpanded = await expandBtnA.getAttribute('aria-expanded');
|
||||
if (isExpanded !== 'true') {
|
||||
await groupNavItemA.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
const tagNavItem = tagsTreeA
|
||||
.locator('nav-item')
|
||||
.filter({ hasText: tagName })
|
||||
.first();
|
||||
await tagNavItem.waitFor({ state: 'visible' });
|
||||
|
||||
// Right-click on tag to get context menu
|
||||
await tagNavItem.click({ button: 'right' });
|
||||
await clientA.page.waitForTimeout(300);
|
||||
|
||||
// Find and click delete option
|
||||
const deleteBtn = clientA.page
|
||||
.locator('.mat-mdc-menu-panel button')
|
||||
.filter({ hasText: /delete/i })
|
||||
.first();
|
||||
await deleteBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await deleteBtn.click();
|
||||
|
||||
// Confirm deletion if there's a dialog
|
||||
const confirmBtn = clientA.page
|
||||
.locator('mat-dialog-container button')
|
||||
.filter({ hasText: /delete|confirm|yes/i })
|
||||
.first();
|
||||
try {
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await confirmBtn.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
} catch {
|
||||
// No confirmation dialog, that's fine
|
||||
}
|
||||
|
||||
console.log('[BulkTag] Tag deleted on Client A');
|
||||
|
||||
// 6. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client A synced after tag deletion');
|
||||
|
||||
// 7. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkTag] Client B synced');
|
||||
|
||||
// Wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Navigate both clients to Today view
|
||||
await clientA.page.goto('/#/tag/TODAY/work');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/tag/TODAY/work');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// VERIFICATION: Tag should be gone on both clients
|
||||
console.log('[BulkTag] Verifying tag is gone...');
|
||||
|
||||
// Check that the tag name doesn't appear in the nav (it should be deleted)
|
||||
// We check in the Tags section specifically
|
||||
const tagItemA = clientA.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.locator(`nav-item:has-text("${tagName}")`);
|
||||
const tagItemB = clientB.page
|
||||
.locator('nav-list-tree')
|
||||
.filter({ hasText: 'Tags' })
|
||||
.locator(`nav-item:has-text("${tagName}")`);
|
||||
|
||||
await expect(tagItemA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(tagItemB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[BulkTag] ✓ Tag is gone on both clients');
|
||||
|
||||
// VERIFICATION: All tasks still exist but WITHOUT the tag
|
||||
console.log('[BulkTag] Verifying tasks exist without the tag...');
|
||||
|
||||
// Check first few tasks on Client A
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const taskName = taskNames[i];
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskLocatorA).toBeVisible({ timeout: 5000 });
|
||||
// Task should NOT contain the tag name
|
||||
await expect(taskLocatorA).not.toContainText(tagName);
|
||||
}
|
||||
console.log('[BulkTag] ✓ Tasks on Client A exist without tag');
|
||||
|
||||
// Check first few tasks on Client B
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const taskName = taskNames[i];
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskLocatorB).toBeVisible({ timeout: 5000 });
|
||||
// Task should NOT contain the tag name
|
||||
await expect(taskLocatorB).not.toContainText(tagName);
|
||||
}
|
||||
console.log('[BulkTag] ✓ Tasks on Client B exist without tag');
|
||||
|
||||
// Verify task count matches
|
||||
const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
expect(countA).toBe(countB);
|
||||
expect(countA).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[BulkTag] ✓ Both clients have ${countA} tasks`);
|
||||
|
||||
console.log('[BulkTag] ✓ Tag deletion atomic cleanup test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskVisible } from '../../utils/supersync-assertions';
|
||||
|
||||
/**
|
||||
* SuperSync Planner E2E Tests
|
||||
|
|
@ -18,24 +18,7 @@ import {
|
|||
* - Virtual TODAY_TAG behavior
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `planner-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Planner Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Planner Sync', () => {
|
||||
/**
|
||||
* Test: Task scheduled for tomorrow syncs correctly
|
||||
*
|
||||
|
|
@ -46,61 +29,61 @@ base.describe('@supersync Planner Sync', () => {
|
|||
* 4. Client B navigates to planner
|
||||
* 5. Verify task appears on tomorrow in planner
|
||||
*/
|
||||
base(
|
||||
'Task scheduled for tomorrow syncs to planner',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task scheduled for tomorrow syncs to planner', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Schedules Task for Tomorrow ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Schedules Task for Tomorrow ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `Tomorrow-${uniqueId}`;
|
||||
// sd:1d means schedule 1 day from now (tomorrow)
|
||||
await clientA.workView.addTask(`${taskName} sd:1d`);
|
||||
console.log('[Planner Test] Client A created task for tomorrow');
|
||||
const taskName = `Tomorrow-${uniqueId}`;
|
||||
// sd:1d means schedule 1 day from now (tomorrow)
|
||||
await clientA.workView.addTask(`${taskName} sd:1d`);
|
||||
console.log('[Planner Test] Client A created task for tomorrow');
|
||||
|
||||
// Task should NOT be visible in TODAY work view (it's for tomorrow)
|
||||
// But let's verify it was created
|
||||
await clientA.page.waitForTimeout(500);
|
||||
// Task should NOT be visible in TODAY work view (it's for tomorrow)
|
||||
// But let's verify it was created
|
||||
await clientA.page.waitForTimeout(500);
|
||||
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Planner Test] Client A synced');
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Planner Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads and Checks Planner ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Planner Test] Client B synced');
|
||||
// ============ PHASE 3: Client B Downloads and Checks Planner ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Planner Test] Client B synced');
|
||||
|
||||
// Navigate to planner
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
console.log('[Planner Test] Client B on planner page');
|
||||
// Navigate to planner
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
console.log('[Planner Test] Client B on planner page');
|
||||
|
||||
// Look for task in tomorrow's section
|
||||
// The planner shows days as columns or rows with date headers
|
||||
const taskInPlanner = clientB.page.locator(
|
||||
`planner-day:has-text("${taskName}"), .planner-day-tasks:has-text("${taskName}")`,
|
||||
);
|
||||
// Look for task in tomorrow's section
|
||||
// The planner shows days as columns or rows with date headers
|
||||
const taskInPlanner = clientB.page.locator(
|
||||
`planner-day:has-text("${taskName}"), .planner-day-tasks:has-text("${taskName}")`,
|
||||
);
|
||||
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Planner Test] Task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Planner Test] Task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Moving task to today in planner syncs correctly
|
||||
|
|
@ -112,49 +95,49 @@ base.describe('@supersync Planner Sync', () => {
|
|||
* 4. Both sync
|
||||
* 5. Verify task now in TODAY on Client B
|
||||
*/
|
||||
base(
|
||||
'Scheduled task visible in planner on synced client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Scheduled task visible in planner on synced client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Setup ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `Scheduled-${uniqueId}`;
|
||||
// sd:2d means schedule for 2 days from now
|
||||
await clientA.workView.addTask(`${taskName} sd:2d`);
|
||||
console.log('[Scheduled Test] Client A created task for 2 days from now');
|
||||
const taskName = `Scheduled-${uniqueId}`;
|
||||
// sd:2d means schedule for 2 days from now
|
||||
await clientA.workView.addTask(`${taskName} sd:2d`);
|
||||
console.log('[Scheduled Test] Client A created task for 2 days from now');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Both synced');
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Both synced');
|
||||
|
||||
// ============ PHASE 2: Verify in Planner on Client B ============
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
// ============ PHASE 2: Verify in Planner on Client B ============
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
|
||||
// Task should appear in planner
|
||||
const taskInPlanner = clientB.page.locator(`text=${taskName}`).first();
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Scheduled Test] Scheduled task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Task should appear in planner
|
||||
const taskInPlanner = clientB.page.locator(`text=${taskName}`).first();
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Scheduled Test] Scheduled task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Task with dueDay maintains virtual TODAY_TAG behavior
|
||||
|
|
@ -166,59 +149,58 @@ base.describe('@supersync Planner Sync', () => {
|
|||
* 4. Client B syncs
|
||||
* 5. Verify task in TODAY on Client B (via dueDay, not tagIds)
|
||||
*/
|
||||
base(
|
||||
'TODAY tag membership via dueDay syncs correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('TODAY tag membership via dueDay syncs correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Task for Today ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Task for Today ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `TodayTask-${uniqueId}`;
|
||||
// sd:today schedules for today, setting dueDay
|
||||
await clientA.workView.addTask(`${taskName} sd:today`);
|
||||
console.log('[TODAY Test] Client A created task for today');
|
||||
const taskName = `TodayTask-${uniqueId}`;
|
||||
// sd:today schedules for today, setting dueDay
|
||||
await clientA.workView.addTask(`${taskName} sd:today`);
|
||||
console.log('[TODAY Test] Client A created task for today');
|
||||
|
||||
// Verify task appears in TODAY view
|
||||
await waitForTask(clientA.page, taskName);
|
||||
console.log('[TODAY Test] Task visible in TODAY on Client A');
|
||||
// Verify task appears in TODAY view
|
||||
await waitForTask(clientA.page, taskName);
|
||||
console.log('[TODAY Test] Task visible in TODAY on Client A');
|
||||
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[TODAY Test] Both synced');
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[TODAY Test] Both synced');
|
||||
|
||||
// ============ PHASE 3: Verify on Client B ============
|
||||
// Task should appear in TODAY view on Client B
|
||||
// This confirms dueDay synced correctly and TODAY_TAG virtual membership works
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// ============ PHASE 3: Verify on Client B ============
|
||||
// Task should appear in TODAY view on Client B
|
||||
// This confirms dueDay synced correctly and TODAY_TAG virtual membership works
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible();
|
||||
console.log('[TODAY Test] Task visible in TODAY on Client B');
|
||||
await expectTaskVisible(clientB, taskName);
|
||||
console.log('[TODAY Test] Task visible in TODAY on Client B');
|
||||
|
||||
// Verify we're in TODAY tag context (URL check)
|
||||
const currentUrl = clientB.page.url();
|
||||
expect(currentUrl).toContain('TODAY');
|
||||
// Verify we're in TODAY tag context (URL check)
|
||||
const currentUrl = clientB.page.url();
|
||||
expect(currentUrl).toContain('TODAY');
|
||||
|
||||
console.log('[TODAY Test] Virtual TODAY_TAG membership working');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[TODAY Test] Virtual TODAY_TAG membership working');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Scheduling task for future date syncs
|
||||
|
|
@ -229,48 +211,48 @@ base.describe('@supersync Planner Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Client B checks planner for future date
|
||||
*/
|
||||
base(
|
||||
'Task scheduled for future date syncs to planner',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task scheduled for future date syncs to planner', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Schedules Task for Future ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Schedules Task for Future ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `Future-${uniqueId}`;
|
||||
// sd:3d means schedule 3 days from now
|
||||
await clientA.workView.addTask(`${taskName} sd:3d`);
|
||||
console.log('[Future Test] Client A created task for 3 days from now');
|
||||
const taskName = `Future-${uniqueId}`;
|
||||
// sd:3d means schedule 3 days from now
|
||||
await clientA.workView.addTask(`${taskName} sd:3d`);
|
||||
console.log('[Future Test] Client A created task for 3 days from now');
|
||||
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Future Test] Both synced');
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Future Test] Both synced');
|
||||
|
||||
// ============ PHASE 3: Verify in Planner ============
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
// ============ PHASE 3: Verify in Planner ============
|
||||
await clientB.page.goto('/#/planner');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('planner', { timeout: 10000 });
|
||||
|
||||
// The task should appear somewhere in the planner view
|
||||
const taskInPlanner = clientB.page.locator(`text=${taskName}`).first();
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Future Test] Task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// The task should appear somewhere in the planner view
|
||||
const taskInPlanner = clientB.page.locator(`text=${taskName}`).first();
|
||||
await expect(taskInPlanner).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Future Test] Task visible in planner on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -19,24 +18,7 @@ import {
|
|||
* - deletedInstanceDates sync
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `repeat-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Repeat Task Advanced Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Repeat Task Advanced Sync', () => {
|
||||
/**
|
||||
* Test: Scheduled task (with dueDay) syncs between clients
|
||||
*
|
||||
|
|
@ -48,57 +30,57 @@ base.describe('@supersync Repeat Task Advanced Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify task appears on Client B with scheduled indicator
|
||||
*/
|
||||
base(
|
||||
'Scheduled task with dueDay syncs to other client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Scheduled task with dueDay syncs to other client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Scheduled Task ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Scheduled Task ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Use sd:today shorthand to set dueDay to today
|
||||
const taskName = `ScheduledTask-${uniqueId}`;
|
||||
await clientA.workView.addTask(`${taskName} sd:today`);
|
||||
console.log('[Scheduled Test] Client A created scheduled task');
|
||||
// Use sd:today shorthand to set dueDay to today
|
||||
const taskName = `ScheduledTask-${uniqueId}`;
|
||||
await clientA.workView.addTask(`${taskName} sd:today`);
|
||||
console.log('[Scheduled Test] Client A created scheduled task');
|
||||
|
||||
// Verify task was created
|
||||
await waitForTask(clientA.page, taskName);
|
||||
// Verify task was created
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Client A synced');
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Client B synced');
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Scheduled Test] Client B synced');
|
||||
|
||||
// ============ PHASE 4: Verify on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// ============ PHASE 4: Verify on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible();
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible();
|
||||
|
||||
// Task should show scheduled indicator (sun icon for today)
|
||||
// The exact indicator depends on UI, but task should exist
|
||||
console.log('[Scheduled Test] Task visible on Client B');
|
||||
// Task should show scheduled indicator (sun icon for today)
|
||||
// The exact indicator depends on UI, but task should exist
|
||||
console.log('[Scheduled Test] Task visible on Client B');
|
||||
|
||||
console.log('[Scheduled Test] Scheduled task synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Scheduled Test] Scheduled task synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Task with time estimate syncs correctly
|
||||
|
|
@ -109,60 +91,60 @@ base.describe('@supersync Repeat Task Advanced Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify time estimate appears on Client B
|
||||
*/
|
||||
base(
|
||||
'Task with time estimate syncs to other client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task with time estimate syncs to other client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Task with Estimate ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Task with Estimate ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Use t:1h shorthand to set 1 hour estimate
|
||||
const taskName = `EstimateTask-${uniqueId}`;
|
||||
await clientA.workView.addTask(`${taskName} t:1h`);
|
||||
console.log('[Estimate Test] Client A created task with 1h estimate');
|
||||
// Use t:1h shorthand to set 1 hour estimate
|
||||
const taskName = `EstimateTask-${uniqueId}`;
|
||||
await clientA.workView.addTask(`${taskName} t:1h`);
|
||||
console.log('[Estimate Test] Client A created task with 1h estimate');
|
||||
|
||||
await waitForTask(clientA.page, taskName);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Task exists on Client A (estimate is stored but may not be visible in compact view)
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorA).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Estimate Test] Task visible on Client A');
|
||||
// Task exists on Client A (estimate is stored but may not be visible in compact view)
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorA).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Estimate Test] Task visible on Client A');
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Estimate Test] Client A synced');
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Estimate Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Estimate Test] Client B synced');
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Estimate Test] Client B synced');
|
||||
|
||||
// ============ PHASE 4: Verify Task on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// ============ PHASE 4: Verify Task on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Estimate Test] Task visible on Client B');
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Estimate Test] Task visible on Client B');
|
||||
|
||||
// Note: Time estimate is synced as task data, but UI display varies
|
||||
// The key test is that the task synced successfully
|
||||
console.log('[Estimate Test] Task with time estimate synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Note: Time estimate is synced as task data, but UI display varies
|
||||
// The key test is that the task synced successfully
|
||||
console.log('[Estimate Test] Task with time estimate synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Updating scheduled task properties syncs
|
||||
|
|
@ -174,68 +156,68 @@ base.describe('@supersync Repeat Task Advanced Sync', () => {
|
|||
* 4. Both sync
|
||||
* 5. Verify Client B has updated properties
|
||||
*/
|
||||
base(
|
||||
'Updating scheduled task properties syncs',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Updating scheduled task properties syncs', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Setup ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const originalName = `Original-${uniqueId}`;
|
||||
const updatedName = `Updated-${uniqueId}`;
|
||||
const originalName = `Original-${uniqueId}`;
|
||||
const updatedName = `Updated-${uniqueId}`;
|
||||
|
||||
await clientA.workView.addTask(`${originalName} sd:today`);
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.workView.addTask(`${originalName} sd:today`);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await waitForTask(clientB.page, originalName);
|
||||
console.log('[Update Test] Both clients have original task');
|
||||
await waitForTask(clientB.page, originalName);
|
||||
console.log('[Update Test] Both clients have original task');
|
||||
|
||||
// ============ PHASE 2: Client A Updates Task ============
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${originalName}")`);
|
||||
await taskLocatorA.dblclick();
|
||||
// ============ PHASE 2: Client A Updates Task ============
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${originalName}")`);
|
||||
await taskLocatorA.dblclick();
|
||||
|
||||
const editInput = clientA.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInput.fill(updatedName);
|
||||
await clientA.page.keyboard.press('Enter');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
console.log('[Update Test] Client A renamed task');
|
||||
const editInput = clientA.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInput.fill(updatedName);
|
||||
await clientA.page.keyboard.press('Enter');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
console.log('[Update Test] Client A renamed task');
|
||||
|
||||
// ============ PHASE 3: Sync Update ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
// ============ PHASE 3: Sync Update ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// ============ PHASE 4: Verify Update on Client B ============
|
||||
await waitForTask(clientB.page, updatedName);
|
||||
// ============ PHASE 4: Verify Update on Client B ============
|
||||
await waitForTask(clientB.page, updatedName);
|
||||
|
||||
// Original name should no longer exist
|
||||
const originalExists = await clientB.page
|
||||
.locator(`task:has-text("${originalName}")`)
|
||||
.count();
|
||||
expect(originalExists).toBe(0);
|
||||
// Original name should no longer exist
|
||||
const originalExists = await clientB.page
|
||||
.locator(`task:has-text("${originalName}")`)
|
||||
.count();
|
||||
expect(originalExists).toBe(0);
|
||||
|
||||
console.log('[Update Test] Task update synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Update Test] Task update synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Both clients create same scheduled task concurrently
|
||||
|
|
@ -249,59 +231,59 @@ base.describe('@supersync Repeat Task Advanced Sync', () => {
|
|||
* 3. Both sync
|
||||
* 4. Verify both tasks exist on both clients
|
||||
*/
|
||||
base(
|
||||
'Concurrent scheduled task creation merges correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Concurrent scheduled task creation merges correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// ============ PHASE 2: Concurrent Task Creation ============
|
||||
const taskAName = `TaskA-${uniqueId}`;
|
||||
const taskBName = `TaskB-${uniqueId}`;
|
||||
// ============ PHASE 2: Concurrent Task Creation ============
|
||||
const taskAName = `TaskA-${uniqueId}`;
|
||||
const taskBName = `TaskB-${uniqueId}`;
|
||||
|
||||
// Both create tasks scheduled for today, without syncing
|
||||
await clientA.workView.addTask(`${taskAName} sd:today`);
|
||||
console.log('[Concurrent Test] Client A created scheduled task');
|
||||
// Both create tasks scheduled for today, without syncing
|
||||
await clientA.workView.addTask(`${taskAName} sd:today`);
|
||||
console.log('[Concurrent Test] Client A created scheduled task');
|
||||
|
||||
await clientB.workView.addTask(`${taskBName} sd:today`);
|
||||
console.log('[Concurrent Test] Client B created scheduled task');
|
||||
await clientB.workView.addTask(`${taskBName} sd:today`);
|
||||
console.log('[Concurrent Test] Client B created scheduled task');
|
||||
|
||||
// ============ PHASE 3: Sync Both ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Final convergence
|
||||
console.log('[Concurrent Test] All clients synced');
|
||||
// ============ PHASE 3: Sync Both ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Final convergence
|
||||
console.log('[Concurrent Test] All clients synced');
|
||||
|
||||
// ============ PHASE 4: Verify Both Tasks Exist on Both Clients ============
|
||||
await waitForTask(clientA.page, taskAName);
|
||||
await waitForTask(clientA.page, taskBName);
|
||||
await waitForTask(clientB.page, taskAName);
|
||||
await waitForTask(clientB.page, taskBName);
|
||||
// ============ PHASE 4: Verify Both Tasks Exist on Both Clients ============
|
||||
await waitForTask(clientA.page, taskAName);
|
||||
await waitForTask(clientA.page, taskBName);
|
||||
await waitForTask(clientB.page, taskAName);
|
||||
await waitForTask(clientB.page, taskBName);
|
||||
|
||||
const countA = await clientA.page.locator('task').count();
|
||||
const countB = await clientB.page.locator('task').count();
|
||||
const countA = await clientA.page.locator('task').count();
|
||||
const countB = await clientB.page.locator('task').count();
|
||||
|
||||
expect(countA).toBe(countB);
|
||||
expect(countA).toBe(2);
|
||||
expect(countA).toBe(countB);
|
||||
expect(countA).toBe(2);
|
||||
|
||||
console.log('[Concurrent Test] Both scheduled tasks exist on both clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Concurrent Test] Both scheduled tasks exist on both clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskVisible } from '../../utils/supersync-assertions';
|
||||
|
||||
/**
|
||||
* SuperSync Repeatable Task E2E Tests
|
||||
|
|
@ -23,10 +23,6 @@ import {
|
|||
* src/app/core/persistence/operation-log/integration/repeat-task-sync.integration.spec.ts
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
// Helper to create a scheduled task using a SimulatedE2EClient
|
||||
// Note: Setting up actual repeat config requires navigating the detail panel
|
||||
// which is complex. This simplified version just schedules the task for today.
|
||||
|
|
@ -57,21 +53,7 @@ const createScheduledTask = async (
|
|||
await page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
||||
/**
|
||||
* Scenario: Scheduled Task Syncs to Second Client
|
||||
*
|
||||
|
|
@ -84,57 +66,55 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify Client B has the task
|
||||
*/
|
||||
base(
|
||||
'Scheduled task syncs to second client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Scheduled task syncs to second client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client A
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Setup Client B
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client B
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Client A creates a scheduled task
|
||||
const taskName = `ScheduledTask-${testRunId}`;
|
||||
await createScheduledTask(clientA, taskName);
|
||||
// 1. Client A creates a scheduled task
|
||||
const taskName = `ScheduledTask-${testRunId}`;
|
||||
await createScheduledTask(clientA, taskName);
|
||||
|
||||
// Verify task exists on Client A
|
||||
await waitForTask(clientA.page, taskName);
|
||||
// Verify task exists on Client A
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// 2. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
// 2. Client A syncs
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 3. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
// 3. Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// 4. Verify Client B has the task
|
||||
// Wait for UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// 4. Verify Client B has the task
|
||||
// Wait for UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// The task should appear on Client B's today list
|
||||
const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskOnB).toBeVisible({ timeout: 10000 });
|
||||
// The task should appear on Client B's today list
|
||||
await expectTaskVisible(clientB, taskName, 10000);
|
||||
|
||||
console.log(
|
||||
'[ScheduledTaskSync] ✓ Scheduled task synced successfully to second client',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
'[ScheduledTaskSync] ✓ Scheduled task synced successfully to second client',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Scheduled Task After Full Sync Import
|
||||
|
|
@ -150,61 +130,57 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
|||
* 4. Client B (fresh) sets up sync
|
||||
* 5. Verify Client B receives the task
|
||||
*/
|
||||
base(
|
||||
'Scheduled task created after import syncs correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Scheduled task created after import syncs correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// 1. Client A sets up sync (triggers initial sync)
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// 1. Client A sets up sync (triggers initial sync)
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 2. Immediately create a scheduled task (simulates the edge case)
|
||||
const taskName = `ScheduledAfterImport-${testRunId}`;
|
||||
await createScheduledTask(clientA, taskName);
|
||||
// 2. Immediately create a scheduled task (simulates the edge case)
|
||||
const taskName = `ScheduledAfterImport-${testRunId}`;
|
||||
await createScheduledTask(clientA, taskName);
|
||||
|
||||
// Verify task on Client A
|
||||
await waitForTask(clientA.page, taskName);
|
||||
// Verify task on Client A
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// 3. Client A syncs to upload the task
|
||||
await clientA.sync.syncAndWait();
|
||||
// 3. Client A syncs to upload the task
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// 4. Client B (fresh setup) joins
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
// 4. Client B (fresh setup) joins
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Wait for sync to complete
|
||||
await clientB.sync.syncAndWait();
|
||||
// Wait for sync to complete
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// 5. Verify Client B has the task instance
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// 5. Verify Client B has the task instance
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskOnB).toBeVisible({ timeout: 15000 });
|
||||
await expectTaskVisible(clientB, taskName, 15000);
|
||||
|
||||
// Verify it's the same task (not a duplicate created by Client B)
|
||||
const taskCount = await clientB.page
|
||||
.locator(`task:has-text("${taskName}")`)
|
||||
.count();
|
||||
expect(taskCount).toBe(1);
|
||||
// Verify it's the same task (not a duplicate created by Client B)
|
||||
const taskCount = await clientB.page
|
||||
.locator(`task:has-text("${taskName}")`)
|
||||
.count();
|
||||
expect(taskCount).toBe(1);
|
||||
|
||||
console.log(
|
||||
'[ScheduledAfterImport] ✓ Task created after import synced correctly',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[ScheduledAfterImport] ✓ Task created after import synced correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Task Deletion Syncs to Other Client
|
||||
|
|
@ -219,9 +195,7 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
|||
* 4. Client B syncs
|
||||
* 5. Verify Client B no longer has the task
|
||||
*/
|
||||
base('Task deletion syncs to other client', async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
test('Task deletion syncs to other client', async ({ browser, baseURL, testRunId }) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
|
@ -248,8 +222,7 @@ base.describe('@supersync SuperSync Repeatable Task Sync', () => {
|
|||
// 2. Client B syncs and verifies task exists
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
const taskOnB = clientB.page.locator(`task:has-text("${taskName}")`).first();
|
||||
await expect(taskOnB).toBeVisible({ timeout: 10000 });
|
||||
await expectTaskVisible(clientB, taskName, 10000);
|
||||
|
||||
// 3. Client A deletes the task
|
||||
const taskOnA = clientA.page.locator(`task:has-text("${taskName}")`).first();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -25,26 +24,8 @@ import {
|
|||
* to ensure all data is transferred to the new server.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
// Run server migration tests serially to avoid rate limiting when creating multiple test users
|
||||
base.describe.serial('@supersync SuperSync Server Migration', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe.serial('@supersync SuperSync Server Migration', () => {
|
||||
/**
|
||||
* Server Migration Scenario: Client A migrates to new server, Client B receives all data
|
||||
*
|
||||
|
|
@ -66,96 +47,96 @@ base.describe.serial('@supersync SuperSync Server Migration', () => {
|
|||
* 7. Client B syncs
|
||||
* 8. Client B should have ALL of Client A's data (tasks, projects, tags)
|
||||
*/
|
||||
base(
|
||||
'Client A migrates to new server, Client B receives all data',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Client A migrates to new server, Client B receives all data', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
// === PHASE 1: Setup with "old server" (user1) ===
|
||||
console.log('[Test] Phase 1: Setting up Client A with initial server');
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
const syncConfig1 = getSuperSyncConfig(user1);
|
||||
try {
|
||||
// === PHASE 1: Setup with "old server" (user1) ===
|
||||
console.log('[Test] Phase 1: Setting up Client A with initial server');
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
const syncConfig1 = getSuperSyncConfig(user1);
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig1);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig1);
|
||||
|
||||
// Create test data on Client A
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
// Create test data on Client A
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.workView.addTask(task3);
|
||||
|
||||
// Sync to "old server"
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced to initial server');
|
||||
// Sync to "old server"
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced to initial server');
|
||||
|
||||
// Verify tasks exist on Client A
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
// Verify tasks exist on Client A
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
|
||||
// === PHASE 2: Simulate server migration ===
|
||||
console.log('[Test] Phase 2: Migrating Client A to new server');
|
||||
// === PHASE 2: Simulate server migration ===
|
||||
console.log('[Test] Phase 2: Migrating Client A to new server');
|
||||
|
||||
// Create a new test user (simulates a fresh/new server)
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
const syncConfig2 = getSuperSyncConfig(user2);
|
||||
// Create a new test user (simulates a fresh/new server)
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
const syncConfig2 = getSuperSyncConfig(user2);
|
||||
|
||||
// Client A changes to new server credentials
|
||||
// This simulates switching sync providers or migrating to a new server
|
||||
await clientA.sync.setupSuperSync(syncConfig2);
|
||||
// Client A changes to new server credentials
|
||||
// This simulates switching sync providers or migrating to a new server
|
||||
await clientA.sync.setupSuperSync(syncConfig2);
|
||||
|
||||
// Sync to the "new server"
|
||||
// This is where the bug occurs:
|
||||
// - Server detects gap (sinceSeq > 0 but server is empty)
|
||||
// - Client resets lastServerSeq to 0
|
||||
// - Client should upload FULL STATE (not just pending ops)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced to new server (migration complete)');
|
||||
// Sync to the "new server"
|
||||
// This is where the bug occurs:
|
||||
// - Server detects gap (sinceSeq > 0 but server is empty)
|
||||
// - Client resets lastServerSeq to 0
|
||||
// - Client should upload FULL STATE (not just pending ops)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Client A synced to new server (migration complete)');
|
||||
|
||||
// Verify Client A still has all tasks after migration
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
// Verify Client A still has all tasks after migration
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
|
||||
// === PHASE 3: Client B joins new server ===
|
||||
console.log('[Test] Phase 3: Client B joining new server');
|
||||
// === PHASE 3: Client B joins new server ===
|
||||
console.log('[Test] Phase 3: Client B joining new server');
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig2);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig2);
|
||||
|
||||
// Client B syncs - should receive ALL of Client A's data
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Test] Client B synced with new server');
|
||||
// Client B syncs - should receive ALL of Client A's data
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Test] Client B synced with new server');
|
||||
|
||||
// === PHASE 4: Verification ===
|
||||
console.log('[Test] Phase 4: Verifying Client B has all data');
|
||||
// === PHASE 4: Verification ===
|
||||
console.log('[Test] Phase 4: Verifying Client B has all data');
|
||||
|
||||
// This is the critical assertion - Client B should have ALL tasks
|
||||
// If the bug exists, Client B will only have partial data or no tasks
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
// This is the critical assertion - Client B should have ALL tasks
|
||||
// If the bug exists, Client B will only have partial data or no tasks
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
// Additional verification - count tasks to ensure no duplicates
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(3);
|
||||
// Additional verification - count tasks to ensure no duplicates
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(3);
|
||||
|
||||
console.log('[Test] SUCCESS: Client B received all data after server migration');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Test] SUCCESS: Client B received all data after server migration');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Server Migration with Pending Local Changes
|
||||
|
|
@ -163,134 +144,134 @@ base.describe.serial('@supersync SuperSync Server Migration', () => {
|
|||
* Tests that pending local operations are preserved during server migration.
|
||||
* Client creates new data, then migrates before syncing.
|
||||
*/
|
||||
base(
|
||||
'Client A migrates with pending local changes, all data syncs',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Client A migrates with pending local changes, all data syncs', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
// Setup with initial server
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
const syncConfig1 = getSuperSyncConfig(user1);
|
||||
try {
|
||||
// Setup with initial server
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
const syncConfig1 = getSuperSyncConfig(user1);
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig1);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig1);
|
||||
|
||||
// Create and sync initial data
|
||||
const task1 = `Initial-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
// Wait for task to be fully created before syncing
|
||||
await waitForTask(clientA.page, task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Task 1 created and synced to server 1');
|
||||
// Create and sync initial data
|
||||
const task1 = `Initial-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
// Wait for task to be fully created before syncing
|
||||
await waitForTask(clientA.page, task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Task 1 created and synced to server 1');
|
||||
|
||||
// Create MORE data AFTER syncing (pending local changes)
|
||||
const task2 = `Pending-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
// Wait for task to be fully created in store before migration
|
||||
await waitForTask(clientA.page, task2);
|
||||
console.log('[Test] Task 2 created (pending local change)');
|
||||
// DON'T sync yet - task2 is a pending local change
|
||||
// Create MORE data AFTER syncing (pending local changes)
|
||||
const task2 = `Pending-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
// Wait for task to be fully created in store before migration
|
||||
await waitForTask(clientA.page, task2);
|
||||
console.log('[Test] Task 2 created (pending local change)');
|
||||
// DON'T sync yet - task2 is a pending local change
|
||||
|
||||
// Migrate to new server
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
const syncConfig2 = getSuperSyncConfig(user2);
|
||||
console.log('[Test] Migrating to new server...');
|
||||
await clientA.sync.setupSuperSync(syncConfig2);
|
||||
// Migrate to new server
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
const syncConfig2 = getSuperSyncConfig(user2);
|
||||
console.log('[Test] Migrating to new server...');
|
||||
await clientA.sync.setupSuperSync(syncConfig2);
|
||||
|
||||
// Sync to new server (should include both synced and pending data)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Migration sync completed');
|
||||
// Sync to new server (should include both synced and pending data)
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Test] Migration sync completed');
|
||||
|
||||
// Verify Client A still has both tasks after migration
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
// Verify Client A still has both tasks after migration
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
|
||||
// Client B joins
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig2);
|
||||
// Add settling time before sync
|
||||
await clientB.page.waitForTimeout(500);
|
||||
await clientB.sync.syncAndWait();
|
||||
// Client B joins
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig2);
|
||||
// Add settling time before sync
|
||||
await clientB.page.waitForTimeout(500);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify Client B has BOTH tasks
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
// Verify Client B has BOTH tasks
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(2);
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${testRunId}")`);
|
||||
const taskCountB = await taskLocatorB.count();
|
||||
expect(taskCountB).toBe(2);
|
||||
|
||||
console.log('[Test] SUCCESS: Pending local changes preserved during migration');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Test] SUCCESS: Pending local changes preserved during migration');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Multiple Migrations: Client A migrates twice
|
||||
*
|
||||
* Tests that multiple server migrations work correctly.
|
||||
*/
|
||||
base(
|
||||
'Client A can migrate multiple times without data loss',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Client A can migrate multiple times without data loss', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120000); // 2 minutes - migration tests need extra time
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
// First server
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user1));
|
||||
try {
|
||||
// First server
|
||||
const user1 = await createTestUser(`${testRunId}-server1`);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user1));
|
||||
|
||||
const task1 = `Server1-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
const task1 = `Server1-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// First migration
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user2));
|
||||
// First migration
|
||||
const user2 = await createTestUser(`${testRunId}-server2`);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user2));
|
||||
|
||||
const task2 = `Server2-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.sync.syncAndWait();
|
||||
const task2 = `Server2-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Second migration
|
||||
const user3 = await createTestUser(`${testRunId}-server3`);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user3));
|
||||
// Second migration
|
||||
const user3 = await createTestUser(`${testRunId}-server3`);
|
||||
await clientA.sync.setupSuperSync(getSuperSyncConfig(user3));
|
||||
|
||||
const task3 = `Server3-${testRunId}`;
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.sync.syncAndWait();
|
||||
const task3 = `Server3-${testRunId}`;
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify Client A has all tasks
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
// Verify Client A has all tasks
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
await waitForTask(clientA.page, task3);
|
||||
|
||||
// Client B joins the final server
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(getSuperSyncConfig(user3));
|
||||
await clientB.sync.syncAndWait();
|
||||
// Client B joins the final server
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(getSuperSyncConfig(user3));
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Client B should have ALL tasks from all migrations
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
// Client B should have ALL tasks from all migrations
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
console.log('[Test] SUCCESS: Multiple migrations preserved all data');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Test] SUCCESS: Multiple migrations preserved all data');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA).catch(() => {});
|
||||
if (clientB) await closeClient(clientB).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -15,178 +14,160 @@ import {
|
|||
* sync correctly between clients using absolute values.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
/**
|
||||
* Helper to navigate to settings and create a simple counter
|
||||
*/
|
||||
const createSimpleCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
title: string,
|
||||
type: 'click' | 'stopwatch',
|
||||
): Promise<void> => {
|
||||
// Navigate to settings using the correct selector
|
||||
const settingsBtn = client.page.locator(
|
||||
'magic-side-nav .tour-settingsMenuBtn, magic-side-nav nav-item:has([icon="settings"]) button',
|
||||
);
|
||||
await settingsBtn.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await settingsBtn.click();
|
||||
await client.page.waitForURL(/config/);
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Click on Simple Counters section (it's inside a collapsible component)
|
||||
// The translated title is "Simple Counters & Habit Tracking"
|
||||
// It's under "Productivity Helper" section, may need to scroll to see it
|
||||
const simpleCountersSection = client.page.locator(
|
||||
'.collapsible-header:has-text("Simple Counter")',
|
||||
);
|
||||
|
||||
// Scroll to section and wait for it
|
||||
await simpleCountersSection.scrollIntoViewIfNeeded();
|
||||
await simpleCountersSection.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await simpleCountersSection.click();
|
||||
|
||||
// Wait for collapsible to expand
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Click Add Counter button - text is "Add simple counter/ habit"
|
||||
// This is a formly repeat type that adds fields inline (not a dialog)
|
||||
// The repeat section type has a footer with the add button
|
||||
const addBtn = client.page.locator(
|
||||
'repeat-section-type .footer button, button:has-text("Add simple counter")',
|
||||
);
|
||||
await addBtn.scrollIntoViewIfNeeded();
|
||||
await addBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await addBtn.click();
|
||||
|
||||
// Wait for inline form fields to appear
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Find the newly added counter row (last one in the list)
|
||||
// The repeat section type creates .row elements inside .list-wrapper
|
||||
const counterRows = client.page.locator('repeat-section-type .row');
|
||||
const lastCounterRow = counterRows.last();
|
||||
|
||||
// Fill title - find the title input in the last counter row
|
||||
const titleInput = lastCounterRow.locator('input').first();
|
||||
await titleInput.scrollIntoViewIfNeeded();
|
||||
await titleInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await titleInput.fill(title);
|
||||
|
||||
// Select type - find the select in the last counter row
|
||||
const typeSelect = lastCounterRow.locator('mat-select').first();
|
||||
await typeSelect.scrollIntoViewIfNeeded();
|
||||
await typeSelect.click();
|
||||
await client.page.waitForTimeout(300);
|
||||
const typeOption = client.page.locator(
|
||||
`mat-option:has-text("${type === 'click' ? 'Click Counter' : 'Stopwatch'}")`,
|
||||
);
|
||||
await typeOption.click();
|
||||
|
||||
// Wait for dropdown to close
|
||||
await client.page.waitForTimeout(300);
|
||||
|
||||
// Save the form - the simple counter cfg has a Save button
|
||||
const saveBtn = client.page.locator(
|
||||
'simple-counter-cfg button:has-text("Save"), .submit-button:has-text("Save")',
|
||||
);
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
await saveBtn.click();
|
||||
|
||||
// Wait for save to complete
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Navigate back to work view using home button or similar
|
||||
await client.page.goto('/#/tag/TODAY/tasks');
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
|
||||
await client.page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
base.describe('@supersync Simple Counter Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
/**
|
||||
* Helper to get the counter value from the header by title (using mat-tooltip)
|
||||
* Desktop counters have [matTooltip]="title" which Angular renders as ng-reflect-message
|
||||
*/
|
||||
const getCounterValue = async (
|
||||
client: SimulatedE2EClient,
|
||||
counterTitle: string,
|
||||
): Promise<string> => {
|
||||
// Wait for simple counters to be rendered
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
// Find the counter by its tooltip (title)
|
||||
// Angular Material's matTooltip directive sets ng-reflect-message attribute
|
||||
const counterBtn = client.page.locator(
|
||||
`simple-counter-button[ng-reflect-message="${counterTitle}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper to navigate to settings and create a simple counter
|
||||
*/
|
||||
const createSimpleCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
title: string,
|
||||
type: 'click' | 'stopwatch',
|
||||
): Promise<void> => {
|
||||
// Navigate to settings using the correct selector
|
||||
const settingsBtn = client.page.locator(
|
||||
'magic-side-nav .tour-settingsMenuBtn, magic-side-nav nav-item:has([icon="settings"]) button',
|
||||
);
|
||||
await settingsBtn.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await settingsBtn.click();
|
||||
await client.page.waitForURL(/config/);
|
||||
await client.page.waitForTimeout(500);
|
||||
// If not found by ng-reflect, try finding by the wrapper
|
||||
if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
// Alternative: find by checking all counters
|
||||
const allCounters = client.page.locator('simple-counter-button');
|
||||
const count = await allCounters.count();
|
||||
console.log(`Found ${count} simple counter buttons`);
|
||||
|
||||
// Click on Simple Counters section (it's inside a collapsible component)
|
||||
// The translated title is "Simple Counters & Habit Tracking"
|
||||
// It's under "Productivity Helper" section, may need to scroll to see it
|
||||
const simpleCountersSection = client.page.locator(
|
||||
'.collapsible-header:has-text("Simple Counter")',
|
||||
);
|
||||
|
||||
// Scroll to section and wait for it
|
||||
await simpleCountersSection.scrollIntoViewIfNeeded();
|
||||
await simpleCountersSection.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await simpleCountersSection.click();
|
||||
|
||||
// Wait for collapsible to expand
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Click Add Counter button - text is "Add simple counter/ habit"
|
||||
// This is a formly repeat type that adds fields inline (not a dialog)
|
||||
// The repeat section type has a footer with the add button
|
||||
const addBtn = client.page.locator(
|
||||
'repeat-section-type .footer button, button:has-text("Add simple counter")',
|
||||
);
|
||||
await addBtn.scrollIntoViewIfNeeded();
|
||||
await addBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await addBtn.click();
|
||||
|
||||
// Wait for inline form fields to appear
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Find the newly added counter row (last one in the list)
|
||||
// The repeat section type creates .row elements inside .list-wrapper
|
||||
const counterRows = client.page.locator('repeat-section-type .row');
|
||||
const lastCounterRow = counterRows.last();
|
||||
|
||||
// Fill title - find the title input in the last counter row
|
||||
const titleInput = lastCounterRow.locator('input').first();
|
||||
await titleInput.scrollIntoViewIfNeeded();
|
||||
await titleInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await titleInput.fill(title);
|
||||
|
||||
// Select type - find the select in the last counter row
|
||||
const typeSelect = lastCounterRow.locator('mat-select').first();
|
||||
await typeSelect.scrollIntoViewIfNeeded();
|
||||
await typeSelect.click();
|
||||
await client.page.waitForTimeout(300);
|
||||
const typeOption = client.page.locator(
|
||||
`mat-option:has-text("${type === 'click' ? 'Click Counter' : 'Stopwatch'}")`,
|
||||
);
|
||||
await typeOption.click();
|
||||
|
||||
// Wait for dropdown to close
|
||||
await client.page.waitForTimeout(300);
|
||||
|
||||
// Save the form - the simple counter cfg has a Save button
|
||||
const saveBtn = client.page.locator(
|
||||
'simple-counter-cfg button:has-text("Save"), .submit-button:has-text("Save")',
|
||||
);
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
await saveBtn.click();
|
||||
|
||||
// Wait for save to complete
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Navigate back to work view using home button or similar
|
||||
await client.page.goto('/#/tag/TODAY/tasks');
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
|
||||
await client.page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the counter value from the header by title (using mat-tooltip)
|
||||
* Desktop counters have [matTooltip]="title" which Angular renders as ng-reflect-message
|
||||
*/
|
||||
const getCounterValue = async (
|
||||
client: SimulatedE2EClient,
|
||||
counterTitle: string,
|
||||
): Promise<string> => {
|
||||
// Wait for simple counters to be rendered
|
||||
await client.page.waitForTimeout(500);
|
||||
|
||||
// Find the counter by its tooltip (title)
|
||||
// Angular Material's matTooltip directive sets ng-reflect-message attribute
|
||||
const counterBtn = client.page.locator(
|
||||
`simple-counter-button[ng-reflect-message="${counterTitle}"]`,
|
||||
);
|
||||
|
||||
// If not found by ng-reflect, try finding by the wrapper
|
||||
if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
// Alternative: find by checking all counters
|
||||
const allCounters = client.page.locator('simple-counter-button');
|
||||
const count = await allCounters.count();
|
||||
console.log(`Found ${count} simple counter buttons`);
|
||||
|
||||
// Return last counter's value if we can't find by title
|
||||
if (count > 0) {
|
||||
const lastCounter = allCounters.last();
|
||||
const label = lastCounter.locator('.label');
|
||||
if (await label.isVisible()) {
|
||||
return (await label.textContent()) || '0';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
await expect(counterBtn).toBeVisible({ timeout: 10000 });
|
||||
const label = counterBtn.locator('.label');
|
||||
// If no label exists (count is 0), return '0'
|
||||
if (!(await label.isVisible())) {
|
||||
return '0';
|
||||
}
|
||||
return (await label.textContent()) || '0';
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to increment a click counter by title
|
||||
*/
|
||||
const incrementClickCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
counterTitle: string,
|
||||
): Promise<void> => {
|
||||
// Find the counter by its tooltip (title)
|
||||
const counterBtn = client.page.locator(
|
||||
`simple-counter-button[ng-reflect-message="${counterTitle}"]`,
|
||||
);
|
||||
|
||||
// If not found by ng-reflect, use last counter
|
||||
if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
const allCounters = client.page.locator('simple-counter-button');
|
||||
// Return last counter's value if we can't find by title
|
||||
if (count > 0) {
|
||||
const lastCounter = allCounters.last();
|
||||
await lastCounter.locator('.main-btn').click();
|
||||
return;
|
||||
const label = lastCounter.locator('.label');
|
||||
if (await label.isVisible()) {
|
||||
return (await label.textContent()) || '0';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
return '0';
|
||||
}
|
||||
|
||||
await counterBtn.locator('.main-btn').click();
|
||||
};
|
||||
await expect(counterBtn).toBeVisible({ timeout: 10000 });
|
||||
const label = counterBtn.locator('.label');
|
||||
// If no label exists (count is 0), return '0'
|
||||
if (!(await label.isVisible())) {
|
||||
return '0';
|
||||
}
|
||||
return (await label.textContent()) || '0';
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to increment a click counter by title
|
||||
*/
|
||||
const incrementClickCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
counterTitle: string,
|
||||
): Promise<void> => {
|
||||
// Find the counter by its tooltip (title)
|
||||
const counterBtn = client.page.locator(
|
||||
`simple-counter-button[ng-reflect-message="${counterTitle}"]`,
|
||||
);
|
||||
|
||||
// If not found by ng-reflect, use last counter
|
||||
if (!(await counterBtn.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
const allCounters = client.page.locator('simple-counter-button');
|
||||
const lastCounter = allCounters.last();
|
||||
await lastCounter.locator('.main-btn').click();
|
||||
return;
|
||||
}
|
||||
|
||||
await counterBtn.locator('.main-btn').click();
|
||||
};
|
||||
|
||||
test.describe('@supersync Simple Counter Sync', () => {
|
||||
/**
|
||||
* Scenario: Click counter syncs with absolute value
|
||||
*
|
||||
|
|
@ -200,68 +181,67 @@ base.describe('@supersync Simple Counter Sync', () => {
|
|||
* 4. Client B syncs
|
||||
* 5. Verify Client B sees the same value (3)
|
||||
*/
|
||||
base(
|
||||
'Click counter syncs correctly between clients',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
const counterTitle = `ClickTest-${uniqueId}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Click counter syncs correctly between clients', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
const counterTitle = `ClickTest-${uniqueId}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Setup & Increment ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Setup & Increment ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create click counter
|
||||
await createSimpleCounter(clientA, counterTitle, 'click');
|
||||
// Create click counter
|
||||
await createSimpleCounter(clientA, counterTitle, 'click');
|
||||
|
||||
// Wait for counter to appear
|
||||
await clientA.page.waitForTimeout(500);
|
||||
// Wait for counter to appear
|
||||
await clientA.page.waitForTimeout(500);
|
||||
|
||||
// Increment 3 times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Verify Client A shows 3
|
||||
const valueA = await getCounterValue(clientA, counterTitle);
|
||||
expect(valueA).toBe('3');
|
||||
console.log(`Client A counter value: ${valueA}`);
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
|
||||
// ============ PHASE 2: Client B Sync & Verify ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
|
||||
// Wait for UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify Client B sees the same value
|
||||
const valueB = await getCounterValue(clientB, counterTitle);
|
||||
console.log(`Client B counter value: ${valueB}`);
|
||||
expect(valueB).toBe('3');
|
||||
|
||||
console.log('✓ Click counter sync verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Increment 3 times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify Client A shows 3
|
||||
const valueA = await getCounterValue(clientA, counterTitle);
|
||||
expect(valueA).toBe('3');
|
||||
console.log(`Client A counter value: ${valueA}`);
|
||||
|
||||
// Sync A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
|
||||
// ============ PHASE 2: Client B Sync & Verify ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Sync B
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
|
||||
// Wait for UI to update
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify Client B sees the same value
|
||||
const valueB = await getCounterValue(clientB, counterTitle);
|
||||
console.log(`Client B counter value: ${valueB}`);
|
||||
expect(valueB).toBe('3');
|
||||
|
||||
console.log('✓ Click counter sync verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: Click counter on Client B doesn't get wrong value
|
||||
|
|
@ -277,84 +257,83 @@ base.describe('@supersync Simple Counter Sync', () => {
|
|||
* 3. Client C syncs
|
||||
* 4. Verify Client C sees 3 (not 0 or any other wrong value)
|
||||
*/
|
||||
base(
|
||||
'Click counter maintains correct value across multiple clients',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(180000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
const counterTitle = `MultiClientClick-${uniqueId}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
test('Click counter maintains correct value across multiple clients', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
const counterTitle = `MultiClientClick-${uniqueId}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A creates and increments ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A creates and increments ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
await createSimpleCounter(clientA, counterTitle, 'click');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
await createSimpleCounter(clientA, counterTitle, 'click');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
|
||||
// Increment to 2
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
// Increment to 2
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
await incrementClickCounter(clientA, counterTitle);
|
||||
await clientA.page.waitForTimeout(200);
|
||||
|
||||
const valueA = await getCounterValue(clientA, counterTitle);
|
||||
expect(valueA).toBe('2');
|
||||
console.log(`Client A counter value: ${valueA}`);
|
||||
const valueA = await getCounterValue(clientA, counterTitle);
|
||||
expect(valueA).toBe('2');
|
||||
console.log(`Client A counter value: ${valueA}`);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('Client A synced.');
|
||||
|
||||
// ============ PHASE 2: Client B syncs and increments ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B initial sync done.');
|
||||
// ============ PHASE 2: Client B syncs and increments ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B initial sync done.');
|
||||
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify B got the value from A
|
||||
let valueB = await getCounterValue(clientB, counterTitle);
|
||||
expect(valueB).toBe('2');
|
||||
console.log(`Client B after sync: ${valueB}`);
|
||||
// Verify B got the value from A
|
||||
let valueB = await getCounterValue(clientB, counterTitle);
|
||||
expect(valueB).toBe('2');
|
||||
console.log(`Client B after sync: ${valueB}`);
|
||||
|
||||
// B increments (should be 3)
|
||||
await incrementClickCounter(clientB, counterTitle);
|
||||
await clientB.page.waitForTimeout(200);
|
||||
// B increments (should be 3)
|
||||
await incrementClickCounter(clientB, counterTitle);
|
||||
await clientB.page.waitForTimeout(200);
|
||||
|
||||
valueB = await getCounterValue(clientB, counterTitle);
|
||||
expect(valueB).toBe('3');
|
||||
console.log(`Client B after increment: ${valueB}`);
|
||||
valueB = await getCounterValue(clientB, counterTitle);
|
||||
expect(valueB).toBe('3');
|
||||
console.log(`Client B after increment: ${valueB}`);
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('Client B synced.');
|
||||
|
||||
// ============ PHASE 3: Client C syncs and verifies ============
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('Client C synced.');
|
||||
// ============ PHASE 3: Client C syncs and verifies ============
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('Client C synced.');
|
||||
|
||||
await clientC.page.waitForTimeout(1000);
|
||||
await clientC.page.waitForTimeout(1000);
|
||||
|
||||
// Verify C sees 3 (not 0 or any other wrong value)
|
||||
const valueC = await getCounterValue(clientC, counterTitle);
|
||||
console.log(`Client C counter value: ${valueC}`);
|
||||
expect(valueC).toBe('3');
|
||||
// Verify C sees 3 (not 0 or any other wrong value)
|
||||
const valueC = await getCounterValue(clientC, counterTitle);
|
||||
console.log(`Client C counter value: ${valueC}`);
|
||||
expect(valueC).toBe('3');
|
||||
|
||||
console.log('✓ Multi-client click counter sync verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('✓ Multi-client click counter sync verification passed!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -25,25 +24,7 @@ import {
|
|||
* other clients whose ops were skipped).
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Snapshot Vector Clock', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync SuperSync Snapshot Vector Clock', () => {
|
||||
/**
|
||||
* Scenario 1: Fresh client joins after snapshot optimization
|
||||
*
|
||||
|
|
@ -63,153 +44,146 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => {
|
|||
* With the fix, Client C receives snapshotVectorClock and can create
|
||||
* dominating updates.
|
||||
*/
|
||||
base(
|
||||
'Fresh client syncs correctly after snapshot optimization (no infinite loop)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
test('Fresh client syncs correctly after snapshot optimization (no infinite loop)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// === Phase 1: Client A creates initial data and syncs ===
|
||||
console.log('[SnapshotClock] Phase 1: Client A creates initial data');
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// === Phase 1: Client A creates initial data and syncs ===
|
||||
console.log('[SnapshotClock] Phase 1: Client A creates initial data');
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create several tasks to build up operation history
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
const taskA3 = `A3-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
await clientA.workView.addTask(taskA3);
|
||||
await waitForTask(clientA.page, taskA3);
|
||||
// Create several tasks to build up operation history
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
const taskA3 = `A3-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
await clientA.workView.addTask(taskA3);
|
||||
await waitForTask(clientA.page, taskA3);
|
||||
|
||||
// Sync to server
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[SnapshotClock] Client A synced initial tasks');
|
||||
// Sync to server
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[SnapshotClock] Client A synced initial tasks');
|
||||
|
||||
// === Phase 2: Client B joins and makes changes ===
|
||||
console.log('[SnapshotClock] Phase 2: Client B joins and makes changes');
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// === Phase 2: Client B joins and makes changes ===
|
||||
console.log('[SnapshotClock] Phase 2: Client B joins and makes changes');
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has all tasks
|
||||
await waitForTask(clientB.page, taskA1);
|
||||
await waitForTask(clientB.page, taskA2);
|
||||
await waitForTask(clientB.page, taskA3);
|
||||
// Verify B has all tasks
|
||||
await waitForTask(clientB.page, taskA1);
|
||||
await waitForTask(clientB.page, taskA2);
|
||||
await waitForTask(clientB.page, taskA3);
|
||||
|
||||
// Client B creates more tasks (these will be after any potential snapshot)
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
await waitForTask(clientB.page, taskB2);
|
||||
// Client B creates more tasks (these will be after any potential snapshot)
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
const taskB2 = `B2-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await clientB.workView.addTask(taskB2);
|
||||
await waitForTask(clientB.page, taskB2);
|
||||
|
||||
// Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[SnapshotClock] Client B created and synced tasks');
|
||||
// Client B syncs
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[SnapshotClock] Client B created and synced tasks');
|
||||
|
||||
// Client A syncs to get B's changes
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskB1);
|
||||
await waitForTask(clientA.page, taskB2);
|
||||
// Client A syncs to get B's changes
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskB1);
|
||||
await waitForTask(clientA.page, taskB2);
|
||||
|
||||
// === Phase 3: Client C joins fresh (simulates snapshot optimization) ===
|
||||
console.log('[SnapshotClock] Phase 3: Client C joins fresh');
|
||||
clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
// === Phase 3: Client C joins fresh (simulates snapshot optimization) ===
|
||||
console.log('[SnapshotClock] Phase 3: Client C joins fresh');
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync - Client C downloads all data
|
||||
// Server may use snapshot optimization here depending on server state
|
||||
await clientC.sync.syncAndWait();
|
||||
// Initial sync - Client C downloads all data
|
||||
// Server may use snapshot optimization here depending on server state
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Verify C has all existing tasks
|
||||
await waitForTask(clientC.page, taskA1);
|
||||
await waitForTask(clientC.page, taskA2);
|
||||
await waitForTask(clientC.page, taskA3);
|
||||
await waitForTask(clientC.page, taskB1);
|
||||
await waitForTask(clientC.page, taskB2);
|
||||
console.log('[SnapshotClock] Client C downloaded all tasks');
|
||||
// Verify C has all existing tasks
|
||||
await waitForTask(clientC.page, taskA1);
|
||||
await waitForTask(clientC.page, taskA2);
|
||||
await waitForTask(clientC.page, taskA3);
|
||||
await waitForTask(clientC.page, taskB1);
|
||||
await waitForTask(clientC.page, taskB2);
|
||||
console.log('[SnapshotClock] Client C downloaded all tasks');
|
||||
|
||||
// === Phase 4: Client C makes changes ===
|
||||
// This is the critical part - C's changes need to sync without getting stuck
|
||||
console.log('[SnapshotClock] Phase 4: Client C makes changes');
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
const taskC2 = `C2-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await clientC.workView.addTask(taskC2);
|
||||
await waitForTask(clientC.page, taskC2);
|
||||
// === Phase 4: Client C makes changes ===
|
||||
// This is the critical part - C's changes need to sync without getting stuck
|
||||
console.log('[SnapshotClock] Phase 4: Client C makes changes');
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
const taskC2 = `C2-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await clientC.workView.addTask(taskC2);
|
||||
await waitForTask(clientC.page, taskC2);
|
||||
|
||||
// Client C syncs - without the fix, this could get stuck in infinite loop
|
||||
// Set a shorter timeout to catch infinite loops quickly
|
||||
const syncPromise = clientC.sync.syncAndWait();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Sync timed out - possible infinite loop')),
|
||||
30000,
|
||||
),
|
||||
);
|
||||
// Client C syncs - without the fix, this could get stuck in infinite loop
|
||||
// Set a shorter timeout to catch infinite loops quickly
|
||||
const syncPromise = clientC.sync.syncAndWait();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Sync timed out - possible infinite loop')),
|
||||
30000,
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.race([syncPromise, timeoutPromise]);
|
||||
console.log('[SnapshotClock] Client C synced successfully (no infinite loop)');
|
||||
await Promise.race([syncPromise, timeoutPromise]);
|
||||
console.log('[SnapshotClock] Client C synced successfully (no infinite loop)');
|
||||
|
||||
// === Phase 5: Verify all clients converge ===
|
||||
console.log('[SnapshotClock] Phase 5: Verifying convergence');
|
||||
// === Phase 5: Verify all clients converge ===
|
||||
console.log('[SnapshotClock] Phase 5: Verifying convergence');
|
||||
|
||||
// Sync all clients to converge
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
// Sync all clients to converge
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Wait for UI to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
await clientC.page.waitForTimeout(1000);
|
||||
// Wait for UI to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
await clientC.page.waitForTimeout(1000);
|
||||
|
||||
// All tasks should be present on all clients
|
||||
const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskC1, taskC2];
|
||||
// All tasks should be present on all clients
|
||||
const allTasks = [taskA1, taskA2, taskA3, taskB1, taskB2, taskC1, taskC2];
|
||||
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await waitForTask(clientB.page, task);
|
||||
await waitForTask(clientC.page, task);
|
||||
}
|
||||
|
||||
// Count tasks to ensure consistency
|
||||
const countA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countC = await clientC.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
|
||||
expect(countA).toBe(7);
|
||||
expect(countB).toBe(7);
|
||||
expect(countC).toBe(7);
|
||||
|
||||
console.log('[SnapshotClock] ✓ All clients converged with 7 tasks each');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await waitForTask(clientB.page, task);
|
||||
await waitForTask(clientC.page, task);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Count tasks to ensure consistency
|
||||
const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countC = await clientC.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
|
||||
expect(countA).toBe(7);
|
||||
expect(countB).toBe(7);
|
||||
expect(countC).toBe(7);
|
||||
|
||||
console.log('[SnapshotClock] ✓ All clients converged with 7 tasks each');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 2: Multiple fresh clients joining sequentially
|
||||
|
|
@ -217,125 +191,118 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => {
|
|||
* Tests that multiple clients can join fresh and sync correctly,
|
||||
* each receiving proper snapshotVectorClock values.
|
||||
*/
|
||||
base(
|
||||
'Multiple fresh clients join and sync correctly after snapshot',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(180000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
let clientD: SimulatedE2EClient | null = null;
|
||||
test('Multiple fresh clients join and sync correctly after snapshot', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
let clientD: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Client A: Creates initial data
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Client A: Creates initial data
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client A created initial task');
|
||||
const taskA1 = `A1-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
await waitForTask(clientA.page, taskA1);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client A created initial task');
|
||||
|
||||
// Client B: Joins, adds data
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskA1);
|
||||
// Client B: Joins, adds data
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskA1);
|
||||
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client B joined and added task');
|
||||
const taskB1 = `B1-${testRunId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
await waitForTask(clientB.page, taskB1);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client B joined and added task');
|
||||
|
||||
// Close Client B to simulate it going offline
|
||||
await closeClient(clientB);
|
||||
clientB = null;
|
||||
// Close Client B to simulate it going offline
|
||||
await closeClient(clientB);
|
||||
clientB = null;
|
||||
|
||||
// Client A continues working
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
await clientA.sync.syncAndWait();
|
||||
// Client A continues working
|
||||
const taskA2 = `A2-${testRunId}`;
|
||||
await clientA.workView.addTask(taskA2);
|
||||
await waitForTask(clientA.page, taskA2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client C: Joins fresh (never saw Client B's changes locally)
|
||||
clientC = await createSimulatedClient(browser, appUrl, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
// Client C: Joins fresh (never saw Client B's changes locally)
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// C should have all tasks from A and B
|
||||
await waitForTask(clientC.page, taskA1);
|
||||
await waitForTask(clientC.page, taskA2);
|
||||
await waitForTask(clientC.page, taskB1);
|
||||
// C should have all tasks from A and B
|
||||
await waitForTask(clientC.page, taskA1);
|
||||
await waitForTask(clientC.page, taskA2);
|
||||
await waitForTask(clientC.page, taskB1);
|
||||
|
||||
// C adds its own task
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client C joined and added task');
|
||||
// C adds its own task
|
||||
const taskC1 = `C1-${testRunId}`;
|
||||
await clientC.workView.addTask(taskC1);
|
||||
await waitForTask(clientC.page, taskC1);
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client C joined and added task');
|
||||
|
||||
// Client D: Another fresh client joins
|
||||
clientD = await createSimulatedClient(browser, appUrl, 'D', testRunId);
|
||||
await clientD.sync.setupSuperSync(syncConfig);
|
||||
await clientD.sync.syncAndWait();
|
||||
// Client D: Another fresh client joins
|
||||
clientD = await createSimulatedClient(browser, baseURL!, 'D', testRunId);
|
||||
await clientD.sync.setupSuperSync(syncConfig);
|
||||
await clientD.sync.syncAndWait();
|
||||
|
||||
// D should have all tasks
|
||||
await waitForTask(clientD.page, taskA1);
|
||||
await waitForTask(clientD.page, taskA2);
|
||||
await waitForTask(clientD.page, taskB1);
|
||||
await waitForTask(clientD.page, taskC1);
|
||||
// D should have all tasks
|
||||
await waitForTask(clientD.page, taskA1);
|
||||
await waitForTask(clientD.page, taskA2);
|
||||
await waitForTask(clientD.page, taskB1);
|
||||
await waitForTask(clientD.page, taskC1);
|
||||
|
||||
// D adds its own task
|
||||
const taskD1 = `D1-${testRunId}`;
|
||||
await clientD.workView.addTask(taskD1);
|
||||
await waitForTask(clientD.page, taskD1);
|
||||
await clientD.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client D joined and added task');
|
||||
// D adds its own task
|
||||
const taskD1 = `D1-${testRunId}`;
|
||||
await clientD.workView.addTask(taskD1);
|
||||
await waitForTask(clientD.page, taskD1);
|
||||
await clientD.sync.syncAndWait();
|
||||
console.log('[MultiClient] Client D joined and added task');
|
||||
|
||||
// Final sync for all active clients
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
await clientD.sync.syncAndWait();
|
||||
// Final sync for all active clients
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
await clientD.sync.syncAndWait();
|
||||
|
||||
// Verify all active clients have all 5 tasks
|
||||
const allTasks = [taskA1, taskA2, taskB1, taskC1, taskD1];
|
||||
// Verify all active clients have all 5 tasks
|
||||
const allTasks = [taskA1, taskA2, taskB1, taskC1, taskD1];
|
||||
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await waitForTask(clientC.page, task);
|
||||
await waitForTask(clientD.page, task);
|
||||
}
|
||||
|
||||
const countA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countC = await clientC.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countD = await clientD.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
|
||||
expect(countA).toBe(5);
|
||||
expect(countC).toBe(5);
|
||||
expect(countD).toBe(5);
|
||||
|
||||
console.log('[MultiClient] ✓ All clients converged with 5 tasks each');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
if (clientD) await closeClient(clientD);
|
||||
for (const task of allTasks) {
|
||||
await waitForTask(clientA.page, task);
|
||||
await waitForTask(clientC.page, task);
|
||||
await waitForTask(clientD.page, task);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countC = await clientC.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countD = await clientD.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
|
||||
expect(countA).toBe(5);
|
||||
expect(countC).toBe(5);
|
||||
expect(countD).toBe(5);
|
||||
|
||||
console.log('[MultiClient] ✓ All clients converged with 5 tasks each');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
if (clientD) await closeClient(clientD);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 3: Fresh client with concurrent modifications
|
||||
|
|
@ -344,117 +311,112 @@ base.describe('@supersync SuperSync Snapshot Vector Clock', () => {
|
|||
* has concurrent modifications with an existing client. This is
|
||||
* the most likely scenario to trigger the infinite loop bug.
|
||||
*/
|
||||
base(
|
||||
'Fresh client handles concurrent modifications after snapshot',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Fresh client handles concurrent modifications after snapshot', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Client A: Establishes baseline
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Client A: Establishes baseline
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create initial tasks
|
||||
const sharedTask = `Shared-${testRunId}`;
|
||||
await clientA.workView.addTask(sharedTask);
|
||||
await waitForTask(clientA.page, sharedTask);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ConcurrentFresh] Client A created shared task');
|
||||
// Create initial tasks
|
||||
const sharedTask = `Shared-${testRunId}`;
|
||||
await clientA.workView.addTask(sharedTask);
|
||||
await waitForTask(clientA.page, sharedTask);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ConcurrentFresh] Client A created shared task');
|
||||
|
||||
// Add more operations to build up history
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await clientA.workView.addTask(`Filler-${i}-${testRunId}`);
|
||||
}
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ConcurrentFresh] Client A added filler tasks');
|
||||
|
||||
// Client B: Joins fresh
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has all tasks
|
||||
await waitForTask(clientB.page, sharedTask);
|
||||
console.log('[ConcurrentFresh] Client B joined and synced');
|
||||
|
||||
// Now create concurrent modifications
|
||||
// Client A: Marks shared task as done
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
console.log('[ConcurrentFresh] Client A marked shared task done');
|
||||
|
||||
// Client B: Also marks shared task as done (concurrent change)
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
console.log('[ConcurrentFresh] Client B marked shared task done (concurrent)');
|
||||
|
||||
// Client A syncs first
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client B syncs - this is where the infinite loop could occur
|
||||
// without the snapshotVectorClock fix
|
||||
const syncPromise = clientB.sync.syncAndWait();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Sync timed out - possible infinite loop')),
|
||||
30000,
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.race([syncPromise, timeoutPromise]);
|
||||
console.log('[ConcurrentFresh] Client B synced without infinite loop');
|
||||
|
||||
// Final sync to ensure convergence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Wait for UI to settle
|
||||
await clientA.page.waitForTimeout(500);
|
||||
await clientB.page.waitForTimeout(500);
|
||||
|
||||
// Verify both clients show the shared task as done
|
||||
await waitForTask(clientA.page, sharedTask);
|
||||
await waitForTask(clientB.page, sharedTask);
|
||||
|
||||
const finalTaskA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
const finalTaskB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
|
||||
await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
|
||||
// Verify task counts match
|
||||
const countA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const countB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(countA).toBe(countB);
|
||||
|
||||
console.log('[ConcurrentFresh] ✓ Concurrent modifications resolved correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Add more operations to build up history
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await clientA.workView.addTask(`Filler-${i}-${testRunId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[ConcurrentFresh] Client A added filler tasks');
|
||||
|
||||
// Client B: Joins fresh
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has all tasks
|
||||
await waitForTask(clientB.page, sharedTask);
|
||||
console.log('[ConcurrentFresh] Client B joined and synced');
|
||||
|
||||
// Now create concurrent modifications
|
||||
// Client A: Marks shared task as done
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorA).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
console.log('[ConcurrentFresh] Client A marked shared task done');
|
||||
|
||||
// Client B: Also marks shared task as done (concurrent change)
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorB).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
console.log('[ConcurrentFresh] Client B marked shared task done (concurrent)');
|
||||
|
||||
// Client A syncs first
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Client B syncs - this is where the infinite loop could occur
|
||||
// without the snapshotVectorClock fix
|
||||
const syncPromise = clientB.sync.syncAndWait();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Sync timed out - possible infinite loop')),
|
||||
30000,
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.race([syncPromise, timeoutPromise]);
|
||||
console.log('[ConcurrentFresh] Client B synced without infinite loop');
|
||||
|
||||
// Final sync to ensure convergence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Wait for UI to settle
|
||||
await clientA.page.waitForTimeout(500);
|
||||
await clientB.page.waitForTimeout(500);
|
||||
|
||||
// Verify both clients show the shared task as done
|
||||
await waitForTask(clientA.page, sharedTask);
|
||||
await waitForTask(clientB.page, sharedTask);
|
||||
|
||||
const finalTaskA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
const finalTaskB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${sharedTask}")`)
|
||||
.first();
|
||||
|
||||
await expect(finalTaskA).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
await expect(finalTaskB).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
|
||||
// Verify task counts match
|
||||
const countA = await clientA.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
const countB = await clientB.page.locator(`task:has-text("${testRunId}")`).count();
|
||||
expect(countA).toBe(countB);
|
||||
|
||||
console.log('[ConcurrentFresh] ✓ Concurrent modifications resolved correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { ImportPage } from '../../pages/import.page';
|
||||
|
|
@ -37,25 +36,7 @@ import { waitForAppReady } from '../../utils/waits';
|
|||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-stale-clock-regression.spec.ts
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync @regression Stale Clock Regression', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync @regression Stale Clock Regression', () => {
|
||||
/**
|
||||
* Regression test: Operations created during SYNC_IMPORT hydration use correct clock
|
||||
*
|
||||
|
|
@ -81,153 +62,153 @@ base.describe('@supersync @regression Stale Clock Regression', () => {
|
|||
* - No sync errors on either client
|
||||
* - B's reload + sync didn't cause data loss
|
||||
*/
|
||||
base(
|
||||
'Operations created during SYNC_IMPORT hydration use merged clock (regression)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Operations created during SYNC_IMPORT hydration use merged clock (regression)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
console.log('[Stale Clock] Phase 1: Setting up both clients');
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
console.log('[Stale Clock] Phase 1: Setting up both clients');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync to establish vector clocks
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Both clients synced initially');
|
||||
// Initial sync to establish vector clocks
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Both clients synced initially');
|
||||
|
||||
// ============ PHASE 2: Create Task Scheduled for Today ============
|
||||
console.log('[Stale Clock] Phase 2: Creating task scheduled for today');
|
||||
// ============ PHASE 2: Create Task Scheduled for Today ============
|
||||
console.log('[Stale Clock] Phase 2: Creating task scheduled for today');
|
||||
|
||||
// This task will affect TODAY_TAG, which may trigger repair during hydration
|
||||
const taskToday = `Today-Task-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskToday);
|
||||
// This task will affect TODAY_TAG, which may trigger repair during hydration
|
||||
const taskToday = `Today-Task-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskToday);
|
||||
|
||||
// Schedule for today by opening task details and setting due date
|
||||
// Note: Using the planner page or task context menu might be needed
|
||||
// For simplicity, just create a regular task - TODAY_TAG repair happens
|
||||
// when there's any state inconsistency during loadAllData
|
||||
// Schedule for today by opening task details and setting due date
|
||||
// Note: Using the planner page or task context menu might be needed
|
||||
// For simplicity, just create a regular task - TODAY_TAG repair happens
|
||||
// when there's any state inconsistency during loadAllData
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Stale Clock] Client A created and synced: ${taskToday}`);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[Stale Clock] Client A created and synced: ${taskToday}`);
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskToday);
|
||||
console.log('[Stale Clock] Client B received the task');
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskToday);
|
||||
console.log('[Stale Clock] Client B received the task');
|
||||
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Stale Clock] Phase 3: Client A importing backup');
|
||||
// ============ PHASE 3: Client A Imports Backup ============
|
||||
console.log('[Stale Clock] Phase 3: Client A importing backup');
|
||||
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
|
||||
// Import backup (contains different tasks - E2E Import Test tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Stale Clock] Client A imported backup');
|
||||
// Import backup (contains different tasks - E2E Import Test tasks)
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
console.log('[Stale Clock] Client A imported backup');
|
||||
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Re-enable sync after import (import overwrites globalConfig)
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Stale Clock] Client A has imported tasks');
|
||||
// Wait for imported task to be visible
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Stale Clock] Client A has imported tasks');
|
||||
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Stale Clock] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
// ============ PHASE 4: Sync to Propagate SYNC_IMPORT ============
|
||||
console.log('[Stale Clock] Phase 4: Syncing to propagate SYNC_IMPORT');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client A synced (SYNC_IMPORT uploaded)');
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client A synced (SYNC_IMPORT uploaded)');
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client B synced (received SYNC_IMPORT)');
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client B synced (received SYNC_IMPORT)');
|
||||
|
||||
// ============ PHASE 5: Client B Reloads (Triggers Hydration) ============
|
||||
console.log('[Stale Clock] Phase 5: Client B reloading (triggers hydration)');
|
||||
// ============ PHASE 5: Client B Reloads (Triggers Hydration) ============
|
||||
console.log('[Stale Clock] Phase 5: Client B reloading (triggers hydration)');
|
||||
|
||||
// This is the CRITICAL step - reload triggers fresh hydration
|
||||
// With the bug, operations created during loadAllData would get stale clocks
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
console.log('[Stale Clock] Client B reloaded, hydration complete');
|
||||
// This is the CRITICAL step - reload triggers fresh hydration
|
||||
// With the bug, operations created during loadAllData would get stale clocks
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
console.log('[Stale Clock] Client B reloaded, hydration complete');
|
||||
|
||||
// Navigate to work view
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
// Navigate to work view
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Stale Clock] Client B showing imported data after reload');
|
||||
// Wait for imported task to appear
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
console.log('[Stale Clock] Client B showing imported data after reload');
|
||||
|
||||
// ============ PHASE 6: Client B Syncs After Reload ============
|
||||
console.log('[Stale Clock] Phase 6: Client B syncing after reload');
|
||||
// ============ PHASE 6: Client B Syncs After Reload ============
|
||||
console.log('[Stale Clock] Phase 6: Client B syncing after reload');
|
||||
|
||||
// This sync should succeed - any ops created during hydration should have correct clocks
|
||||
// With the bug, ops would be rejected as CONFLICT_STALE and treated as permanent rejection
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client B synced after reload');
|
||||
// This sync should succeed - any ops created during hydration should have correct clocks
|
||||
// With the bug, ops would be rejected as CONFLICT_STALE and treated as permanent rejection
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client B synced after reload');
|
||||
|
||||
// Client A syncs to receive any merged ops from B
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client A synced to receive B updates');
|
||||
// Client A syncs to receive any merged ops from B
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Stale Clock] Client A synced to receive B updates');
|
||||
|
||||
// Brief wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
// Brief wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// ============ PHASE 7: Verify Consistent State ============
|
||||
console.log('[Stale Clock] Phase 7: Verifying consistent state');
|
||||
// ============ PHASE 7: Verify Consistent State ============
|
||||
console.log('[Stale Clock] Phase 7: Verifying consistent state');
|
||||
|
||||
// Navigate both to work view
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
// Navigate both to work view
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Both clients should have imported tasks
|
||||
const importedTaskOnA = clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
// Both clients should have imported tasks
|
||||
const importedTaskOnA = clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
const importedTaskOnB = clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
);
|
||||
|
||||
await expect(importedTaskOnA).toBeVisible({ timeout: 5000 });
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Stale Clock] ✓ Both clients have imported tasks');
|
||||
await expect(importedTaskOnA).toBeVisible({ timeout: 5000 });
|
||||
await expect(importedTaskOnB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Stale Clock] Both clients have imported tasks');
|
||||
|
||||
// Original task should be gone (clean slate from import)
|
||||
const originalTaskOnA = clientA.page.locator(`task:has-text("${taskToday}")`);
|
||||
const originalTaskOnB = clientB.page.locator(`task:has-text("${taskToday}")`);
|
||||
// Original task should be gone (clean slate from import)
|
||||
const originalTaskOnA = clientA.page.locator(`task:has-text("${taskToday}")`);
|
||||
const originalTaskOnB = clientB.page.locator(`task:has-text("${taskToday}")`);
|
||||
|
||||
await expect(originalTaskOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(originalTaskOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Stale Clock] ✓ Original tasks are gone (clean slate)');
|
||||
await expect(originalTaskOnA).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(originalTaskOnB).not.toBeVisible({ timeout: 5000 });
|
||||
console.log('[Stale Clock] Original tasks are gone (clean slate)');
|
||||
|
||||
// Check for sync error indicators
|
||||
// The snack bar would show if there were rejected ops
|
||||
const errorSnack = clientB.page.locator('simple-snack-bar.error');
|
||||
await expect(errorSnack).not.toBeVisible({ timeout: 2000 });
|
||||
console.log('[Stale Clock] ✓ No sync errors on Client B');
|
||||
// Check for sync error indicators
|
||||
// The snack bar would show if there were rejected ops
|
||||
const errorSnack = clientB.page.locator('simple-snack-bar.error');
|
||||
await expect(errorSnack).not.toBeVisible({ timeout: 2000 });
|
||||
console.log('[Stale Clock] No sync errors on Client B');
|
||||
|
||||
console.log('[Stale Clock] ✓ Stale clock regression test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Stale Clock] Stale clock regression test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test: Multiple reloads after SYNC_IMPORT don't cause accumulating errors
|
||||
|
|
@ -235,101 +216,101 @@ base.describe('@supersync @regression Stale Clock Regression', () => {
|
|||
* With the original bug, each reload could create more stale ops that get rejected.
|
||||
* This test verifies that multiple reload cycles don't cause accumulating issues.
|
||||
*/
|
||||
base(
|
||||
'Multiple reloads after SYNC_IMPORT remain stable (no accumulating errors)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Multiple reloads after SYNC_IMPORT remain stable (no accumulating errors)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ Setup ============
|
||||
console.log('[Multi-Reload] Setting up clients');
|
||||
// ============ Setup ============
|
||||
console.log('[Multi-Reload] Setting up clients');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// ============ Import Backup ============
|
||||
console.log('[Multi-Reload] Client A importing backup');
|
||||
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Multi-Reload] SYNC_IMPORT propagated');
|
||||
|
||||
// ============ Multiple Reload Cycles ============
|
||||
console.log('[Multi-Reload] Starting reload cycles');
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
console.log(`[Multi-Reload] Reload cycle ${cycle}/3`);
|
||||
|
||||
// Reload Client B
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
|
||||
// Navigate and verify state
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
// Sync after reload
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// ============ Import Backup ============
|
||||
console.log('[Multi-Reload] Client A importing backup');
|
||||
|
||||
const importPage = new ImportPage(clientA.page);
|
||||
await importPage.navigateToImportPage();
|
||||
const backupPath = ImportPage.getFixturePath('test-backup.json');
|
||||
await importPage.importBackupFile(backupPath);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Multi-Reload] SYNC_IMPORT propagated');
|
||||
|
||||
// ============ Multiple Reload Cycles ============
|
||||
console.log('[Multi-Reload] Starting reload cycles');
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
console.log(`[Multi-Reload] Reload cycle ${cycle}/3`);
|
||||
|
||||
// Reload Client B
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
|
||||
// Navigate and verify state
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await waitForTask(clientB.page, 'E2E Import Test - Active Task With Subtask');
|
||||
|
||||
// Sync after reload
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Check for errors
|
||||
const errorSnack = clientB.page.locator('simple-snack-bar.error');
|
||||
const isErrorVisible = await errorSnack.isVisible().catch(() => false);
|
||||
if (isErrorVisible) {
|
||||
throw new Error(
|
||||
`Sync error appeared on reload cycle ${cycle} - stale clock bug may not be fixed`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Multi-Reload] ✓ Cycle ${cycle} completed without errors`);
|
||||
// Check for errors
|
||||
const errorSnack = clientB.page.locator('simple-snack-bar.error');
|
||||
const isErrorVisible = await errorSnack.isVisible().catch(() => false);
|
||||
if (isErrorVisible) {
|
||||
throw new Error(
|
||||
`Sync error appeared on reload cycle ${cycle} - stale clock bug may not be fixed`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Final Verification ============
|
||||
console.log('[Multi-Reload] Final verification');
|
||||
|
||||
// Sync A to get any updates from B
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Both should have imported task
|
||||
await expect(
|
||||
clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
console.log('[Multi-Reload] ✓ Multi-reload regression test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
console.log(`[Multi-Reload] Cycle ${cycle} completed without errors`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============ Final Verification ============
|
||||
console.log('[Multi-Reload] Final verification');
|
||||
|
||||
// Sync A to get any updates from B
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientA.page.goto('/#/work-view');
|
||||
await clientB.page.goto('/#/work-view');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Both should have imported task
|
||||
await expect(
|
||||
clientA.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
clientB.page.locator(
|
||||
'task:has-text("E2E Import Test - Active Task With Subtask")',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
console.log('[Multi-Reload] Multi-reload regression test PASSED!');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -18,10 +17,6 @@ import {
|
|||
* - Need for quieter logging
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a simulated client with quiet console logging.
|
||||
* Only errors are logged, other console output is suppressed.
|
||||
|
|
@ -45,20 +40,8 @@ const createQuietClient = async (
|
|||
return client;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Stress Tests', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
test.describe('@supersync SuperSync Stress Tests', () => {
|
||||
// Server health check is handled automatically by the supersync fixture
|
||||
|
||||
/**
|
||||
* Scenario: Bulk Sync (Slow Device Recovery)
|
||||
|
|
@ -73,102 +56,104 @@ base.describe('@supersync SuperSync Stress Tests', () => {
|
|||
* 3. Verify B has all tasks from A
|
||||
* 4. Verify no spurious conflicts or errors
|
||||
*/
|
||||
base(
|
||||
'Bulk sync: Many operations sync without cascade conflicts',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(120000); // Bulk operations need more time
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Bulk sync: Many operations sync without cascade conflicts', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}, testInfo) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
testInfo.setTimeout(120000); // Bulk operations need more time
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup Client A (quiet mode)
|
||||
clientA = await createQuietClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client A (quiet mode)
|
||||
clientA = await createQuietClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// 1. Client A creates many tasks (simulating a day's work)
|
||||
const taskCount = 10;
|
||||
const taskNames: string[] = [];
|
||||
// 1. Client A creates many tasks (simulating a day's work)
|
||||
const taskCount = 10;
|
||||
const taskNames: string[] = [];
|
||||
|
||||
console.log(`[BulkSync] Creating ${taskCount} tasks...`);
|
||||
console.log(`[BulkSync] Creating ${taskCount} tasks...`);
|
||||
|
||||
for (let i = 1; i <= taskCount; i++) {
|
||||
const taskName = `BulkTask-${i}-${testRunId}`;
|
||||
taskNames.push(taskName);
|
||||
// Use skipClose=true for faster creation
|
||||
await clientA.workView.addTask(taskName, i < taskCount);
|
||||
}
|
||||
|
||||
// Verify tasks were created
|
||||
const createdCount = await clientA.page.locator('task').count();
|
||||
expect(createdCount).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[BulkSync] Created ${createdCount} tasks`);
|
||||
|
||||
// Mark some tasks as done using correct selector (need hover to show button)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const taskLocator = clientA.page
|
||||
.locator(`task:has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
await clientA.page.waitForTimeout(100);
|
||||
}
|
||||
console.log('[BulkSync] Marked 3 tasks as done');
|
||||
|
||||
// Sync all changes from A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkSync] Client A synced');
|
||||
|
||||
// 2. Setup Client B and sync (downloads all operations at once)
|
||||
clientB = await createQuietClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkSync] Client B synced (bulk download)');
|
||||
|
||||
// Wait for UI to settle after bulk sync
|
||||
await clientB.page.waitForTimeout(2000);
|
||||
|
||||
// 3. Verify B has all tasks from A
|
||||
const taskCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(taskCountB).toBe(taskCount);
|
||||
console.log(`[BulkSync] Client B has all ${taskCount} tasks`);
|
||||
|
||||
// Verify done status of first 3 tasks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const taskLocator = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
}
|
||||
console.log('[BulkSync] Done status verified on Client B');
|
||||
|
||||
// 4. Do another round of sync to verify no spurious conflicts
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify counts still match
|
||||
const finalCountA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const finalCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(finalCountA).toBe(taskCount);
|
||||
expect(finalCountB).toBe(taskCount);
|
||||
|
||||
console.log('[BulkSync] Bulk sync completed without cascade conflicts');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
for (let i = 1; i <= taskCount; i++) {
|
||||
const taskName = `BulkTask-${i}-${testRunId}`;
|
||||
taskNames.push(taskName);
|
||||
// Use skipClose=true for faster creation
|
||||
await clientA.workView.addTask(taskName, i < taskCount);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify tasks were created
|
||||
const createdCount = await clientA.page.locator('task').count();
|
||||
expect(createdCount).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[BulkSync] Created ${createdCount} tasks`);
|
||||
|
||||
// Mark some tasks as done using correct selector (need hover to show button)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const taskLocator = clientA.page
|
||||
.locator(`task:has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
await clientA.page.waitForTimeout(100);
|
||||
}
|
||||
console.log('[BulkSync] Marked 3 tasks as done');
|
||||
|
||||
// Sync all changes from A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[BulkSync] Client A synced');
|
||||
|
||||
// 2. Setup Client B and sync (downloads all operations at once)
|
||||
clientB = await createQuietClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[BulkSync] Client B synced (bulk download)');
|
||||
|
||||
// Wait for UI to settle after bulk sync
|
||||
await clientB.page.waitForTimeout(2000);
|
||||
|
||||
// 3. Verify B has all tasks from A
|
||||
const taskCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(taskCountB).toBe(taskCount);
|
||||
console.log(`[BulkSync] Client B has all ${taskCount} tasks`);
|
||||
|
||||
// Verify done status of first 3 tasks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const taskLocator = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
}
|
||||
console.log('[BulkSync] Done status verified on Client B');
|
||||
|
||||
// 4. Do another round of sync to verify no spurious conflicts
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify counts still match
|
||||
const finalCountA = await clientA.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
const finalCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(finalCountA).toBe(taskCount);
|
||||
expect(finalCountB).toBe(taskCount);
|
||||
|
||||
console.log('[BulkSync] Bulk sync completed without cascade conflicts');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario: High Volume Sync (197 Operations)
|
||||
|
|
@ -191,216 +176,218 @@ base.describe('@supersync SuperSync Stress Tests', () => {
|
|||
* 4. Client B syncs (piggybacks 100, downloads remaining 97)
|
||||
* 5. Verify B has all 50 tasks with correct done states
|
||||
*/
|
||||
base(
|
||||
'High volume sync: 197 operations sync correctly (tests piggyback limit)',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
testInfo.setTimeout(300000); // 5 minutes
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('High volume sync: 197 operations sync correctly (tests piggyback limit)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}, testInfo) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
testInfo.setTimeout(300000); // 5 minutes
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup Client A (regular mode to see logs)
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// Setup Client A (regular mode to see logs)
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Create 50 tasks (50 ops)
|
||||
const taskCount = 50;
|
||||
const taskNames: string[] = [];
|
||||
// Create 50 tasks (50 ops)
|
||||
const taskCount = 50;
|
||||
const taskNames: string[] = [];
|
||||
|
||||
console.log(`[HighVolume] Creating ${taskCount} tasks...`);
|
||||
console.log(`[HighVolume] Creating ${taskCount} tasks...`);
|
||||
|
||||
for (let i = 1; i <= taskCount; i++) {
|
||||
const taskName = `HighVol-${i}-${testRunId}`;
|
||||
taskNames.push(taskName);
|
||||
// Fast task creation - skipClose=true for all but last
|
||||
await clientA.workView.addTask(taskName, i < taskCount);
|
||||
for (let i = 1; i <= taskCount; i++) {
|
||||
const taskName = `HighVol-${i}-${testRunId}`;
|
||||
taskNames.push(taskName);
|
||||
// Fast task creation - skipClose=true for all but last
|
||||
await clientA.workView.addTask(taskName, i < taskCount);
|
||||
|
||||
// Progress logging every 5 tasks
|
||||
if (i % 5 === 0) {
|
||||
console.log(`[HighVolume] Created ${i}/${taskCount} tasks`);
|
||||
}
|
||||
// Progress logging every 5 tasks
|
||||
if (i % 5 === 0) {
|
||||
console.log(`[HighVolume] Created ${i}/${taskCount} tasks`);
|
||||
}
|
||||
|
||||
// Extra wait after all tasks created to ensure persistence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// Verify tasks created
|
||||
const createdCount = await clientA.page.locator('task').count();
|
||||
expect(createdCount).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[HighVolume] Created ${createdCount} tasks total`);
|
||||
|
||||
// Mark 49 tasks as done (49 × 3 = 147 more ops = 197 total)
|
||||
const doneCount = 49;
|
||||
// Each mark-as-done creates 3 ops: updateTask, planTasksForToday, Tag Update
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const expectedOpsCount = taskCount + doneCount * 3;
|
||||
console.log(`[HighVolume] Marking ${doneCount} tasks as done...`);
|
||||
for (let i = 0; i < doneCount; i++) {
|
||||
const taskLocator = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await taskLocator.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
// Wait for done state to be applied
|
||||
await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
|
||||
// Progress logging every 5 tasks
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`[HighVolume] Marked ${i + 1}/${doneCount} tasks as done`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra wait after all done marking to ensure persistence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
console.log(`[HighVolume] All ${expectedOpsCount} operations created locally`);
|
||||
|
||||
// Sync all changes from A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[HighVolume] Client A synced 99 operations');
|
||||
|
||||
// VALIDATION: Check pending ops count on Client A after sync
|
||||
// Query IndexedDB directly for unsynced operations (SUP_OPS database)
|
||||
const pendingOpsInfoA = await clientA.page.evaluate(async () => {
|
||||
try {
|
||||
return new Promise((resolve) => {
|
||||
// Operation log uses SUP_OPS database, not SUP
|
||||
const request = indexedDB.open('SUP_OPS');
|
||||
request.onerror = () =>
|
||||
resolve({ error: 'Failed to open SUP_OPS IndexedDB' });
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const storeNames = Array.from(db.objectStoreNames);
|
||||
// Check if ops store exists
|
||||
if (!storeNames.includes('ops')) {
|
||||
resolve({
|
||||
error: 'ops store not found in SUP_OPS',
|
||||
stores: storeNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction('ops', 'readonly');
|
||||
const store = tx.objectStore('ops');
|
||||
const getAllRequest = store.getAll();
|
||||
getAllRequest.onsuccess = () => {
|
||||
const allEntries = getAllRequest.result;
|
||||
// Unsynced = no syncedAt and no rejectedAt
|
||||
const unsynced = allEntries.filter(
|
||||
(e: { syncedAt?: number; rejectedAt?: number }) =>
|
||||
!e.syncedAt && !e.rejectedAt,
|
||||
);
|
||||
resolve({
|
||||
totalEntries: allEntries.length,
|
||||
unsyncedCount: unsynced.length,
|
||||
unsyncedOpTypes: unsynced
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
(e: { op: { actionType: string; opType: string } }) =>
|
||||
`${e.op.opType}:${e.op.actionType}`,
|
||||
),
|
||||
});
|
||||
};
|
||||
getAllRequest.onerror = () =>
|
||||
resolve({ error: 'Failed to read ops store' });
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: String(e) };
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
'[HighVolume] VALIDATION - Client A pending ops after sync:',
|
||||
JSON.stringify(pendingOpsInfoA),
|
||||
);
|
||||
|
||||
// Setup Client B and sync (bulk download) - NOT quiet to see debug logs
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[HighVolume] Client B synced (bulk download)');
|
||||
|
||||
// Wait for UI to settle after bulk sync
|
||||
// The done states need time to reflect in Angular change detection
|
||||
await clientB.page.waitForTimeout(5000);
|
||||
|
||||
// Trigger change detection by scrolling
|
||||
await clientB.page.evaluate(() => {
|
||||
window.scrollTo(0, 100);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify all tasks exist on B
|
||||
const taskCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(taskCountB).toBe(taskCount);
|
||||
console.log(`[HighVolume] Client B has all ${taskCount} tasks`);
|
||||
|
||||
// DEBUG: Check NgRx store state directly using window.__NGRX_STORE__
|
||||
const storeState = await clientB.page.evaluate((tid: string) => {
|
||||
try {
|
||||
// Access NgRx store via window if available
|
||||
// @ts-expect-error - accessing app internals
|
||||
const store = window.__NGRX_STORE__ || window.__store__;
|
||||
if (store) {
|
||||
const state = store.getState?.() || store.getValue?.();
|
||||
if (state?.task?.entities) {
|
||||
const allTasks = Object.values(state.task.entities) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isDone: boolean;
|
||||
}>;
|
||||
const testTasks = allTasks.filter((t) => t?.title?.includes(tid));
|
||||
const doneTasks = testTasks.filter((t) => t?.isDone);
|
||||
return {
|
||||
method: 'NgRx Store',
|
||||
totalTestTasks: testTasks.length,
|
||||
doneTestTasks: doneTasks.length,
|
||||
sampleTasks: testTasks.slice(0, 3).map((t) => ({
|
||||
id: t.id,
|
||||
isDone: t.isDone,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DOM check
|
||||
const allTaskEls = document.querySelectorAll('task');
|
||||
const testTasks = Array.from(allTaskEls).filter((el) =>
|
||||
el.textContent?.includes(tid),
|
||||
);
|
||||
const doneTasks = testTasks.filter((el) => el.classList.contains('isDone'));
|
||||
return {
|
||||
method: 'DOM Fallback',
|
||||
totalTestTasks: testTasks.length,
|
||||
doneTestTasks: doneTasks.length,
|
||||
sampleClasses: testTasks.slice(0, 3).map((el) => el.className),
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: String(e) };
|
||||
}
|
||||
}, testRunId);
|
||||
console.log('[HighVolume] State check:', JSON.stringify(storeState));
|
||||
|
||||
// Verify done status (49 should be done, 1 should be open)
|
||||
const doneCountB = await clientB.page
|
||||
.locator(`task.isDone:has-text("${testRunId}")`)
|
||||
.count();
|
||||
console.log(`[HighVolume] DOM shows ${doneCountB} done tasks`);
|
||||
expect(doneCountB).toBe(doneCount);
|
||||
console.log('[HighVolume] Done states verified on Client B');
|
||||
|
||||
console.log(`[HighVolume] ${expectedOpsCount} operations applied successfully`);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Extra wait after all tasks created to ensure persistence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// Verify tasks created
|
||||
const createdCount = await clientA.page.locator('task').count();
|
||||
expect(createdCount).toBeGreaterThanOrEqual(taskCount);
|
||||
console.log(`[HighVolume] Created ${createdCount} tasks total`);
|
||||
|
||||
// Mark 49 tasks as done (49 × 3 = 147 more ops = 197 total)
|
||||
const doneCount = 49;
|
||||
// Each mark-as-done creates 3 ops: updateTask, planTasksForToday, Tag Update
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const expectedOpsCount = taskCount + doneCount * 3;
|
||||
console.log(`[HighVolume] Marking ${doneCount} tasks as done...`);
|
||||
for (let i = 0; i < doneCount; i++) {
|
||||
const taskLocator = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskNames[i]}")`)
|
||||
.first();
|
||||
await taskLocator.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await taskLocator.hover();
|
||||
await taskLocator.locator('.task-done-btn').click();
|
||||
// Wait for done state to be applied
|
||||
await expect(taskLocator).toHaveClass(/isDone/, { timeout: 5000 });
|
||||
|
||||
// Progress logging every 5 tasks
|
||||
if ((i + 1) % 5 === 0) {
|
||||
console.log(`[HighVolume] Marked ${i + 1}/${doneCount} tasks as done`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra wait after all done marking to ensure persistence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
console.log(`[HighVolume] All ${expectedOpsCount} operations created locally`);
|
||||
|
||||
// Sync all changes from A
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[HighVolume] Client A synced 99 operations');
|
||||
|
||||
// VALIDATION: Check pending ops count on Client A after sync
|
||||
// Query IndexedDB directly for unsynced operations (SUP_OPS database)
|
||||
const pendingOpsInfoA = await clientA.page.evaluate(async () => {
|
||||
try {
|
||||
return new Promise((resolve) => {
|
||||
// Operation log uses SUP_OPS database, not SUP
|
||||
const request = indexedDB.open('SUP_OPS');
|
||||
request.onerror = () =>
|
||||
resolve({ error: 'Failed to open SUP_OPS IndexedDB' });
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const storeNames = Array.from(db.objectStoreNames);
|
||||
// Check if ops store exists
|
||||
if (!storeNames.includes('ops')) {
|
||||
resolve({
|
||||
error: 'ops store not found in SUP_OPS',
|
||||
stores: storeNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction('ops', 'readonly');
|
||||
const store = tx.objectStore('ops');
|
||||
const getAllRequest = store.getAll();
|
||||
getAllRequest.onsuccess = () => {
|
||||
const allEntries = getAllRequest.result;
|
||||
// Unsynced = no syncedAt and no rejectedAt
|
||||
const unsynced = allEntries.filter(
|
||||
(e: { syncedAt?: number; rejectedAt?: number }) =>
|
||||
!e.syncedAt && !e.rejectedAt,
|
||||
);
|
||||
resolve({
|
||||
totalEntries: allEntries.length,
|
||||
unsyncedCount: unsynced.length,
|
||||
unsyncedOpTypes: unsynced
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
(e: { op: { actionType: string; opType: string } }) =>
|
||||
`${e.op.opType}:${e.op.actionType}`,
|
||||
),
|
||||
});
|
||||
};
|
||||
getAllRequest.onerror = () =>
|
||||
resolve({ error: 'Failed to read ops store' });
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: String(e) };
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
'[HighVolume] VALIDATION - Client A pending ops after sync:',
|
||||
JSON.stringify(pendingOpsInfoA),
|
||||
);
|
||||
|
||||
// Setup Client B and sync (bulk download) - NOT quiet to see debug logs
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[HighVolume] Client B synced (bulk download)');
|
||||
|
||||
// Wait for UI to settle after bulk sync
|
||||
// The done states need time to reflect in Angular change detection
|
||||
await clientB.page.waitForTimeout(5000);
|
||||
|
||||
// Trigger change detection by scrolling
|
||||
await clientB.page.evaluate(() => {
|
||||
window.scrollTo(0, 100);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify all tasks exist on B
|
||||
const taskCountB = await clientB.page
|
||||
.locator(`task:has-text("${testRunId}")`)
|
||||
.count();
|
||||
expect(taskCountB).toBe(taskCount);
|
||||
console.log(`[HighVolume] Client B has all ${taskCount} tasks`);
|
||||
|
||||
// DEBUG: Check NgRx store state directly using window.__NGRX_STORE__
|
||||
const storeState = await clientB.page.evaluate((tid: string) => {
|
||||
try {
|
||||
// Access NgRx store via window if available
|
||||
// @ts-expect-error - accessing app internals
|
||||
const store = window.__NGRX_STORE__ || window.__store__;
|
||||
if (store) {
|
||||
const state = store.getState?.() || store.getValue?.();
|
||||
if (state?.task?.entities) {
|
||||
const allTasks = Object.values(state.task.entities) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isDone: boolean;
|
||||
}>;
|
||||
const testTasks = allTasks.filter((t) => t?.title?.includes(tid));
|
||||
const doneTasks = testTasks.filter((t) => t?.isDone);
|
||||
return {
|
||||
method: 'NgRx Store',
|
||||
totalTestTasks: testTasks.length,
|
||||
doneTestTasks: doneTasks.length,
|
||||
sampleTasks: testTasks.slice(0, 3).map((t) => ({
|
||||
id: t.id,
|
||||
isDone: t.isDone,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DOM check
|
||||
const allTaskEls = document.querySelectorAll('task');
|
||||
const testTasks = Array.from(allTaskEls).filter((el) =>
|
||||
el.textContent?.includes(tid),
|
||||
);
|
||||
const doneTasks = testTasks.filter((el) => el.classList.contains('isDone'));
|
||||
return {
|
||||
method: 'DOM Fallback',
|
||||
totalTestTasks: testTasks.length,
|
||||
doneTestTasks: doneTasks.length,
|
||||
sampleClasses: testTasks.slice(0, 3).map((el) => el.className),
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: String(e) };
|
||||
}
|
||||
}, testRunId);
|
||||
console.log('[HighVolume] State check:', JSON.stringify(storeState));
|
||||
|
||||
// Verify done status (49 should be done, 1 should be open)
|
||||
const doneCountB = await clientB.page
|
||||
.locator(`task.isDone:has-text("${testRunId}")`)
|
||||
.count();
|
||||
console.log(`[HighVolume] DOM shows ${doneCountB} done tasks`);
|
||||
expect(doneCountB).toBe(doneCount);
|
||||
console.log('[HighVolume] Done states verified on Client B');
|
||||
|
||||
console.log(`[HighVolume] ${expectedOpsCount} operations applied successfully`);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -16,24 +15,7 @@ import {
|
|||
* Uses drag-and-drop to reorder tasks and verifies the order is replicated.
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `order-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Task Ordering Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Task Ordering Sync', () => {
|
||||
/**
|
||||
* Test: Task order is consistent between clients after sync
|
||||
*
|
||||
|
|
@ -46,91 +28,91 @@ base.describe('@supersync Task Ordering Sync', () => {
|
|||
* Note: This tests that task ordering is preserved through sync,
|
||||
* not that specific reordering actions work (which is UI-dependent).
|
||||
*/
|
||||
base(
|
||||
'Task reordering syncs to other client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task reordering syncs to other client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const task1Name = `Task1-${uniqueId}`;
|
||||
const task2Name = `Task2-${uniqueId}`;
|
||||
const task3Name = `Task3-${uniqueId}`;
|
||||
const task1Name = `Task1-${uniqueId}`;
|
||||
const task2Name = `Task2-${uniqueId}`;
|
||||
const task3Name = `Task3-${uniqueId}`;
|
||||
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
await clientA.workView.addTask(task3Name);
|
||||
console.log('[Order Test] Client A created 3 tasks');
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
await clientA.workView.addTask(task3Name);
|
||||
console.log('[Order Test] Client A created 3 tasks');
|
||||
|
||||
// Verify all tasks exist on Client A
|
||||
await clientA.page.waitForTimeout(500);
|
||||
const tasksA = clientA.page.locator('task');
|
||||
await expect(tasksA).toHaveCount(3);
|
||||
// Verify all tasks exist on Client A
|
||||
await clientA.page.waitForTimeout(500);
|
||||
const tasksA = clientA.page.locator('task');
|
||||
await expect(tasksA).toHaveCount(3);
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Order Test] Client A synced');
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Order Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Order Test] Client B synced');
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Order Test] Client B synced');
|
||||
|
||||
// Verify Client B has all 3 tasks
|
||||
await waitForTask(clientB.page, task1Name);
|
||||
await waitForTask(clientB.page, task2Name);
|
||||
await waitForTask(clientB.page, task3Name);
|
||||
console.log('[Order Test] Client B has all 3 tasks');
|
||||
// Verify Client B has all 3 tasks
|
||||
await waitForTask(clientB.page, task1Name);
|
||||
await waitForTask(clientB.page, task2Name);
|
||||
await waitForTask(clientB.page, task3Name);
|
||||
console.log('[Order Test] Client B has all 3 tasks');
|
||||
|
||||
// ============ PHASE 4: Verify Order on Both Clients ============
|
||||
// Get task order on both clients using innerText to avoid duplicates
|
||||
const getTaskOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const tasks = client.page.locator('task .task-title');
|
||||
const count = await tasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const titleEl = tasks.nth(i);
|
||||
const text = await titleEl.innerText();
|
||||
order.push(text.trim());
|
||||
}
|
||||
return order;
|
||||
};
|
||||
// ============ PHASE 4: Verify Order on Both Clients ============
|
||||
// Get task order on both clients using innerText to avoid duplicates
|
||||
const getTaskOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const tasks = client.page.locator('task .task-title');
|
||||
const count = await tasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const titleEl = tasks.nth(i);
|
||||
const text = await titleEl.innerText();
|
||||
order.push(text.trim());
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
const orderA = await getTaskOrder(clientA);
|
||||
const orderB = await getTaskOrder(clientB);
|
||||
const orderA = await getTaskOrder(clientA);
|
||||
const orderB = await getTaskOrder(clientB);
|
||||
|
||||
console.log('[Order Test] Client A order:', orderA);
|
||||
console.log('[Order Test] Client B order:', orderB);
|
||||
console.log('[Order Test] Client A order:', orderA);
|
||||
console.log('[Order Test] Client B order:', orderB);
|
||||
|
||||
// Verify both clients have 3 tasks
|
||||
expect(orderA.length).toBe(3);
|
||||
expect(orderB.length).toBe(3);
|
||||
// Verify both clients have 3 tasks
|
||||
expect(orderA.length).toBe(3);
|
||||
expect(orderB.length).toBe(3);
|
||||
|
||||
// Orders should match exactly
|
||||
expect(orderA).toEqual(orderB);
|
||||
// Orders should match exactly
|
||||
expect(orderA).toEqual(orderB);
|
||||
|
||||
// Verify all our tasks are present
|
||||
expect(orderA.some((t) => t.includes('Task1'))).toBe(true);
|
||||
expect(orderA.some((t) => t.includes('Task2'))).toBe(true);
|
||||
expect(orderA.some((t) => t.includes('Task3'))).toBe(true);
|
||||
// Verify all our tasks are present
|
||||
expect(orderA.some((t) => t.includes('Task1'))).toBe(true);
|
||||
expect(orderA.some((t) => t.includes('Task2'))).toBe(true);
|
||||
expect(orderA.some((t) => t.includes('Task3'))).toBe(true);
|
||||
|
||||
console.log('[Order Test] Orders match on both clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Order Test] Orders match on both clients');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Subtask ordering syncs between clients
|
||||
|
|
@ -141,104 +123,102 @@ base.describe('@supersync Task Ordering Sync', () => {
|
|||
* 3. Client B syncs (downloads)
|
||||
* 4. Verify subtask order is consistent on both clients
|
||||
*/
|
||||
base(
|
||||
'Subtask ordering syncs to other client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Subtask ordering syncs to other client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Parent and Subtasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Parent and Subtasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const parentName = `Parent-${uniqueId}`;
|
||||
const sub1Name = `Sub1-${uniqueId}`;
|
||||
const sub2Name = `Sub2-${uniqueId}`;
|
||||
const sub3Name = `Sub3-${uniqueId}`;
|
||||
const parentName = `Parent-${uniqueId}`;
|
||||
const sub1Name = `Sub1-${uniqueId}`;
|
||||
const sub2Name = `Sub2-${uniqueId}`;
|
||||
const sub3Name = `Sub3-${uniqueId}`;
|
||||
|
||||
await clientA.workView.addTask(parentName);
|
||||
const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first();
|
||||
await clientA.workView.addTask(parentName);
|
||||
const parentTask = clientA.page.locator(`task:has-text("${parentName}")`).first();
|
||||
|
||||
await clientA.workView.addSubTask(parentTask, sub1Name);
|
||||
await clientA.workView.addSubTask(parentTask, sub2Name);
|
||||
await clientA.workView.addSubTask(parentTask, sub3Name);
|
||||
console.log('[Subtask Order Test] Client A created parent with 3 subtasks');
|
||||
await clientA.workView.addSubTask(parentTask, sub1Name);
|
||||
await clientA.workView.addSubTask(parentTask, sub2Name);
|
||||
await clientA.workView.addSubTask(parentTask, sub3Name);
|
||||
console.log('[Subtask Order Test] Client A created parent with 3 subtasks');
|
||||
|
||||
// Expand parent to see subtasks
|
||||
const expandBtn = parentTask.locator('.expand-btn');
|
||||
if (await expandBtn.isVisible()) {
|
||||
await expandBtn.click();
|
||||
}
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Subtask Order Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Subtask Order Test] Client B synced');
|
||||
|
||||
// Expand parent on Client B
|
||||
const parentTaskB = clientB.page
|
||||
.locator(`task:has-text("${parentName}")`)
|
||||
.first();
|
||||
const expandBtnB = parentTaskB.locator('.expand-btn');
|
||||
if (await expandBtnB.isVisible()) {
|
||||
await expandBtnB.click();
|
||||
}
|
||||
|
||||
// Verify Client B has all subtasks
|
||||
await waitForTask(clientB.page, sub1Name);
|
||||
await waitForTask(clientB.page, sub2Name);
|
||||
await waitForTask(clientB.page, sub3Name);
|
||||
console.log('[Subtask Order Test] Client B has all subtasks');
|
||||
|
||||
// ============ PHASE 4: Verify Subtask Order ============
|
||||
// Get subtask order using innerText to avoid duplicate text issues
|
||||
const getSubtaskOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const subtasks = client.page.locator('task.hasNoSubTasks .task-title');
|
||||
const count = await subtasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await subtasks.nth(i).innerText();
|
||||
order.push(text.trim());
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
const subtaskOrderA = await getSubtaskOrder(clientA);
|
||||
const subtaskOrderB = await getSubtaskOrder(clientB);
|
||||
|
||||
console.log('[Subtask Order Test] Client A subtask order:', subtaskOrderA);
|
||||
console.log('[Subtask Order Test] Client B subtask order:', subtaskOrderB);
|
||||
|
||||
// Verify both have 3 subtasks
|
||||
expect(subtaskOrderA.length).toBe(3);
|
||||
expect(subtaskOrderB.length).toBe(3);
|
||||
|
||||
// Orders should match
|
||||
expect(subtaskOrderA).toEqual(subtaskOrderB);
|
||||
|
||||
// Verify all subtasks are present
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub1'))).toBe(true);
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub2'))).toBe(true);
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub3'))).toBe(true);
|
||||
|
||||
console.log('[Subtask Order Test] Subtask orders match');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Expand parent to see subtasks
|
||||
const expandBtn = parentTask.locator('.expand-btn');
|
||||
if (await expandBtn.isVisible()) {
|
||||
await expandBtn.click();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Subtask Order Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Downloads ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Subtask Order Test] Client B synced');
|
||||
|
||||
// Expand parent on Client B
|
||||
const parentTaskB = clientB.page.locator(`task:has-text("${parentName}")`).first();
|
||||
const expandBtnB = parentTaskB.locator('.expand-btn');
|
||||
if (await expandBtnB.isVisible()) {
|
||||
await expandBtnB.click();
|
||||
}
|
||||
|
||||
// Verify Client B has all subtasks
|
||||
await waitForTask(clientB.page, sub1Name);
|
||||
await waitForTask(clientB.page, sub2Name);
|
||||
await waitForTask(clientB.page, sub3Name);
|
||||
console.log('[Subtask Order Test] Client B has all subtasks');
|
||||
|
||||
// ============ PHASE 4: Verify Subtask Order ============
|
||||
// Get subtask order using innerText to avoid duplicate text issues
|
||||
const getSubtaskOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const subtasks = client.page.locator('task.hasNoSubTasks .task-title');
|
||||
const count = await subtasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await subtasks.nth(i).innerText();
|
||||
order.push(text.trim());
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
const subtaskOrderA = await getSubtaskOrder(clientA);
|
||||
const subtaskOrderB = await getSubtaskOrder(clientB);
|
||||
|
||||
console.log('[Subtask Order Test] Client A subtask order:', subtaskOrderA);
|
||||
console.log('[Subtask Order Test] Client B subtask order:', subtaskOrderB);
|
||||
|
||||
// Verify both have 3 subtasks
|
||||
expect(subtaskOrderA.length).toBe(3);
|
||||
expect(subtaskOrderB.length).toBe(3);
|
||||
|
||||
// Orders should match
|
||||
expect(subtaskOrderA).toEqual(subtaskOrderB);
|
||||
|
||||
// Verify all subtasks are present
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub1'))).toBe(true);
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub2'))).toBe(true);
|
||||
expect(subtaskOrderA.some((t) => t.includes('Sub3'))).toBe(true);
|
||||
|
||||
console.log('[Subtask Order Test] Subtask orders match');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Concurrent reordering resolves via LWW
|
||||
|
|
@ -250,90 +230,90 @@ base.describe('@supersync Task Ordering Sync', () => {
|
|||
* 4. Both sync - LWW should resolve
|
||||
* 5. Verify consistent state
|
||||
*/
|
||||
base(
|
||||
'Concurrent reordering resolves consistently',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Concurrent reordering resolves consistently', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients with Same Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Setup Both Clients with Same Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const task1Name = `Task1-${uniqueId}`;
|
||||
const task2Name = `Task2-${uniqueId}`;
|
||||
const task3Name = `Task3-${uniqueId}`;
|
||||
const task1Name = `Task1-${uniqueId}`;
|
||||
const task2Name = `Task2-${uniqueId}`;
|
||||
const task3Name = `Task3-${uniqueId}`;
|
||||
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
await clientA.workView.addTask(task3Name);
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
await clientA.workView.addTask(task3Name);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify both have all tasks
|
||||
await waitForTask(clientA.page, task1Name);
|
||||
await waitForTask(clientB.page, task1Name);
|
||||
console.log('[Concurrent Order Test] Both clients have all tasks');
|
||||
// Verify both have all tasks
|
||||
await waitForTask(clientA.page, task1Name);
|
||||
await waitForTask(clientB.page, task1Name);
|
||||
console.log('[Concurrent Order Test] Both clients have all tasks');
|
||||
|
||||
// ============ PHASE 2: Concurrent Reordering ============
|
||||
// Client A moves Task1 to top
|
||||
const task1A = clientA.page.locator(`task:has-text("${task1Name}")`);
|
||||
await task1A.click();
|
||||
await clientA.page.keyboard.press('Control+Shift+ArrowUp');
|
||||
await clientA.page.waitForTimeout(300);
|
||||
console.log('[Concurrent Order Test] Client A moved Task1 to top');
|
||||
// ============ PHASE 2: Concurrent Reordering ============
|
||||
// Client A moves Task1 to top
|
||||
const task1A = clientA.page.locator(`task:has-text("${task1Name}")`);
|
||||
await task1A.click();
|
||||
await clientA.page.keyboard.press('Control+Shift+ArrowUp');
|
||||
await clientA.page.waitForTimeout(300);
|
||||
console.log('[Concurrent Order Test] Client A moved Task1 to top');
|
||||
|
||||
// Client B moves Task3 to top (concurrent, no sync yet)
|
||||
const task3B = clientB.page.locator(`task:has-text("${task3Name}")`);
|
||||
await task3B.click();
|
||||
await clientB.page.keyboard.press('Control+Shift+ArrowUp');
|
||||
await clientB.page.waitForTimeout(300);
|
||||
console.log('[Concurrent Order Test] Client B moved Task3 to top');
|
||||
// Client B moves Task3 to top (concurrent, no sync yet)
|
||||
const task3B = clientB.page.locator(`task:has-text("${task3Name}")`);
|
||||
await task3B.click();
|
||||
await clientB.page.keyboard.press('Control+Shift+ArrowUp');
|
||||
await clientB.page.waitForTimeout(300);
|
||||
console.log('[Concurrent Order Test] Client B moved Task3 to top');
|
||||
|
||||
// ============ PHASE 3: Sync - LWW Resolution ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Final sync to converge
|
||||
console.log('[Concurrent Order Test] All clients synced');
|
||||
// ============ PHASE 3: Sync - LWW Resolution ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Final sync to converge
|
||||
console.log('[Concurrent Order Test] All clients synced');
|
||||
|
||||
// ============ PHASE 4: Verify Consistent State ============
|
||||
const getOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const tasks = client.page.locator('task');
|
||||
const count = await tasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const title = await tasks.nth(i).locator('.task-title').textContent();
|
||||
order.push(title?.trim() || '');
|
||||
}
|
||||
return order;
|
||||
};
|
||||
// ============ PHASE 4: Verify Consistent State ============
|
||||
const getOrder = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const tasks = client.page.locator('task');
|
||||
const count = await tasks.count();
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const title = await tasks.nth(i).locator('.task-title').textContent();
|
||||
order.push(title?.trim() || '');
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
const orderA = await getOrder(clientA);
|
||||
const orderB = await getOrder(clientB);
|
||||
const orderA = await getOrder(clientA);
|
||||
const orderB = await getOrder(clientB);
|
||||
|
||||
console.log('[Concurrent Order Test] Client A final order:', orderA);
|
||||
console.log('[Concurrent Order Test] Client B final order:', orderB);
|
||||
console.log('[Concurrent Order Test] Client A final order:', orderA);
|
||||
console.log('[Concurrent Order Test] Client B final order:', orderB);
|
||||
|
||||
// Both clients should have the same order (eventual consistency)
|
||||
expect(orderA.length).toBe(3);
|
||||
expect(orderB.length).toBe(3);
|
||||
expect(orderA).toEqual(orderB);
|
||||
// Both clients should have the same order (eventual consistency)
|
||||
expect(orderA.length).toBe(3);
|
||||
expect(orderB.length).toBe(3);
|
||||
expect(orderA).toEqual(orderB);
|
||||
|
||||
console.log('[Concurrent Order Test] Both clients have consistent order');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Concurrent Order Test] Both clients have consistent order');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
startTimeTracking,
|
||||
stopTimeTracking,
|
||||
getTaskElement,
|
||||
markTaskDone,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { expectTaskVisible } from '../../utils/supersync-assertions';
|
||||
import { waitForAppReady } from '../../utils/waits';
|
||||
|
||||
/**
|
||||
|
|
@ -19,24 +23,7 @@ import { waitForAppReady } from '../../utils/waits';
|
|||
* - Large time values precision
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `timetrack-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Time Tracking Advanced Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Time Tracking Advanced Sync', () => {
|
||||
/**
|
||||
* Test: Time tracking data persists after archive
|
||||
*
|
||||
|
|
@ -49,117 +36,110 @@ base.describe('@supersync Time Tracking Advanced Sync', () => {
|
|||
* 6. Client A syncs
|
||||
* 7. Both verify time in worklog
|
||||
*/
|
||||
base(
|
||||
'Time tracking data persists after archive',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Time tracking data persists after archive', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates and Tracks Task ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates and Tracks Task ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `ArchiveTime-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
console.log('[Archive Time Test] Client A created task');
|
||||
const taskName = `ArchiveTime-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
console.log('[Archive Time Test] Client A created task');
|
||||
|
||||
// Start time tracking
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocatorA.hover();
|
||||
const startBtn = taskLocatorA.locator('.start-task-btn');
|
||||
await startBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await startBtn.click();
|
||||
console.log('[Archive Time Test] Started time tracking');
|
||||
// Start time tracking
|
||||
await startTimeTracking(clientA, taskName);
|
||||
console.log('[Archive Time Test] Started time tracking');
|
||||
|
||||
// Wait for time to accumulate
|
||||
await clientA.page.waitForTimeout(5000);
|
||||
// Wait for time to accumulate
|
||||
await clientA.page.waitForTimeout(5000);
|
||||
|
||||
// Stop tracking
|
||||
await taskLocatorA.hover();
|
||||
const pauseBtn = taskLocatorA.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await pauseBtn.click();
|
||||
console.log('[Archive Time Test] Stopped time tracking');
|
||||
// Stop tracking
|
||||
await stopTimeTracking(clientA, taskName);
|
||||
console.log('[Archive Time Test] Stopped time tracking');
|
||||
|
||||
// Capture tracked time
|
||||
const timeVal = taskLocatorA.locator('.time-wrapper .time-val').first();
|
||||
await expect(timeVal).toBeVisible({ timeout: 5000 });
|
||||
const trackedTime = await timeVal.textContent();
|
||||
console.log(`[Archive Time Test] Tracked time: ${trackedTime}`);
|
||||
// Capture tracked time
|
||||
const taskLocatorA = getTaskElement(clientA, taskName);
|
||||
const timeVal = taskLocatorA.locator('.time-wrapper .time-val').first();
|
||||
await expect(timeVal).toBeVisible({ timeout: 5000 });
|
||||
const trackedTime = await timeVal.textContent();
|
||||
console.log(`[Archive Time Test] Tracked time: ${trackedTime}`);
|
||||
|
||||
// Mark as done
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.task-done-btn').click();
|
||||
console.log('[Archive Time Test] Marked task done');
|
||||
// Mark as done
|
||||
await markTaskDone(clientA, taskName);
|
||||
console.log('[Archive Time Test] Marked task done');
|
||||
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Client A synced');
|
||||
// ============ PHASE 2: Sync to Server ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Client B Archives ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Client B synced');
|
||||
// ============ PHASE 3: Client B Archives ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Client B synced');
|
||||
|
||||
// Verify task exists on B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// Verify task exists on B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
// Archive via Finish Day
|
||||
const finishDayBtn = clientB.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
console.log('[Archive Time Test] Client B clicked Finish Day');
|
||||
// Archive via Finish Day
|
||||
const finishDayBtn = clientB.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
console.log('[Archive Time Test] Client B clicked Finish Day');
|
||||
|
||||
await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
|
||||
// Click save to archive
|
||||
const saveBtn = clientB.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
console.log('[Archive Time Test] Client B archived');
|
||||
// Click save to archive
|
||||
const saveBtn = clientB.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
console.log('[Archive Time Test] Client B archived');
|
||||
|
||||
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
|
||||
// ============ PHASE 4: Sync Archive ============
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Both synced archive');
|
||||
// ============ PHASE 4: Sync Archive ============
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Archive Time Test] Both synced archive');
|
||||
|
||||
// ============ PHASE 5: Verify Time in Worklog ============
|
||||
// Navigate to worklog on Client A
|
||||
await clientA.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientA.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
// ============ PHASE 5: Verify Time in Worklog ============
|
||||
// Navigate to worklog on Client A
|
||||
await clientA.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientA.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
|
||||
// Expand week to see tasks
|
||||
const weekRow = clientA.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify task with time appears in worklog
|
||||
const taskInWorklog = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskName}")`,
|
||||
);
|
||||
await expect(taskInWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Archive Time Test] Task visible in worklog with time data');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Expand week to see tasks
|
||||
const weekRow = clientA.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify task with time appears in worklog
|
||||
const taskInWorklog = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskName}")`,
|
||||
);
|
||||
await expect(taskInWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Archive Time Test] Task visible in worklog with time data');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Large time values sync with precision
|
||||
|
|
@ -170,57 +150,55 @@ base.describe('@supersync Time Tracking Advanced Sync', () => {
|
|||
* 3. Client B syncs
|
||||
* 4. Verify time estimate is exactly 8h on Client B
|
||||
*/
|
||||
base(
|
||||
'Task with large time estimate syncs correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Task with large time estimate syncs correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates Task with Large Estimate ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates Task with Large Estimate ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `LargeTime-${uniqueId}`;
|
||||
// Use t:8h for 8 hour estimate
|
||||
await clientA.workView.addTask(`${taskName} t:8h`);
|
||||
console.log('[Large Time Test] Client A created task with 8h estimate');
|
||||
const taskName = `LargeTime-${uniqueId}`;
|
||||
// Use t:8h for 8 hour estimate
|
||||
await clientA.workView.addTask(`${taskName} t:8h`);
|
||||
console.log('[Large Time Test] Client A created task with 8h estimate');
|
||||
|
||||
await waitForTask(clientA.page, taskName);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
// Task exists on Client A (estimate is stored but may not be visible in compact view)
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorA).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Large Time Test] Task visible on Client A');
|
||||
// Task exists on Client A (estimate is stored but may not be visible in compact view)
|
||||
await expectTaskVisible(clientA, taskName);
|
||||
console.log('[Large Time Test] Task visible on Client A');
|
||||
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
// ============ PHASE 2: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// ============ PHASE 3: Verify on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
// ============ PHASE 3: Verify on Client B ============
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await expect(taskLocatorB).toBeVisible({ timeout: 5000 });
|
||||
console.log('[Large Time Test] Task visible on Client B');
|
||||
await expectTaskVisible(clientB, taskName);
|
||||
console.log('[Large Time Test] Task visible on Client B');
|
||||
|
||||
// Note: Time estimate is stored as task data, UI display varies
|
||||
// The key test is that the task synced successfully
|
||||
console.log('[Large Time Test] Task with time estimate synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Note: Time estimate is stored as task data, UI display varies
|
||||
// The key test is that the task synced successfully
|
||||
console.log('[Large Time Test] Task with time estimate synced successfully');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Concurrent time tracking resolves via LWW
|
||||
|
|
@ -231,99 +209,85 @@ base.describe('@supersync Time Tracking Advanced Sync', () => {
|
|||
* 3. Client B tracks 5 seconds concurrently (started before A sync), stops, syncs
|
||||
* 4. Verify final time is consistent (LWW - later sync wins)
|
||||
*/
|
||||
base(
|
||||
'Concurrent time tracking resolves consistently',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Concurrent time tracking resolves consistently', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Setup Both Clients ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `ConcurrentTime-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
const taskName = `ConcurrentTime-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await waitForTask(clientA.page, taskName);
|
||||
await waitForTask(clientB.page, taskName);
|
||||
console.log('[Concurrent Time Test] Both clients have task');
|
||||
await waitForTask(clientA.page, taskName);
|
||||
await waitForTask(clientB.page, taskName);
|
||||
console.log('[Concurrent Time Test] Both clients have task');
|
||||
|
||||
// ============ PHASE 2: Client A Tracks Time ============
|
||||
const taskLocatorA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.start-task-btn').click();
|
||||
console.log('[Concurrent Time Test] Client A started tracking');
|
||||
// ============ PHASE 2: Client A Tracks Time ============
|
||||
await startTimeTracking(clientA, taskName);
|
||||
console.log('[Concurrent Time Test] Client A started tracking');
|
||||
|
||||
await clientA.page.waitForTimeout(3000);
|
||||
await clientA.page.waitForTimeout(3000);
|
||||
|
||||
await taskLocatorA.hover();
|
||||
const pauseBtnA = taskLocatorA.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtnA.click();
|
||||
console.log('[Concurrent Time Test] Client A stopped after 3s');
|
||||
await stopTimeTracking(clientA, taskName);
|
||||
console.log('[Concurrent Time Test] Client A stopped after 3s');
|
||||
|
||||
// ============ PHASE 3: Client B Tracks Time (Concurrent) ============
|
||||
const taskLocatorB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.start-task-btn').click();
|
||||
console.log('[Concurrent Time Test] Client B started tracking');
|
||||
// ============ PHASE 3: Client B Tracks Time (Concurrent) ============
|
||||
await startTimeTracking(clientB, taskName);
|
||||
console.log('[Concurrent Time Test] Client B started tracking');
|
||||
|
||||
await clientB.page.waitForTimeout(5000);
|
||||
await clientB.page.waitForTimeout(5000);
|
||||
|
||||
await taskLocatorB.hover();
|
||||
const pauseBtnB = taskLocatorB.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtnB.click();
|
||||
console.log('[Concurrent Time Test] Client B stopped after 5s');
|
||||
await stopTimeTracking(clientB, taskName);
|
||||
console.log('[Concurrent Time Test] Client B stopped after 5s');
|
||||
|
||||
// ============ PHASE 4: Sync Both ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Converge
|
||||
console.log('[Concurrent Time Test] All synced');
|
||||
// ============ PHASE 4: Sync Both ============
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait(); // Converge
|
||||
console.log('[Concurrent Time Test] All synced');
|
||||
|
||||
// ============ PHASE 5: Verify Consistent State ============
|
||||
// Reload to ensure UI reflects final state
|
||||
await clientA.page.reload();
|
||||
await waitForAppReady(clientA.page);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
// ============ PHASE 5: Verify Consistent State ============
|
||||
// Reload to ensure UI reflects final state
|
||||
await clientA.page.reload();
|
||||
await waitForAppReady(clientA.page);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await clientB.page.reload();
|
||||
await waitForAppReady(clientB.page);
|
||||
await waitForTask(clientB.page, taskName);
|
||||
|
||||
const taskA = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
const taskB = clientB.page.locator(`task:has-text("${taskName}")`);
|
||||
const taskA = getTaskElement(clientA, taskName);
|
||||
const taskB = getTaskElement(clientB, taskName);
|
||||
|
||||
const timeA = await taskA
|
||||
.locator('.time-wrapper .time-val')
|
||||
.first()
|
||||
.textContent();
|
||||
const timeB = await taskB
|
||||
.locator('.time-wrapper .time-val')
|
||||
.first()
|
||||
.textContent();
|
||||
const timeA = await taskA.locator('.time-wrapper .time-val').first().textContent();
|
||||
const timeB = await taskB.locator('.time-wrapper .time-val').first().textContent();
|
||||
|
||||
console.log(`[Concurrent Time Test] Client A final time: ${timeA}`);
|
||||
console.log(`[Concurrent Time Test] Client B final time: ${timeB}`);
|
||||
console.log(`[Concurrent Time Test] Client A final time: ${timeA}`);
|
||||
console.log(`[Concurrent Time Test] Client B final time: ${timeB}`);
|
||||
|
||||
// Times should match (LWW resolution)
|
||||
expect(timeA?.trim()).toBe(timeB?.trim());
|
||||
// Times should match (LWW resolution)
|
||||
expect(timeA?.trim()).toBe(timeB?.trim());
|
||||
|
||||
console.log('[Concurrent Time Test] Time tracking resolved consistently');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log('[Concurrent Time Test] Time tracking resolved consistently');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
isServerHealthy,
|
||||
markTaskDone,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
|
|
@ -17,24 +17,7 @@ import {
|
|||
* - Multiple archives across clients
|
||||
*/
|
||||
|
||||
let testCounter = 0;
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `worklog-${Date.now()}-${workerIndex}-${testCounter++}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync Worklog Sync', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn('SuperSync server not healthy - skipping tests');
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
test.describe('@supersync Worklog Sync', () => {
|
||||
/**
|
||||
* Test: Archived tasks appear in worklog on synced client
|
||||
*
|
||||
|
|
@ -45,96 +28,91 @@ base.describe('@supersync Worklog Sync', () => {
|
|||
* 4. Client B syncs
|
||||
* 5. Client B verifies tasks in worklog
|
||||
*/
|
||||
base(
|
||||
'Archived tasks appear in worklog on synced client',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Archived tasks appear in worklog on synced client', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates and Completes Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates and Completes Tasks ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const task1Name = `Worklog1-${uniqueId}`;
|
||||
const task2Name = `Worklog2-${uniqueId}`;
|
||||
const task1Name = `Worklog1-${uniqueId}`;
|
||||
const task2Name = `Worklog2-${uniqueId}`;
|
||||
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
console.log('[Worklog Test] Client A created 2 tasks');
|
||||
await clientA.workView.addTask(task1Name);
|
||||
await clientA.workView.addTask(task2Name);
|
||||
console.log('[Worklog Test] Client A created 2 tasks');
|
||||
|
||||
// Mark both as done
|
||||
const task1 = clientA.page.locator(`task:has-text("${task1Name}")`);
|
||||
await task1.hover();
|
||||
await task1.locator('.task-done-btn').click();
|
||||
// Mark both as done
|
||||
await markTaskDone(clientA, task1Name);
|
||||
await markTaskDone(clientA, task2Name);
|
||||
console.log('[Worklog Test] Client A marked tasks done');
|
||||
|
||||
const task2 = clientA.page.locator(`task:has-text("${task2Name}")`);
|
||||
await task2.hover();
|
||||
await task2.locator('.task-done-btn').click();
|
||||
console.log('[Worklog Test] Client A marked tasks done');
|
||||
// ============ PHASE 2: Client A Archives ============
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
|
||||
// ============ PHASE 2: Client A Archives ============
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
const saveBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
|
||||
const saveBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Worklog Test] Client A archived tasks');
|
||||
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Worklog Test] Client A archived tasks');
|
||||
// ============ PHASE 3: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Worklog Test] Client A synced');
|
||||
|
||||
// ============ PHASE 3: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Worklog Test] Client A synced');
|
||||
// ============ PHASE 4: Client B Downloads and Checks Worklog ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Worklog Test] Client B synced');
|
||||
|
||||
// ============ PHASE 4: Client B Downloads and Checks Worklog ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[Worklog Test] Client B synced');
|
||||
// Navigate to worklog
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
console.log('[Worklog Test] Client B navigated to worklog');
|
||||
|
||||
// Navigate to worklog
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
console.log('[Worklog Test] Client B navigated to worklog');
|
||||
|
||||
// Expand week to see tasks
|
||||
const weekRow = clientB.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify both tasks appear in worklog
|
||||
const task1InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${task1Name}")`,
|
||||
);
|
||||
const task2InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${task2Name}")`,
|
||||
);
|
||||
|
||||
await expect(task1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
await expect(task2InWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Worklog Test] Both tasks visible in worklog on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Expand week to see tasks
|
||||
const weekRow = clientB.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify both tasks appear in worklog
|
||||
const task1InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${task1Name}")`,
|
||||
);
|
||||
const task2InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${task2Name}")`,
|
||||
);
|
||||
|
||||
await expect(task1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
await expect(task2InWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Worklog Test] Both tasks visible in worklog on Client B');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Worklog shows correct time data after sync
|
||||
|
|
@ -144,86 +122,85 @@ base.describe('@supersync Worklog Sync', () => {
|
|||
* 2. Client A archives
|
||||
* 3. Client B syncs and checks worklog for time data
|
||||
*/
|
||||
base(
|
||||
'Worklog shows correct time data after sync',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Worklog shows correct time data after sync', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Creates, Tracks, Completes ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Creates, Tracks, Completes ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskName = `TimedWorklog-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
const taskName = `TimedWorklog-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
|
||||
// Track time
|
||||
const task = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await task.hover();
|
||||
await task.locator('.start-task-btn').click();
|
||||
await clientA.page.waitForTimeout(3000);
|
||||
await task.hover();
|
||||
const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtn.click();
|
||||
console.log('[Timed Worklog Test] Tracked 3 seconds');
|
||||
// Track time using helper functions (need to add imports)
|
||||
const task = clientA.page.locator(`task:has-text("${taskName}")`);
|
||||
await task.hover();
|
||||
await task.locator('.start-task-btn').click();
|
||||
await clientA.page.waitForTimeout(3000);
|
||||
await task.hover();
|
||||
const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtn.click();
|
||||
console.log('[Timed Worklog Test] Tracked 3 seconds');
|
||||
|
||||
// Complete task
|
||||
await task.hover();
|
||||
await task.locator('.task-done-btn').click();
|
||||
// Complete task
|
||||
await markTaskDone(clientA, taskName);
|
||||
|
||||
// ============ PHASE 2: Archive ============
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
// ============ PHASE 2: Archive ============
|
||||
const finishDayBtn = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await finishDayBtn.click();
|
||||
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
|
||||
const saveBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
const saveBtn = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await saveBtn.click();
|
||||
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
|
||||
// ============ PHASE 3: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
// ============ PHASE 3: Sync ============
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// ============ PHASE 4: Client B Checks Worklog ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// ============ PHASE 4: Client B Checks Worklog ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
|
||||
// Expand week
|
||||
const weekRow = clientB.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Verify task appears in worklog
|
||||
const taskInWorklog = clientB.page.locator(`text=${taskName}`);
|
||||
await expect(taskInWorklog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The task with tracked time should appear in the worklog
|
||||
// Time data is stored but exact UI format varies
|
||||
console.log('[Timed Worklog Test] Task with time data visible in worklog');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
// Expand week
|
||||
const weekRow = clientB.page.locator('.week-row').first();
|
||||
if (await weekRow.isVisible()) {
|
||||
await weekRow.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify task appears in worklog
|
||||
const taskInWorklog = clientB.page.locator(`text=${taskName}`);
|
||||
await expect(taskInWorklog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The task with tracked time should appear in the worklog
|
||||
// Time data is stored but exact UI format varies
|
||||
console.log('[Timed Worklog Test] Task with time data visible in worklog');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Multiple archive operations sync correctly
|
||||
|
|
@ -236,120 +213,116 @@ base.describe('@supersync Worklog Sync', () => {
|
|||
* 5. Both sync
|
||||
* 6. Verify all archived tasks in worklog on both
|
||||
*/
|
||||
base(
|
||||
'Multiple archive operations sync correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
test('Multiple archive operations sync correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const uniqueId = Date.now();
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ============ PHASE 1: Client A Archives First Batch ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
// ============ PHASE 1: Client A Archives First Batch ============
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
const taskA1 = `BatchA1-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
const taskA1 = `BatchA1-${uniqueId}`;
|
||||
await clientA.workView.addTask(taskA1);
|
||||
|
||||
const task1 = clientA.page.locator(`task:has-text("${taskA1}")`);
|
||||
await task1.hover();
|
||||
await task1.locator('.task-done-btn').click();
|
||||
await markTaskDone(clientA, taskA1);
|
||||
|
||||
// Archive
|
||||
const finishDayBtnA = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtnA.click();
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
// Archive
|
||||
const finishDayBtnA = clientA.page.locator('.e2e-finish-day');
|
||||
await finishDayBtnA.click();
|
||||
await clientA.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
|
||||
const saveBtnA = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtnA.click();
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client A archived first batch');
|
||||
const saveBtnA = clientA.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtnA.click();
|
||||
await clientA.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client A archived first batch');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// ============ PHASE 2: Client B Archives Second Batch ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
// ============ PHASE 2: Client B Archives Second Batch ============
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
const taskB1 = `BatchB1-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
const taskB1 = `BatchB1-${uniqueId}`;
|
||||
await clientB.workView.addTask(taskB1);
|
||||
|
||||
const task2 = clientB.page.locator(`task:has-text("${taskB1}")`);
|
||||
await task2.hover();
|
||||
await task2.locator('.task-done-btn').click();
|
||||
await markTaskDone(clientB, taskB1);
|
||||
|
||||
// Archive
|
||||
const finishDayBtnB = clientB.page.locator('.e2e-finish-day');
|
||||
await finishDayBtnB.click();
|
||||
await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
// Archive
|
||||
const finishDayBtnB = clientB.page.locator('.e2e-finish-day');
|
||||
await finishDayBtnB.click();
|
||||
await clientB.page.waitForURL(/daily-summary/, { timeout: 10000 });
|
||||
|
||||
const saveBtnB = clientB.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtnB.click();
|
||||
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client B archived second batch');
|
||||
const saveBtnB = clientB.page.locator(
|
||||
'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
|
||||
);
|
||||
await saveBtnB.click();
|
||||
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client B archived second batch');
|
||||
|
||||
// ============ PHASE 3: Sync Both ============
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Multi Archive Test] Both synced');
|
||||
// ============ PHASE 3: Sync Both ============
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[Multi Archive Test] Both synced');
|
||||
|
||||
// ============ PHASE 4: Verify All Archives in Worklog ============
|
||||
// Check Client A worklog
|
||||
await clientA.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientA.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
// ============ PHASE 4: Verify All Archives in Worklog ============
|
||||
// Check Client A worklog
|
||||
await clientA.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientA.page.waitForLoadState('networkidle');
|
||||
await clientA.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
|
||||
const weekRowA = clientA.page.locator('.week-row').first();
|
||||
if (await weekRowA.isVisible()) {
|
||||
await weekRowA.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const taskA1InWorklog = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskA1}")`,
|
||||
);
|
||||
const taskB1InWorklogA = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskB1}")`,
|
||||
);
|
||||
|
||||
await expect(taskA1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
await expect(taskB1InWorklogA).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client A has both archives in worklog');
|
||||
|
||||
// Check Client B worklog
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
|
||||
const weekRowB = clientB.page.locator('.week-row').first();
|
||||
if (await weekRowB.isVisible()) {
|
||||
await weekRowB.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const taskA1InWorklogB = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskA1}")`,
|
||||
);
|
||||
const taskB1InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskB1}")`,
|
||||
);
|
||||
|
||||
await expect(taskA1InWorklogB).toBeVisible({ timeout: 10000 });
|
||||
await expect(taskB1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client B has both archives in worklog');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
const weekRowA = clientA.page.locator('.week-row').first();
|
||||
if (await weekRowA.isVisible()) {
|
||||
await weekRowA.click();
|
||||
await clientA.page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const taskA1InWorklog = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskA1}")`,
|
||||
);
|
||||
const taskB1InWorklogA = clientA.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskB1}")`,
|
||||
);
|
||||
|
||||
await expect(taskA1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
await expect(taskB1InWorklogA).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client A has both archives in worklog');
|
||||
|
||||
// Check Client B worklog
|
||||
await clientB.page.goto('/#/tag/TODAY/worklog');
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
await clientB.page.waitForSelector('worklog', { timeout: 10000 });
|
||||
|
||||
const weekRowB = clientB.page.locator('.week-row').first();
|
||||
if (await weekRowB.isVisible()) {
|
||||
await weekRowB.click();
|
||||
await clientB.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const taskA1InWorklogB = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskA1}")`,
|
||||
);
|
||||
const taskB1InWorklog = clientB.page.locator(
|
||||
`.task-summary-table .task-title:has-text("${taskB1}")`,
|
||||
);
|
||||
|
||||
await expect(taskA1InWorklogB).toBeVisible({ timeout: 10000 });
|
||||
await expect(taskB1InWorklog).toBeVisible({ timeout: 10000 });
|
||||
console.log('[Multi Archive Test] Client B has both archives in worklog');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -42,6 +42,8 @@ test.describe('Task detail', () => {
|
|||
// Flipping the meridiem should guarantee a change
|
||||
timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A'));
|
||||
await timeInput.fill(timeInputText);
|
||||
// Blur to ensure Angular registers the change before saving
|
||||
await timeInput.blur();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(createdInfo).not.toHaveText(createdInfoText!);
|
||||
|
|
@ -75,6 +77,8 @@ test.describe('Task detail', () => {
|
|||
// Flipping the meridiem should guarantee a change
|
||||
timeInputText = timeInputText!.replace(/([AP])/, (_, c) => (c === 'A' ? 'P' : 'A'));
|
||||
await timeInput.fill(timeInputText);
|
||||
// Blur to ensure Angular registers the change before saving
|
||||
await timeInput.blur();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(completedInfo).not.toHaveText(completedInfoText!);
|
||||
|
|
|
|||
303
e2e/utils/supersync-assertions.ts
Normal file
303
e2e/utils/supersync-assertions.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import {
|
||||
getTaskElement,
|
||||
getDoneTaskElement,
|
||||
getSubtaskElement,
|
||||
getDoneSubtaskElement,
|
||||
getUndoneSubtaskElement,
|
||||
getTaskCount,
|
||||
getTaskTitles,
|
||||
hasTaskOnClient,
|
||||
type SimulatedE2EClient,
|
||||
} from './supersync-helpers';
|
||||
|
||||
/**
|
||||
* Assertion helpers for SuperSync E2E tests.
|
||||
*
|
||||
* These provide reusable, readable assertions for common sync test patterns.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Task Visibility Assertions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert a task is visible on a client.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskVisible = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await expect(task).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a task is NOT visible on a client.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskNotVisible = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await expect(task).not.toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a task is done (visible with done state).
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskDone = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
const task = getDoneTaskElement(client, taskName);
|
||||
await expect(task).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a subtask is visible.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectSubtaskVisible = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
const task = getSubtaskElement(client, taskName);
|
||||
await expect(task).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a subtask is done.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to check
|
||||
* @param timeout - Optional timeout in ms (default: 5000)
|
||||
*/
|
||||
export const expectSubtaskDone = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 5000,
|
||||
): Promise<void> => {
|
||||
const task = getDoneSubtaskElement(client, taskName);
|
||||
await expect(task).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a subtask is NOT done.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to check
|
||||
* @param timeout - Optional timeout in ms (default: 5000)
|
||||
*/
|
||||
export const expectSubtaskNotDone = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 5000,
|
||||
): Promise<void> => {
|
||||
const task = getUndoneSubtaskElement(client, taskName);
|
||||
await expect(task).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Client Assertions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert a task exists on all provided clients.
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskOnAllClients = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
await Promise.all(
|
||||
clients.map((client) => expectTaskVisible(client, taskName, timeout)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a task does NOT exist on any of the provided clients.
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskNotOnAnyClient = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
await Promise.all(
|
||||
clients.map((client) => expectTaskNotVisible(client, taskName, timeout)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a task is done on all provided clients.
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
* @param taskName - The task name to check
|
||||
* @param timeout - Optional timeout in ms (default: 10000)
|
||||
*/
|
||||
export const expectTaskDoneOnAllClients = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
taskName: string,
|
||||
timeout = 10000,
|
||||
): Promise<void> => {
|
||||
await Promise.all(clients.map((client) => expectTaskDone(client, taskName, timeout)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert task count is equal across all clients.
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
*/
|
||||
export const expectEqualTaskCount = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
): Promise<void> => {
|
||||
const counts = await Promise.all(clients.map((client) => getTaskCount(client)));
|
||||
const firstCount = counts[0];
|
||||
for (let i = 1; i < counts.length; i++) {
|
||||
expect(counts[i]).toBe(firstCount);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert task count is a specific value on a client.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param expectedCount - The expected task count
|
||||
*/
|
||||
export const expectTaskCount = (
|
||||
client: SimulatedE2EClient,
|
||||
expectedCount: number,
|
||||
): Promise<void> => {
|
||||
return getTaskCount(client).then((count) => {
|
||||
expect(count).toBe(expectedCount);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert task order matches across clients.
|
||||
*
|
||||
* @param clientA - First client
|
||||
* @param clientB - Second client
|
||||
*/
|
||||
export const expectTaskOrderMatches = async (
|
||||
clientA: SimulatedE2EClient,
|
||||
clientB: SimulatedE2EClient,
|
||||
): Promise<void> => {
|
||||
const orderA = await getTaskTitles(clientA);
|
||||
const orderB = await getTaskTitles(clientB);
|
||||
expect(orderA).toEqual(orderB);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert all clients have the same task order.
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
*/
|
||||
export const expectSameTaskOrder = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
): Promise<void> => {
|
||||
if (clients.length < 2) return;
|
||||
|
||||
const orders = await Promise.all(clients.map((client) => getTaskTitles(client)));
|
||||
const firstOrder = orders[0];
|
||||
for (let i = 1; i < orders.length; i++) {
|
||||
expect(orders[i]).toEqual(firstOrder);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert a task exists on a client (boolean check).
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to check
|
||||
* @param exists - Whether the task should exist (default: true)
|
||||
*/
|
||||
export const expectTaskExists = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
exists = true,
|
||||
): Promise<void> => {
|
||||
const hasIt = await hasTaskOnClient(client, taskName);
|
||||
expect(hasIt).toBe(exists);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Consistency Assertions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert all clients have consistent state (same task count and tasks visible).
|
||||
*
|
||||
* @param clients - Array of simulated E2E clients
|
||||
* @param taskNames - Array of task names that should exist on all clients
|
||||
*/
|
||||
export const expectConsistentState = async (
|
||||
clients: SimulatedE2EClient[],
|
||||
taskNames: string[],
|
||||
): Promise<void> => {
|
||||
await expectEqualTaskCount(clients);
|
||||
for (const taskName of taskNames) {
|
||||
await expectTaskOnAllClients(clients, taskName);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert time tracking indicator is visible on a task.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name
|
||||
* @param timeout - Optional timeout in ms (default: 5000)
|
||||
*/
|
||||
export const expectTimeTrackingActive = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 5000,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
const indicator = task.locator('.play-icon-indicator');
|
||||
await expect(indicator).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert time tracking indicator is NOT visible on a task.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name
|
||||
* @param timeout - Optional timeout in ms (default: 5000)
|
||||
*/
|
||||
export const expectTimeTrackingInactive = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
timeout = 5000,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
const indicator = task.locator('.play-icon-indicator');
|
||||
await expect(indicator).not.toBeVisible({ timeout });
|
||||
};
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import { type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||
import {
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import { SuperSyncPage, type SuperSyncConfig } from '../pages/supersync.page';
|
||||
import { WorkViewPage } from '../pages/work-view.page';
|
||||
import { waitForAppReady } from './waits';
|
||||
|
|
@ -311,3 +316,326 @@ export const hasTask = async (page: Page, taskName: string): Promise<boolean> =>
|
|||
const count = await page.locator(`task:has-text("${escapedName}")`).count();
|
||||
return count > 0;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Task Element Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Escape special characters in a string for use in CSS :has-text() selector.
|
||||
*/
|
||||
const escapeForSelector = (text: string): string => {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a task element locator by task name.
|
||||
* This replaces the common pattern: `client.page.locator(\`task:has-text("${taskName}")\`)`
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to search for
|
||||
* @returns Locator for the task element
|
||||
*/
|
||||
export const getTaskElement = (client: SimulatedE2EClient, taskName: string): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(`task:has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a task element locator from a page by task name.
|
||||
* Use this when you have a page but not a client.
|
||||
*
|
||||
* @param page - The Playwright page
|
||||
* @param taskName - The task name to search for
|
||||
* @returns Locator for the task element
|
||||
*/
|
||||
export const getTaskElementFromPage = (page: Page, taskName: string): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return page.locator(`task:has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a subtask element (task without subtasks) by name.
|
||||
* Useful for targeting subtasks without matching their parent.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to search for
|
||||
* @returns Locator for the subtask element
|
||||
*/
|
||||
export const getSubtaskElement = (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(`task.hasNoSubTasks:has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a done task element by name.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to search for
|
||||
* @returns Locator for the done task element
|
||||
*/
|
||||
export const getDoneTaskElement = (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(`task.isDone:has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a done subtask element by name.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to search for
|
||||
* @returns Locator for the done subtask element
|
||||
*/
|
||||
export const getDoneSubtaskElement = (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(`task.hasNoSubTasks.isDone:has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an undone subtask element by name.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The subtask name to search for
|
||||
* @returns Locator for the undone subtask element
|
||||
*/
|
||||
export const getUndoneSubtaskElement = (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(
|
||||
`task.hasNoSubTasks:not(.isDone):has-text("${escapedName}")`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a parent task element (task with subtasks) by name.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to search for
|
||||
* @returns Locator for the parent task element
|
||||
*/
|
||||
export const getParentTaskElement = (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Locator => {
|
||||
const escapedName = escapeForSelector(taskName);
|
||||
return client.page.locator(`task:not(.hasNoSubTasks):has-text("${escapedName}")`);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Task Action Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mark a task as done by hovering and clicking the done button.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to mark as done
|
||||
*/
|
||||
export const markTaskDone = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await task.hover();
|
||||
await task.locator('.task-done-btn').click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a subtask as done by hovering and clicking the done button.
|
||||
* Uses getSubtaskElement to avoid matching parent tasks.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param subtaskName - The subtask name to mark as done
|
||||
*/
|
||||
export const markSubtaskDone = async (
|
||||
client: SimulatedE2EClient,
|
||||
subtaskName: string,
|
||||
): Promise<void> => {
|
||||
const subtask = getSubtaskElement(client, subtaskName);
|
||||
await subtask.hover();
|
||||
await subtask.locator('.task-done-btn').click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand a parent task to show its subtasks.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The parent task name
|
||||
*/
|
||||
export const expandTask = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
const expandBtn = task.locator('.expand-btn');
|
||||
if (await expandBtn.isVisible()) {
|
||||
await expandBtn.click();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a task via keyboard shortcut (Backspace) and confirm if dialog appears.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to delete
|
||||
*/
|
||||
export const deleteTask = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await task.click();
|
||||
await client.page.keyboard.press('Backspace');
|
||||
|
||||
// Confirm deletion if dialog appears
|
||||
const confirmBtn = client.page.locator('mat-dialog-actions button:has-text("Delete")');
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
await client.page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rename a task by double-clicking and filling the input.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param oldName - The current task name
|
||||
* @param newName - The new task name
|
||||
*/
|
||||
export const renameTask = async (
|
||||
client: SimulatedE2EClient,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, oldName);
|
||||
await task.locator('task-title').click();
|
||||
await client.page.waitForSelector('task textarea', { state: 'visible' });
|
||||
await client.page.locator('task textarea').fill(newName);
|
||||
await client.page.keyboard.press('Tab');
|
||||
await client.page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start time tracking on a task.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to start tracking
|
||||
*/
|
||||
export const startTimeTracking = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await task.hover();
|
||||
const startBtn = task.locator('.start-task-btn');
|
||||
await startBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await startBtn.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop time tracking on a task.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to stop tracking
|
||||
*/
|
||||
export const stopTimeTracking = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<void> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
await task.hover();
|
||||
const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))');
|
||||
await pauseBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await pauseBtn.click();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Task Query Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the task count on a client.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @returns The number of tasks
|
||||
*/
|
||||
export const getTaskCount = async (client: SimulatedE2EClient): Promise<number> => {
|
||||
return client.page.locator('task').count();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all task titles as an array.
|
||||
* Useful for comparing task order between clients.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @returns Array of task titles in order
|
||||
*/
|
||||
export const getTaskTitles = async (client: SimulatedE2EClient): Promise<string[]> => {
|
||||
const tasks = client.page.locator('task .task-title');
|
||||
const count = await tasks.count();
|
||||
const titles: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await tasks.nth(i).innerText();
|
||||
titles.push(text.trim());
|
||||
}
|
||||
return titles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the tracked time display text for a task.
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name
|
||||
* @returns The time display text or null if not visible
|
||||
*/
|
||||
export const getTaskTimeDisplay = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<string | null> => {
|
||||
const task = getTaskElement(client, taskName);
|
||||
const timeVal = task.locator('.time-wrapper .time-val').first();
|
||||
if (await timeVal.isVisible()) {
|
||||
return timeVal.textContent();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a task has the given text (exists on client).
|
||||
*
|
||||
* @param client - The simulated E2E client
|
||||
* @param taskName - The task name to check
|
||||
* @returns true if the task exists
|
||||
*/
|
||||
export const hasTaskOnClient = async (
|
||||
client: SimulatedE2EClient,
|
||||
taskName: string,
|
||||
): Promise<boolean> => {
|
||||
return hasTask(client.page, taskName);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Test Setup Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique test run ID for data isolation.
|
||||
* This replaces the common `generateTestRunId` function in test files.
|
||||
*
|
||||
* @param workerIndex - The Playwright worker index from testInfo
|
||||
* @returns A unique test run ID string
|
||||
*/
|
||||
export const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue