fix(sync): handle data validation errors and improve client ID management

- Separate JsonParseError and SyncDataCorruptedError handlers in sync wrapper
- Handle corrupted remote data gracefully instead of throwing
- Add getOrGenerateClientId() as unified entry point, eliminating dual injection
  of ClientIdService + CLIENT_ID_PROVIDER in snapshot-upload, file-based-encryption,
  and sync-hydration services
- Use crypto.getRandomValues() instead of Math.random() for client ID generation
- Warn user when stored client ID is invalid and must be regenerated
- Fix flaky e2e supersync tests (premature waitForURL match on daily-summary URL)
This commit is contained in:
Johannes Millan 2026-04-15 22:29:32 +02:00
parent 81788143b6
commit 0e9218bd68
87 changed files with 1875 additions and 3372 deletions

View file

@ -47,18 +47,9 @@ export const test = base.extend<SuperSyncFixtures>({
* Also checks server health and skips test if server unavailable.
*/
testRunId: async ({}, use, testInfo) => {
// Check server health once per worker.
// Prefer the result pre-computed in globalSetup (stored as an env var before workers
// were forked) to avoid a stampede of concurrent HTTP requests from all workers
// simultaneously — which can overload the server and produce false negatives.
// Check server health once per worker
if (serverHealthyCache === null) {
if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) {
serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true';
} else {
// Fallback for when tests are run directly without going through globalSetup
// (e.g., `npx playwright test --config ... e2e/tests/sync/foo.spec.ts`)
serverHealthyCache = await isServerHealthy();
}
serverHealthyCache = await isServerHealthy();
if (!serverHealthyCache) {
console.warn(
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
@ -78,13 +69,9 @@ export const test = base.extend<SuperSyncFixtures>({
* Tests are automatically skipped if the server is not healthy.
*/
serverHealthy: async ({}, use, testInfo) => {
// Only check once per worker — use globalSetup env var if available (see testRunId above)
// Only check once per worker
if (serverHealthyCache === null) {
if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) {
serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true';
} else {
serverHealthyCache = await isServerHealthy();
}
serverHealthyCache = await isServerHealthy();
if (!serverHealthyCache) {
console.warn(
'SuperSync server not healthy at http://localhost:1901 - skipping tests',

View file

@ -2,7 +2,6 @@ import { FullConfig } from '@playwright/test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { isServerHealthy } from './utils/supersync-helpers';
/**
* Warm up the dev server by fetching the app once before any tests start.
@ -70,20 +69,6 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
config.projects[0]?.use?.baseURL ||
'http://localhost:4242';
await warmUpDevServer(baseURL);
// Check SuperSync server health ONCE here, before workers start.
// Without this, each worker checks independently on startup — with many workers
// running simultaneously, the concurrent health-check requests can overload the
// supersync server and cause false negatives, making workers skip all their tests.
// By storing the result in an env var set before workers are forked, every worker
// reads the cached result instantly instead of making HTTP requests.
const healthy = await isServerHealthy().catch(() => false);
process.env.SUPERSYNC_SERVER_HEALTHY = healthy ? 'true' : 'false';
if (healthy) {
console.log('SuperSync server healthy — supersync tests will run');
} else {
console.log('SuperSync server not available — supersync tests will be skipped');
}
};
export default globalSetup;

View file

@ -191,14 +191,8 @@ export class SuperSyncPage extends BasePage {
// piggybacked ops in the response, causing data to sync between clients
// outside of explicit syncAndWait(). Set here (not in createSimulatedClient)
// so tests using enableWebSocket:true still get immediate uploads.
// 3. Block WsTriggeredDownloadService: Even when routeWebSocket() closes the
// connection, a WS notification can slip through the moment the connection
// opens (before it's closed). Setting __SP_E2E_BLOCK_WS_DOWNLOAD ensures
// WsTriggeredDownloadService ignores any such notifications, preventing
// uncontrolled background syncs that race with explicit syncAndWait() calls.
await this.page.evaluate(() => {
(globalThis as any).__SP_E2E_BLOCK_IMMEDIATE_UPLOAD = true;
(globalThis as any).__SP_E2E_BLOCK_WS_DOWNLOAD = true;
});
}

View file

@ -9,7 +9,6 @@ import {
renameTask,
archiveDoneTasks,
expectTaskInWorklog,
hasTaskInWorklog,
navigateToWorkView,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
@ -268,30 +267,10 @@ test.describe('@supersync Archive Conflict Resolution', () => {
console.log('[BugB] Client B: task not visible in active list');
// ============ PHASE 7: Verify task IN worklog ============
// After archive-wins conflict + sync rounds, the archived task may appear under
// either the original title (if archive applied first) or the renamed title (if
// the rename op was also synced). Accept either to avoid brittleness.
const clientAFound =
(await hasTaskInWorklog(clientA, taskName)) ||
(await hasTaskInWorklog(clientA, taskRenamed));
if (!clientAFound) {
throw new Error(
`[BugB] Client A: Expected task to be in worklog under "${taskName}" or "${taskRenamed}"`,
);
}
await expectTaskInWorklog(clientA, taskName);
console.log('[BugB] Client A: task found in worklog');
// Client B applied the rename locally before archive was received, so the task
// is archived under the renamed title. But also accept the original in case the
// archive was applied before the rename reached Client B.
const clientBFound =
(await hasTaskInWorklog(clientB, taskRenamed)) ||
(await hasTaskInWorklog(clientB, taskName));
if (!clientBFound) {
throw new Error(
`[BugB] Client B: Expected task to be in worklog under "${taskRenamed}" or "${taskName}"`,
);
}
await expectTaskInWorklog(clientB, taskName);
console.log('[BugB] Client B: task found in worklog');
console.log('[BugB] ✓ Test B passed: LWW Update did not resurrect archived task');

View file

@ -672,11 +672,8 @@ test.describe('@supersync SuperSync E2E', () => {
await saveAndGoHomeBtn.click();
console.log('[Archive Test] Client B clicked Save and go home (archiving)');
// Wait for navigation back to work view.
// Use negative lookahead to avoid matching /tag/TODAY/daily-summary prematurely.
await clientB.page.waitForURL(/(tag\/TODAY(?!\/daily-summary))/, {
timeout: 10000,
});
// Wait for navigation back to work view
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
await clientB.page.waitForLoadState('networkidle');
console.log('[Archive Test] Client B back on work view after archiving');

View file

@ -239,34 +239,8 @@ export const createSimulatedClient = async (
}
});
// Navigate to app with retry for transient ERR_CONNECTION_REFUSED.
// Under parallel load (many workers × 2-3 browser contexts each), the Angular
// dev server can temporarily refuse connections. Retrying recovers from this
// without failing the test outright.
let lastGotoError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await page.goto('/');
lastGotoError = null;
break;
} catch (e) {
lastGotoError = e as Error;
const isConnectionRefused = lastGotoError.message.includes(
'ERR_CONNECTION_REFUSED',
);
if (attempt < 2 && isConnectionRefused) {
const delay = 1000 * (attempt + 1);
console.log(
`[Client ${clientName}] page.goto('/') failed (attempt ${attempt + 1}/3): ERR_CONNECTION_REFUSED — retrying in ${delay}ms`,
);
await new Promise((r) => setTimeout(r, delay));
} else {
break; // Non-connection error or last attempt — let it throw below
}
}
}
if (lastGotoError) throw lastGotoError;
// Navigate to app and wait for ready
await page.goto('/');
await waitForAppReady(page);
const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`);
@ -527,10 +501,6 @@ export const markTaskDone = async (
const task = getTaskElement(client, taskName);
await task.hover();
await task.locator('done-toggle').click();
// Wait for the 200ms animation delay in toggleDoneWithAnimation to complete.
// During CDK drag animation the task may temporarily resolve to 2 elements;
// use .first() to avoid strict-mode violations.
await expect(task.first()).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
};
/**
@ -547,8 +517,6 @@ export const markSubtaskDone = async (
const subtask = getSubtaskElement(client, subtaskName);
await subtask.hover();
await subtask.locator('done-toggle').click();
// Wait for the 200ms animation delay in toggleDoneWithAnimation to complete
await expect(subtask).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
};
/**