fix(sync): fix simple counter sync and quota exceeded handling

- Add null checks in simple-counter.reducer.ts to prevent crashes when
  entity doesn't exist (fixes "Cannot read properties of undefined")
- Add missing entityIds to updateAllSimpleCounters action - was causing
  operation log to skip persistence
- Add try-catch in operation-log-upload.service.ts to show alert when
  storage quota exceeded (413 error)
- Fix supersync-network-failure.spec.ts test timing - route interception
  must happen before task creation
- Add documentation to CLAUDE.md for running supersync tests with
  real-time output using --reporter=line
This commit is contained in:
Johannes Millan 2025-12-29 17:09:46 +01:00
parent a7cd442551
commit 016e680c5f
5 changed files with 95 additions and 37 deletions

View file

@ -48,6 +48,7 @@ npm run test:file <filepath>
- Unit tests: `npm test` - Uses Jasmine/Karma, tests are co-located with source files (`.spec.ts`)
- E2E tests: `npm run e2e` - Uses Nightwatch, located in `/e2e/src/`
- Playwright E2E tests: Located in `/e2e/`
- `npm run e2e:playwright` - Run all tests with minimal output (shows failures clearly)
- `npm run e2e:playwright:file <path>` - Run a single test file with detailed output
- Example: `npm run e2e:playwright:file tests/work-view/work-view.spec.ts`
@ -58,6 +59,21 @@ npm run test:file <filepath>
- Use `--grep "test name"` to run a single test: `npm run e2e:file <path> -- --grep "test name" --retries=0`
- Tests take ~20s each, don't use excessive timeouts
- Each test run includes a fresh server start (~5s overhead)
- **IMPORTANT for Claude**: When running the full supersync suite, use playwright directly with a line reporter for real-time output (the `npm run e2e:supersync` script buffers output):
```bash
# Start the server first
docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml up -d supersync && \
until curl -s http://localhost:1901/health > /dev/null 2>&1; do sleep 1; done && \
echo 'Server ready!'
# Run with line reporter for real-time output
npx playwright test --config e2e/playwright.config.ts --grep @supersync --reporter=line
# Stop server when done
docker compose -f docker-compose.yaml -f docker-compose.e2e.yaml down supersync
```
- Linting: `npm run lint` - ESLint for TypeScript, Stylelint for SCSS
## Architecture Overview

View file

@ -617,12 +617,20 @@ base.describe('@supersync Network Failure Recovery', () => {
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync(syncConfig);
const taskName = `Task-${testRunId}-quota`;
await clientA.workView.addTask(taskName);
await waitForTask(clientA.page, taskName);
// Set up dialog handler to dismiss any alerts BEFORE setting up route
let alertShown = false;
clientA.page.on('dialog', async (dialog) => {
console.log(`[Test] Alert shown: ${dialog.message()}`);
alertShown = true;
await dialog.accept();
});
// Intercept and return storage quota exceeded
await clientA.page.route('**/api/sync/ops/**', async (route) => {
// Wait for initial sync to complete so we have a baseline
await clientA.sync.syncAndWait();
// Intercept and return storage quota exceeded BEFORE creating task
// so the immediate upload service gets the error
await clientA.page.route('**/api/sync/ops', async (route) => {
if (route.request().method() === 'POST') {
console.log('[Test] Simulating storage quota exceeded');
await route.fulfill({
@ -645,15 +653,12 @@ base.describe('@supersync Network Failure Recovery', () => {
}
});
// Set up dialog handler to dismiss any alerts
let alertShown = false;
clientA.page.on('dialog', async (dialog) => {
console.log(`[Test] Alert shown: ${dialog.message()}`);
alertShown = true;
await dialog.accept();
});
// Now create a task - the immediate upload will get 413
const taskName = `Task-${testRunId}-quota`;
await clientA.workView.addTask(taskName);
await waitForTask(clientA.page, taskName);
// Sync - should fail with quota exceeded
// Wait for immediate upload to trigger and hit the 413
try {
await clientA.sync.triggerSync();
await clientA.page.waitForTimeout(3000);
@ -672,7 +677,7 @@ base.describe('@supersync Network Failure Recovery', () => {
console.log('[StorageQuota] ✓ Server quota exceeded handling test PASSED');
} finally {
if (clientA) {
await clientA.page.unroute('**/api/sync/ops/**').catch(() => {});
await clientA.page.unroute('**/api/sync/ops').catch(() => {});
}
if (clientA) await closeClient(clientA);
}

View file

@ -70,7 +70,9 @@ export const updateAllSimpleCounters = createAction(
meta: {
isPersistent: true,
entityType: 'SIMPLE_COUNTER',
entityIds: counterProps.items.map((item) => item.id),
opType: OpType.Update,
isBulk: true,
} satisfies PersistentActionMeta,
}),
);

View file

@ -110,40 +110,51 @@ const _reducer = createReducer<SimpleCounterState>(
return newState;
}),
on(setSimpleCounterCounterToday, (state, { id, newVal, today }) =>
adapter.updateOne(
on(setSimpleCounterCounterToday, (state, { id, newVal, today }) => {
const entity = state.entities[id];
if (!entity) {
return state;
}
return adapter.updateOne(
{
id,
changes: {
countOnDay: {
...(state.entities[id] as SimpleCounter).countOnDay,
...entity.countOnDay,
[today]: newVal,
},
},
},
state,
),
),
);
}),
on(setSimpleCounterCounterForDate, (state, { id, newVal, date }) =>
adapter.updateOne(
on(setSimpleCounterCounterForDate, (state, { id, newVal, date }) => {
const entity = state.entities[id];
if (!entity) {
return state;
}
return adapter.updateOne(
{
id,
changes: {
countOnDay: {
...(state.entities[id] as SimpleCounter).countOnDay,
...entity.countOnDay,
[date]: newVal,
},
},
},
state,
),
),
);
}),
// Non-persistent local UI update for ClickCounter
// Sync happens via setSimpleCounterCounterToday with absolute value
on(increaseSimpleCounterCounterToday, (state, { id, increaseBy, today }) => {
const oldEntity = state.entities[id] as SimpleCounter;
const oldEntity = state.entities[id];
if (!oldEntity) {
return state;
}
const currentTotalCount = oldEntity.countOnDay || {};
const currentVal = currentTotalCount[today] || 0;
const newValForToday = currentVal + increaseBy;
@ -163,7 +174,10 @@ const _reducer = createReducer<SimpleCounterState>(
// Non-persistent local UI update for ClickCounter
on(decreaseSimpleCounterCounterToday, (state, { id, decreaseBy, today }) => {
const oldEntity = state.entities[id] as SimpleCounter;
const oldEntity = state.entities[id];
if (!oldEntity) {
return state;
}
const currentTotalCount = oldEntity.countOnDay || {};
const currentVal = currentTotalCount[today] || 0;
const newValForToday = Math.max(0, currentVal - decreaseBy);
@ -183,7 +197,10 @@ const _reducer = createReducer<SimpleCounterState>(
// Non-persistent local tick for StopWatch - immediate UI update
on(tickSimpleCounterLocal, (state, { id, increaseBy, today }) => {
const oldEntity = state.entities[id] as SimpleCounter;
const oldEntity = state.entities[id];
if (!oldEntity) {
return state;
}
const currentTotalCount = oldEntity.countOnDay || {};
const currentVal = currentTotalCount[today] || 0;
const newValForToday = currentVal + increaseBy;
@ -230,15 +247,19 @@ const _reducer = createReducer<SimpleCounterState>(
);
}),
on(toggleSimpleCounterCounter, (state, { id }) =>
adapter.updateOne(
on(toggleSimpleCounterCounter, (state, { id }) => {
const entity = state.entities[id];
if (!entity) {
return state;
}
return adapter.updateOne(
{
id,
changes: { isOn: !(state.entities[id] as SimpleCounter).isOn },
changes: { isOn: !entity.isOn },
},
state,
),
),
);
}),
on(setSimpleCounterCounterOn, (state, { id }) =>
adapter.updateOne(

View file

@ -212,11 +212,25 @@ export class OperationLogUploadService {
`OperationLogUploadService: Uploading batch of ${chunk.length} ops via API`,
);
const response = await syncProvider.uploadOps(
chunk,
clientId,
lastKnownServerSeq,
);
let response;
try {
response = await syncProvider.uploadOps(chunk, clientId, lastKnownServerSeq);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
OpLog.error(`OperationLogUploadService: Upload failed: ${message}`);
// Check for storage quota exceeded - show strong alert
if (
message.includes('STORAGE_QUOTA_EXCEEDED') ||
message.includes('Storage quota exceeded')
) {
alert(
'Sync storage is full! Your data is NOT syncing to the server. ' +
'Please archive old tasks or upgrade your plan to continue syncing.',
);
}
throw err; // Re-throw to propagate the error
}
// Mark successfully accepted ops as synced
const acceptedSeqs = response.results