super-productivity/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts
Johannes Millan 508998c6a1
Improve on sync (#7736)
* fix(android): restore share title derivation and dedupe shared tasks

Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from
edb102534e, so the share handler stopped sending the page subject and
defaulted the title to the literal "Shared Content". The frontend's
subject -> title -> derived title chain then always fell through to that
placeholder, producing blank-looking shared tasks.

- Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent
  so the frontend can derive a meaningful title from the URL or note.
- Ignore empty/blank shared text instead of creating a useless task.
- Skip handleIntent() on Activity recreation (config change) so the same
  share Intent isn't re-processed into a duplicate task.
- Extract buildTaskTitle/readableUrl as pure functions with unit tests
  and guard the effect against empty payloads.

* fix(snack): scale error/warning snack duration with message length

Long error messages (e.g. multi-sentence sync errors) were auto-dismissed
after a fixed 8s, too short to read. Error/warning snacks now stay visible
proportional to message length (~90ms/char), clamped to 10-30s.

* feat(tasks): skip undo snack when deleting a blank task

A sub task or parent task with an empty title and no data (notes, time,
estimate, attachments, issue link, reminder, repeat, scheduling,
deadline, non-blank sub tasks) no longer shows the undo-delete snack.

* fix(sync): include archive data in REPAIR operations

validateAndRepairCurrentState built the REPAIR op from the synchronous
getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld
(archives live in IndexedDB, not NgRx state). The resulting REPAIR op
carried empty archives, so every other client that applied it
overwrote its archive with nothing — wiping archived tasks on all
devices except the one that ran the repair.

- Use getStateSnapshotAsync() so the REPAIR op carries real archives.
- Extend the empty-archive overwrite guard in
  ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair
  (previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth.

* test(sync): add archive REPAIR round-trip integration test

Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService
and ArchiveOperationHandler against real IndexedDB to verify archive data
survives the REPAIR-op round-trip:

- getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not
- archive round-trips from client A's IndexedDB through a REPAIR op into a
  fresh client B's IndexedDB
- a REPAIR op carrying empty archives no longer wipes a client that has
  archive data (empty-archive guard regression)

* perf(sync): skip archive IndexedDB reads when post-sync state is valid

validateAndRepairCurrentState validated the full async snapshot (two
IndexedDB archive reads + structured-clone deserialization) on every
Checkpoint D, even when state was valid and no repair was needed. It now
validates the cheap synchronous snapshot first and only loads the async
snapshot (with archives) when a repair is actually required — the rare
path. The REPAIR op still carries archive data.

Also addresses multi-review follow-ups:
- archive-operation-handler: reword the empty-archive guard comment so it
  no longer over-promises reconciliation for REPAIR ops.
- archive-repair-roundtrip test: add isPersistent to the applied-op meta
  to match the real applier; scope the file docstring accurately.

* fix(task-repeat-cfg): schedule inbox task for today when made recurring

When an Inbox task (no dueDay) was made repeatable via the dialog with a
recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence
branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from
task.created as a fallback, so a task created today looked already scheduled
and dueDay was never set.

Key the decision on task.dueDay directly. Skip timed tasks and tasks that
already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and
timed scheduling is handled by addRepeatCfgToTaskUpdateTask$.

Closes #7725

* fix(tasks): correct monthly first/last-day recurrence anchoring

The "Every month on the first day" and "Every month on the last day"
quick settings scheduled the first task instance in the past. Both
presets produced a backdated startDate (1st of the current month;
hardcoded January 31), which getFirstRepeatOccurrence returns verbatim
for monthly recurrences.

- MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month
  that is today or later.
- MONTHLY_LAST_DAY anchors startDate to the current month's last day
  and sets a new monthlyLastDay flag, so the occurrence engine clamps
  to month-end every month regardless of startDate's day-of-month.
- _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a
  config leaves the preset (CUSTOM mode has no control for it).

Closes #7726

* fix(task-repeat-cfg): re-anchor start date after instance deleted

When the user moved a repeat config's startDate earlier after deleting
its only live task instance, the stale lastTaskCreationDay anchor kept
suppressing every projected/created instance between the new startDate
and the old anchor.

rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay
when a live task instance existed (the #7423 fix) — it returned early
before the re-anchoring when there was none. Hoist the
isStartDateMovedEarlier detection above that early return: when no live
instance exists but startDate moved earlier, re-anchor to the day
before the new first occurrence so it and every following day is
created and projected fresh.

Closes #7724

* test(task-repeat-cfg): cover startDate re-anchor with no live instance

Add coverage for the #7724 fix beyond the effect unit test:

- Selector integration tests: feed a config re-anchored to the day
  before the new startDate through selectTaskRepeatCfgsForExactDay and
  assert it projects the new startDate and every following day, while
  still excluding the anchor day and earlier. Also documents that the
  stale anchor suppresses the gap days.
- E2E reproduction (recurring-move-start-date-earlier-no-instance):
  create a recurring task, delete its live instance, move startDate
  earlier via a transparent projection, and assert the new days appear
  in the planner. Verified to fail on pre-fix code.

* feat(sync): move clientId from pf into SUP_OPS for atomic rotation

Migrate the sync clientId out of the legacy `pf` IndexedDB database into
`SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins
the atomic transaction in `runDestructiveStateReplacement`, so destructive
flows (clean-slate, backup-restore) rotate it atomically with
OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database
two-phase commit.

- ClientIdService rewritten: SUP_OPS-backed via an independent connection,
  inline one-time pf->SUP_OPS migration, error-aware resolver. Read
  failures propagate (getOrGenerateClientId never mints a fresh id over a
  transient error — that would orphan the device's non-regenerable
  identity); loadClientId never throws.
- Delete withRotation, generateNewClientId and the CAS/rollback machinery.
- Extract pure generateClientId() + isValidClientIdFormat() into
  core/util/generate-client-id.ts.
- pf becomes a read-only, one-time migration source (never written/deleted).

Closes #7732

* fix(sync): dedup SUP_OPS connection open in ClientIdService

Address multi-agent review findings on the clientId migration:

- _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise;
  concurrent cold-start callers previously each opened their own
  SUP_OPS connection, leaking all but the last.
- _putClientIdIfAbsent() collapsed to a single tx.done / exit point.
- db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore
  count assertions were stale and failing after the schema bump.
- operation-log-migration.service.ts: correct a misleading comment about
  the genesis-op clientId fallback.

* refactor(sync): align ClientIdService SUP_OPS open with in-house idiom

Re-review of the connection-leak fix recommended matching
OperationLogStoreService._ensureInit's pattern:

- _getSupOpsDb() clears the in-flight promise in .catch (failure only)
  instead of an unconditional finally — the resolved handle lives in
  _supOpsDb, so the promise field is pure in-flight coordination state.
- close/versionchange handlers now also null _supOpsDbPromise, so a
  stale (closed) connection is never re-handed-out.
- Add a regression test asserting _openSupOpsDb runs exactly once for
  concurrent cold-start callers.

* docs(sync): link the single-connection follow-up to #7735

Reference the tracked follow-up issue from the ClientIdService JSDoc
and the plan's out-of-scope section, so the deliberate trade-off (one
extra SUP_OPS connection) is traceable rather than forgotten.

* test(sync): update ClientIdService spies for getOrGenerateClientId

The op-log capture effect now resolves the clientId via
getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()).
These two specs still mocked only loadClientId, so the effect called an
undefined method, captured no op, and 4 tests failed in the full suite.

- task-done-replay.integration.spec.ts
- operation-log-lock-reentry.regression.spec.ts

* test(sync): open SUP_OPS versionless in e2e read helpers

DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at
the hardcoded old version 5 to read state after the app had already
upgraded it to v6, throwing VersionError. Open versionless instead —
matches the ~10 other e2e files that already do, and is future-proof
against the next schema bump.

- migration/legacy-data-migration.spec.ts (x2)
- sync/supersync-legacy-migration-sync.spec.ts
- sync/webdav-legacy-migration-sync.spec.ts
- recurring/invalid-clock-string-bug-7067.spec.ts
2026-05-22 17:49:25 +02:00

208 lines
8.1 KiB
TypeScript

import { expect, test } from '../../fixtures/test.fixture';
/**
* Bug: https://github.com/super-productivity/super-productivity/issues/7067
*
* An invalid `startTime` on a TaskRepeatCfg (e.g. from sync/import/migration)
* causes `getDateTimeFromClockString()` to throw "Invalid clock string" at
* runtime whenever a task list renders the repeat info chip.
*
* Reproduction steps (from the issue's first stack trace):
* 1. A TaskRepeatCfg has startTime = "INVALID_CLOCK_STRING" in the store
* (arriving via sync or a legacy corrupt save).
* 2. The tag-list component computes indicator chips for every task.
* 3. `getTaskRepeatInfoText()` calls `getDateTimeFromClockString(startTime)`
* which throws: "Invalid clock string".
*
* Expected: The app should handle an invalid startTime gracefully (skip it or
* fall back to no-time display) without crashing.
* Actual: Error is thrown inside the computed signal, crashing the view.
*/
test('should not crash when a repeat config has an invalid startTime in the store (bug #7067)', async ({
page,
workViewPage,
taskPage,
}) => {
// ── Phase 1: Create a task with a timed repeat config ─────────────────────
await workViewPage.waitForTaskList();
await workViewPage.addTask('RepeatBug7067');
const task = taskPage.getTaskByText('RepeatBug7067').first();
await expect(task).toBeVisible({ timeout: 10000 });
await taskPage.openTaskDetail(task);
await page
.locator('task-detail-item')
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) })
.click();
const repeatDialog = page.locator('mat-dialog-container');
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// Set a valid startTime so the config has startTime + remindAt in the store
await repeatDialog.locator('collapsible .collapsible-header').last().click();
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
await expect(startTimeField).toBeVisible({ timeout: 5000 });
await startTimeField.fill('10:30');
await startTimeField.blur();
await page.waitForTimeout(300);
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
await saveBtn.click();
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
await page.keyboard.press('Escape');
// Wait for the op to be flushed to IndexedDB before we try to read it
await page.waitForTimeout(1500);
// ── Phase 2: Corrupt the startTime ────────────────────────────────────────
// Strategy A (dev mode): use ng.getComponent to dispatch directly to the
// in-memory NgRx store — no page reload needed.
// Strategy B (production mode): ng.getComponent is stripped; instead we
// inject a corrupt op directly into IndexedDB and reload the page so the
// hydrator replays it. The ops store accepts unencoded full-format ops
// alongside compact-encoded ones (isCompactOperation guard in store service).
const pageErrors: string[] = [];
page.on('pageerror', (err) => pageErrors.push(err.message));
const devModeCorrupted = await page.evaluate((): boolean => {
const ng = (window as any).ng;
if (!ng?.getComponent) return false;
for (const el of Array.from(document.querySelectorAll('*'))) {
try {
const comp = ng.getComponent(el) as any;
if (!comp) continue;
const store = comp._store ?? comp.store ?? comp.__store;
if (!store?.dispatch) continue;
let state: any = null;
store
.subscribe((s: any) => {
state = s;
})
.unsubscribe();
if (!state?.taskRepeatCfg?.ids?.length) continue;
// Find the config we just created (startTime === '10:30')
const cfgId = state.taskRepeatCfg.ids.find(
(id: string) => state.taskRepeatCfg.entities[id]?.startTime === '10:30',
);
if (!cfgId) continue;
store.dispatch({
type: '[TaskRepeatCfg] Update TaskRepeatCfg',
taskRepeatCfg: { id: cfgId, changes: { startTime: 'INVALID_CLOCK_STRING' } },
});
return true;
} catch {
// ignore
}
}
return false;
});
if (devModeCorrupted) {
// Dev mode: store already has the corrupt value; just navigate within the SPA.
await page.goto('/#/tag/TODAY/tasks');
await page.waitForTimeout(2000);
} else {
// Production mode: write a corrupt op to IndexedDB and reload so the
// hydrator replays it.
const idbCorrupted = await page.evaluate(async (): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
const req = indexedDB.open('SUP_OPS');
req.onerror = () => resolve(false);
req.onsuccess = () => {
const db = req.result;
// Read all ops to find the TASK_REPEAT_CFG entry with startTime '10:30'
const readTx = db.transaction(['ops'], 'readonly');
const opsStore = readTx.objectStore('ops');
const allOpsReq = opsStore.getAll();
allOpsReq.onerror = () => resolve(false);
allOpsReq.onsuccess = () => {
const entries: any[] = allOpsReq.result;
// Find the most recent TASK_REPEAT_CFG op
let cfgId: string | null = null;
let clientId: string | null = null;
let vectorClock: Record<string, number> = {};
let schemaVersion = 2;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const op = entry.op;
if (!op) continue;
// Handle both compact format (short keys) and full format
const entityType = op.e ?? op.entityType;
if (entityType !== 'TASK_REPEAT_CFG') continue;
// Compact: op.d = entityId; full: op.entityId
cfgId = op.d ?? op.entityId ?? null;
clientId = op.c ?? op.clientId ?? null;
vectorClock = op.v ?? op.vectorClock ?? {};
schemaVersion = op.s ?? op.schemaVersion ?? 2;
break;
}
if (!cfgId) {
resolve(false);
return;
}
// Write a new full-format op (the store decodes compact ops but
// passes full ops straight through — see isCompactOperation guard).
const writeTx = db.transaction(['ops'], 'readwrite');
const writeStore = writeTx.objectStore('ops');
const corruptEntry = {
// seq omitted → IndexedDB auto-increments to max+1
op: {
id: crypto.randomUUID(),
actionType: '[TaskRepeatCfg] Update TaskRepeatCfg',
opType: 'UPD',
entityType: 'TASK_REPEAT_CFG',
entityId: cfgId,
payload: {
actionPayload: {
taskRepeatCfg: {
id: cfgId,
changes: { startTime: 'INVALID_CLOCK_STRING' },
},
},
entityChanges: [],
},
clientId: clientId ?? 'e2e-test-client',
vectorClock,
timestamp: Date.now(),
schemaVersion,
},
appliedAt: Date.now(),
source: 'local' as const,
};
const addReq = writeStore.add(corruptEntry);
addReq.onerror = () => resolve(false);
addReq.onsuccess = () => resolve(true);
};
};
});
});
expect(idbCorrupted).toBe(true); // guard: verify IDB injection worked
// Reload so the hydrator replays the corrupt op, then navigate to TODAY tag
await page.reload({ waitUntil: 'networkidle' });
await page.goto('/#/tag/TODAY/tasks');
await page.waitForTimeout(2000);
}
// ── Phase 3: Verify no "Invalid clock string" crash ───────────────────────
const clockErrors = pageErrors.filter((e) => e.includes('Invalid clock string'));
expect(clockErrors).toHaveLength(0);
});