diff --git a/.github/workflows/sync-compat.yml b/.github/workflows/sync-compat.yml index c2b8b01f6..a9c814023 100644 --- a/.github/workflows/sync-compat.yml +++ b/.github/workflows/sync-compat.yml @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: elkimek/getbased-relay - ref: 2e7979ea5c72d0e6e0652b241d845c3b77762a24 + ref: 4f44ebb32d8c44d29503cb075d57ce48a7519b28 path: .ci/getbased-relay persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f71b21128..db372a61a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ on: type: choice required: false default: 'off' - options: ['off', 'webkit-lens-wearable', 'webkit-pwa-routstr'] + options: ['off', 'webkit-lens-wearable', 'webkit-pwa-routstr', 'webkit-evolu-runtime'] workflow_call: inputs: full_suite: @@ -25,14 +25,14 @@ on: permissions: contents: read concurrency: - group: tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.full_suite == true && 'full' || 'scoped' }}${{ github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-lens-wearable' && '-webkit-diagnostic' || '' }}${{ github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-pwa-routstr' && '-webkit-pwa-routstr-diagnostic' || '' }} + group: tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.full_suite == true && 'full' || 'scoped' }}${{ github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-lens-wearable' && '-webkit-diagnostic' || '' }}${{ github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-pwa-routstr' && '-webkit-pwa-routstr-diagnostic' || '' }}${{ github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-evolu-runtime' && '-webkit-evolu-diagnostic' || '' }} cancel-in-progress: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && inputs.full_suite != true }} env: GETBASED_KIT_ARTIFACT: ${{ github.workspace }}/dist CI_TEST_PLAN: .local-notes/ci/plan.json jobs: prepare: - if: github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr') + if: github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr' && inputs.diagnostic != 'webkit-evolu-runtime') runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -168,7 +168,9 @@ jobs: - name: Full unit coverage and unchanged critical behavior floors if: needs.prepare.outputs.full == 'true' run: | - node node_modules/vitest/vitest.mjs run --coverage + # Leave room for native TypeScript and nested producer subprocesses. + # Keep the complete suite, coverage denominator and deadlines intact. + node node_modules/vitest/vitest.mjs run --coverage --maxWorkers=2 node node_modules/vitest/vitest.mjs run --config vitest.critical.config.js - name: Affected unit tests and mandatory changed-source checks if: needs.prepare.outputs.full != 'true' @@ -516,9 +518,9 @@ jobs: if-no-files-found: error retention-days: 14 test: - name: ${{ github.event_name == 'workflow_dispatch' && (inputs.diagnostic == 'webkit-lens-wearable' || inputs.diagnostic == 'webkit-pwa-routstr') && 'DIAGNOSTIC normal gate omitted' || 'test' }} + name: ${{ github.event_name == 'workflow_dispatch' && (inputs.diagnostic == 'webkit-lens-wearable' || inputs.diagnostic == 'webkit-pwa-routstr' || inputs.diagnostic == 'webkit-evolu-runtime') && 'DIAGNOSTIC normal gate omitted' || 'test' }} needs: [prepare, unit, native, kit, performance, coverage] - if: always() && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr')) + if: always() && (github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr' && inputs.diagnostic != 'webkit-evolu-runtime')) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -782,3 +784,75 @@ jobs: include-hidden-files: true if-no-files-found: warn retention-days: 7 + + webkit_evolu_diagnostics: + name: DIAGNOSTIC Cocoa Evolu restart (${{ matrix.project }}; not a release gate) + if: github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-evolu-runtime' + runs-on: macos-15 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + project: [kit-webkit, kit-mobile-webkit] + env: + GETBASED_KIT_ACCEPTANCE_GUARD: '1' + GETBASED_KIT_GUARD_REPORT: .local-notes/ci/evolu-diagnostic/guard.json + PLAYWRIGHT_KIT_OUTPUT_DIR: .local-notes/ci/evolu-diagnostic/results + GETBASED_KIT_CASE_SCOPE: all + GETBASED_KIT_PROJECT: ${{ matrix.project }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: npm + - run: node scripts/fetch-catalog.mts + - run: npm ci + - run: npx --no-install playwright install --with-deps webkit + - name: Build and verify the actual diagnostic artifact + run: | + set -euo pipefail + node scripts/build-sveltekit.mjs --output dist + node scripts/ci-artifact.mjs seal --artifact dist --transport .local-notes/ci/evolu-diagnostic/transport + node scripts/ci-artifact.mjs verify --artifact dist --transport .local-notes/ci/evolu-diagnostic/transport --github-env + - name: Run the canonical Evolu offline restart case without retries + id: diagnostic_case + env: + PROJECT: ${{ matrix.project }} + run: node node_modules/@playwright/test/cli.js test --config=playwright.sveltekit.config.js --project="$PROJECT" --workers=1 --retries=0 tests/pwa/sveltekit-evolu-runtime.spec.ts + - name: Require the exact successful diagnostic scope + if: always() && steps.diagnostic_case.outcome != 'skipped' + env: + PROJECT: ${{ matrix.project }} + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import fs from 'node:fs'; + const report = JSON.parse(fs.readFileSync(process.env.GETBASED_KIT_GUARD_REPORT, 'utf8')); + assert.equal(report.execution, true); + assert.equal(report.status, 'passed'); + assert.deepEqual(report.issues, []); + assert.equal(report.tests.length, 1); + const test = report.tests[0]; + assert.equal(test.file, 'tests/pwa/sveltekit-evolu-runtime.spec.ts'); + assert.equal(test.project, process.env.PROJECT); + assert.equal(test.attemptCount, 1); + assert.equal(test.status, 'passed'); + assert.equal(test.expectedStatus, 'passed'); + assert.equal(test.declaredExpectedStatus, 'passed'); + assert.equal(test.outcome, 'expected'); + for (const annotations of [test.annotations, test.declaredAnnotations]) assert.equal(annotations.some(value => ['skip', 'fixme'].includes(value.type)), false); + NODE + - name: Label the focused evidence + if: always() + run: echo 'Focused Cocoa Evolu diagnostics only; the normal release matrix is still required.' >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: diagnostic-evolu-${{ matrix.project }}-${{ github.run_id }} + path: .local-notes/ci/evolu-diagnostic + include-hidden-files: true + if-no-files-found: error + retention-days: 7 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b560c78d9..610d57d1f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,6 +47,19 @@ checks use the fixed inventory in ## Presentation and lifecycle contract +Foreground modals and the Sync popover cancel optional Dashboard Chat startup, +including a request still awaiting module or presentation loading; explicit Chat +actions retain their normal behavior. +Optional empty-Dashboard Chat startup owns its reservation by render generation +and profile. Failed loads or a narrower viewport release it; completion from a +retired render cannot clear a newer reservation. +Lazy Chat open/toggle requests retain their initiating profile through module +loading. Panel opening checks that profile and its open intent after each +asynchronous load, so a deleted profile's pending request cannot initialize a +conversation on its temporary sync fallback or another selected profile. +An interrupted visible opening closes only while it still owns the latest open +intent, leaving a newer opening intact. + `body-measurement-model.ts` owns method-specific waist/hip/SAD field identities and validated portable-mirror projection. `wearables-manual.ts` owns their paired numeric writes and deletion clocks through the existing encrypted manual-reading @@ -88,7 +101,9 @@ before dispatch, publication and persistence. Retired controls cannot start new or update replacement views. Accepted domain writes can finish for their original owner after closure; they must not publish into a new owner's UI or adopt unrelated unsaved fields. Domain transactions, locks and explicit field baselines decide -commit/rollback, not component mount lifetime. +commit/rollback, not component mount lifetime. A save adopting into a replacement +object for the same profile uses that object's own read baseline, so stale loaded +values cannot undo the committed edit and newer unsaved fields remain intact. Keep credentials and private metadata in dedicated live-field/service ports, not general display snapshots or action attributes. Existing explicit Sync recovery @@ -352,6 +367,14 @@ downgrade is not a database rollback: previous web runtimes cannot open every new-format database. The v7 recovery path always retains its separate runtime and database identity. +Overlapping forced pushes retain per-profile generations. A newer push supersedes +older asynchronous planning and completion; a newer saved edit invalidates an +older plan before its SDK write. Superseded snapshots cannot write stale deltas, +acknowledge a newer dirty marker or release another push's in-flight guard. A +superseded plan settles its status before releasing ownership and cannot hide +another profile's pending send. Manual sync retries the current snapshot within +its existing deadline. + V3 profile snapshots retain scalar values with per-field edit clocks, so an unrelated save cannot give an unchanged scalar a newer clock. Full snapshots protect peers whose profile and item-row query updates arrive separately. @@ -363,6 +386,34 @@ insert contract. Never-configured absent fields do not create clear rows. Legacy envelopes retain their profile-clock fallback. Explicit recovery also re-emits scalar rows, including clears. Planning or row-mutation failures keep local dirty markers queued and stop success confirmation. + +Backup recovery acknowledges each committed profile independently before relay +tombstones can apply. Its journal stays readable by older tabs; generation, +owner and dirty-token checks prevent an old completion from acknowledging a +newer restore. Demo and explicitly deleted profiles are not republished. +Explicit local delete intent acknowledges recovery even after the profile leaves +the saved list; absence alone never discards a recovery entry. +Unavailable profiles retain their recovery protection without re-uploading +completed profiles. A failed progress write retains an in-memory commit receipt; +automatic failed publishes have a bounded retry budget, reset by an explicit +Sync now/resume or a newer local edit. +Identity rotation confirms a durable local recovery journal before changing the +owner. Failed writes/readback retain the original identity and saved Sync state. +If the SDK rejects a reset while its actual owner is unchanged, restore the exact +prior journal only while this operation still owns the staged generation. A +newer journal or an identity changed before startup failed retains its recovery. +The staged journal also binds to a domain-separated SHA-256 fingerprint of the +intended mnemonic; recovery words are never stored in the journal. Every +acknowledgement preserves that binding. A second tab with an older identity +defers recovery even while the originating tab is awaiting SDK reset. +Publishing stays suspended in the retiring runtime until replacement; the +reloaded owner publishes complete recovery. The old owner/query cache cannot +acknowledge the new owner's recovery. A deliberate join retargets existing +pending recovery before SDK reset, with the same owned rollback on failure. +Startup reconciliation may bypass the in-flight guard but retains the canonical +payload no-op check; an unchanged startup must not append relay history. +AI-setting retries stop after a successful unchanged receipt; unavailable +dirty-marker storage must not silently discard the requested check. Profile metadata carries its own item row and durable edit clock; unrelated data rebroadcasts retain it. Legacy metadata uses the envelope clock, and equal-clock metadata conflicts resolve deterministically. Pulls defer pending local metadata edits @@ -370,6 +421,10 @@ and ignore exact cached versions replaced by successful local pushes. Successful recovery durably advances the local metadata clock; metadata edited while the push is pending stays queued with a clock beyond that recovery. +Sync status emits only changed fields. An open status popover updates its +information in place, retaining action-button identity and keyboard focus so +replication activity cannot interrupt hover or a click in progress. + The static assembler externalizes Kit's classic bootstrap to satisfy the existing CSP without permitting inline executable scripts. It copies only declared public assets, retains `/`, `/index.html`, `/app` and `/app/`, and uses the existing diff --git a/MODULE_MAP.md b/MODULE_MAP.md index c6ea21408..39bb88b53 100644 --- a/MODULE_MAP.md +++ b/MODULE_MAP.md @@ -10,7 +10,7 @@ The human-maintained architecture contract is in [`ARCHITECTURE.md`](ARCHITECTUR | Metric | Current | | --- | ---: | | Modules | 1867 | -| Internal import edges | 5711 | +| Internal import edges | 5717 | | Dynamic internal edges | 139 | | Modules participating in cycles | 0 | | Cyclic components | 0 | @@ -65,14 +65,14 @@ High fan-in modules have many dependants; high fan-out modules coordinate many d | High fan-in | Dependants | High fan-out | Imports | | --- | ---: | --- | ---: | -| [`js/state.ts`](js/state.ts) | 261 | [`js/app-shell-hooks.ts`](js/app-shell-hooks.ts) | 79 | +| [`js/state.ts`](js/state.ts) | 263 | [`js/app-shell-hooks.ts`](js/app-shell-hooks.ts) | 79 | | [`js/utils.ts`](js/utils.ts) | 260 | [`js/chat-send.ts`](js/chat-send.ts) | 38 | | [`js/caught-error.ts`](js/caught-error.ts) | 94 | [`js/app-light-sun-modules.ts`](js/app-light-sun-modules.ts) | 36 | | [`js/data.ts`](js/data.ts) | 87 | [`js/provider-panels.ts`](js/provider-panels.ts) | 34 | | [`js/modal-lifecycle.ts`](js/modal-lifecycle.ts) | 85 | [`js/settings.ts`](js/settings.ts) | 34 | | [`js/runtime-callbacks.ts`](js/runtime-callbacks.ts) | 65 | [`js/pdf-import.ts`](js/pdf-import.ts) | 31 | | [`js/profile.ts`](js/profile.ts) | 54 | [`js/provider-wallet-panels.ts`](js/provider-wallet-panels.ts) | 30 | -| [`js/api.ts`](js/api.ts) | 52 | [`js/sync-configure.ts`](js/sync-configure.ts) | 29 | +| [`js/api.ts`](js/api.ts) | 52 | [`js/sync-configure.ts`](js/sync-configure.ts) | 30 | | [`js/crypto.ts`](js/crypto.ts) | 41 | [`js/wearables-connect.ts`](js/wearables-connect.ts) | 28 | | [`js/schema.ts`](js/schema.ts) | 41 | [`js/chat-empty-state.ts`](js/chat-empty-state.ts) | 26 | | [`js/ai-feature-routing.ts`](js/ai-feature-routing.ts) | 39 | [`js/export.ts`](js/export.ts) | 26 | @@ -874,7 +874,7 @@ Native browser modules shipped with the static application. - [`js/chat-layout-view.ts`](js/chat-layout-view.ts) → no in-scope imports - [`js/chat-layout.ts`](js/chat-layout.ts) → [`js/chat-layout-view.ts`](js/chat-layout-view.ts) - [`js/chat-live-message-component.ts`](js/chat-live-message-component.ts) → [`js/chat-transcript-presentation.ts`](js/chat-transcript-presentation.ts), [`js/components/ChatLiveMessage.svelte`](js/components/ChatLiveMessage.svelte), [`js/components/ChatStreamingText.svelte`](js/components/ChatStreamingText.svelte), [`js/state.ts`](js/state.ts) -- [`js/chat-loader.ts`](js/chat-loader.ts) → [`js/app-ai-interaction-modules.ts`](js/app-ai-interaction-modules.ts) *(dynamic)* +- [`js/chat-loader.ts`](js/chat-loader.ts) → [`js/app-ai-interaction-modules.ts`](js/app-ai-interaction-modules.ts) *(dynamic)*, [`js/state.ts`](js/state.ts) - [`js/chat-marker-prompts.ts`](js/chat-marker-prompts.ts) → [`js/chat-history.ts`](js/chat-history.ts), [`js/chat-panel.ts`](js/chat-panel.ts), [`js/chat-runtime.ts`](js/chat-runtime.ts), [`js/chat-threads.ts`](js/chat-threads.ts), [`js/context-source-registry.ts`](js/context-source-registry.ts), [`js/data.ts`](js/data.ts), [`js/marker-analysis.ts`](js/marker-analysis.ts), [`js/state.ts`](js/state.ts), [`js/therapy-correlations.ts`](js/therapy-correlations.ts) *(dynamic)*, [`js/utils.ts`](js/utils.ts) - [`js/chat-message-action-attrs.ts`](js/chat-message-action-attrs.ts) → [`js/utils.ts`](js/utils.ts) - [`js/chat-message-actions-classes.ts`](js/chat-message-actions-classes.ts) → no in-scope imports @@ -905,7 +905,7 @@ Native browser modules shipped with the static application. - [`js/chat-optional-onboarding-classes.ts`](js/chat-optional-onboarding-classes.ts) → no in-scope imports - [`js/chat-optional-onboarding-presentation.ts`](js/chat-optional-onboarding-presentation.ts) → [`js/chat-onboarding-presentation.ts`](js/chat-onboarding-presentation.ts), [`js/dna-evidence.ts`](js/dna-evidence.ts), [`js/supplement-medication-domain.ts`](js/supplement-medication-domain.ts) - [`js/chat-optional-setup-component.ts`](js/chat-optional-setup-component.ts) → [`js/chat-optional-onboarding-presentation.ts`](js/chat-optional-onboarding-presentation.ts), [`js/components/ChatOptionalSetup.svelte`](js/components/ChatOptionalSetup.svelte), [`js/genetics-import-events.ts`](js/genetics-import-events.ts), [`js/state.ts`](js/state.ts) -- [`js/chat-panel.ts`](js/chat-panel.ts) → [`js/agent-chat-settings.ts`](js/agent-chat-settings.ts), [`js/api.ts`](js/api.ts), [`js/chat-backend-selection.ts`](js/chat-backend-selection.ts), [`js/chat-composer-input-port.ts`](js/chat-composer-input-port.ts), [`js/chat-composer.ts`](js/chat-composer.ts), [`js/chat-header-presentation.ts`](js/chat-header-presentation.ts), [`js/chat-history.ts`](js/chat-history.ts), [`js/chat-images.ts`](js/chat-images.ts), [`js/chat-layout.ts`](js/chat-layout.ts), [`js/chat-mobile-viewport.ts`](js/chat-mobile-viewport.ts), [`js/chat-model-controls.ts`](js/chat-model-controls.ts), [`js/chat-nudge.ts`](js/chat-nudge.ts), [`js/chat-personalities.ts`](js/chat-personalities.ts), [`js/chat-personality-picker-view.ts`](js/chat-personality-picker-view.ts), [`js/chat-summaries.ts`](js/chat-summaries.ts), [`js/chat-threads.ts`](js/chat-threads.ts), [`js/chat-voice-view.ts`](js/chat-voice-view.ts), [`js/utils.ts`](js/utils.ts) +- [`js/chat-panel.ts`](js/chat-panel.ts) → [`js/agent-chat-settings.ts`](js/agent-chat-settings.ts), [`js/api.ts`](js/api.ts), [`js/chat-backend-selection.ts`](js/chat-backend-selection.ts), [`js/chat-composer-input-port.ts`](js/chat-composer-input-port.ts), [`js/chat-composer.ts`](js/chat-composer.ts), [`js/chat-header-presentation.ts`](js/chat-header-presentation.ts), [`js/chat-history.ts`](js/chat-history.ts), [`js/chat-images.ts`](js/chat-images.ts), [`js/chat-layout.ts`](js/chat-layout.ts), [`js/chat-mobile-viewport.ts`](js/chat-mobile-viewport.ts), [`js/chat-model-controls.ts`](js/chat-model-controls.ts), [`js/chat-nudge.ts`](js/chat-nudge.ts), [`js/chat-personalities.ts`](js/chat-personalities.ts), [`js/chat-personality-picker-view.ts`](js/chat-personality-picker-view.ts), [`js/chat-summaries.ts`](js/chat-summaries.ts), [`js/chat-threads.ts`](js/chat-threads.ts), [`js/chat-voice-view.ts`](js/chat-voice-view.ts), [`js/state.ts`](js/state.ts), [`js/utils.ts`](js/utils.ts) - [`js/chat-persona-editor-component.ts`](js/chat-persona-editor-component.ts) → [`js/components/ChatPersonalityEditor.svelte`](js/components/ChatPersonalityEditor.svelte), [`js/modal-lifecycle.ts`](js/modal-lifecycle.ts) - [`js/chat-persona-editor-contract.ts`](js/chat-persona-editor-contract.ts) → no in-scope imports - [`js/chat-personalities.ts`](js/chat-personalities.ts) → [`js/chat-context-status.ts`](js/chat-context-status.ts), [`js/chat-header-presentation.ts`](js/chat-header-presentation.ts), [`js/chat-layout-view.ts`](js/chat-layout-view.ts), [`js/chat-personality-editing.ts`](js/chat-personality-editing.ts), [`js/chat-personality-models.ts`](js/chat-personality-models.ts), [`js/chat-personality-picker-view.ts`](js/chat-personality-picker-view.ts), [`js/chat-personality-policy.ts`](js/chat-personality-policy.ts), [`js/chat-runtime.ts`](js/chat-runtime.ts), [`js/chat-threads.ts`](js/chat-threads.ts), [`js/constants.ts`](js/constants.ts), [`js/state.ts`](js/state.ts), [`js/utils.ts`](js/utils.ts) @@ -4640,10 +4640,10 @@ Native browser modules shipped with the static application. - [`js/sync-actions.ts`](js/sync-actions.ts) → [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/state.ts`](js/state.ts), [`js/sync-cutover.ts`](js/sync-cutover.ts), [`js/sync-delta-surfaces.ts`](js/sync-delta-surfaces.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/sync-messenger.ts`](js/sync-messenger.ts), [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts), [`js/sync-scalar-snapshot.ts`](js/sync-scalar-snapshot.ts), [`js/sync-state.ts`](js/sync-state.ts), [`js/sync-storage-cleanup.ts`](js/sync-storage-cleanup.ts), [`js/utils.ts`](js/utils.ts) - [`js/sync-apply.ts`](js/sync-apply.ts) → [`js/app-extension-runtime.ts`](js/app-extension-runtime.ts), [`js/crypto.ts`](js/crypto.ts), [`js/routstr-session.ts`](js/routstr-session.ts), [`js/routstr-validation.ts`](js/routstr-validation.ts), [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts), [`js/sync-payload-collectors.ts`](js/sync-payload-collectors.ts), [`js/sync-runtime.ts`](js/sync-runtime.ts), [`js/voice-settings-schema.ts`](js/voice-settings-schema.ts) -- [`js/sync-backup-restore-state.ts`](js/sync-backup-restore-state.ts) → [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/sync-delta-snapshot.ts`](js/sync-delta-snapshot.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts) +- [`js/sync-backup-restore-state.ts`](js/sync-backup-restore-state.ts) → [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/sync-delta-snapshot.ts`](js/sync-delta-snapshot.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/unique-id.ts`](js/unique-id.ts) - [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts) → [`js/chat-personality-storage.ts`](js/chat-personality-storage.ts), [`js/crypto.ts`](js/crypto.ts), [`js/state.ts`](js/state.ts), [`js/sync-chat-merge.ts`](js/sync-chat-merge.ts) *(dynamic)*, [`js/sync-payload-collectors.ts`](js/sync-payload-collectors.ts), [`js/sync-state.ts`](js/sync-state.ts) - [`js/sync-chat-merge.ts`](js/sync-chat-merge.ts) → [`js/chat-personality-merge.ts`](js/chat-personality-merge.ts) -- [`js/sync-configure.ts`](js/sync-configure.ts) → [`js/data.ts`](js/data.ts), [`js/lab-context-loader.ts`](js/lab-context-loader.ts), [`js/lab-context-wearables.ts`](js/lab-context-wearables.ts), [`js/profile.ts`](js/profile.ts), [`js/sync-actions.ts`](js/sync-actions.ts), [`js/sync-cutover.ts`](js/sync-cutover.ts), [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-diagnose-ui.ts`](js/sync-diagnose-ui.ts), [`js/sync-diagnostics-context.ts`](js/sync-diagnostics-context.ts), [`js/sync-environment.ts`](js/sync-environment.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-init.ts`](js/sync-init.ts), [`js/sync-messenger.ts`](js/sync-messenger.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-pull-rebroadcast.ts`](js/sync-pull-rebroadcast.ts), [`js/sync-pull.ts`](js/sync-pull.ts), [`js/sync-push.ts`](js/sync-push.ts), [`js/sync-reconcile.ts`](js/sync-reconcile.ts), [`js/sync-recovery.ts`](js/sync-recovery.ts), [`js/sync-relay-health.ts`](js/sync-relay-health.ts), [`js/sync-runtime.ts`](js/sync-runtime.ts), [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts), [`js/sync-settings-state.ts`](js/sync-settings-state.ts), [`js/sync-state.ts`](js/sync-state.ts), [`js/sync-storage-cleanup.ts`](js/sync-storage-cleanup.ts), [`js/sync-subscriptions.ts`](js/sync-subscriptions.ts), [`js/sync-tombstones.ts`](js/sync-tombstones.ts), [`js/sync-ui.ts`](js/sync-ui.ts), [`js/utils.ts`](js/utils.ts) +- [`js/sync-configure.ts`](js/sync-configure.ts) → [`js/data.ts`](js/data.ts), [`js/lab-context-loader.ts`](js/lab-context-loader.ts), [`js/lab-context-wearables.ts`](js/lab-context-wearables.ts), [`js/profile.ts`](js/profile.ts), [`js/sync-actions.ts`](js/sync-actions.ts), [`js/sync-backup-restore-state.ts`](js/sync-backup-restore-state.ts), [`js/sync-cutover.ts`](js/sync-cutover.ts), [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-diagnose-ui.ts`](js/sync-diagnose-ui.ts), [`js/sync-diagnostics-context.ts`](js/sync-diagnostics-context.ts), [`js/sync-environment.ts`](js/sync-environment.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-init.ts`](js/sync-init.ts), [`js/sync-messenger.ts`](js/sync-messenger.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-pull-rebroadcast.ts`](js/sync-pull-rebroadcast.ts), [`js/sync-pull.ts`](js/sync-pull.ts), [`js/sync-push.ts`](js/sync-push.ts), [`js/sync-reconcile.ts`](js/sync-reconcile.ts), [`js/sync-recovery.ts`](js/sync-recovery.ts), [`js/sync-relay-health.ts`](js/sync-relay-health.ts), [`js/sync-runtime.ts`](js/sync-runtime.ts), [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts), [`js/sync-settings-state.ts`](js/sync-settings-state.ts), [`js/sync-state.ts`](js/sync-state.ts), [`js/sync-storage-cleanup.ts`](js/sync-storage-cleanup.ts), [`js/sync-subscriptions.ts`](js/sync-subscriptions.ts), [`js/sync-tombstones.ts`](js/sync-tombstones.ts), [`js/sync-ui.ts`](js/sync-ui.ts), [`js/utils.ts`](js/utils.ts) - [`js/sync-cutover.ts`](js/sync-cutover.ts) → [`js/sync-delta-snapshot.ts`](js/sync-delta-snapshot.ts), [`js/sync-delta-surfaces.ts`](js/sync-delta-surfaces.ts), [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-payload.ts`](js/sync-payload.ts) - [`js/sync-delta-array-merge.ts`](js/sync-delta-array-merge.ts) → [`js/data-merge.ts`](js/data-merge.ts), [`js/sync-delta-id.ts`](js/sync-delta-id.ts), [`js/sync-delta-pull-snapshot.ts`](js/sync-delta-pull-snapshot.ts), [`js/sync-delta-row-codec.ts`](js/sync-delta-row-codec.ts), [`js/sync-delta-surface-config.ts`](js/sync-delta-surface-config.ts) - [`js/sync-delta-array-planner.ts`](js/sync-delta-array-planner.ts) → [`js/sync-delta-id.ts`](js/sync-delta-id.ts), [`js/sync-delta-planner-context.ts`](js/sync-delta-planner-context.ts), [`js/sync-delta-row-codec.ts`](js/sync-delta-row-codec.ts), [`js/sync-delta-snapshot.ts`](js/sync-delta-snapshot.ts), [`js/sync-delta-surface-config.ts`](js/sync-delta-surface-config.ts), [`js/sync-payload-codec.ts`](js/sync-payload-codec.ts) @@ -4708,14 +4708,14 @@ Native browser modules shipped with the static application. - [`js/sync-pull-maintenance.ts`](js/sync-pull-maintenance.ts) → no in-scope imports - [`js/sync-pull-merge.ts`](js/sync-pull-merge.ts) → [`js/biology-score-context-contract.ts`](js/biology-score-context-contract.ts), [`js/biology-score-persistence.ts`](js/biology-score-persistence.ts), [`js/caught-error.ts`](js/caught-error.ts), [`js/clear-all-profile-reset.ts`](js/clear-all-profile-reset.ts) *(dynamic)*, [`js/crypto.ts`](js/crypto.ts), [`js/data-merge.ts`](js/data-merge.ts), [`js/data.ts`](js/data.ts), [`js/nutrition-sync-sanitize.ts`](js/nutrition-sync-sanitize.ts), [`js/profile-data-writes.ts`](js/profile-data-writes.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/profile.ts`](js/profile.ts), [`js/state.ts`](js/state.ts), [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-origin-state.ts`](js/sync-origin-state.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-profile-fields.ts`](js/sync-profile-fields.ts) - [`js/sync-pull-rebroadcast.ts`](js/sync-pull-rebroadcast.ts) → [`js/state.ts`](js/state.ts), [`js/sync-state.ts`](js/sync-state.ts) -- [`js/sync-pull.ts`](js/sync-pull.ts) → [`js/nutrition-sync-sanitize.ts`](js/nutrition-sync-sanitize.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/routstr-session.ts`](js/routstr-session.ts), [`js/state.ts`](js/state.ts), [`js/sync-apply.ts`](js/sync-apply.ts) *(dynamic)*, [`js/sync-backup-restore-state.ts`](js/sync-backup-restore-state.ts), [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-origin-state.ts`](js/sync-origin-state.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-profile-metadata.ts`](js/sync-profile-metadata.ts), [`js/sync-pull-active-refresh.ts`](js/sync-pull-active-refresh.ts), [`js/sync-pull-maintenance.ts`](js/sync-pull-maintenance.ts), [`js/sync-pull-merge.ts`](js/sync-pull-merge.ts), [`js/sync-pull-rebroadcast.ts`](js/sync-pull-rebroadcast.ts), [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts), [`js/sync-state.ts`](js/sync-state.ts), [`js/sync-tombstones.ts`](js/sync-tombstones.ts) +- [`js/sync-pull.ts`](js/sync-pull.ts) → [`js/nutrition-sync-sanitize.ts`](js/nutrition-sync-sanitize.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/routstr-session.ts`](js/routstr-session.ts), [`js/state.ts`](js/state.ts), [`js/sync-apply.ts`](js/sync-apply.ts) *(dynamic)*, [`js/sync-backup-restore-state.ts`](js/sync-backup-restore-state.ts), [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-origin-state.ts`](js/sync-origin-state.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-profile-metadata.ts`](js/sync-profile-metadata.ts), [`js/sync-pull-active-refresh.ts`](js/sync-pull-active-refresh.ts), [`js/sync-pull-maintenance.ts`](js/sync-pull-maintenance.ts), [`js/sync-pull-merge.ts`](js/sync-pull-merge.ts), [`js/sync-pull-rebroadcast.ts`](js/sync-pull-rebroadcast.ts), [`js/sync-runtime.ts`](js/sync-runtime.ts), [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts), [`js/sync-state.ts`](js/sync-state.ts), [`js/sync-tombstones.ts`](js/sync-tombstones.ts) - [`js/sync-push-deltas.ts`](js/sync-push-deltas.ts) → [`js/caught-error.ts`](js/caught-error.ts), [`js/data-merge.ts`](js/data-merge.ts), [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-scalar-snapshot.ts`](js/sync-scalar-snapshot.ts) - [`js/sync-push.ts`](js/sync-push.ts) → [`js/caught-error.ts`](js/caught-error.ts), [`js/nutrition-sync-sanitize.ts`](js/nutrition-sync-sanitize.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/profile.ts`](js/profile.ts), [`js/sync-chat-merge.ts`](js/sync-chat-merge.ts) *(dynamic)*, [`js/sync-delta.ts`](js/sync-delta.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/sync-origin-state.ts`](js/sync-origin-state.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-profile-metadata.ts`](js/sync-profile-metadata.ts), [`js/sync-push-deltas.ts`](js/sync-push-deltas.ts), [`js/sync-relay-health.ts`](js/sync-relay-health.ts), [`js/sync-scalar-snapshot.ts`](js/sync-scalar-snapshot.ts), [`js/sync-state.ts`](js/sync-state.ts) - [`js/sync-reconcile.ts`](js/sync-reconcile.ts) → [`js/data-merge.ts`](js/data-merge.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/state.ts`](js/state.ts), [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts), [`js/sync-identity.ts`](js/sync-identity.ts), [`js/sync-payload-collectors.ts`](js/sync-payload-collectors.ts), [`js/sync-payload.ts`](js/sync-payload.ts), [`js/sync-state.ts`](js/sync-state.ts) - [`js/sync-recovery.ts`](js/sync-recovery.ts) → no in-scope imports - [`js/sync-relay-health.ts`](js/sync-relay-health.ts) → [`js/caught-error.ts`](js/caught-error.ts), [`js/runtime-callbacks.ts`](js/runtime-callbacks.ts), [`js/sync-relay-self-service.ts`](js/sync-relay-self-service.ts) - [`js/sync-relay-self-service.ts`](js/sync-relay-self-service.ts) → no in-scope imports -- [`js/sync-runtime.ts`](js/sync-runtime.ts) → [`js/chat-runtime.ts`](js/chat-runtime.ts), [`js/provider-wallet-panels.ts`](js/provider-wallet-panels.ts) *(dynamic)* +- [`js/sync-runtime.ts`](js/sync-runtime.ts) → [`js/chat-runtime.ts`](js/chat-runtime.ts), [`js/provider-wallet-panels.ts`](js/provider-wallet-panels.ts) *(dynamic)*, [`js/sync-settings-state.ts`](js/sync-settings-state.ts) - [`js/sync-save-hooks-core.ts`](js/sync-save-hooks-core.ts) → [`js/caught-error.ts`](js/caught-error.ts), [`js/profile-storage-key.ts`](js/profile-storage-key.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/runtime-callbacks.ts`](js/runtime-callbacks.ts), [`js/state.ts`](js/state.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/utils-runtime.ts`](js/utils-runtime.ts) - [`js/sync-save-hooks.ts`](js/sync-save-hooks.ts) → [`js/caught-error.ts`](js/caught-error.ts), [`js/crypto.ts`](js/crypto.ts), [`js/profile-storage-key.ts`](js/profile-storage-key.ts), [`js/profile-sync-policy.ts`](js/profile-sync-policy.ts), [`js/state.ts`](js/state.ts), [`js/sync-chat-apply.ts`](js/sync-chat-apply.ts), [`js/sync-dirty-state.ts`](js/sync-dirty-state.ts), [`js/sync-messenger.ts`](js/sync-messenger.ts), [`js/sync-save-hooks-core.ts`](js/sync-save-hooks-core.ts), [`js/utils-runtime.ts`](js/utils-runtime.ts) - [`js/sync-scalar-snapshot.ts`](js/sync-scalar-snapshot.ts) → [`js/data-merge.ts`](js/data-merge.ts), [`js/profile-data-writes.ts`](js/profile-data-writes.ts), [`js/sync-delta-id.ts`](js/sync-delta-id.ts), [`js/sync-delta-surfaces.ts`](js/sync-delta-surfaces.ts), [`js/sync-payload-codec.ts`](js/sync-payload-codec.ts) diff --git a/js/chat-loader.ts b/js/chat-loader.ts index 8854fc588..72584107b 100644 --- a/js/chat-loader.ts +++ b/js/chat-loader.ts @@ -1,4 +1,5 @@ // chat-loader.js - cached first-use boundary for the closed Chat composition. +import { state } from './state.js'; type ChatModule = typeof import('./app-ai-interaction-modules.js'); @@ -53,8 +54,9 @@ export function isChatModuleLoaded() { * @param {keyof ChatModule} name * @param {unknown[]} args */ -function callChatModule(name: keyof ChatModule, args: unknown[]) { +function callChatModule(name: keyof ChatModule, args: unknown[], profileId?: string) { return loadChatModule().then(module => { + if (profileId !== undefined && state.currentProfile !== profileId) return false; const callback = module[name]; if (typeof callback !== 'function') { throw new Error(`Chat action ${String(name)} is unavailable`); @@ -75,8 +77,8 @@ function callLoadedChatModule(name: keyof ChatModule, args: unknown[], fallback: : fallback; } -export function openChatPanel(...args: unknown[]) { return callChatModule('openChatPanel', args); } -export function toggleChatPanel(...args: unknown[]) { return callChatModule('toggleChatPanel', args); } +export function openChatPanel(...args: unknown[]) { return callChatModule('openChatPanel', args, state.currentProfile); } +export function toggleChatPanel(...args: unknown[]) { return callChatModule('toggleChatPanel', args, state.currentProfile); } export function createNewThread(...args: unknown[]) { return callChatModule('createNewThread', args); } export function createThreadProject(...args: unknown[]) { return callChatModule('createThreadProject', args); } export function clearChatHistory(...args: unknown[]) { return callChatModule('clearChatHistory', args); } diff --git a/js/chat-panel.ts b/js/chat-panel.ts index 2651345b3..f568fba8e 100644 --- a/js/chat-panel.ts +++ b/js/chat-panel.ts @@ -1,4 +1,5 @@ import { publishChatComposerPlaceholder } from './chat-composer-input-port.js'; +import { state } from './state.js'; import { setChatVoiceAvailable } from './chat-voice-view.js'; import { refreshChatHeaderWebSearch } from './chat-header-presentation.js'; import { closePersonalityPickerView } from './chat-personality-picker-view.js'; @@ -261,14 +262,22 @@ export function toggleChatFullscreen() { syncChatLayout(); } -export async function openChatPanel(prefillMessage?: Parameters[0]) { +export async function openChatPanel(prefillMessage?: Parameters[0], options: {canOpen?: () => boolean} = {}) { + if (options.canOpen && !options.canOpen()) return false; const openIntent = ++chatPanelIntent; + const profileId = state.currentProfile; + const isCurrent = () => openIntent === chatPanelIntent && state.currentProfile === profileId; + const cancelOpening = () => { + if (openIntent === chatPanelIntent) closeChatPanel(); + return false; + }; const panel = document.getElementById('chat-panel'); const backdrop = document.getElementById('chat-backdrop'); if (!panel || !backdrop) return false; const returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; if (!(await loadChatPresentationStylesheetsForAction())) return false; - if (openIntent !== chatPanelIntent) return false; + if (!isCurrent()) return cancelOpening(); + if (options.canOpen && !options.canOpen()) return false; chatPanelReturnFocus = returnFocus; panel.classList.add('open'); updateChatPanelAccessibility(panel, true); @@ -294,6 +303,7 @@ export async function openChatPanel(prefillMessage?: Parameters 768; if (!shouldAutoStartEmptyTour && state.chatHistory.length === 0) { - if (isDesktopChatOnboardingViewport && !document.getElementById('chat-panel')?.classList.contains('open')) { + const profile = state.currentProfile; + const ownsReservation = () => generation === onboardingGeneration && state.currentProfile === profile + && main.isConnected && document.getElementById('main-content') === main; + const releaseReservation = () => { if (ownsReservation()) document.body.classList.remove('chat-autostart-reserved'); }; + const hasForegroundOverlay = () => !!document.querySelector('.modal-overlay.show,.confirm-overlay.show,#sync-popover'); + const canOpen = () => ownsReservation() && document.body.classList.contains('chat-autostart-reserved') + && (getDashboardPageRuntimeValue('innerWidth') as number) > 768 && !hasForegroundOverlay(); + if (isDesktopChatOnboardingViewport && !hasForegroundOverlay() && !document.getElementById('chat-panel')?.classList.contains('open')) { document.body.classList.add('chat-autostart-reserved'); } setTimeout(() => { - if (!isDesktopChatOnboardingViewport || (getDashboardPageRuntimeValue('innerWidth') as number) <= 768) return; + if (!ownsReservation()) return; + if (!isDesktopChatOnboardingViewport || (getDashboardPageRuntimeValue('innerWidth') as number) <= 768) { releaseReservation(); return; } // Rendering real data or an explicit chat close removes this // reservation. Treat that as cancellation so a stale onboarding // timer cannot reopen chat after the user has dismissed it. @@ -247,8 +257,14 @@ export function createDashboardPageView(deps: unknown) { document.body.classList.remove('chat-autostart-reserved'); return; } - if (dashboardPageRuntimeDeps.openChatPanel) (dashboardPageRuntimeDeps.openChatPanel as () => unknown)(); - else document.body.classList.remove('chat-autostart-reserved'); + if (!canOpen()) { releaseReservation(); return; } + // This optional background load may fail if the origin disappears + // before the worker controls its in-flight imports. Keep Welcome + // usable and never let an old request clear a newer reservation. + try { + const opened = (dashboardPageRuntimeDeps.openChatPanel as ((message: undefined, options: {canOpen: () => boolean}) => unknown) | null)?.(undefined, {canOpen}); + void Promise.resolve(opened).then(releaseReservation, releaseReservation); + } catch { releaseReservation(); } }, 800); } return; diff --git a/js/data-core.ts b/js/data-core.ts index 8658b8566..cec1d91b1 100644 --- a/js/data-core.ts +++ b/js/data-core.ts @@ -263,7 +263,13 @@ export function createDataCore(ports: DataCorePorts) { try { const live = ports.state.currentProfile === profileId ? ports.state.importedData : null; if (live && (options.activeSave || base)) { - const result = (0, ports.rebaseLiveProfileData)(options.activeSave ? intent : (0, ports.profileDataBaseline)(live) || base, live, persisted); + // A reload may install another object for this same profile while + // the save awaits storage. Its own origin distinguishes a stale + // loaded value from a new unsaved edit; this save's intent belongs + // only to the object it captured. + const liveBase = options.activeSave && live === source + ? intent : (0, ports.profileDataBaseline)(live) || base; + const result = (0, ports.rebaseLiveProfileData)(liveBase, live, persisted); if (persisted.biologyScoreAI) result.data.biologyScoreAI = persisted.biologyScoreAI; (0, ports.adoptProfileData)(live, result.data); (0, ports.rememberProfileData)(live, result.baseline); diff --git a/js/sync-actions.ts b/js/sync-actions.ts index 6f651d85b..9508b7097 100644 --- a/js/sync-actions.ts +++ b/js/sync-actions.ts @@ -20,7 +20,7 @@ import { getProfileSyncBlockReason } from './profile-sync-policy.js'; export { cleanStorage } from './sync-storage-cleanup.js'; export { onChatSaved, onDataSaved, onProfileSaved } from './sync-save-hooks.js'; -export interface SyncPushOptions { force?: boolean; completeSnapshot?: boolean; allowTombstoneResurrection?: boolean } +export interface SyncPushOptions { force?: boolean; skipUnchanged?: boolean; completeSnapshot?: boolean; allowTombstoneResurrection?: boolean } export interface SyncPushResult { ok?: boolean; skipped?: boolean; reason?: string; error?: unknown } type PushProfile = (profileId: string | null | undefined, data: unknown, options?: SyncPushOptions) => Promise; @@ -89,7 +89,7 @@ async function pushCurrentProfileWhenIdle() { await new Promise(resolve => setTimeout(resolve, 50)); } result = await pushCurrentProfile(); - if (result?.reason !== 'in-flight') return result; + if (result?.reason !== 'in-flight' && result?.reason !== 'superseded') return result; // A pull can schedule a union rebroadcast just before it resolves. If // that timer wins the race with this manual push, wait for it instead of // reporting a false failure (or asking the user to press Sync again). diff --git a/js/sync-backup-restore-state.ts b/js/sync-backup-restore-state.ts index cdb4670a3..40260bdda 100644 --- a/js/sync-backup-restore-state.ts +++ b/js/sync-backup-restore-state.ts @@ -1,31 +1,167 @@ // sync-backup-restore-state.js - durable handoff from backup restore to Sync. import { clearProfileDeltaSnapshots } from './sync-delta-snapshot.js'; -import { markSyncProfileDirty } from './sync-dirty-state.js'; -import { clearProfileSyncDeleteState } from './profile-sync-policy.js'; +import { discardSyncProfileDirty, getSyncDirtyToken, markSyncProfileDirty } from './sync-dirty-state.js'; +import { clearProfileSyncDeleteState, getProfileSyncBlockReason } from './profile-sync-policy.js'; +import { createUniqueId } from './unique-id.js'; export const SYNC_BACKUP_RESTORE_PENDING_KEY = 'labcharts-sync-backup-restore-pending'; +/** Bind recovery to its intended identity without storing recovery words. */ +export async function fingerprintBackupRestoreOwner(mnemonic: string) { + const normalized = mnemonic.normalize('NFKD').trim().toLowerCase().split(/\s+/).join(' '); + if (!normalized) throw new Error('Sync identity is unavailable'); + const bytes = new TextEncoder().encode(`getbased:sync-recovery-owner:${normalized}`); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); +} + function isSafeProfileId(value: unknown): value is string { return typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value); } -export function markBackupRestorePending(profileIds: readonly unknown[]) { +function pendingJournal() { + try { + const raw = localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + const parsed: unknown = JSON.parse(raw || '[]'); + const entries: unknown[] = Array.isArray(parsed) ? parsed : []; + const markers = entries.filter((entry): entry is {kind: string; generation: string; ownerFingerprint?: unknown} => !!entry && typeof entry === 'object' + && (entry as {kind?: unknown}).kind === 'restore-generation' && typeof (entry as {generation?: unknown}).generation === 'string'); + const ownerFingerprint = markers.length === 1 ? markers[0]!.ownerFingerprint : undefined; + return {readable: markers.length <= 1 && (ownerFingerprint === undefined || (typeof ownerFingerprint === 'string' && /^[a-f0-9]{64}$/.test(ownerFingerprint))), + ownerFingerprint: typeof ownerFingerprint === 'string' ? ownerFingerprint : undefined, profileIds: [...new Set(entries.filter(isSafeProfileId))], + generation: markers.length === 1 ? markers[0]!.generation : `legacy:${raw || '[]'}`}; + } catch { return {readable: false, profileIds: [] as string[], generation: ''}; } +} + +function journalValue(profileIds: readonly string[], generation: string, ownerFingerprint?: string) { + // Keep an array so older tabs still see every pending profile and ignore + // this non-profile entry through their existing safe-ID filter. + return JSON.stringify([...profileIds, {kind: 'restore-generation', generation, ...(ownerFingerprint ? {ownerFingerprint} : {})}]); +} + +export interface BackupRestoreStage { rollback(): boolean } + +function writePendingBackupRestore(profileIds: readonly unknown[], required: boolean, ownerFingerprint?: string) { const safeIds = [...new Set((Array.isArray(profileIds) ? profileIds : []).filter(isSafeProfileId))]; - if (safeIds.length === 0) return []; + let previous: string | null = null; + let value: string | undefined; + const rollback = () => { + try { + // A later restore owns its journal even if this owner operation fails. + if (value === undefined || localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY) !== value) return true; + if (previous === null) localStorage.removeItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + else localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, previous); + return true; + } catch { return false; } + }; + if (safeIds.length === 0) return {profileIds: safeIds, rollback}; try { - localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, JSON.stringify(safeIds)); - } catch {} - return safeIds; + if (required) previous = localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + if (ownerFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(ownerFingerprint)) throw new Error('Invalid recovery owner fingerprint'); + value = journalValue(safeIds, createUniqueId('restore_'), ownerFingerprint); + localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, value); + if (required && localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY) !== value) throw new Error('Recovery journal was not retained'); + } catch { + if (required) { rollback(); throw new Error('Could not save sync recovery progress'); } + } + return {profileIds: safeIds, rollback}; +} + +export function markBackupRestorePending(profileIds: readonly unknown[], options: {required?: boolean} = {}) { + return writePendingBackupRestore(profileIds, !!options.required).profileIds; +} + +/** Confirm durable intent and retain an owned rollback for a rejected owner reset. */ +export function stageBackupRestorePending(profileIds: readonly unknown[], options: {ownerFingerprint?: string} = {}): BackupRestoreStage { + return writePendingBackupRestore(profileIds, true, options.ownerFingerprint); } export function getPendingBackupRestoreProfileIds() { + return pendingJournal().profileIds; +} + +function acknowledgeProfile(profileId: string, generation: string) { + const current = pendingJournal(); + if (!current.readable || current.generation !== generation) return false; + const remaining = current.profileIds.filter(id => id !== profileId); try { - const parsed = JSON.parse(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY) || '[]'); - return [...new Set((Array.isArray(parsed) ? parsed : []).filter(isSafeProfileId))]; - } catch { - return []; + if (remaining.length) localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, journalValue(remaining, generation, current.ownerFingerprint)); + else localStorage.removeItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + return true; + } catch { return false; } +} + +interface RestorePublisher { + runtime: unknown; + ownerId: string; + ownerMnemonic?: string; + isCurrent(): boolean; + getProfiles(): readonly {id?: unknown}[]; + pushProfilesById(ids: readonly string[], options: {force: true; completeSnapshot: true}): Promise<{succeeded: number; skipped: number; failed: number}>; +} +interface RestoreProgress { + runtime: unknown; ownerId: string; generation: string; + committed: Map; + attempts: Map; +} +let progress: RestoreProgress | undefined; + +/** An explicit Sync now/resume may retry failures; committed receipts survive. */ +export function resetBackupRestoreRetryBudget() { progress?.attempts.clear(); } + +/** Commit and acknowledge each profile once before accepting relay tombstones. */ +export async function republishPendingBackupRestore(deps: RestorePublisher) { + const journal = pendingJournal(); + const deferred = (reason: string) => ({complete: false, remaining: pendingJournal().profileIds.length, reason}); + if (!journal.readable) return deferred('progress-unavailable'); + if (!journal.profileIds.length) return {complete: true, remaining: 0, reason: ''}; + if (!deps.ownerId || !deps.isCurrent()) return deferred('identity-unavailable'); + if (journal.ownerFingerprint) { + let actualFingerprint: string; + try { actualFingerprint = await fingerprintBackupRestoreOwner(deps.ownerMnemonic || ''); } + catch { return deferred('identity-unavailable'); } + if (actualFingerprint !== journal.ownerFingerprint) return deferred('identity-changed'); + } + if (!progress || progress.runtime !== deps.runtime || progress.ownerId !== deps.ownerId || progress.generation !== journal.generation) { + progress = {runtime: deps.runtime, ownerId: deps.ownerId, generation: journal.generation, committed: new Map(), attempts: new Map()}; + } + const owned = progress; + const current = () => deps.isCurrent() && pendingJournal().generation === journal.generation; + for (const profileId of journal.profileIds) { + if (!current()) return deferred('identity-changed'); + const profiles = deps.getProfiles(); + const blocked = getProfileSyncBlockReason(profileId, profiles); + if (blocked === 'demo' || blocked === 'delete-intent') { + discardSyncProfileDirty(profileId); + if (!acknowledgeProfile(profileId, journal.generation)) return deferred('progress-unavailable'); + continue; + } + if (blocked || !profiles.some(profile => profile.id === profileId)) continue; + const dirtyToken = getSyncDirtyToken(profileId); + if (owned.committed.has(profileId) && (dirtyToken === null || dirtyToken === owned.committed.get(profileId))) { + if (!acknowledgeProfile(profileId, journal.generation)) return deferred('progress-unavailable'); + owned.committed.delete(profileId); owned.attempts.delete(profileId); + continue; + } + owned.committed.delete(profileId); + const previous = owned.attempts.get(profileId); + const attempt = previous?.dirtyToken === dirtyToken ? previous : {dirtyToken, count: 0, retryAt: 0}; + if (attempt.count >= 3 || Date.now() < attempt.retryAt) continue; + attempt.count++; attempt.retryAt = Date.now() + 10_000; owned.attempts.set(profileId, attempt); + let result; + try { result = await deps.pushProfilesById([profileId], {force: true, completeSnapshot: true}); } + catch { continue; } + if (!current()) return deferred('identity-changed'); + if (result.succeeded !== 1 || result.failed || result.skipped) continue; + const after = getSyncDirtyToken(profileId); + if (after !== null && after !== dirtyToken) continue; + owned.committed.set(profileId, dirtyToken); + if (!acknowledgeProfile(profileId, journal.generation)) return deferred('progress-unavailable'); + owned.committed.delete(profileId); owned.attempts.delete(profileId); } + const remaining = pendingJournal().profileIds.length; + return {complete: remaining === 0, remaining, reason: remaining ? 'profiles-pending' : ''}; } export function clearBackupRestorePending() { diff --git a/js/sync-configure.ts b/js/sync-configure.ts index 9c5dbebb7..e1392d957 100644 --- a/js/sync-configure.ts +++ b/js/sync-configure.ts @@ -21,12 +21,13 @@ import { configureSyncDiagnostics } from './sync-diagnostics-context.js'; import { bindSyncUIStatusUpdates, configureSyncUI, initSyncUIDelegates } from './sync-ui.js'; import { configureSyncDiagnoseUI, showSyncDiagnose } from './sync-diagnose-ui.js'; import { - configureSyncActions, forceResendCurrentProfile, pushAllProfiles, + configureSyncActions, forceResendCurrentProfile, pushDirtyProfiles, pushProfilesById, syncNow, } from './sync-actions.js'; import { bindSyncSaveHookEvents, configureSyncSaveHooks } from './sync-save-hooks.js'; import { configureSyncPush, isSyncPushInFlight, pushProfile } from './sync-push.js'; import { configureSyncPayload } from './sync-payload.js'; +import { stageBackupRestorePending } from './sync-backup-restore-state.js'; import { configureSyncRecovery } from './sync-recovery.js'; import { configureSyncInit } from './sync-init.js'; import { isSyncRebroadcastSettling } from './sync-pull-rebroadcast.js'; @@ -134,7 +135,7 @@ export function configureSyncModules({ enableSync }: { getAppOwner: getSyncAppOwner, getAppOwnerError: getSyncAppOwnerError, getEvolu: getSyncEvolu, - seedLocalProfiles: () => pushAllProfiles({ force: true }), + seedLocalProfiles: async options => stageBackupRestorePending(getProfiles().map(profile => profile.id), options), }); configureSyncDiagnostics({ diff --git a/js/sync-diagnose-identity-actions.ts b/js/sync-diagnose-identity-actions.ts index b5dbcfaa0..ba4bbd998 100644 --- a/js/sync-diagnose-identity-actions.ts +++ b/js/sync-diagnose-identity-actions.ts @@ -134,7 +134,7 @@ export async function confirmRotateIdentity(btn?: SyncDiagnoseActionElement | nu } const ok = await restoreMnemonicForDiagnose(words.join(' '), { seedLocal: true }); if (!ok) { - showNotification('Restore returned false — generated mnemonic was rejected', 'error'); + showNotification('Could not apply sync identity. Resolve the error above, then retry.', 'error'); applyBtn.disabled = false; applyBtn.textContent = 'Apply on this device'; return; diff --git a/js/sync-identity.ts b/js/sync-identity.ts index acfa3213a..436c1a9dc 100644 --- a/js/sync-identity.ts +++ b/js/sync-identity.ts @@ -6,12 +6,12 @@ import { loadScriptOnce, showNotification } from './utils.js'; import { clearSyncDisableStorage, } from './sync-disable-cleanup.js'; -import { getPendingBackupRestoreProfileIds } from './sync-backup-restore-state.js'; +import { fingerprintBackupRestoreOwner, getPendingBackupRestoreProfileIds, stageBackupRestorePending, type BackupRestoreStage } from './sync-backup-restore-state.js'; import { scheduleSyncRuntimeReload } from './sync-runtime.js'; -import { setSyncEnabled } from './sync-settings-state.js'; +import { setSyncEnabled, setSyncPublishingSuspended } from './sync-settings-state.js'; interface SyncAppOwner { id?: unknown; mnemonic?: string } -interface SyncEvolu { restoreAppOwner: (mnemonic: string, options?: { reload?: boolean }) => unknown } +interface SyncEvolu { appOwner?: Promise; restoreAppOwner: (mnemonic: string, options?: { reload?: boolean }) => unknown } declare const qrcode: QRCodeFactory | undefined; @@ -20,7 +20,8 @@ let _qrCodeLoad: Promise | null = null; let _getAppOwner: () => SyncAppOwner | null = () => null; let _getAppOwnerError: () => unknown = () => null; let _getEvolu: () => SyncEvolu | null = () => null; -let _seedLocalProfiles: () => Promise = async () => {}; +let _seedLocalProfiles: (options: {ownerFingerprint: string}) => Promise = async () => {}; +let _pendingRecoveryRollback: {runtime: SyncEvolu; recovery: BackupRestoreStage} | null = null; export const RESTORE_JOIN_PENDING_KEY = 'labcharts-sync-restore-join-pending'; export const RESTORE_NOTICE_KEY = 'labcharts-sync-restore-notice'; @@ -39,7 +40,7 @@ export function consumeSyncRestoreNotice() { kind = sessionStorage.getItem(RESTORE_NOTICE_KEY) || ''; sessionStorage.removeItem(RESTORE_NOTICE_KEY); } catch {} - if (kind === 'seed-local') return 'Sync identity restored and this device’s data was republished.'; + if (kind === 'seed-local') return 'Sync identity restored. Publishing this device’s data…'; if (kind === 'join') return 'Joined existing sync identity. Syncing data from your other device…'; return null; } @@ -187,14 +188,47 @@ export async function restoreFromMnemonic(mnemonic: string, options: { seedLocal showNotification('Sync is still starting. Wait a moment and try again.', 'error'); return false; } + if (_pendingRecoveryRollback) { + if (_pendingRecoveryRollback.runtime === evolu) { + if (!_pendingRecoveryRollback.recovery.rollback()) { + showNotification('Sync recovery progress could not be restored. Your local data is retained. Retry after browser storage is available.', 'error'); + return false; + } + setSyncPublishingSuspended(false); + } + _pendingRecoveryRollback = null; + } const normalizedMnemonic = normalizeMnemonic(mnemonic); - const previousMnemonic = normalizeMnemonic(currentAppOwner()?.mnemonic || ''); + const previousOwner = currentAppOwner(); + const previousMnemonic = normalizeMnemonic(previousOwner?.mnemonic || ''); const identityChanged = !previousMnemonic || previousMnemonic !== normalizedMnemonic; setRestoreNotice(options?.seedLocal ? 'seed-local' : 'join'); + let recovery: BackupRestoreStage | void = undefined; + let ownerRestored = false; + const shouldStageRecovery = !!options?.seedLocal || getPendingBackupRestoreProfileIds().length > 0; + if (shouldStageRecovery) { + if (setSyncPublishingSuspended(true)) return false; + try { + // Confirm durable recovery before resetting the owner or its database. + const ownerFingerprint = await fingerprintBackupRestoreOwner(normalizedMnemonic); + if (currentEvolu() !== evolu) return false; + recovery = options?.seedLocal + ? await _seedLocalProfiles({ownerFingerprint}) + : stageBackupRestorePending(getPendingBackupRestoreProfileIds(), {ownerFingerprint}); + } catch { + if (currentEvolu() !== evolu) return false; + setSyncPublishingSuspended(false); + clearRestoreNotice(); + showNotification('Could not save sync recovery progress. Your identity and local data are unchanged. Free browser storage, then retry.', 'error'); + return false; + } + if (currentEvolu() !== evolu) { recovery?.rollback(); return false; } + } try { // Keep Evolu 7 from reloading before join/seed finalization. Evolu 8 also // accepts this option; GetBased schedules the controlled reload below. await evolu.restoreAppOwner(normalizedMnemonic, { reload: false }); + ownerRestored = true; if (currentEvolu() !== evolu) return false; if (identityChanged) { const reset = await import('./clear-all-profile-reset.js'); @@ -219,10 +253,9 @@ export async function restoreFromMnemonic(mnemonic: string, options: { seedLocal clearSyncDisableStorage(); } if (options?.seedLocal) { + // The retiring owner/query cache stays suspended until the reload. setRestoreJoinPending(false); - await _seedLocalProfiles(); - if (currentEvolu() !== evolu) return false; - showNotification('Restored mnemonic and seeded this device — reloading…', 'success'); + showNotification('Restored mnemonic — reloading to publish this device’s data…', 'success'); } else { // This device is joining an existing owner. On first pull, old local // tombstones from the previous owner must not veto the source device's @@ -235,6 +268,31 @@ export async function restoreFromMnemonic(mnemonic: string, options: { seedLocal return true; } catch { if (currentEvolu() !== evolu) return false; + if (recovery && !ownerRestored) { + // Query the SDK rather than the app's cached owner: v8 can have changed + // its durable identity before a replacement-runtime failure is reported. + let actualOwner = currentAppOwner(); + try { if (evolu.appOwner) actualOwner = await evolu.appOwner || null; } catch { actualOwner = null; } + if (currentEvolu() !== evolu) return false; + if (actualOwner && typeof actualOwner.id === 'string' && actualOwner.id && actualOwner.id === previousOwner?.id) { + if (!recovery.rollback()) { + _pendingRecoveryRollback = {runtime: evolu, recovery}; + clearRestoreNotice(); + showNotification('Sync recovery progress could not be restored. Your local data is retained. Retry after browser storage is available.', 'error'); + return false; + } + } else { + clearRestoreNotice(); + showNotification('Sync identity startup did not finish. Your local data is retained. Reload to finish recovery.', 'error'); + return false; + } + } + if (shouldStageRecovery && ownerRestored) { + clearRestoreNotice(); + showNotification('Sync identity startup did not finish. Your local data is retained. Reload to finish recovery.', 'error'); + return false; + } + if (shouldStageRecovery) setSyncPublishingSuspended(false); clearRestoreNotice(); console.error('[sync] Restore failed: invalid or unavailable identity'); showNotification('Invalid mnemonic', 'error'); diff --git a/js/sync-pull.ts b/js/sync-pull.ts index ebb0a6398..5ae8c3eb0 100644 --- a/js/sync-pull.ts +++ b/js/sync-pull.ts @@ -32,6 +32,7 @@ interface SyncPullDeps { import { encodeMergedRoutstrSessions, ROUTSTR_SESSIONS_KEY } from './routstr-session.js'; import { state } from './state.js'; +import { getSyncAppOwner } from './sync-runtime.js'; import { parseSyncPayload } from './sync-payload.js'; import { applyChatData, chatDataNeedsRebroadcast, getChatDataLocalLockRemainingMs } from './sync-chat-apply.js'; import { refreshActiveProfileAfterPull } from './sync-pull-active-refresh.js'; @@ -48,7 +49,7 @@ import { } from './sync-state.js'; import { isLocalSyncCommitEcho } from './sync-origin-state.js'; import { - clearBackupRestorePending, getPendingBackupRestoreProfileIds, + getPendingBackupRestoreProfileIds, republishPendingBackupRestore, resetBackupRestoreRetryBudget, } from './sync-backup-restore-state.js'; import { getProfileSyncBlockReason, isDemoProfileRecord, @@ -198,6 +199,7 @@ export function clearSyncPullTimers() { } export function forcePull() { + resetBackupRestoreRetryBudget(); if (!currentEvolu() || !currentProfileQuery()) { console.warn('[sync] Cannot force pull — Evolu not initialized'); return undefined; @@ -272,12 +274,18 @@ async function receiveSync() { const restoredProfileIds = getPendingBackupRestoreProfileIds(); if (restoredProfileIds.length > 0) { dbg(`Restore preflight: republishing ${restoredProfileIds.length} restored profile(s)`); - const restored = await _pushProfilesById(restoredProfileIds, { force: true, completeSnapshot: true }); - if (restored.failed > 0 || restored.skipped > 0 || restored.succeeded !== restoredProfileIds.length) { - logSyncEvent('skip', `Pull deferred — ${restored.succeeded}/${restoredProfileIds.length} restored profiles were republished`); + const ownerId = getSyncAppOwner()?.id || ''; + const restored = await republishPendingBackupRestore({ + runtime: evolu, ownerId, ownerMnemonic: getSyncAppOwner()?.mnemonic || '', getProfiles: _getProfiles, pushProfilesById: _pushProfilesById, + isCurrent: () => currentEvolu() === evolu && getSyncAppOwner()?.id === ownerId && _isSyncEnabled(), + }); + if (!restored.complete) { + logSyncEvent('skip', `Pull deferred — ${restored.remaining} restored profile(s) still need recovery`); + if (restored.reason !== 'identity-changed' && restored.reason !== 'identity-unavailable') { + updateSyncStatus({lastError: {type: 'BackupRestorePending', message: 'Some restored profiles could not be committed. Local data is retained. Make those profiles available, then retry Sync now.', at: Date.now()}}); + } return; } - clearBackupRestorePending(); } // Resolve remote tombstones before processing live rows. The tombstone diff --git a/js/sync-push.ts b/js/sync-push.ts index 55a5e8cb1..c6ecbbf4a 100644 --- a/js/sync-push.ts +++ b/js/sync-push.ts @@ -45,6 +45,9 @@ let _getProfiles: () => StoredProfileRecord[] = () => []; // instead of silently blocking every subsequent push for the session. let _syncing = false; let _syncingSince = 0; +let pushSequence = 0; +let activePushGeneration = 0; +const profilePushGenerations = new Map(); interface SyncPushDependencies { getEvolu: typeof _getEvolu; @@ -121,9 +124,31 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt console.warn('[sync] another push is in-flight'); return { ok: false, skipped: true, reason: 'in-flight' }; } + const generation = ++pushSequence; + activePushGeneration = generation; + profilePushGenerations.set(profileId, generation); _syncing = true; _syncingSince = Date.now(); const dirtyToken = getSyncDirtyToken(profileId); + const isCurrentGeneration = () => profilePushGenerations.get(profileId) === generation; + const isCurrentSnapshot = () => isCurrentGeneration() && getSyncDirtyToken(profileId) === dirtyToken; + const releasePush = () => { + if (profilePushGenerations.get(profileId) === generation) profilePushGenerations.delete(profileId); + _syncing = profilePushGenerations.size > 0; + }; + const updateCurrentStatus = (status: Parameters[0]) => { + const ownsStatus = activePushGeneration === generation + || (isCurrentGeneration() && profilePushGenerations.size === 1); + if (!ownsStatus || ((status.push === 'confirmed' || status.push === 'idle') && profilePushGenerations.size > 1)) return; + updateSyncStatus(status); + }; + const superseded = (): SyncPushResult => { + // Decide ownership before releasing this generation; another profile may + // still own a pending send, or this may be the last outstanding plan. + updateCurrentStatus({push: 'idle', pushStartedAt: null}); + releasePush(); + return {ok: false, skipped: true, reason: 'superseded'}; + }; const outboundData = normalizedImportedDataForPush(importedData as ProfileData); // Post-enable schema-drift detection. enablePhase2Cutover gates ON // readiness AT FLIP TIME, but if a future commit adds a new write site @@ -177,8 +202,8 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt const devCount = Array.isArray(outboundData?.lightDevices) ? outboundData.lightDevices.length : 0; const { deltaPlans, deltaOpCount: dataDeltaOpCount, planningErrors } = await planProfileDeltas(profileId, outboundData, {forceScalars: opts.completeSnapshot === true}); if (planningErrors?.length) { - updateSyncStatus({ push: 'error', lastError: { type: 'DeltaPlanError', message: 'Sync could not prepare a complete update. Your local changes are still queued.', at: Date.now() } }); - _syncing = false; + updateCurrentStatus({ push: 'error', lastError: { type: 'DeltaPlanError', message: 'Sync could not prepare a complete update. Your local changes are still queued.', at: Date.now() } }); + releasePush(); return { ok: false, skipped: true, reason: 'delta-plan-failed' }; } const metadataSource = _getProfiles().find(profile => profile.id === profileId); @@ -192,22 +217,31 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt completeSnapshot: opts.completeSnapshot === true, }); + // Forced startup/recovery sends may overlap another push. Planning and + // gzip can yield while a newer edit commits; the older snapshot must not + // assign that stale value a fresh clock or acknowledge the newer save. + if (!isCurrentSnapshot()) return superseded(); + // Evolu's relay quota measures append-only message history, not the size // of the current profile. Updating an identical row therefore consumes // storage even though the user added nothing. Compare the complete wire // payload and delta plan before assigning a new syncedAt clock. Explicit // force pushes bypass this guard for relay rebuild/recovery workflows. - if (!opts.force && existing && deltaOpCount === 0 + // Startup may bypass the in-flight guard while still skipping an identical + // canonical wire payload; its local divergence check can be conservative. + if ((!opts.force || opts.skipUnchanged) && existing && deltaOpCount === 0 && (existing.dataJson === dataJson || await equivalentSyncPayload(existing.dataJson, dataJson))) { + if (!isCurrentSnapshot()) return superseded(); if (!await commitProfileMetadataClock(profileId, metadataSource, metadata.profile)) throw new Error('Could not retain the metadata edit clock'); + if (!isCurrentSnapshot()) return superseded(); const skipMsg = `No changes to push ${profileId.slice(0,8)}`; _debug(skipMsg); logSyncEvent('skip', skipMsg); - updateSyncStatus({ + updateCurrentStatus({ push: 'confirmed', pushStartedAt: null, pushConfirmedAt: Date.now(), lastError: null, }); clearSyncProfileDirty(profileId, dirtyToken); - _syncing = false; + releasePush(); return { ok: true, skipped: true, reason: 'unchanged' }; } @@ -216,27 +250,31 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt const queuedAt = Date.now(); _debug(`${queueMsg} @ ${queuedAt}`); logSyncEvent('queue', queueMsg); - updateSyncStatus({ push: 'pending', pushStartedAt: queuedAt }); + updateCurrentStatus({ push: 'pending', pushStartedAt: queuedAt }); return await new Promise((resolve) => { let completed = false; let watchdogId: ReturnType | null = null; const finish = (result: SyncPushResult) => { - _syncing = false; + completed = true; + releasePush(); if (watchdogId !== null) { clearTimeout(watchdogId); watchdogId = null; } resolve(result); }; const onComplete = async () => { + if (completed) return; completed = true; + if (!isCurrentGeneration()) { finish(superseded()); return; } if (!applyCommittedDeltas(profileId, dataJson, deltaPlans, deltaOpCount, _debug) || !commitScalarPayloadClocks(profileId, outboundData, scalarClocks) || !await commitProfileMetadataClock(profileId, metadataSource, metadata.profile)) { - updateSyncStatus({ push: 'error', pushStartedAt: null, lastError: { type: 'DeltaCommitError', message: 'Sync could not commit a complete update. Your local changes are still queued.', at: Date.now() } }); + updateCurrentStatus({ push: 'error', pushStartedAt: null, lastError: { type: 'DeltaCommitError', message: 'Sync could not commit a complete update. Your local changes are still queued.', at: Date.now() } }); finish({ ok: false, reason: 'delta-commit-failed' }); return; } + if (!isCurrentGeneration()) { finish(superseded()); return; } const elapsed = Date.now() - queuedAt; - updateSyncStatus({ + updateCurrentStatus({ push: 'confirmed', pushStartedAt: null, pushConfirmedAt: Date.now(), lastError: null, }); const okMsg = `Push committed ${profileId.slice(0,8)} (${elapsed}ms) — sun=${sunCount} dev=${devCount}`; @@ -276,12 +314,13 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt if (!completed) { console.warn('[sync] Push NOT committed after 30s'); logSyncEvent('skip', `Push stuck >30s — try reloading`); - updateSyncStatus({ push: 'error', lastError: { type: 'PushStuck', message: 'Evolu replication did not complete in 30s', at: Date.now() } }); + updateCurrentStatus({ push: 'error', lastError: { type: 'PushStuck', message: 'Evolu replication did not complete in 30s', at: Date.now() } }); finish({ ok: false, reason: 'timeout' }); } }, 30_000); try { + if (!isCurrentSnapshot()) { completed = true; finish(superseded()); return; } if (existing) { // profileId is repeated on every update so post-compaction replicas // see it on every CRDT message — without this, a relay that drops @@ -304,15 +343,15 @@ export async function pushProfile(profileId: unknown, importedData: unknown, opt // local-sync-ts is now bumped inside onComplete only — see comment there. } catch (e) { console.error('[sync] Push failed:', e); - updateSyncStatus({ push: 'error', lastError: { type: 'PushError', message: getErrorMessage(e), at: Date.now() } }); + updateCurrentStatus({ push: 'error', lastError: { type: 'PushError', message: getErrorMessage(e), at: Date.now() } }); finish({ ok: false, error: e }); } }); } catch (e) { console.error('[sync] Push failed:', e); - updateSyncStatus({ push: 'error', lastError: { type: 'PushError', message: getErrorMessage(e), at: Date.now() } }); + updateCurrentStatus({ push: 'error', lastError: { type: 'PushError', message: getErrorMessage(e), at: Date.now() } }); // Synchronous error path — onComplete will never fire, release the lock. - _syncing = false; + releasePush(); return { ok: false, error: e }; } // _syncing now released by onComplete / watchdog / catch — NOT here. The diff --git a/js/sync-reconcile.ts b/js/sync-reconcile.ts index 990f4d184..ffad24038 100644 --- a/js/sync-reconcile.ts +++ b/js/sync-reconcile.ts @@ -109,5 +109,5 @@ export async function reconcileLocalStorageWithEvolu() { // Force-push so the next watchdog cycle can't lose us a clearly-needed // catch-up. Bypasses the _syncing guard if it was wedged from a prior // session - the same wedge that caused the divergence in the first place. - await _pushProfile(state.currentProfile, state.importedData, { force: true }); + await _pushProfile(state.currentProfile, state.importedData, { force: true, skipUnchanged: true }); } diff --git a/js/sync-runtime.ts b/js/sync-runtime.ts index aebfbdea2..e9829dbd1 100644 --- a/js/sync-runtime.ts +++ b/js/sync-runtime.ts @@ -26,6 +26,7 @@ import { refreshChatWebSearchToggleRuntime, updateChatHeaderModelRuntime, } from './chat-runtime.js'; +import { setSyncPublishingSuspended } from './sync-settings-state.js'; let _evolu: SyncRuntimeClient | null = null; let _profileQuery: unknown = null; @@ -73,6 +74,7 @@ export function getSyncQueryLoadedPromise() { return _queryLoadedPromise; } export function isSyncEvoluReady() { return !!_evolu; } export function setSyncEvolu(evolu: unknown) { + if (_evolu !== evolu) setSyncPublishingSuspended(false); _evolu = evolu as SyncRuntimeClient | null; } diff --git a/js/sync-save-hooks-core.ts b/js/sync-save-hooks-core.ts index 82fd81d70..1f23721db 100644 --- a/js/sync-save-hooks-core.ts +++ b/js/sync-save-hooks-core.ts @@ -99,8 +99,10 @@ export function createSyncSaveHooks({ return; } try { - const result = await (0, syncSaveHooksDeps.pushProfile)(profileId, importedData); - if ((result as { skipped?: unknown } | null | undefined)?.skipped && attempt < 60) scheduleAISettingsPush(profileId, importedData, attempt + 1); + const result = await (0, syncSaveHooksDeps.pushProfile)(profileId, importedData) as {ok?: unknown; skipped?: unknown} | null | undefined; + // A successful unchanged receipt is terminal, just like a commit. + // Only an unsuccessful skipped attempt still needs a retry. + if (result?.skipped && result.ok !== true && attempt < 60) scheduleAISettingsPush(profileId, importedData, attempt + 1); } catch {} }, attempt === 0 ? 250 : 1000); } diff --git a/js/sync-settings-state.ts b/js/sync-settings-state.ts index 630978096..f03d40ee8 100644 --- a/js/sync-settings-state.ts +++ b/js/sync-settings-state.ts @@ -6,6 +6,14 @@ export const SYNC_PAUSED_STORAGE_KEY = 'labcharts-sync-paused'; let _syncConfigured = false; let _syncPaused = false; let _syncStatePrimed = false; +let _publishingSuspended = false; + +/** Owner handoff blocks the retiring runtime without changing saved settings. */ +export function setSyncPublishingSuspended(suspended: boolean) { + const previous = _publishingSuspended; + _publishingSuspended = suspended; + return previous; +} export function primeSyncState() { if (!_syncStatePrimed) { @@ -13,12 +21,12 @@ export function primeSyncState() { _syncPaused = _syncConfigured && localStorage.getItem(SYNC_PAUSED_STORAGE_KEY) === 'true'; _syncStatePrimed = true; } - return _syncConfigured && !_syncPaused; + return _syncConfigured && !_syncPaused && !_publishingSuspended; } export function isSyncEnabled() { if (!_syncStatePrimed) primeSyncState(); - return _syncConfigured && !_syncPaused; + return _syncConfigured && !_syncPaused && !_publishingSuspended; } export function isSyncConfigured() { diff --git a/js/sync-state.ts b/js/sync-state.ts index 1c5ca8580..dce95d83f 100644 --- a/js/sync-state.ts +++ b/js/sync-state.ts @@ -101,6 +101,7 @@ export function getSyncStatusNotice(status: SyncStatus): string | null { } export function updateSyncStatus(partial: Partial) { + if (Object.entries(partial).every(([key, value]) => Object.is(_syncStatus[key as keyof SyncStatus], value))) return; Object.assign(_syncStatus, partial); _emitSyncStatus(); } diff --git a/js/sync-ui.ts b/js/sync-ui.ts index c8599c3f5..c64bc3d76 100644 --- a/js/sync-ui.ts +++ b/js/sync-ui.ts @@ -146,11 +146,7 @@ export function updateSyncIndicator() { const syncText = (value: unknown): string => value == null ? '' : String(value); -export function toggleSyncDetail() { - let pop = document.getElementById('sync-popover'); - if (pop) { pop.remove(); return; } - const btn = document.getElementById('sync-indicator-btn'); - if (!btn) return; +function createSyncDetail() { const s = getSyncStatus(); const relayUrl = getSyncRelay(); const replicationFailed = s.replication === 'Offline' || s.replication === 'Error'; @@ -173,7 +169,7 @@ export function toggleSyncDetail() { const pullLabel = s.pullReceivedAt ? `Checked ${_timeAgo(s.pullReceivedAt)}` : '\u2014'; const errorLine = s.lastError ? { type: syncText(s.lastError.type), age: _timeAgo(s.lastError.at) } : null; - pop = document.createElement('div'); + const pop = document.createElement('div'); pop.id = 'sync-popover'; pop.className = 'sync-popover'; // Recent sync events list - debug-only. Useful when phone vs desktop @@ -216,6 +212,62 @@ export function toggleSyncDetail() { // This is the original second debug read, after relay escaping and quota. debugControls: isDebugMode(), }); + return pop; +} + +// Preserve the actual controls while status text changes. Replacing a hovered +// button between pointerdown and click loses the action and keyboard focus. +function syncNodeKey(node: Node): string | null { + if (!(node instanceof Element)) return null; + const action = node.getAttribute('data-sync-ui-action'); + if (action) return `action:${action}`; + if (node.querySelector('[data-sync-ui-action="sync-now"]')) return 'actions'; + if (node.querySelector('[data-sync-ui-action="copy-events"]')) return 'events'; + return null; +} +function patchSyncChildren(current: Node, next: Node) { + let cursor = current.firstChild; + const desiredKeys = new Set(Array.from(next.childNodes).map(syncNodeKey).filter(Boolean)); + const keyed = new Map(Array.from(current.childNodes).flatMap(node => { + const key = syncNodeKey(node); return key ? [[key, node] as const] : []; + })); + for (const desired of Array.from(next.childNodes)) { + const key = syncNodeKey(desired); + const candidate = key ? keyed.get(key) : cursor && !syncNodeKey(cursor) ? cursor : null; + const compatible = candidate && candidate.nodeType === desired.nodeType + && (!(candidate instanceof Element) || desired instanceof Element && candidate.tagName === desired.tagName); + if (!compatible) { + current.insertBefore(desired.cloneNode(true), cursor); + continue; + } + // Remove obsolete status nodes before an existing control group instead + // of moving that group: even a synchronous DOM move drops its focus. + while (candidate !== cursor && cursor && (!syncNodeKey(cursor) || !desiredKeys.has(syncNodeKey(cursor)))) { + const nextSibling = cursor.nextSibling; current.removeChild(cursor); cursor = nextSibling; + } + if (candidate !== cursor) current.insertBefore(candidate, cursor); + if (candidate instanceof Element && desired instanceof Element) { + for (const attr of Array.from(candidate.attributes)) if (!desired.hasAttribute(attr.name)) candidate.removeAttribute(attr.name); + // The presentation owns this closed attribute set. Keep names literal so + // action-ownership audits can classify every emitted native attribute. + if (desired.hasAttribute('class') && candidate.getAttribute('class') !== desired.getAttribute('class')) candidate.setAttribute('class', desired.getAttribute('class')!); + if (desired.hasAttribute('style') && candidate.getAttribute('style') !== desired.getAttribute('style')) candidate.setAttribute('style', desired.getAttribute('style')!); + if (desired.hasAttribute('title') && candidate.getAttribute('title') !== desired.getAttribute('title')) candidate.setAttribute('title', desired.getAttribute('title')!); + if (desired.hasAttribute('role') && candidate.getAttribute('role') !== desired.getAttribute('role')) candidate.setAttribute('role', desired.getAttribute('role')!); + if (desired.hasAttribute('data-sync-ui-action') && candidate.getAttribute('data-sync-ui-action') !== desired.getAttribute('data-sync-ui-action')) candidate.setAttribute('data-sync-ui-action', desired.getAttribute('data-sync-ui-action')!); + patchSyncChildren(candidate, desired); + } else if (candidate.nodeValue !== desired.nodeValue) candidate.nodeValue = desired.nodeValue; + cursor = candidate.nextSibling; + } + while (cursor) { const nextSibling = cursor.nextSibling; current.removeChild(cursor); cursor = nextSibling; } +} + +export function toggleSyncDetail() { + const existing = document.getElementById('sync-popover'); + if (existing) { existing.remove(); return; } + const btn = document.getElementById('sync-indicator-btn'); + if (!btn) return; + const pop = createSyncDetail(); const parent = btn.parentElement; if (!parent) return; parent.style.position = tsStaticStyles.syncPopoverAnchor; @@ -228,14 +280,11 @@ export function toggleSyncDetail() { export function bindSyncUIStatusUpdates() { if (_statusBound) return; _statusBound = true; - // Subscribe to status changes -> repaint indicator + re-render the popover - // in place so a watchdog flip (e.g. 30s push-stuck) updates the labels and - // the Reload button styling without the user closing / reopening the panel. + // Update status in place while retaining open controls and their focus. subscribeSyncStatus(() => { updateSyncIndicator(); - if (document.getElementById('sync-popover')) { - toggleSyncDetail(); toggleSyncDetail(); - } + const pop = document.getElementById('sync-popover'); + if (pop) patchSyncChildren(pop, createSyncDetail()); }); } diff --git a/scripts/native-coverage-capture.mts b/scripts/native-coverage-capture.mts index 1ef6b111b..ff63f37dc 100644 --- a/scripts/native-coverage-capture.mts +++ b/scripts/native-coverage-capture.mts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {productionFunctionIndex} from './coverage-source.mjs'; import {nativeDigest, nativeSelected, readNativeBlob, readNativeCapture, readNativeRegular} from './native-coverage-storage.mjs'; -import {nativeFunctionMapper, UnmappedNativeFunction} from './native-coverage-functions.mjs'; +import {nativeFunctionMappers, UnmappedNativeFunction} from './native-coverage-functions.mjs'; import {replayNativeTransform, NATIVE_WRAPPER_PREFIX} from './native-coverage-transform.mjs'; import type {NativeCapture, NativeContext, NativeGeometry, NativeMappedCapture, NativeObservation, NativeRaw, NativeSuiteCapture, NativeTransform} from './native-coverage-types.mjs'; export function validateNativeEnvironment(capture: NativeCapture, independent: string): void { @@ -34,8 +34,16 @@ export class NativeCaptureMapper { const raw = JSON.parse(readNativeBlob(config.directory, capture.raw).toString('utf8')) as NativeRaw; assert(Array.isArray(raw.result) && Number.isFinite(raw.timestamp) && Array.isArray(capture.snapshots) && Array.isArray(capture.moduleExecutionInfo)); assert.equal(raw.result.length, capture.snapshots.length, 'Missing native script snapshot'); - const scriptIds = new Set(), result: NativeMappedCapture = {called: [], unmapped: [], unattributed: []}; + const scriptIds = new Set(), results: NativeMappedCapture[] = []; + const pending: Array<{transform: NativeTransform; complete: (mapper: ReturnType[number]) => void}> = []; + const flush = () => { + if (!pending.length) return; + const mappers = nativeFunctionMappers(pending.map(item => item.transform)); + for (const [index, item] of pending.entries()) item.complete(mappers[index]!); + pending.length = 0; + }; for (const script of raw.result) { + const result: NativeMappedCapture = {called: [], unmapped: [], unattributed: []}; results.push(result); assert(typeof script.scriptId === 'string' && typeof script.url === 'string' && !scriptIds.has(script.scriptId)); scriptIds.add(script.scriptId); const matches = capture.snapshots.filter(snapshot => snapshot.scriptId === script.scriptId); assert.equal(matches.length, 1, 'Ambiguous native script snapshot'); const snapshot = matches[0]!; assert.equal(snapshot.url, script.url); assert(snapshot.metadata && snapshot.metadata.scriptId === script.scriptId && snapshot.metadata.url === script.url); @@ -55,7 +63,17 @@ export class NativeCaptureMapper { const canonical = readNativeRegular(path.join(config.root, relative)).toString('utf8'); const cacheKey = JSON.stringify([relative, snapshot.source.sha256, capture.environment]); const entries = this.#mapped.get(cacheKey) ?? new Map(); - if (script.functions.some(fn => !entries.has(`${fn.ranges[0]!.startOffset}:${fn.ranges[0]!.endOffset}`))) { + this.#mapped.set(cacheKey, entries); + const unseen = script.functions.filter(fn => !entries.has(`${fn.ranges[0]!.startOffset}:${fn.ranges[0]!.endOffset}`)); + const finish = () => { + let owned = this.#ownership.get(relative); if (!owned) {owned = new Set(productionFunctionIndex(canonical, relative, config.root).owned.map(fn => `${fn.start}:${fn.end}`)); this.#ownership.set(relative, owned);} + for (const native of script.functions) { + const first = native.ranges[0]!, identity = entries.get(`${first.startOffset}:${first.endOffset}`); assert(identity); + if ('reason' in identity) result.unmapped.push({file: relative, start: first.startOffset, end: first.endOffset, count: first.count, runtimeLength: runtime.length, reason: identity.reason}); + else if (first.count > 0 && owned.has(`${identity.start}:${identity.end}`)) result.called.push({file: relative, ...identity, count: first.count}); + } + }; + if (unseen.length) { let transform: NativeTransform; if (runtime === canonical) transform = {file: path.join(config.root, relative), canonical, runtime, code: runtime, map: null, offset: 0}; else { @@ -64,22 +82,20 @@ export class NativeCaptureMapper { const info = capture.moduleExecutionInfo.filter(([file]) => file === path.join(config.root, relative)); assert.equal(info.length, 1, 'Missing public module execution offset'); transform = await replayNativeTransform(config.root, relative, canonical, runtime, snapshot, info[0]![1].startOffset, unique('geometry'), unique('observation'), capture.environment); } - const mapper = nativeFunctionMapper(transform); - for (const native of script.functions) { - const first = native.ranges[0]!, key = `${first.startOffset}:${first.endOffset}`; - try {const mapped = mapper(native); entries.set(key, {start: mapped.start, end: mapped.end});} - catch (error) {if (!(error instanceof UnmappedNativeFunction)) throw error; entries.set(key, {reason: error.message});} - } - // Only compact identity facts survive a script. AST facts, decoded maps and token closures are released. - this.#mapped.set(cacheKey, entries); - } - let owned = this.#ownership.get(relative); if (!owned) {owned = new Set(productionFunctionIndex(canonical, relative, config.root).owned.map(fn => `${fn.start}:${fn.end}`)); this.#ownership.set(relative, owned);} - for (const native of script.functions) { - const first = native.ranges[0]!, identity = entries.get(`${first.startOffset}:${first.endOffset}`); assert(identity); - if ('reason' in identity) result.unmapped.push({file: relative, start: first.startOffset, end: first.endOffset, count: first.count, runtimeLength: runtime.length, reason: identity.reason}); - else if (first.count > 0 && owned.has(`${identity.start}:${identity.end}`)) result.called.push({file: relative, ...identity, count: first.count}); - } + pending.push({transform, complete: mapper => { + for (const native of unseen) { + const first = native.ranges[0]!, key = `${first.startOffset}:${first.endOffset}`; + try {const mapped = mapper(native); entries.set(key, {start: mapped.start, end: mapped.end});} + catch (error) {if (!(error instanceof UnmappedNativeFunction)) throw error; entries.set(key, {reason: error.message});} + } + finish(); + }}); + // Release compiler projects and token/map closures after each bounded + // batch; only scalar identity facts are cached between captures. + if (pending.length === 16) flush(); + } else finish(); } - return result; + flush(); + return {called: results.flatMap(result => result.called), unmapped: results.flatMap(result => result.unmapped), unattributed: results.flatMap(result => result.unattributed)}; } } diff --git a/scripts/native-coverage-functions.mts b/scripts/native-coverage-functions.mts index 417c257bf..3613a7f1c 100644 --- a/scripts/native-coverage-functions.mts +++ b/scripts/native-coverage-functions.mts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import * as ts from 'typescript/unstable/ast'; import {skipTrivia, createScanner} from 'typescript/unstable/ast/scanner'; import {decode} from '@jridgewell/sourcemap-codec'; import type {Profiler} from 'node:inspector'; -import {withParsedSource} from './native-typescript-ast.js'; +import {withParsedSources} from './native-typescript-ast.js'; import type {NativeTransform} from './native-coverage-types.mjs'; interface Token {start: number; end: number; kind: string; text: string;} interface FunctionFact { @@ -31,9 +32,8 @@ function tail(node: ts.Node, ast: ts.SourceFile, source: string): Token { const ending: ts.Node[] = []; node.forEachChild(child => {if (child.end === end) ending.push(child);}); supported(ending.length === 1, 'TAIL_UNSUPPORTED'); return tail(ending[0]!, ast, source); } -function facts(source: string, file: string): FunctionFact[] { - return withParsedSource(source, file, (ast, project) => { - assert.equal(project.program.getSyntacticDiagnostics().length, 0, 'Malformed native source syntax'); const result: FunctionFact[] = []; +function facts(source: string, ast: ts.SourceFile): FunctionFact[] { + const result: FunctionFact[] = []; function visit(node: ts.Node): void { if (ts.isFunctionLikeDeclaration(node) && node.body) { let body = node.body; while (ts.isParenthesizedExpression(body)) body = body.expression; @@ -58,7 +58,6 @@ function facts(source: string, file: string): FunctionFact[] { node.forEachChild(visit); } visit(ast); return result; - }); } const lineStarts = (source: string): number[] => {const out = [0]; for (let i = 0; i < source.length; i++) if (source[i] === '\n') out.push(i + 1); return out;}; function position(source: string, lines: number[], line: number, column: number): number { @@ -67,7 +66,31 @@ function position(source: string, lines: number[], line: number, column: number) } /** Exact scalar own-entry mapping. No byte-overlap, nearest point, name, or body-hash credit. */ export function nativeFunctionMapper(transform: NativeTransform): (native: Profiler.FunctionCoverage) => {start: number; end: number; count: number} { - const {runtime, canonical, map, offset, code, file} = transform, canonicalFacts = facts(canonical, file), runtimeFacts = facts(runtime, file + '.runtime.js'); + return nativeFunctionMappers([transform])[0]!; +} +/** Bounded syntax batching shares compiler setup, never execution counters. */ +export function nativeFunctionMappers(transforms: readonly NativeTransform[]): Array<(native: Profiler.FunctionCoverage) => {start: number; end: number; count: number}> { + assert(transforms.length > 0 && transforms.length <= 16, 'Native mapping batch must contain 1–16 transforms'); + const sources = new Map(); + const names = transforms.map(({file, canonical, runtime}, index) => { + const extension = path.extname(file); + const canonicalName = `${file}.canonical-batch-${index}${extension}`, runtimeName = `${file}.runtime-batch-${index}.js`; + sources.set(canonicalName, canonical); sources.set(runtimeName, runtime); + return [canonicalName, runtimeName] as const; + }); + // Only scalar token facts escape the bounded project. Every transform still + // owns its exact source-map, identity, ambiguity and native range checks. + const parsed = withParsedSources(sources, (files, project) => { + assert.equal(project.program.getSyntacticDiagnostics().length, 0, 'Malformed native source syntax'); + return names.map(([canonicalName, runtimeName], index) => [facts(transforms[index]!.canonical, files.get(canonicalName)!), facts(transforms[index]!.runtime, files.get(runtimeName)!)] as const); + }); + return transforms.map((transform, index) => createNativeFunctionMapper(transform, ...parsed[index]!)); +} +function createNativeFunctionMapper(transform: NativeTransform, canonicalFacts: FunctionFact[], runtimeFacts: FunctionFact[]): (native: Profiler.FunctionCoverage) => {start: number; end: number; count: number} { + const {runtime, canonical, map, offset, code, file} = transform; + const runtimeIndex = new Map(), canonicalIndex = new Map(); + for (const fact of runtimeFacts) { const key = `${fact.logicalStart}:${fact.end}`; const rows = runtimeIndex.get(key) ?? []; rows.push(fact); runtimeIndex.set(key, rows); } + for (const fact of canonicalFacts) { const key = `${fact.logicalStart}:${fact.bodyStart}`; const rows = canonicalIndex.get(key) ?? []; rows.push(fact); canonicalIndex.set(key, rows); } const forward = new Map(), reverse = new Map>(); if (map) { assert(map.version === 3 && map.sources.length === 1 && map.sources[0] === file && map.sourcesContent?.[0] === canonical, 'Source map does not bind canonical source'); @@ -93,9 +116,9 @@ export function nativeFunctionMapper(transform: NativeTransform): (native: Profi return native => { assert(native.ranges.length > 0, 'Missing native own entry'); const entry = native.ranges[0]!; for (const range of native.ranges) assert(Number.isSafeInteger(range.startOffset) && Number.isSafeInteger(range.endOffset) && Number.isSafeInteger(range.count) && range.count >= 0 && range.startOffset >= 0 && range.endOffset > range.startOffset && range.endOffset <= runtime.length, 'Malformed native own range'); - const matches = runtimeFacts.filter(fn => fn.logicalStart === entry.startOffset && fn.end === entry.endOffset); supported(matches.length === 1, 'RUNTIME_IDENTITY'); const fn = matches[0]!; + const matches = runtimeIndex.get(`${entry.startOffset}:${entry.endOffset}`) ?? []; supported(matches.length === 1, 'RUNTIME_IDENTITY'); const fn = matches[0]!; supported(!fn.unsupported && fn.entry && fn.body && fn.tail, fn.unsupported ?? 'TOKEN_UNSUPPORTED'); - const logical = anchor(fn.logicalStart), body = anchor(fn.bodyStart), candidates = canonicalFacts.filter(candidate => candidate.logicalStart === logical && candidate.bodyStart === body); + const logical = anchor(fn.logicalStart), body = anchor(fn.bodyStart), candidates = canonicalIndex.get(`${logical}:${body}`) ?? []; supported(candidates.length === 1, 'CANONICAL_IDENTITY'); const original = candidates[0]!; supported(!original.unsupported && original.entry && original.body && original.tail, original.unsupported ?? 'TOKEN_UNSUPPORTED'); supported(fn.form === original.form && fn.async === original.async && fn.generator === original.generator, 'FUNCTION_SYNTAX'); diff --git a/scripts/playwright-coverage.mts b/scripts/playwright-coverage.mts index d6b18c6c0..04884590f 100644 --- a/scripts/playwright-coverage.mts +++ b/scripts/playwright-coverage.mts @@ -78,22 +78,27 @@ const COVERAGE_FIXTURES: BrowserFixture[] = [ }], ]; -function unionLength(ranges: readonly CoverageRange[]) { - if (!ranges.length) return 0; +function unionRanges(ranges: readonly CoverageRange[]): CoverageRange[] { + if (!ranges.length) return []; const sorted = [...ranges].sort((a, b) => a.start - b.start); - let covered = 0; + const merged: CoverageRange[] = []; let curStart = sorted[0]!.start; let curEnd = sorted[0]!.end; for (let i = 1; i < sorted.length; i += 1) { if (sorted[i]!.start <= curEnd) { curEnd = Math.max(curEnd, sorted[i]!.end); } else { - covered += curEnd - curStart; + merged.push({start: curStart, end: curEnd}); curStart = sorted[i]!.start; curEnd = sorted[i]!.end; } } - return covered + (curEnd - curStart); + merged.push({start: curStart, end: curEnd}); + return merged; +} + +function unionLength(ranges: readonly CoverageRange[]) { + return unionRanges(ranges).reduce((total, range) => total + range.end - range.start, 0); } function cleanUrl(url: unknown) { @@ -199,11 +204,15 @@ function getFileMetrics(model: CoverageModel, file: string, total: unknown = 0, } function addCoveredRanges(metrics: FileMetrics, ranges: readonly RawRange[]) { + const next: CoverageRange[] = []; for (const range of ranges) { const start = Math.max(0, (range.start ?? 0) as number); const end = Math.max(start, (range.end ?? 0) as number); - if (end > start) metrics.ranges.push({ start, end }); + if (end > start) next.push({ start, end }); } + // Byte coverage is an exact interval union. Retaining every duplicate from + // thousands of captures changes no credit and can exhaust the Node heap. + if (next.length) metrics.ranges = unionRanges([...metrics.ranges, ...next]); } function addFunction(metrics: FileMetrics, key: string, name: unknown, called: boolean, collector = 'v8') { @@ -402,18 +411,18 @@ function normalizeFixture(fixture: BrowserFixture | FixtureReader) { return fixture; } -function readPlaywrightCoverageShards() { - if (!fs.existsSync(playwrightCoverageDir)) return { entries: [], fixtures: [], files: [], unreadable: [] }; +export function readPlaywrightCoverageShards(directory = playwrightCoverageDir) { + const model: CoverageModel = new Map(); + if (!fs.existsSync(directory)) return { model, fixtures: [], files: [], unreadable: [] }; - const files = fs.readdirSync(playwrightCoverageDir) + const files = fs.readdirSync(directory) .filter(file => file.endsWith('.json')) .sort(); - const entries: RawEntry[] = []; const fixtures: FixtureReader[] = []; const unreadable: Array<{ file: string; error: unknown }> = []; for (const file of files) { - const shardPath = path.join(playwrightCoverageDir, file); + const shardPath = path.join(directory, file); let shard: RawShard; try { shard = JSON.parse(fs.readFileSync(shardPath, 'utf8')); @@ -422,7 +431,10 @@ function readPlaywrightCoverageShards() { continue; } const shardEntries = Array.isArray(shard.entries) ? shard.entries : []; - entries.push(...shardEntries); + // Fold one shard at a time with the same exact source/function checks. + // Never retain all source strings + // and raw function/range trees while also building the aggregate model. + addBrowserEntriesToModel(model, shardEntries); fixtures.push({ name: shard.titlePath?.slice(-1)[0] || shard.title || path.basename(file, '.json'), path: shard.file || path.relative(repoRoot, shardPath).split(path.sep).join('/'), @@ -431,7 +443,7 @@ function readPlaywrightCoverageShards() { }); } - return { entries, fixtures, files, unreadable }; + return { model, fixtures, files, unreadable }; } function enforceCoverageGate(report: CoverageSummary) { @@ -449,9 +461,7 @@ function enforceCoverageGate(report: CoverageSummary) { ); } -async function writeCoverageReport(entries: readonly RawEntry[], fixtures: readonly (BrowserFixture | FixtureReader)[], options: { playwrightScope?: string } = {}) { - const playwrightModel: CoverageModel = new Map(); - addBrowserEntriesToModel(playwrightModel, entries); +async function writeCoverageReport(playwrightModel: CoverageModel, fixtures: readonly (BrowserFixture | FixtureReader)[], options: { playwrightScope?: string } = {}) { const playwright = summarizeCoverageModel(playwrightModel); const unitCoverage = await readVitestCoverageModel(); @@ -554,7 +564,7 @@ async function main() { throw new Error(`No readable Playwright coverage shards found in ${playwrightCoverageDir}.`); } console.log(`Reading ${suiteCoverage.fixtures.length} readable Playwright coverage shard(s) from ${path.relative(repoRoot, playwrightCoverageDir)}`); - const report = await writeCoverageReport(suiteCoverage.entries, suiteCoverage.fixtures, { + const report = await writeCoverageReport(suiteCoverage.model, suiteCoverage.fixtures, { playwrightScope: 'Playwright suite coverage shards', }); enforceCoverageGate(report); @@ -574,20 +584,20 @@ async function main() { }); await smokeTestApp(browser); - const entries: RawEntry[] = []; + const model: CoverageModel = new Map(); let firstFailure: unknown = null; console.log(`Running Playwright coverage sampler against ${BASE_URL}`); for (const [name, testPath, options = {}] of COVERAGE_FIXTURES) { console.log(` - ${name}`); const result = await runCoverageFixture(browser, testPath, options); - entries.push(...result.entries); + addBrowserEntriesToModel(model, result.entries); if (result.failure) { firstFailure = result.failure; break; } } - const report = await writeCoverageReport(entries, COVERAGE_FIXTURES, { + const report = await writeCoverageReport(model, COVERAGE_FIXTURES, { playwrightScope: 'sampled Playwright Chromium fixtures', }); if (firstFailure) throw firstFailure; diff --git a/tests/backup-sync-restore.test.ts b/tests/backup-sync-restore.test.ts index 1bdcf8972..8d4860c17 100644 --- a/tests/backup-sync-restore.test.ts +++ b/tests/backup-sync-restore.test.ts @@ -1,15 +1,172 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import {clearSyncProfileDirty, getSyncDirtyToken, markSyncProfileDirty} from '../js/sync-dirty-state.js'; import { clearProfileDeltaSnapshots } from '../js/sync-delta-snapshot.js'; import { getPendingBackupRestoreProfileIds, - prepareRestoredProfilesForSync, + prepareRestoredProfilesForSync, markBackupRestorePending, republishPendingBackupRestore, + resetBackupRestoreRetryBudget, SYNC_BACKUP_RESTORE_PENDING_KEY, + stageBackupRestorePending, fingerprintBackupRestoreOwner, } from '../js/sync-backup-restore-state.js'; describe('backup restore sync handoff', () => { beforeEach(() => { localStorage.clear(); }); + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + + function publisher(profiles: Array<{id: string; tags?: string[]}>) { + return {runtime: {}, ownerId: 'synthetic-owner', isCurrent: () => true, getProfiles: () => profiles, + pushProfilesById: vi.fn(async (ids: readonly string[]) => { + for (const id of ids) clearSyncProfileDirty(id, getSyncDirtyToken(id)); + return {succeeded: 1, failed: 0, skipped: 0}; + })}; + } + + it('binds a staged journal to the intended identity across old tabs and partial progress', async () => { + const mnemonic = 'synthetic intended recovery identity'; + const ownerFingerprint = await fingerprintBackupRestoreOwner(mnemonic); + stageBackupRestorePending(['first', 'second'], {ownerFingerprint}); + expect(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)).not.toContain(mnemonic); + const old = {...publisher([{id: 'first'}, {id: 'second'}]), ownerMnemonic: 'synthetic retiring identity'}; + expect((await republishPendingBackupRestore(old)).reason).toBe('identity-changed'); + expect(old.pushProfilesById).not.toHaveBeenCalled(); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first', 'second']); + const next = {...publisher([{id: 'first'}]), ownerId: 'new-owner', ownerMnemonic: mnemonic}; + expect((await republishPendingBackupRestore(next)).complete).toBe(false); + expect(getPendingBackupRestoreProfileIds()).toEqual(['second']); + expect(JSON.parse(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)!).at(-1).ownerFingerprint).toBe(ownerFingerprint); + expect((await republishPendingBackupRestore(old)).reason).toBe('identity-changed'); + expect(old.pushProfilesById).not.toHaveBeenCalled(); + next.getProfiles = () => [{id: 'second'}]; + expect((await republishPendingBackupRestore(next)).complete).toBe(true); + expect(next.pushProfilesById.mock.calls.map(([ids]) => ids)).toEqual([['first'], ['second']]); + }); + + it('normalizes identity fingerprints without retaining recovery words', async () => { + expect(await fingerprintBackupRestoreOwner(' SYNTHETIC target\nidentity ')).toBe(await fingerprintBackupRestoreOwner('synthetic target identity')); + await expect(fingerprintBackupRestoreOwner(' ')).rejects.toThrow('Sync identity is unavailable'); + }); + + it.each(['unavailable', 'malformed', 'ambiguous'] as const)('retains bound recovery when its identity is %s', async kind => { + const ownerFingerprint = await fingerprintBackupRestoreOwner('synthetic target identity'); + stageBackupRestorePending(['first'], {ownerFingerprint}); + if (kind !== 'unavailable') { + const raw = JSON.parse(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)!); + if (kind === 'malformed') raw.at(-1).ownerFingerprint = 'invalid'; + else raw.push({...raw.at(-1)}); + localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, JSON.stringify(raw)); + } + const deps = publisher([{id: 'first'}]); + expect((await republishPendingBackupRestore(deps)).complete).toBe(false); + expect(deps.pushProfilesById).not.toHaveBeenCalled(); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first']); + }); + + it('does not roll back a newer journal when a staged owner operation fails', () => { + markBackupRestorePending(['previous']); + const staged = stageBackupRestorePending(['candidate']); + markBackupRestorePending(['newer']); + const newer = localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + expect(staged.rollback()).toBe(true); + expect(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)).toBe(newer); + expect(getPendingBackupRestoreProfileIds()).toEqual(['newer']); + }); + + it('acknowledges each committed profile while retaining an unavailable recovery', async () => { + prepareRestoredProfilesForSync({profiles: [{profileId: 'good'}, {profileId: 'demo'}, {profileId: 'missing'}]}); + const deps = publisher([{id: 'good'}, {id: 'demo', tags: ['demo']}, {id: 'missing'}]); + deps.pushProfilesById.mockImplementation(async ids => { + if (ids[0] === 'missing') return {succeeded: 0, failed: 0, skipped: 1}; + clearSyncProfileDirty(ids[0], getSyncDirtyToken(ids[0])); + return {succeeded: 1, failed: 0, skipped: 0}; + }); + for (let i = 0; i < 20; i++) expect((await republishPendingBackupRestore(deps)).complete).toBe(false); + expect(deps.pushProfilesById.mock.calls.map(([ids]) => ids)).toEqual([['good'], ['missing']]); + expect(getPendingBackupRestoreProfileIds()).toEqual(['missing']); + expect(deps.getProfiles()).toEqual([{id: 'good'}, {id: 'demo', tags: ['demo']}, {id: 'missing'}]); + }); + + it('retains a missing profile without publishing another completed profile again', async () => { + markBackupRestorePending(['good', 'missing']); + const deps = publisher([{id: 'good'}]); + await republishPendingBackupRestore(deps); await republishPendingBackupRestore(deps); + expect(deps.pushProfilesById).toHaveBeenCalledExactlyOnceWith(['good'], {force: true, completeSnapshot: true}); + expect(getPendingBackupRestoreProfileIds()).toEqual(['missing']); + }); + + it.each([false, true])('acknowledges an explicitly deleted absent profile with bound journal %s', async bound => { + const mnemonic = 'synthetic intended recovery identity'; + if (bound) stageBackupRestorePending(['deleted'], {ownerFingerprint: await fingerprintBackupRestoreOwner(mnemonic)}); + else markBackupRestorePending(['deleted']); + markSyncProfileDirty('deleted'); + const {markLocalProfileDeleteIntent} = await import('../js/profile-sync-policy.js'); + expect(markLocalProfileDeleteIntent('deleted')).toBe(true); + const deps = {...publisher([]), ownerMnemonic: mnemonic}; + expect((await republishPendingBackupRestore(deps)).complete).toBe(true); + expect(getPendingBackupRestoreProfileIds()).toEqual([]); + expect(getSyncDirtyToken('deleted')).toBeNull(); + expect(deps.pushProfilesById).not.toHaveBeenCalled(); + expect(localStorage.getItem('labcharts-profile-delete-intent-deleted')).not.toBeNull(); + }); + + it('reads old array journals and keeps new journals readable by old tabs', async () => { + localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, JSON.stringify(['first', 'second'])); + expect((await republishPendingBackupRestore(publisher([{id: 'first'}, {id: 'second'}]))).complete).toBe(true); + expect(getPendingBackupRestoreProfileIds()).toEqual([]); + markBackupRestorePending(['first', 'second']); + const oldReader = JSON.parse(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)!).filter((id: unknown) => typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id)); + expect(oldReader).toEqual(['first', 'second']); + }); + + it('does not re-upload an acknowledged commit when its progress cannot be stored', async () => { + markBackupRestorePending(['first', 'second']); + const deps = publisher([{id: 'first'}, {id: 'second'}]); + const write = localStorage.setItem.bind(localStorage); + const failed = vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { + if (key === SYNC_BACKUP_RESTORE_PENDING_KEY) throw new Error('Synthetic journal write failure'); + write(key, value); + }); + for (let i = 0; i < 10; i++) expect((await republishPendingBackupRestore(deps)).reason).toBe('progress-unavailable'); + expect(deps.pushProfilesById).toHaveBeenCalledOnce(); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first', 'second']); + failed.mockRestore(); + expect((await republishPendingBackupRestore(deps)).complete).toBe(true); + expect(deps.pushProfilesById.mock.calls.map(([ids]) => ids)).toEqual([['first'], ['second']]); + }); + + it.each(['journal', 'owner', 'dirty'] as const)('does not acknowledge a newer %s after an old push completes', async kind => { + prepareRestoredProfilesForSync({profiles: [{profileId: 'first'}]}); + const deps = publisher([{id: 'first'}]); let current = true; + deps.isCurrent = () => current; + let finish!: (value: {succeeded: number; failed: number; skipped: number}) => void; + deps.pushProfilesById.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const pending = republishPendingBackupRestore(deps); + if (kind === 'journal') prepareRestoredProfilesForSync({profiles: [{profileId: 'first'}]}); + if (kind === 'owner') current = false; + if (kind === 'dirty') markSyncProfileDirty('first'); + finish({succeeded: 1, failed: 0, skipped: 0}); + expect((await pending).complete).toBe(false); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first']); + current = true; + if (kind === 'owner') deps.ownerId = 'new-synthetic-owner'; + expect((await republishPendingBackupRestore(deps)).complete).toBe(true); + expect(deps.pushProfilesById).toHaveBeenCalledTimes(2); + }); + + it('bounds automatic failures and allows explicit retry or a newer local edit', async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-31T00:00:00Z')); + prepareRestoredProfilesForSync({profiles: [{profileId: 'first'}]}); + const deps = publisher([{id: 'first'}]); deps.pushProfilesById.mockResolvedValue({succeeded: 0, failed: 1, skipped: 0}); + for (let i = 0; i < 10; i++) { await republishPendingBackupRestore(deps); vi.advanceTimersByTime(10_000); } + expect(deps.pushProfilesById).toHaveBeenCalledTimes(3); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first']); + markSyncProfileDirty('first'); await republishPendingBackupRestore(deps); + expect(deps.pushProfilesById).toHaveBeenCalledTimes(4); + resetBackupRestoreRetryBudget(); deps.pushProfilesById.mockResolvedValue({succeeded: 1, failed: 0, skipped: 0}); + expect((await republishPendingBackupRestore(deps)).complete).toBe(true); + expect(deps.pushProfilesById).toHaveBeenCalledTimes(5); + }); it('clears planner state while preserving telemetry', () => { const profileId = 'restore-profile'; diff --git a/tests/ci-browser-contracts.test.ts b/tests/ci-browser-contracts.test.ts index 2d81d4782..1a976cd42 100644 --- a/tests/ci-browser-contracts.test.ts +++ b/tests/ci-browser-contracts.test.ts @@ -147,11 +147,13 @@ describe('manual WebKit diagnostic isolation', () => { function verifyGates(source: string) { const prepare = expression(source, 'prepare', 'if'), aggregate = expression(source, 'test', 'if'); const diagnosticJob = expression(source, 'webkit_diagnostics', 'if'), pwaDiagnosticJob = expression(source, 'webkit_pwa_routstr_diagnostics', 'if'), aggregateName = expression(source, 'test', 'name'); - expect(source).toContain("default: 'off'\n options: ['off', 'webkit-lens-wearable', 'webkit-pwa-routstr']"); - for (const event of ['pull_request', 'push', 'workflow_call', 'workflow_dispatch']) for (const mode of ['off', '', 'webkit-lens-wearable', 'webkit-pwa-routstr', 'foreign']) { + expect(source).toContain("default: 'off'\n options: ['off', 'webkit-lens-wearable', 'webkit-pwa-routstr', 'webkit-evolu-runtime']"); + for (const event of ['pull_request', 'push', 'workflow_call', 'workflow_dispatch']) for (const mode of ['off', '', 'webkit-lens-wearable', 'webkit-pwa-routstr', 'webkit-evolu-runtime', 'foreign']) { const lensDiagnostic = event === 'workflow_dispatch' && mode === 'webkit-lens-wearable'; const pwaDiagnostic = event === 'workflow_dispatch' && mode === 'webkit-pwa-routstr'; - const diagnostic = lensDiagnostic || pwaDiagnostic; + const evoluDiagnostic = event === 'workflow_dispatch' && mode === 'webkit-evolu-runtime'; + const diagnostic = lensDiagnostic || pwaDiagnostic || evoluDiagnostic; + expect(evaluate(expression(source, 'webkit_evolu_diagnostics', 'if'), event, mode)).toBe(evoluDiagnostic); expect(evaluate(prepare, event, mode), `${event}/${mode} prepare`).toBe(!diagnostic); expect(evaluate(diagnosticJob, event, mode), `${event}/${mode} Lens diagnostic`).toBe(lensDiagnostic); expect(evaluate(pwaDiagnosticJob, event, mode), `${event}/${mode} PWA diagnostic`).toBe(pwaDiagnostic); @@ -170,11 +172,13 @@ describe('manual WebKit diagnostic isolation', () => { it('keeps every normal event and failed-prepare aggregator intact, including forged diagnostic inputs', () => verifyGates(workflow)); it('rejects event bypass, missing always, default-on diagnostics and a misleading required check name', () => { for (const changed of [ - workflow.replace(" if: github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr')", " if: inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr'"), + workflow.replace(" if: github.event_name != 'workflow_dispatch' || (inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr' && inputs.diagnostic != 'webkit-evolu-runtime')", " if: inputs.diagnostic != 'webkit-lens-wearable' && inputs.diagnostic != 'webkit-pwa-routstr'"), workflow.replace(' if: always() && (', ' if: ('), workflow.replace(" if: github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-lens-wearable'", " if: inputs.diagnostic == 'webkit-lens-wearable'"), workflow.replace(" if: github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-pwa-routstr'", " if: inputs.diagnostic == 'webkit-pwa-routstr'"), workflow.replace(" && inputs.diagnostic != 'webkit-pwa-routstr'", ""), + workflow.replace(" && inputs.diagnostic != 'webkit-evolu-runtime'", ""), + workflow.replace(" if: github.event_name == 'workflow_dispatch' && inputs.diagnostic == 'webkit-evolu-runtime'", " if: inputs.diagnostic == 'webkit-evolu-runtime'"), workflow.replace('DIAGNOSTIC normal gate omitted', 'test'), workflow.replace("default: 'off'", "default: 'webkit-lens-wearable'"), ]) expect(() => verifyGates(changed)).toThrow(); @@ -252,6 +256,23 @@ describe('manual WebKit diagnostic isolation', () => { expect(canonical).toContain("test('static Routstr models retain private and regular choices through unavailable-origin cold launch'"); expect(canonical).toContain('test.setTimeout(120_000)'); }); + it('keeps focused Evolu diagnostics on actual Cocoa with unchanged restart assertions', () => { + const diagnostic = job(workflow, 'webkit_evolu_diagnostics'); + expect(diagnostic).toContain('runs-on: macos-15'); + expect(diagnostic).toContain('project: [kit-webkit, kit-mobile-webkit]'); + expect(diagnostic).toContain('timeout-minutes: 20'); + expect(diagnostic).toContain('GETBASED_KIT_ACCEPTANCE_GUARD: \'1\''); + expect(diagnostic).toContain('persist-credentials: false'); + expect(diagnostic).toContain('node-version: 24'); + expect(diagnostic).toContain('npx --no-install playwright install --with-deps webkit'); + expect(diagnostic).toContain('ci-artifact.mjs verify --artifact dist'); + expect(diagnostic).toContain('--workers=1 --retries=0 tests/pwa/sveltekit-evolu-runtime.spec.ts'); + expect(diagnostic).toContain("assert.equal(report.tests.length, 1)"); + expect(diagnostic).toContain("assert.equal(test.attemptCount, 1)"); + expect(diagnostic).toContain("assert.equal(test.declaredExpectedStatus, 'passed')"); + expect(diagnostic).not.toMatch(/\b(?:secrets|continue-on-error)\b|--(?:grep|headed|shard|timeout)\b|needs:|WEBKIT_SKIA|PLAYWRIGHT_WEBKIT_EXECUTABLE/); + expect(diagnostic).toContain('the normal release matrix is still required'); + }); it('executes the actual Cocoa PWA scope guard and rejects every false one-case result', () => { const diagnostic = job(workflow, 'webkit_pwa_routstr_diagnostics'); const block = / node --input-type=module <<'NODE'\n([\s\S]*?) NODE/.exec(diagnostic)?.[1]; diff --git a/tests/coverage-producer.test.ts b/tests/coverage-producer.test.ts index 5d6202322..79401976c 100644 --- a/tests/coverage-producer.test.ts +++ b/tests/coverage-producer.test.ts @@ -37,12 +37,12 @@ function locationOffset(source: string, position: Position) { expect(position.column === null || (Number.isInteger(position.column) && position.column >= 0)).toBe(true); return lines.slice(0, position.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + (position.column || 0); } -function functionIdentity(source: string, entry: FileCoverage['fnMap'][string], file: string) { +function functionIdentity(source: string, entry: FileCoverage['fnMap'][string], file: string, + index = file.endsWith('.svelte.native.js') || file.endsWith('.svelte.html.js') + ? productionFunctionIndex(source, file, root) : {owned: sourceFunctions(source, file), thirdParty: []}) { // Use the same body-first coordinate contract as the combined collector. const body = [locationOffset(source, entry.loc.start), locationOffset(source, entry.loc.end)] as const; const span = body[1] > body[0] ? body : [locationOffset(source, entry.decl.start), locationOffset(source, entry.decl.end)] as const; - const index = file.endsWith('.svelte.native.js') || file.endsWith('.svelte.html.js') - ? productionFunctionIndex(source, file, root) : {owned: sourceFunctions(source, file), thirdParty: []}; return { owned: matchSourceFunction(index.owned, span[0], span[1], 'istanbul'), framework: matchSourceFunction(index.thirdParty, span[0], span[1], 'istanbul'), @@ -135,9 +135,10 @@ export default {...base, root: ${JSON.stringify(root)}, test: {...base.test, for (const suffix of ['native.js', 'html.js']) { const file = `${component}.${suffix}`, entry = entries.find(entry => entry.path === path.join(root, file))!; const source = fs.readFileSync(path.join(root, file), 'utf8'); let ownedCalled = 0, frameworkCalled = 0; + const index = productionFunctionIndex(source, file, root); for (const [id, fn] of Object.entries(entry.fnMap)) { if (!entry.f[id]) continue; - const identity = functionIdentity(source, fn, file); + const identity = functionIdentity(source, fn, file, index); expect(identity.owned !== null || identity.framework !== null, `${file}: ${fn.name}`).toBe(true); if (identity.owned) ownedCalled++; if (identity.framework) frameworkCalled++; } @@ -152,8 +153,9 @@ export default {...base, root: ${JSON.stringify(root)}, test: {...base.test, // reach the parent collector without inventing execution for untouched code. expect(Object.values(worker.f).some(count => count === 0)).toBe(true); const workerSource = fs.readFileSync(worker.path, 'utf8'); + const workerIndex = {owned: sourceFunctions(workerSource, 'service-worker-runtime.js'), thirdParty: []}; for (const [id, fn] of Object.entries(worker.fnMap)) { - if (worker.f[id]) expect(functionIdentity(workerSource, fn, 'service-worker-runtime.js').owned, fn.name).not.toBeNull(); + if (worker.f[id]) expect(functionIdentity(workerSource, fn, 'service-worker-runtime.js', workerIndex).owned, fn.name).not.toBeNull(); } const browser = path.join(directory, 'browser'); fs.mkdirSync(browser); // This is the transport validator's unit input seam, not a browser execution receipt. diff --git a/tests/dashboard-onboarding-route.test.ts b/tests/dashboard-onboarding-route.test.ts index d59835341..2f5c3cea0 100644 --- a/tests/dashboard-onboarding-route.test.ts +++ b/tests/dashboard-onboarding-route.test.ts @@ -33,3 +33,37 @@ it('releases reserved Dashboard space and cancels delayed guided Chat when the u localStorage.clear(); vi.unstubAllGlobals(); vi.useRealTimers(); } }); + +it.each(['reject', 'throw', 'resize', 'mobile', 'new-render', 'profile-change', 'sync-popover', 'settings-modal', 'guard-withdrawn', 'existing-popover'] as const)('owns optional Chat startup cleanup through %s', async scenario => { + vi.useFakeTimers(); vi.stubGlobal('innerWidth', scenario === 'mobile' ? 390 : 1280); + const previous = {currentView: state.currentView, currentProfile: state.currentProfile, importedData: state.importedData, chatHistory: state.chatHistory, nutritionSummary: state.nutritionSummary}; + let reject!: (error: Error) => void; + const pending = new Promise((_resolve, fail) => { reject = fail; }); + const open = vi.fn((..._args: unknown[]) => { if (scenario === 'throw') throw new Error('Synthetic optional module failure'); return pending; }); + const previousRuntime = configureDashboardPageRuntimeDeps({openChatPanel: open}); + try { + Object.assign(state, {currentView: 'dashboard', currentProfile: 'default', importedData: createDefaultProfileData(), chatHistory: [], nutritionSummary: null}); + localStorage.setItem('labcharts-default-emptyTour', 'completed'); document.body.innerHTML = '
'; + if(scenario==='existing-popover'){const popover=document.createElement('div');popover.id='sync-popover';document.body.append(popover);} + const {showDashboard} = createDashboardPageView({setupDropZone: () => {}, projectAIConnectionReminder: () => null}); + showDashboard({dates: [], categories: {}}); + if(scenario==='existing-popover') expect(document.body.classList.contains('chat-autostart-reserved')).toBe(false); + if (scenario === 'resize') vi.stubGlobal('innerWidth', 390); + if (scenario === 'sync-popover' || scenario === 'settings-modal') { const overlay=document.createElement('div'); overlay.id=scenario; if(scenario==='settings-modal') overlay.className='modal-overlay show'; document.body.append(overlay); } + await vi.advanceTimersByTimeAsync(800); + if (scenario === 'guard-withdrawn') { + const request=open.mock.calls[0]?.[1] as {canOpen?: () => boolean} | undefined; + expect(request?.canOpen?.()).toBe(true); + const popover=document.createElement('div');popover.id='sync-popover';document.body.append(popover); + expect(request?.canOpen?.()).toBe(false); + } + if (scenario === 'new-render') showDashboard({dates: [], categories: {}}); + if (scenario === 'profile-change') state.currentProfile = 'another-profile'; + if (!['throw','resize','mobile','sync-popover','settings-modal','existing-popover'].includes(scenario)) { reject(new Error('Synthetic optional module failure')); await Promise.resolve(); } + expect(document.body.classList.contains('chat-autostart-reserved')).toBe(scenario === 'new-render' || scenario === 'profile-change'); + expect(open).toHaveBeenCalledTimes(['resize','mobile','sync-popover','settings-modal','existing-popover'].includes(scenario) ? 0 : 1); + } finally { + configureDashboardPageRuntimeDeps(previousRuntime); Object.assign(state, previous); + document.body.replaceChildren(); document.body.className = ''; localStorage.clear(); vi.clearAllTimers(); vi.unstubAllGlobals(); vi.useRealTimers(); + } +}); diff --git a/tests/dom-sink-audit.test.ts b/tests/dom-sink-audit.test.ts index efea69e2f..a4cdd6bca 100644 --- a/tests/dom-sink-audit.test.ts +++ b/tests/dom-sink-audit.test.ts @@ -16,8 +16,9 @@ describe('DOM HTML sink audit', () => { }, 15_000); it('rejects altered fingerprints, omitted and stale entries, and discovery totals', () => { - const { current } = auditDomSinks(); - const policy = structuredClone(current); + // The preceding positive case freshly qualifies this maintained policy. + // Mutate its independent expectations, then scan every actual source once. + const policy = JSON.parse(fs.readFileSync('scripts/dom-sink-policy.json', 'utf8')) as ReturnType; const [changed, omitted] = Object.keys(policy.files); if (!changed || !omitted) throw new Error('Expected multiple reviewed sink-bearing modules'); policy.files[changed]!.digest = 'unreviewed'; diff --git a/tests/helpers/offline-launch-diagnostics.ts b/tests/helpers/offline-launch-diagnostics.ts index 9516e1201..3f6bef0ea 100644 --- a/tests/helpers/offline-launch-diagnostics.ts +++ b/tests/helpers/offline-launch-diagnostics.ts @@ -78,7 +78,9 @@ export async function captureOfflineLaunchDiagnostics(page: Page, info: TestInfo const registrations = await bounded(navigator.serviceWorker.getRegistrations(), 'worker registrations'); return { startupErrors: (window as unknown as { __getbasedOfflineStartupErrors?: unknown }).__getbasedOfflineStartupErrors, path: location.pathname, readyState: document.readyState, shellFramework: document.documentElement.dataset.shellFramework, appReady: document.documentElement.dataset.appReady, controller: navigator.serviceWorker.controller && { scriptURL: navigator.serviceWorker.controller.scriptURL, state: navigator.serviceWorker.controller.state }, - registrations: Array.isArray(registrations) ? registrations.map(registration => ({ scope: registration.scope, active: registration.active?.scriptURL, state: registration.active?.state })) : registrations, caches: snapshots }; + registrations: Array.isArray(registrations) ? registrations.map(registration => ({ scope: registration.scope, active: registration.active?.scriptURL, state: registration.active?.state, + installing: registration.installing && {scriptURL: registration.installing.scriptURL, state: registration.installing.state}, + waiting: registration.waiting && {scriptURL: registration.waiting.scriptURL, state: registration.waiting.state} })) : registrations, caches: snapshots }; }); await info.attach('offline-launch-cache-state', { body: JSON.stringify(state, null, 2), contentType: 'application/json' }); } catch (error) { diff --git a/tests/helpers/sync-push-source-contracts.ts b/tests/helpers/sync-push-source-contracts.ts index aa4547319..cc1ad0ed6 100644 --- a/tests/helpers/sync-push-source-contracts.ts +++ b/tests/helpers/sync-push-source-contracts.ts @@ -14,8 +14,9 @@ export function syncPushSourceContracts(source: string) { const push = file.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'pushProfile'); if (!push) return { commitTracksBytes: false, plansBeforeProfileUpdate: false, commitOwnsDeltas: false, successOwnedByComplete: false }; const variables = descendants(push, ts.isVariableDeclaration); + const calls = descendants(push, ts.isCallExpression); const complete = variables.find(node => node.name.getText(file) === 'onComplete')?.initializer; - const success = descendants(push, ts.isCallExpression).filter(call => call.expression.getText(file) === 'finish' + const success = calls.filter(call => call.expression.getText(file) === 'finish' && call.arguments.length === 1 && call.arguments[0]!.getText(file).replace(/\s/g, '') === '{ok:true}'); const successOwnedByComplete = success.length === 1 && !!complete && ts.isArrowFunction(complete) && complete.getStart(file) < success[0]!.getStart(file) && success[0]!.getEnd() < complete.getEnd(); @@ -31,9 +32,9 @@ export function syncPushSourceContracts(source: string) { && node.initializer.expression.arguments.length === 3 && node.initializer.expression.arguments.slice(0, 2).map(arg => arg.getText(file)).join(',') === 'profileId,outboundData' && node.initializer.expression.arguments[2]!.getText(file).replace(/\s/g, '') === '{forceScalars:opts.completeSnapshot===true}'); - const updates = descendants(push, ts.isCallExpression).filter(call => call.expression.getText(file) === 'evolu.update' + const updates = calls.filter(call => call.expression.getText(file) === 'evolu.update' && call.arguments[0] && ts.isStringLiteral(call.arguments[0]) && call.arguments[0].text === 'profileData'); - const applied = descendants(push, ts.isCallExpression).filter(call => call.expression.getText(file) === 'applyCommittedDeltas'); + const applied = calls.filter(call => call.expression.getText(file) === 'applyCommittedDeltas'); const owned = !!complete && ts.isArrowFunction(complete) && applied.length === 1 && applied[0]!.getStart(file) > complete.body.getStart(file) && applied[0]!.getEnd() < complete.body.getEnd(); return { commitTracksBytes: tracked, commitOwnsDeltas: owned, successOwnedByComplete, diff --git a/tests/native-coverage-batching.test.ts b/tests/native-coverage-batching.test.ts new file mode 100644 index 000000000..37eb97239 --- /dev/null +++ b/tests/native-coverage-batching.test.ts @@ -0,0 +1,43 @@ +import {expect, it} from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {pathToFileURL} from 'node:url'; +import {NativeCaptureMapper} from '../scripts/native-coverage-capture.mjs'; +import {nativeDigest, writeNativeBlob, writeNativeCapture, writeNativeJSON} from '../scripts/native-coverage-storage.mjs'; +import type {NativeCapture, NativeContext, NativeRaw, NativeSnapshot, NativeSuiteCapture} from '../scripts/native-coverage-types.mjs'; + +// Synthetic transport controls exercise ordering and counter isolation, without +// claiming execution of production code or manufacturing a release receipt. +it('preserves script order and actual per-capture counters across full and partial batches', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'getbased-mapping-batch-')); + try { + const directory = path.join(root, 'captures'); fs.mkdirSync(directory); fs.mkdirSync(path.join(root, 'js')); + const sources = Array.from({length: 19}, (_, index) => `js/file-${index}.js`); + const texts = new Map(sources.map((file, index) => [file, `export function run() { return ${index}; }`])); + for (const [file, text] of texts) fs.writeFileSync(path.join(root, file), text); + const config: NativeContext = {root, directory, runId: randomUUID(), revision: 'a'.repeat(40), sources}; + const mapper = new NativeCaptureMapper(config); + function capture(rows: Array<{index: number; count: number}>): NativeSuiteCapture { + const id = randomUUID(), raw: NativeRaw = {timestamp: Date.now(), result: []}, snapshots: NativeSnapshot[] = []; + for (const [position, row] of rows.entries()) { + const file = sources[row.index]!, text = texts.get(file)!, scriptId = String(position + 1), url = pathToFileURL(path.join(root, file)).href; + raw.result.push({scriptId, url, functions: [{functionName: 'run', isBlockCoverage: true, ranges: [{startOffset: text.indexOf('function'), endOffset: text.length, count: row.count}]}]}); + snapshots.push({file, scriptId, url, metadata: {scriptId, url, startLine: 0, startColumn: 0, endLine: 0, endColumn: text.length, executionContextId: 1, hash: nativeDigest(text)}, source: writeNativeBlob(directory, text), sourceLength: text.length}); + } + const value: NativeCapture = {schemaVersion: 1, runId: config.runId, revision: config.revision, holderId: randomUUID(), pid: process.pid, environment: {name: 'node', viteEnvironment: 'ssr', consumer: 'server'}, raw: writeNativeJSON(directory, `raw-${id}.json`, raw), snapshots, moduleExecutionInfo: [], completed: true}; + return {file: writeNativeCapture(directory, `capture-${id}.json`, value), testFiles: ['tests/native-coverage-batching.test.ts'], project: '', environment: 'ssr'}; + } + const initial = Array.from({length: 17}, (_, index) => ({index, count: index + 1})); + const first = await mapper.map(capture(initial)); + expect(first.called.map(value => [value.file, value.count])).toEqual(initial.map(value => [sources[value.index], value.count])); + expect(first.unmapped).toEqual([]); + const mixed = [{index: 17, count: 3}, {index: 16, count: 4}, {index: 18, count: 5}, {index: 0, count: 0}]; + const second = await mapper.map(capture(mixed)); + expect(second.called.map(value => [value.file, value.count])).toEqual(mixed.filter(value => value.count > 0).map(value => [sources[value.index], value.count])); + expect(second.unmapped).toEqual([]); + const zeros = await mapper.map(capture(initial.map(value => ({...value, count: 0})))); + expect(zeros.called).toEqual([]); + } finally { fs.rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/native-coverage-mapping.test.ts b/tests/native-coverage-mapping.test.ts index 151519335..800426960 100644 --- a/tests/native-coverage-mapping.test.ts +++ b/tests/native-coverage-mapping.test.ts @@ -9,7 +9,7 @@ import MagicString from 'magic-string'; import {moduleRunnerTransform, transformWithOxc} from 'vite'; import {decode, encode} from '@jridgewell/sourcemap-codec'; import type {EncodedSourceMap} from '@jridgewell/trace-mapping'; -import {nativeFunctionMapper, UnmappedNativeFunction} from '../scripts/native-coverage-functions.mjs'; +import {nativeFunctionMapper, nativeFunctionMappers, UnmappedNativeFunction} from '../scripts/native-coverage-functions.mjs'; import {replayNativeTransform, NATIVE_WRAPPER_PREFIX} from '../scripts/native-coverage-transform.mjs'; import {nativeDigest, writeNativeBlob} from '../scripts/native-coverage-storage.mjs'; import {readNativeFunctionCoverage} from '../scripts/native-coverage-receipt.mjs'; @@ -41,6 +41,22 @@ function own(transform: NativeTransform, count: number) { } describe('native own-entry identities and whole public source witnesses', () => { + it('keeps exact identities and counters separate across a bounded mapping batch', async () => { + const a = await fixture(source), other = '// separate source\nexport function longer(value) { return value + 2; }', b = await fixture(other); + const startOffset = b.runtime.indexOf('function longer'); + const second = {functionName: 'longer', isBlockCoverage: true, ranges: [{startOffset, endOffset: startOffset + other.slice(other.indexOf('function longer')).length, count: 7}]}; + const transforms = Array.from({length: 16}, (_, index) => index % 2 ? b.transform : a.transform); + const maps = nativeFunctionMappers(transforms); + for (const [index, map] of maps.entries()) { + expect(map(index % 2 ? second : own(a.transform, 0))).toEqual(index % 2 + ? {start: other.indexOf('export'), end: other.length, count: 7} + : {start: 0, end: source.length, count: 0}); + } + expect(() => maps[0]!({...own(a.transform, 1), ranges: [{...own(a.transform, 1).ranges[0]!, startOffset: own(a.transform, 1).ranges[0]!.startOffset + 1}]})).toThrow('RUNTIME_IDENTITY'); + expect(() => nativeFunctionMappers([...transforms, a.transform])).toThrow('1–16'); + expect(() => nativeFunctionMappers([])).toThrow('1–16'); + expect(() => nativeFunctionMappers([{...a.transform, file: a.file + '.txt', canonical: source, runtime: source, code: source, map: null, offset: 0}])).toThrow(); + }); it('replays a whole SSR module before rejecting wrapper, source-map and observation corruption', async () => { const value = await fixture(source); const replay = () => replayNativeTransform(value.root, value.relative, value.canonical, value.runtime, value.snapshot, NATIVE_WRAPPER_PREFIX.length, value.geometry, value.observation, value.environment); diff --git a/tests/playwright-coverage-streaming.test.ts b/tests/playwright-coverage-streaming.test.ts new file mode 100644 index 000000000..29c3fdf4d --- /dev/null +++ b/tests/playwright-coverage-streaming.test.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {spawnSync} from 'node:child_process'; +import {expect, it} from 'vitest'; +import {productionFunctionIndex} from '../scripts/coverage-source.mjs'; +import {sourceFingerprint} from '../scripts/coverage-model-helpers.mjs'; +import {addBrowserEntriesToModel, readPlaywrightCoverageShards, summarizeCoverageModel} from '../scripts/playwright-coverage.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +it('streaming preserves exact function/byte unions, source rejection and unreadable-shard diagnostics', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'getbased-coverage-fold-')); + try { + const file = 'js/unique-id.js', source = fs.readFileSync(path.join(root, file), 'utf8'); + const functions = productionFunctionIndex(source, file, root).owned; + expect(functions.length).toBeGreaterThan(1); + const entry = (called: number) => ({url: '/' + file, text: source, ...sourceFingerprint(source), + functions: functions.map((fn, index) => ({functionName: fn.name, ranges: [{startOffset: fn.start, endOffset: fn.end, count: index === called ? 1 : 0}]}))}); + const entries = [entry(0), {...entry(0), sourceHash: '0'.repeat(64)}, entry(1)]; + entries.forEach((value, index) => fs.writeFileSync(path.join(directory, `${index}.json`), JSON.stringify({entries: [value]}))); + fs.writeFileSync(path.join(directory, 'broken.json'), '{broken'); + const expected: Parameters[0] = new Map(); addBrowserEntriesToModel(expected, entries); + const rejected: Parameters[0] = new Map(); addBrowserEntriesToModel(rejected, [entries[1]!]); + expect(rejected.size).toBe(0); + const streamed = readPlaywrightCoverageShards(directory); + expect(summarizeCoverageModel(streamed.model)).toEqual(summarizeCoverageModel(expected)); + const covered = new Uint8Array(source.length); + for (const fn of functions.slice(0, 2)) covered.fill(1, fn.start, fn.end); + expect(summarizeCoverageModel(streamed.model).totals).toEqual({total: source.length, + covered: covered.reduce((total, byte) => total + byte, 0), fnTotal: functions.length, fnCalled: 2}); + expect(streamed.fixtures).toHaveLength(3); expect(streamed.files).toHaveLength(4); + expect(streamed.unreadable.map(item => item.file)).toEqual(['broken.json']); + expect(readPlaywrightCoverageShards(path.join(directory, 'missing')).model.size).toBe(0); + } finally {fs.rmSync(directory, {recursive: true, force: true});} +}); +it('reads thousands of real-source shards within a fixed small Node heap', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'getbased-coverage-stream-')); + try { + const file = 'js/cashu-wallet.js', source = fs.readFileSync(path.join(root, file), 'utf8'); + const fn = productionFunctionIndex(source, file, root).owned[0]!; + const entry = {url: '/' + file, text: source, ...sourceFingerprint(source), + functions: [{functionName: fn.name, ranges: [{startOffset: fn.start, endOffset: fn.end, count: 1}]}]}; + const first = path.join(directory, '0000.json'); fs.writeFileSync(first, JSON.stringify({entries: [entry]})); + // Hard links provide independent shard reads without writing hundreds of MB. + const count = 4000; + for (let index = 1; index < count; index++) fs.linkSync(first, path.join(directory, `${String(index).padStart(4, '0')}.json`)); + const module = pathToFileURL(path.join(root, 'scripts/playwright-coverage.mjs')).href; + const script = `import {readPlaywrightCoverageShards} from ${JSON.stringify(module)}; const result = readPlaywrightCoverageShards(process.argv[1]); console.log(JSON.stringify({fixtures: result.fixtures.length, files: result.files.length}));`; + const child = spawnSync(process.execPath, ['--max-old-space-size=96', '--input-type=module', '--eval', script, directory], + {cwd: root, encoding: 'utf8', timeout: 12000, maxBuffer: 2 * 1024 * 1024}); + expect(child.status, child.stderr.slice(0, 1600)).toBe(0); + expect(JSON.parse(child.stdout.trim())).toEqual({fixtures: count, files: count}); + } finally {fs.rmSync(directory, {recursive: true, force: true});} +}, 15000); diff --git a/tests/playwright/auto-import-feed-e2e.spec.ts b/tests/playwright/auto-import-feed-e2e.spec.ts index 193a24835..880ef1521 100644 --- a/tests/playwright/auto-import-feed-e2e.spec.ts +++ b/tests/playwright/auto-import-feed-e2e.spec.ts @@ -7,6 +7,8 @@ async function openSandbox(page: Page, source: string) { await page.addInitScript(() => { for (const key of ['emptyTour', 'tour']) localStorage.setItem(`labcharts-default-${key}`, 'completed'); localStorage.setItem('labcharts-theme', 'light'); + localStorage.setItem('labcharts-analytics-consent-seen', '1'); + localStorage.setItem('labcharts-analytics-disabled', 'true'); }); const appOrigin = new URL(test.info().project.use.baseURL!).origin; await page.route('**/*', route => { diff --git a/tests/playwright/chat-loader-browser-coverage.spec.ts b/tests/playwright/chat-loader-browser-coverage.spec.ts index 3b7535f23..4e7995016 100644 --- a/tests/playwright/chat-loader-browser-coverage.spec.ts +++ b/tests/playwright/chat-loader-browser-coverage.spec.ts @@ -48,6 +48,26 @@ test.beforeEach(async ({ page }) => { await page.goto('/chat-loader-facade-coverage'); }); +for (const action of ['openChatPanel', 'toggleChatPanel'] as const) { + test(`Chat lazy ${action} cannot act on a replacement profile`, async ({ page }) => { + await page.route('**/js/app-ai-interaction-modules.js*', route => route.fulfill({contentType: 'text/javascript', body: syntheticChatModule})); + const result = await page.evaluate(async ({url, action}) => { + const {state} = await import('/js/state.js'); + const loader = await import(url) as typeof import('../../js/chat-loader.js'); + let release!: () => void; + loader.configureChatLoader({prepareHealthDataContext: () => new Promise(resolve => { release = resolve; })}); + const pending = loader[action]('original-profile'); + state.currentProfile = 'replacement-profile'; + release(); + const stale = await pending; + const callsBeforeFresh = (window as unknown as {__chatFacadeCalls?: unknown[]}).__chatFacadeCalls?.length ?? 0; + const fresh = await loader[action]('replacement-profile'); + return {stale, callsBeforeFresh, fresh}; + }, {url: chatLoaderUrl(), action}); + expect(result).toEqual({stale: false, callsBeforeFresh: 0, fresh: {name: action, args: ['replacement-profile']}}); + }); +} + test('Chat lazy facade preserves every public action before and after first load', async ({ page }) => { let implementationRequests = 0; await page.route('**/js/app-ai-interaction-modules.js*', route => { diff --git a/tests/playwright/chat-presentation-stylesheet-browser-coverage.spec.ts b/tests/playwright/chat-presentation-stylesheet-browser-coverage.spec.ts index ff7e6ff45..56ed8177a 100644 --- a/tests/playwright/chat-presentation-stylesheet-browser-coverage.spec.ts +++ b/tests/playwright/chat-presentation-stylesheet-browser-coverage.spec.ts @@ -12,6 +12,80 @@ const CHAT_PRESENTATION_PATHS = [ '/css/chat-redesign-open.css', ]; +test('a profile handoff cancels Chat opening while presentation is loading', async ({ page }) => { + let stylesheetRoute: import('@playwright/test').Route | undefined; + await page.route('**/css/chat-onboarding.css*', route => { stylesheetRoute = route; }); + await page.goto('/app', {waitUntil: 'load'}); + await page.evaluate(async () => { + (window as Window & {__chatOpenResult?: ReturnType}).__chatOpenResult = (await import('/js/chat-panel.js')).openChatPanel(); + }); + await expect.poll(() => !!stylesheetRoute).toBe(true); + await page.evaluate(async () => { (await import('/js/state.js')).state.currentProfile = 'replacement-profile'; }); + await stylesheetRoute!.fulfill({status: 200, contentType: 'text/css', body: ''}); + expect(await page.evaluate(() => (window as Window & {__chatOpenResult?: ReturnType}).__chatOpenResult)).toBe(false); + await expect(page.locator('#chat-panel')).not.toHaveClass(/open/); + expect(await page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('labcharts-replacement-profile-')))).toEqual([]); +}); + +test('an already withdrawn background Chat request stays cold and preserves its caller reservation', async ({page}) => { + await page.goto('/app', {waitUntil: 'load'}); + const opened = await page.evaluate(async () => { + document.body.classList.add('chat-autostart-reserved'); + return (await import('/js/chat-panel.js')).openChatPanel(undefined, {canOpen: () => false}); + }); + expect(opened).toBe(false); + await expect(page.locator('link[data-chat-presentation-stylesheet]')).toHaveCount(0); + await expect(page.locator('#chat-panel')).not.toHaveClass(/open/); + await expect(page.locator('body')).toHaveClass(/chat-autostart-reserved/); +}); + +test('a foreground Sync popover cancels background Chat during presentation loading', async ({page}) => { + let stylesheetRoute: import('@playwright/test').Route | undefined; + await page.route('**/css/chat-onboarding.css*', route => { stylesheetRoute = route; }); + await page.goto('/app', {waitUntil: 'load'}); + await page.evaluate(async () => { + const panel = await import('/js/chat-panel.js'); + (window as Window & {__chatOpenResult?: ReturnType}).__chatOpenResult = panel.openChatPanel(undefined, {canOpen: () => !document.getElementById('sync-popover')}); + }); + await expect.poll(() => !!stylesheetRoute).toBe(true); + await page.evaluate(() => { const popover=document.createElement('div');popover.id='sync-popover';document.body.append(popover);document.body.classList.add('chat-autostart-reserved'); }); + await stylesheetRoute!.fulfill({status: 200, contentType: 'text/css', body: ''}); + expect(await page.evaluate(() => (window as Window & {__chatOpenResult?: Promise}).__chatOpenResult)).toBe(false); + await expect(page.locator('#chat-panel')).not.toHaveClass(/open/); + await expect(page.locator('body')).toHaveClass(/chat-autostart-reserved/); + await page.evaluate(() => {document.getElementById('sync-popover')!.remove();document.body.classList.remove('chat-autostart-reserved');}); + expect(await page.evaluate(async () => (await import('/js/chat-panel.js')).openChatPanel())).toBe(true); + await expect(page.locator('#chat-panel')).toHaveClass(/open/); +}); + +for (const newerOpen of [false, true]) { + test(`profile handoff after Chat becomes visible retires only its own opening (newer open: ${newerOpen})`, async ({ page }) => { + await page.goto('/app', {waitUntil: 'load'}); + const result = await page.evaluate(async newerOpen => { + const panel = await import('/js/chat-panel.js'); + const {state} = await import('/js/state.js'); + const originalProfile = state.currentProfile; + const read = Storage.prototype.getItem; + let switched = false; + let next: ReturnType | undefined; + Storage.prototype.getItem = function(key) { + if (!switched && key === `labcharts-${originalProfile}-chatPersonalityCustom`) { + switched = true; + state.currentProfile = 'replacement-profile'; + if (newerOpen) next = panel.openChatPanel(); + } + return read.call(this, key); + }; + try { + const first = await panel.openChatPanel(); + const second = next ? await next : null; + return {switched, first, second, visible: document.getElementById('chat-panel')!.classList.contains('open')}; + } finally { Storage.prototype.getItem = read; } + }, newerOpen); + expect(result).toEqual({switched: true, first: false, second: newerOpen ? true : null, visible: newerOpen}); + }); +} + test('Chat presentation stays cold until the panel opens and preserves cascade order', async ({ page }) => { let stylesheetRoute: import("@playwright/test").Route | undefined; let stylesheetRequests = 0; diff --git a/tests/playwright/context-lifestyle-forms-owned.spec.ts b/tests/playwright/context-lifestyle-forms-owned.spec.ts index 533dbd3ae..d35f67a3a 100644 --- a/tests/playwright/context-lifestyle-forms-owned.spec.ts +++ b/tests/playwright/context-lifestyle-forms-owned.spec.ts @@ -5,6 +5,8 @@ test.beforeEach(async ({ page }) => { await page.addInitScript(() => { for (const key of ['emptyTour', 'tour']) localStorage.setItem(`labcharts-default-${key}`, 'completed'); localStorage.setItem('labcharts-encryption-nudge-dismissed', 'true'); + localStorage.setItem('labcharts-analytics-consent-seen', '1'); + localStorage.setItem('labcharts-analytics-disabled', 'true'); }); }); const editors = [ diff --git a/tests/playwright/cycle-shared-owned.spec.ts b/tests/playwright/cycle-shared-owned.spec.ts index 9170f6edc..253ac27ec 100644 --- a/tests/playwright/cycle-shared-owned.spec.ts +++ b/tests/playwright/cycle-shared-owned.spec.ts @@ -122,7 +122,24 @@ async function mountPairs(page: Page): Promise { } else { state.importedData = {menstrualCycle: structuredClone(entry.sample.input.mc ?? null)} as typeof state.importedData; state.profileDob = entry.sample.input.profileDob ?? null; - current = cycle.renderMenstrualCycleSection(structuredClone(entry.sample.input.data ?? null) as Parameters[0], entry.sample.input.opts as Parameters[1]); + const data = structuredClone(entry.sample.input.data ?? null) as Parameters[0]; + const opts = entry.sample.input.opts as Parameters[1]; + current = cycle.renderMenstrualCycleSection(data, opts); + // As in cycle-overview-owned, qualify the additive timing note from + // #1692 before comparing every original node against its frozen tree. + // The new note retains separate real-page body-measurement acceptance. + const timing = cycle.projectMenstrualCycleSection(data, opts).measurementTiming; + const compatible = document.createElement('div'); compatible.innerHTML = current; + const added = compatible.querySelectorAll('[data-cycle-body-timing]'); + if (added.length !== (timing ? 1 : 0)) throw new Error('Cycle measurement timing presence ' + entry.sample.name); + if (timing) { + const note = added[0]!; + if (note.tagName !== 'DIV' || note.querySelector('strong')?.textContent !== `Waist & abdominal depth · ${timing.heading.toLowerCase()}` + || note.querySelector('small')?.textContent !== timing.hint + || (note.querySelector('.cycle-draw-copy > span')?.textContent ?? null) !== timing.dateLabel) throw new Error('Cycle measurement timing content ' + entry.sample.name); + note.remove(); + } + current = compatible.innerHTML; } prepared.push({entry, current}); } diff --git a/tests/playwright/dashboard-widgets-browser-coverage.spec.ts b/tests/playwright/dashboard-widgets-browser-coverage.spec.ts index af74ac099..848e20bd5 100644 --- a/tests/playwright/dashboard-widgets-browser-coverage.spec.ts +++ b/tests/playwright/dashboard-widgets-browser-coverage.spec.ts @@ -212,7 +212,7 @@ test('dashboard widgets browser coverage exercises registry persistence and visi outcomes.resetPrefsRemovesProfileScopedStorage = localStorage.getItem(widgetKey) === null && widgetsModule.DASHBOARD_WIDGET_SOURCE_ORDER.join('|') === 'Labs|Biology Scores|Genome|Body|Light|Insight|Tools' - && widgetsModule.DASHBOARD_MANUAL_BIOMETRIC_METRICS.join('|') === 'weight|bp_systolic|rhr'; + && widgetsModule.DASHBOARD_MANUAL_BIOMETRIC_METRICS.join('|') === 'weight|waist|bp_systolic|rhr'; } finally { removeWidgetKeys(); localStorage.removeItem(biometricKey); @@ -374,7 +374,7 @@ test('dashboard widget renderers browser coverage uses default wearable priority && order.indexOf('steps') < order.indexOf('bp_systolic'); outcomes.emptyMobileTilesFallBackToManualDefaultSelection = - renderers.getDashboardBiometricSelection().join('|') === 'weight|bp_systolic|rhr'; + renderers.getDashboardBiometricSelection().join('|') === 'weight|waist|bp_systolic|rhr'; renderers.saveDashboardBiometricSelection(['weight']); const weightHtml = renderers.renderDashboardWearableTilesWidget(); diff --git a/tests/playwright/routstr-funding-coordination.spec.ts b/tests/playwright/routstr-funding-coordination.spec.ts index afaa1bfcc..b4721102f 100644 --- a/tests/playwright/routstr-funding-coordination.spec.ts +++ b/tests/playwright/routstr-funding-coordination.spec.ts @@ -1,4 +1,4 @@ -type FundingFixtureWindow = Window & {__quoteUpdates: unknown[]; __stopQuoteUpdates: Awaited>; __fundingStats: {checks: number; refreshes: number; paid: boolean}; __fundingMonitor: ReturnType}; +type FundingFixtureWindow = Window & {__quoteUpdates: import('@cashu/cashu-ts').MintQuoteBolt11Response[]; __stopQuoteUpdates: Awaited>; __fundingStats: {checks: number; refreshes: number; paid: boolean}; __fundingMonitor: ReturnType}; import type {Page} from '@playwright/test'; import { routeHtml } from '../helpers/browser-static-routes.js'; @@ -28,7 +28,13 @@ test('the vendored Cashu library receives NUT-17 updates and closes its subscrip (window as unknown as FundingFixtureWindow).__quoteUpdates = []; (window as unknown as FundingFixtureWindow).__stopQuoteUpdates = await wallet.subscribeFundingQuotes('https://mint.push.test', ['q1'], update => (window as unknown as FundingFixtureWindow).__quoteUpdates.push(update), error => { throw error; }); }); - await expect.poll(() => page.evaluate(() => (window as unknown as FundingFixtureWindow).__quoteUpdates)).toEqual([{ quote: 'q1', state: 'PAID', amount: 12 }]); + // The SDK's public response contract preserves its lossless Amount object. + await expect.poll(() => page.evaluate(() => { + const sdk = (globalThis as unknown as {cashuts: typeof import('@cashu/cashu-ts')}).cashuts; + return (window as unknown as FundingFixtureWindow).__quoteUpdates.map(update => ({ + quote: update.quote, state: update.state, amount: update.amount.toBigInt(), nativeAmount: update.amount instanceof sdk.Amount, + })); + })).toEqual([{ quote: 'q1', state: 'PAID', amount: 12n, nativeAmount: true }]); await page.evaluate(() => (window as unknown as FundingFixtureWindow).__stopQuoteUpdates!()); await expect.poll(() => messages.map(message => message.method)).toContain('unsubscribe'); expect(messages[0]!.params).toMatchObject({ kind: 'bolt11_mint_quote', filters: ['q1'] }); diff --git a/tests/playwright/sync-actions-browser-coverage.spec.ts b/tests/playwright/sync-actions-browser-coverage.spec.ts index c9ac7a9a2..3ea244f4d 100644 --- a/tests/playwright/sync-actions-browser-coverage.spec.ts +++ b/tests/playwright/sync-actions-browser-coverage.spec.ts @@ -814,7 +814,18 @@ test('sync indicator popover renders debug actions and copies activity', async ( syncUi.toggleSyncDetail(); if (!document.getElementById('sync-popover')) throw new Error('sync popover did not reopen'); const originalAppendChild = Element.prototype.appendChild; + const originalHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')!; + const retainedPopover = document.getElementById('sync-popover')!; + const retainedButtons = Array.from(retainedPopover.querySelectorAll('[data-sync-ui-action]')) + .filter(button => button.dataset.syncUiAction !== 'reload'); + const focusedButton = retainedPopover.querySelector('[data-sync-ui-action="show-diagnose"]')!; + focusedButton.focus(); let popoverAppendCount = 0; + let popoverRenderCount = 0; + Object.defineProperty(Element.prototype, 'innerHTML', {...originalHTML, set(value: string) { + if (this.id === 'sync-popover') popoverRenderCount += 1; + originalHTML.set!.call(this, value); + }}); Element.prototype.appendChild = function(node:T) { if ((node as unknown as {id?:unknown})?.id === 'sync-popover') popoverAppendCount += 1; return (originalAppendChild as (this:Element,node:T)=>T).call(this, node); @@ -822,15 +833,25 @@ test('sync indicator popover renders debug actions and copies activity', async ( try { syncState.updateSyncStatus({ push: 'confirmed', pushConfirmedAt: Date.now(), lastError: null, relay: 'connected' }); popoverAppendCount = 0; + popoverRenderCount = 0; syncUi.bindSyncUIStatusUpdates(); syncUi.bindSyncUIStatusUpdates(); syncState.updateSyncStatus({ push: 'pending', pushStartedAt: Date.now(), lastError: null, relay: 'connected' }); - await waitFor(() => popoverAppendCount >= 1, 'status-bound popover repaint'); - outcomes.bindStatusUpdatesIsIdempotent = popoverAppendCount === 1 - && !!document.getElementById('sync-popover') + await waitFor(() => retainedPopover.textContent!.includes('Pending'), 'status-bound popover update'); + outcomes.oneStatusRender = popoverRenderCount === 1; + outcomes.noPopoverReappend = popoverAppendCount === 0; + outcomes.retainedPopoverIdentity = document.getElementById('sync-popover') === retainedPopover; + outcomes.retainedPopoverFocus = document.activeElement === focusedButton; + outcomes.retainedPopoverButtons = retainedButtons.every(button => retainedPopover.querySelector(`[data-sync-ui-action="${button.dataset.syncUiAction}"]`) === button); + outcomes.pendingPushRemovesStaleReload = !retainedPopover.querySelector('[data-sync-ui-action="reload"]'); + outcomes.bindStatusUpdatesIsIdempotent = popoverRenderCount === 1 && popoverAppendCount === 0 + && document.getElementById('sync-popover') === retainedPopover + && document.activeElement === focusedButton + && retainedButtons.every(button => retainedPopover.querySelector(`[data-sync-ui-action="${button.dataset.syncUiAction}"]`) === button) && !!slot.querySelector('#sync-indicator-btn .sync-dot-syncing'); } finally { Element.prototype.appendChild = originalAppendChild; + Object.defineProperty(Element.prototype, 'innerHTML', originalHTML); } } finally { syncUi.configureSyncUI(previousSyncUIDeps); diff --git a/tests/playwright/sync-chat-convergence.spec.ts b/tests/playwright/sync-chat-convergence.spec.ts index 145cec088..76b67e087 100644 --- a/tests/playwright/sync-chat-convergence.spec.ts +++ b/tests/playwright/sync-chat-convergence.spec.ts @@ -1,6 +1,7 @@ interface ChatSyncFixture {seed(id:string): Promise extends Promise ? T : ReturnType>;pull(wire:string|undefined):Promise;deleteThread(id:string):Promise;reconcile(wire:string|undefined):Promise;snapshot():ReturnType;wire():string|undefined;pushes():number} import { routeHtml } from '../helpers/browser-static-routes.js'; import { expect, test } from '@playwright/test'; +import { gunzipSync } from 'node:zlib'; // Three independent browser stores with synthetic replica snapshots. The real // app push/pull paths run; this deliberately does not contact a production relay. @@ -106,7 +107,11 @@ test('startup repairs chat-only divergence with no local edit', async ({ browser await page.evaluate(() => (window as unknown as Window & {chatSyncRegression: ChatSyncFixture}).chatSyncRegression.reconcile(JSON.stringify({ _v: 4 }))); expect(await page.evaluate(() => (window as unknown as Window & {chatSyncRegression: ChatSyncFixture}).chatSyncRegression.pushes())).toBe(1); const wire = await page.evaluate(() => (window as unknown as Window & {chatSyncRegression: ChatSyncFixture}).chatSyncRegression.wire()); - expect((JSON.parse(wire as string) as {chatData:{threads:{id?:unknown}[]}}).chatData.threads[0]!.id).toBe('retained'); + expect(wire).toEqual(expect.any(String)); + // Clocked snapshots can exceed the gzip threshold. Decode independently + // of the app codec while retaining the exact chat-content assertion. + const json = wire!.startsWith('GZ|v1|') ? gunzipSync(Buffer.from(wire!.slice(6), 'base64')).toString('utf8') : wire!; + expect((JSON.parse(json) as {chatData:{threads:{id?:unknown}[]}}).chatData.threads[0]!.id).toBe('retained'); } finally { await context.close(); } }); diff --git a/tests/playwright/sync-configure-reconcile-browser-coverage.spec.ts b/tests/playwright/sync-configure-reconcile-browser-coverage.spec.ts index 915b5167d..2103bbfa8 100644 --- a/tests/playwright/sync-configure-reconcile-browser-coverage.spec.ts +++ b/tests/playwright/sync-configure-reconcile-browser-coverage.spec.ts @@ -7,16 +7,18 @@ const openBlankPage = createBlankPage({ status: 200, body: '
', }); -test('sync configure browser coverage seeds local profiles through identity restore', async ({ page }) => { +test('sync configure browser coverage stages local profiles before identity restore', async ({ page }) => { await openBlankPage(page, '/sync-configure-seed-browser-coverage'); const results = await page.evaluate(async ({ configureUrl }) => { - const [configure, identity, runtime, actions, stateModule] = await Promise.all([ + const [configure, identity, runtime, actions, stateModule, recovery, settings] = await Promise.all([ ((import(configureUrl) as Promise) as Promise >), import('/js/sync-identity.js'), import('/js/sync-runtime.js'), import('/js/sync-actions.js'), import('/js/state.js'), + import('/js/sync-backup-restore-state.js'), + import('/js/sync-settings-state.js'), ]); const { state } = stateModule; const outcomes:Record = {}; @@ -29,6 +31,8 @@ test('sync configure browser coverage seeds local profiles through identity rest const pushes:unknown[][] = []; const restored:unknown[] = []; const scheduledTimers:{delay:number;source:string}[] = []; + let stagedBeforeReset: string[] = []; + let publishingSuspended = false; let thrownError:unknown = null; try { @@ -45,6 +49,7 @@ test('sync configure browser coverage seeds local profiles through identity rest lastUpdated: Date.now(), }]; localStorage.setItem(identity.RESTORE_JOIN_PENDING_KEY, 'stale'); + settings.setSyncEnabled(true); (configure.configureSyncModules as unknown as (deps:{enableSync?:()=>unknown}) => ReturnType)({ enableSync: () => true, @@ -57,7 +62,11 @@ test('sync configure browser coverage seeds local profiles through identity rest isSyncing: () => false, }); runtime.setSyncEvolu({ - restoreAppOwner: async (mnemonic:unknown) => { restored.push(mnemonic); }, + restoreAppOwner: async (mnemonic:unknown) => { + stagedBeforeReset = recovery.getPendingBackupRestoreProfileIds(); + publishingSuspended = !settings.isSyncEnabled(); + restored.push(mnemonic); + }, }); (window as unknown as {setTimeout:(fn:TimerHandler,delay?:number)=>number}).setTimeout = (fn, delay = 0) => { @@ -69,15 +78,16 @@ test('sync configure browser coverage seeds local profiles through identity rest outcomes.restoreReturnsTrue = restoredOk === true; outcomes.restoreUsesRuntimeEvolu = restored[0]! === 'coverage seed mnemonic'; - outcomes.configureSeedLocalProfilesPushesAllProfiles = - pushes.length === 1 - && pushes[0]![0]! === 'seed-profile' - && pushes[0]![1]! === state.importedData - && (pushes[0]![2] as {force?:unknown}|null|undefined)?.force === true; + outcomes.configureSeedStagesProfilesWithoutPublishingThroughRetiringRuntime = + pushes.length === 0 && stagedBeforeReset.join(',') === 'seed-profile' + && recovery.getPendingBackupRestoreProfileIds().join(',') === 'seed-profile' + && publishingSuspended && !settings.isSyncEnabled() + && localStorage.getItem('labcharts-sync-enabled') === 'true' + && JSON.stringify(state.importedData) === JSON.stringify({entries: [{id: 'marker-1', value: 12}]}); outcomes.configureSeedClearsRestoreJoinPending = localStorage.getItem(identity.RESTORE_JOIN_PENDING_KEY) === null; outcomes.configureSeedShowsSuccessNotification = - notificationText.includes('seeded this device'); + notificationText.includes('reloading to publish this device'); outcomes.configureSeedSchedulesReload = scheduledTimers.some(timer => ( timer.delay === 500 @@ -88,6 +98,7 @@ test('sync configure browser coverage seeds local profiles through identity rest } finally { window.setTimeout = originalSetTimeout; runtime.clearSyncRuntimeState(); + settings.setSyncPublishingSuspended(false); actions.configureSyncActions({ pushProfile: async () => {}, forcePull: () => {}, @@ -99,6 +110,7 @@ test('sync configure browser coverage seeds local profiles through identity rest (state as {importedData:unknown}).importedData = savedState.importedData; (state as {profiles:unknown}).profiles = savedState.profiles; localStorage.removeItem(identity.RESTORE_JOIN_PENDING_KEY); + localStorage.removeItem(recovery.SYNC_BACKUP_RESTORE_PENDING_KEY); } if (thrownError) throw thrownError; diff --git a/tests/playwright/sync-diagnose-identity-actions-browser-coverage.spec.ts b/tests/playwright/sync-diagnose-identity-actions-browser-coverage.spec.ts index 455a65477..cb1d10487 100644 --- a/tests/playwright/sync-diagnose-identity-actions-browser-coverage.spec.ts +++ b/tests/playwright/sync-diagnose-identity-actions-browser-coverage.spec.ts @@ -174,7 +174,7 @@ test('sync diagnose identity actions cover rotate modal and apply paths', async outcomes.restoreFalseOmitsQrWhenQrGenerationFails = failureOverlay?.querySelector('svg') === null; outcomes.restoreFalseResetsApplyButton = failureApply?.disabled === false && failureApply?.textContent === 'Apply on this device'; - outcomes.restoreFalseNotifies = notifications().includes('Restore returned false'); + outcomes.restoreFalseNotifies = notifications().includes('Could not apply sync identity. Resolve the error above, then retry.'); } finally { context.configureSyncDiagnoseActionContext({ enableSync: async () => false, diff --git a/tests/playwright/sync-identity-browser-coverage.spec.ts b/tests/playwright/sync-identity-browser-coverage.spec.ts index cf74ad8b2..658af4429 100644 --- a/tests/playwright/sync-identity-browser-coverage.spec.ts +++ b/tests/playwright/sync-identity-browser-coverage.spec.ts @@ -52,6 +52,9 @@ test('sync identity browser coverage handles libraries getters pending restore a const results = await page.evaluate(async ({ identityUrl }) => { const identity = (await import(identityUrl) as unknown) as Pick; + const runtimeUrl = '/js/sync-runtime.js'; + const runtime = await import(runtimeUrl) as typeof import('../../js/sync-runtime.js'); + const previousRuntime = runtime.getSyncEvolu(); const expectedOutcomes = [ 'defaultInjectedDepsReturnEmpty', 'defaultSeedLocalDependencyIsCallable', @@ -111,8 +114,11 @@ test('sync identity browser coverage handles libraries getters pending restore a outcomes.defaultSeedLocalDependencyIsCallable = defaultSeedResult === true && identity.isRestoreJoinPending() === false && scheduledDelays.includes(500) - && notifications().includes('seeded this device') - && identity.consumeSyncRestoreNotice()?.includes('data was republished') === true; + && notifications().includes('reloading to publish this device') + && identity.consumeSyncRestoreNotice() === 'Sync identity restored. Publishing this device’s data…'; + // This fixture intercepts reload timers; install the replacement runtime + // explicitly before starting another independent owner-restore scenario. + runtime.setSyncEvolu({...defaultEvolu}); (window as unknown as {bip39?: unknown}).bip39 = { existing: true }; outcomes.ensureBip39UsesExistingGlobal = await identity.ensureBip39() === (window as unknown as {bip39?: unknown}).bip39; @@ -227,8 +233,9 @@ test('sync identity browser coverage handles libraries getters pending restore a && identityDeleteKeys.every(key => localStorage.getItem(key) === null) && identity.isRestoreJoinPending() === false && scheduledDelays.includes(500) - && notifications().includes('seeded this device') - && identity.consumeSyncRestoreNotice()?.includes('data was republished') === true; + && notifications().includes('reloading to publish this device') + && identity.consumeSyncRestoreNotice() === 'Sync identity restored. Publishing this device’s data…'; + runtime.setSyncEvolu({...evolu}); identity.configureSyncIdentity({ getAppOwner: () => ({ id: 'same-owner', mnemonic: 'same owner mnemonic' }), @@ -256,6 +263,7 @@ test('sync identity browser coverage handles libraries getters pending restore a outcomes.restoreWithoutEvoluReturnsFalse = await identity.restoreFromMnemonic('missing evolu') === false && notifications().includes('Sync is still starting'); } finally { + runtime.setSyncEvolu(previousRuntime); (window as unknown as {setTimeout: (handler: () => void, timeout?:number) => number}).setTimeout = savedSetTimeout; if (hadBip39) (window as unknown as {bip39?: unknown}).bip39 = savedBip39; else delete (window as unknown as {bip39?: unknown}).bip39; diff --git a/tests/playwright/sync-orchestration-browser-coverage.spec.ts b/tests/playwright/sync-orchestration-browser-coverage.spec.ts index a6948cde0..888329868 100644 --- a/tests/playwright/sync-orchestration-browser-coverage.spec.ts +++ b/tests/playwright/sync-orchestration-browser-coverage.spec.ts @@ -153,6 +153,8 @@ test('sync pull browser force paths update status and skip unsafe rows', async ( const syncState = (await import(stateUrl) as unknown) as Pick; const tombstones = (await import(tombstonesUrl) as unknown) as Pick; const payload = (await import(payloadUrl) as unknown) as Pick; + const runtime = await import('/js/sync-runtime.js'); + const previousOwner = runtime.getSyncAppOwner(); const outcomes: Record = {}; const warnings: string[] = []; const debugCalls: string[] = []; @@ -207,14 +209,13 @@ test('sync pull browser force paths update status and skip unsafe rows', async ( const restoredProfileId = 'restored_profile'; const restorePendingKey = 'labcharts-sync-backup-restore-pending'; const preflightOrder: unknown[] = []; + const recoveryEvolu = {getQueryRows() { preflightOrder.push('query'); return []; }}; + runtime.setSyncAppOwner({id: 'synthetic-recovery-owner'}); localStorage.setItem(restorePendingKey, JSON.stringify([restoredProfileId])); (pull.configureSyncPull as (options:PullFixtureConfig)=>ReturnType)({ - getEvolu: () => ({ - getQueryRows() { - preflightOrder.push('query'); - return []; - }, - }), + getEvolu: () => recoveryEvolu, + getProfiles: () => [{id: restoredProfileId}], + isSyncEnabled: () => true, getProfileQuery: () => queryToken, pushProfilesById: async (profileIds: unknown, options: unknown) => { preflightOrder.push(`restore:${(profileIds as {join(separator:string):string}).join(',')}:${(options as {force: unknown})?.force === true}`); @@ -270,6 +271,7 @@ test('sync pull browser force paths update status and skip unsafe rows', async ( pull.clearSyncPullTimers(); outcomes.clearTimersLeavesPullIdle = pull.isSyncPulling() === false; } finally { + runtime.setSyncAppOwner(previousOwner); console.warn = originalWarn; pull.clearSyncPullTimers(); syncState.resetSyncStatus(); diff --git a/tests/playwright/sync-push-browser-coverage.spec.ts b/tests/playwright/sync-push-browser-coverage.spec.ts index 6e34cc870..d44399d64 100644 --- a/tests/playwright/sync-push-browser-coverage.spec.ts +++ b/tests/playwright/sync-push-browser-coverage.spec.ts @@ -181,7 +181,14 @@ test('sync push covers browser commit guards cutover drift and watchdog paths', const drifted = await syncPush.pushProfile(profileId, driftImported); const driftUpdates = fake.calls.update.slice(updatesBeforeDrift) .filter(call => call.table === 'profileData'); - const driftPayload = JSON.parse((driftUpdates[0]?.args?.dataJson || '{}') as string) as unknown as {_v?:unknown;importedData?:{entries?:{id?:unknown}[]}|null}; + const driftWire = driftUpdates[0]?.args?.dataJson; + if (typeof driftWire !== 'string') throw new Error('Expected a committed cutover-repair payload'); + // Inspect the actual envelope with the platform decoder, independently + // of the app codec; clock metadata can make this fixture compressed. + const driftJSON = driftWire.startsWith('GZ|v1|') + ? await new Response(new Blob([Uint8Array.from(atob(driftWire.slice(6)), char => char.charCodeAt(0))]).stream().pipeThrough(new DecompressionStream('gzip'))).text() + : driftWire; + const driftPayload = JSON.parse(driftJSON) as {_v?:unknown;importedData?:{entries?:{id?:unknown}[]}|null}; outcomes.cutoverDriftWarnsAndStillCommits = drifted?.ok === true && warnings.some(message => message.includes('Phase 2 cutover drift detected')) diff --git a/tests/playwright/sync-relay-transport-e2e.spec.ts b/tests/playwright/sync-relay-transport-e2e.spec.ts index 4ae933fb6..70dd78e0e 100644 --- a/tests/playwright/sync-relay-transport-e2e.spec.ts +++ b/tests/playwright/sync-relay-transport-e2e.spec.ts @@ -308,7 +308,17 @@ async function setSyntheticData(page: Page, action: unknown) { } else { throw new Error(`Unknown synthetic mutation: ${requestedAction}`); } - return dataModule.saveImportedData(); + const saved = await dataModule.saveImportedData(); + if (requestedAction === 'offline-context' && state.importedData.contextNotes !== 'transport-e2e-offline-recovery') { + const [writes, crypto, profile, dirty] = await Promise.all([ + import('/js/profile-data-writes.js'), import('/js/crypto.js'), import('/js/profile.js'), import('/js/sync-dirty-state.js'), + ]); + const raw = await crypto.encryptedGetItem(profile.profileStorageKey(state.currentProfile, 'imported')); + throw new Error('Offline edit lost before save returned: ' + JSON.stringify({saved, + live: state.importedData.contextNotes, baseline: writes.profileDataBaseline(state.importedData)?.contextNotes, + stored: raw ? JSON.parse(raw).contextNotes : null, dirty: !!dirty.getSyncDirtyToken(state.currentProfile)})); + } + return saved; }, action); } @@ -554,6 +564,12 @@ test('real relay converges devices, resists no-op bloat, recovers offline, and r expect(afterRepeatedReconnect!.messageCount).toBe(afterOldDeviceReconnect!.messageCount); expect(afterRepeatedReconnect!.storedBytes).toBe(afterOldDeviceReconnect!.storedBytes); + if (EVOLU_CLIENT !== 'v7') { + await expect.poll(() => deviceB.page.evaluate(async () => ( + (await import('/js/sync-state.js')).getSyncStatus().replication + )), {timeout: 30_000}).toBe('Synced'); + } + for (const device of devices) expect(device.errors).toEqual([]); } finally { if (cleanupPage && !cleanupPage.isClosed()) { @@ -625,7 +641,11 @@ test('paused note edits do not overwrite context changed on another device', asy await setSyntheticData(b.page, 'update-a'); await syncNow(b.page); await waitForContext(a.page, UPDATED_CONTEXT); expect(await b.page.evaluate(() => import('/js/sync.js').then(sync => sync.pauseSync()))).toBe(true); - await setSyntheticData(a.page, 'offline-context'); await syncNow(a.page); + expect(await setSyntheticData(a.page, 'offline-context')).toBe(true); + await syncNow(a.page); + const afterManualSync = await contextNotes(a.page); + if (afterManualSync !== OFFLINE_CONTEXT) console.error('Offline edit lost during manual sync:', JSON.stringify(await syncDiagnostics(a.page))); + expect(afterManualSync).toBe(OFFLINE_CONTEXT); await waitForStableRelayStorage(a.page); await expect.poll(() => contextNotes(b.page)).toBe(UPDATED_CONTEXT); await setSyntheticData(b.page, 'concurrent-b'); @@ -747,3 +767,374 @@ test('restored profiles survive old tombstones and a new sync identity', async ( await Promise.all(devices.map(device => device.context.close().catch(() => {}))); } }); + +test('identity rotation with partial backup recovery stops repeat uploads and later completes', async ({browser}) => { + test.setTimeout(120_000); + const devices: Array>> = []; + const a = await createDevice(browser, 'recovery device A'); devices.push(a); + try { + await setSyntheticData(a.page, 'seed'); + await a.page.evaluate(async () => { + const {state} = await import('/js/state.js'); + state.importedData.customMarkers = {'custom.synthetic': {name: 'Synthetic marker', unit: 'u', group: null}}; + await (await import('/js/data.js')).saveImportedData(); + }); + const previous = await enableNewIdentity(a.page); + await a.page.evaluate(async () => { (await import('/js/sync.js')).pauseSync(); }); + const ids = await a.page.evaluate(async () => { + const profile = await import('/js/profile.js'); + const demo = await profile.createProfile('Synthetic recovery demo', {tags: ['demo'], skipInitialSync: true}); + const unavailable = await profile.createProfile('Synthetic unavailable recovery', {skipInitialSync: true}); + await profile.switchProfile('default'); + await (await import('/js/crypto.js')).encryptedRemoveItem(profile.profileStorageKey(unavailable, 'imported')); + (await import('/js/sync-backup-restore-state.js')).prepareRestoredProfilesForSync({profiles: [ + {profileId: 'default'}, {profileId: demo}, {profileId: unavailable}, + ]}); + return {demo, unavailable}; + }); + const reloaded = a.page.waitForEvent('load', {timeout: 30_000}); + const rotating = a.page.evaluate(async () => (await import('/js/sync-diagnose-identity-actions.js')).confirmRotateIdentity()); + await a.page.locator('#confirm-ok').click(); await rotating; + await a.page.locator('#rotate-saved-check').check(); + await a.page.locator('#rotate-apply-btn').click(); + await reloaded; + await expect.poll(() => a.page.evaluate(async oldOwner => { + const owner = (await import('/js/sync-runtime.js')).getSyncAppOwner(); + return !!owner?.id && owner.id !== oldOwner; + }, previous.ownerId), {timeout: 30_000}).toBe(true); + const pending = () => a.page.evaluate(async () => (await import('/js/sync-backup-restore-state.js')).getPendingBackupRestoreProfileIds()); + await expect.poll(pending, {timeout: 10_000}).toEqual([ids.unavailable]); + const before = await waitForStableRelayStorage(a.page); + await a.page.waitForTimeout(2000); + const after = await waitForStableRelayStorage(a.page); + expect(after?.messageCount).toBe(before?.messageCount); + expect(after?.storedBytes).toBe(before?.storedBytes); + const recoveredOwner = await waitForOwner(a.page); + for (let reload = 0; reload < 2; reload++) { + await a.page.reload({waitUntil: 'domcontentloaded'}); await waitForOwner(a.page, recoveredOwner.ownerId); + await expect.poll(pending).toEqual([ids.unavailable]); + const quiet = await waitForStableRelayStorage(a.page); + expect(quiet?.messageCount).toBe(before?.messageCount); expect(quiet?.storedBytes).toBe(before?.storedBytes); + } + expect((await profileIds(a.page)).sort()).toEqual(['default', ids.demo, ids.unavailable].sort()); + await waitForContext(a.page, BASELINE_CONTEXT); + await waitForNotes(a.page, ['baseline-note']); + + // Restore only this fixture's deliberately unavailable local snapshot, then + // use the real manual retry path. Existing committed data must stay intact. + await a.page.evaluate(async profileId => { + const profile = await import('/js/profile.js'); + const data = profile.createDefaultProfileData(); data.contextNotes = 'transport-recovered-inactive'; + await (await import('/js/crypto.js')).encryptedSetItem(profile.profileStorageKey(profileId, 'imported'), JSON.stringify(data)); + (await import('/js/sync-dirty-state.js')).markSyncProfileDirty(profileId); + }, ids.unavailable); + expect((await syncNow(a.page))?.ok).toBe(true); + await expect.poll(pending).toEqual([]); + const identity = await waitForOwner(a.page); + const b = await createDevice(browser, 'recovery device B'); devices.push(b); + await joinIdentity(b.page, identity.mnemonic, identity.ownerId); + await activateProfile(b.page, 'default'); await waitForContext(b.page, BASELINE_CONTEXT); await waitForNotes(b.page, ['baseline-note']); + await activateProfile(b.page, ids.unavailable); await waitForContext(b.page, 'transport-recovered-inactive'); + expect((await profileIds(b.page)).sort()).toEqual(['default', ids.unavailable].sort()); + if (EVOLU_CLIENT === 'v8') for (const device of devices) { + await expect.poll(() => device.page.evaluate(async () => { + const evolu = (await import('/js/sync-runtime.js')).getSyncEvolu(); + if (!evolu?.subscribeOwnerSyncStatus) throw new Error('Missing actual modern SDK sync status'); + let phase = ''; const stop = evolu.subscribeOwnerSyncStatus(status => { phase = status.type; }); stop(); return phase; + }), {timeout: 30_000}).toBe('Synced'); + } + for (const device of devices) expect(device.errors).toEqual([]); + } finally { + await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync()).catch(() => {}); + for (const device of devices.reverse()) await device.context.close(); + } +}); + +test('native identity rotation retains its owner and data when recovery storage is full', async ({browser}) => { + const a = await createDevice(browser, 'journal failure device A'); + try { + await setSyntheticData(a.page, 'seed'); + const previous = await enableNewIdentity(a.page); + await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync()); + await a.page.evaluate(() => { + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (key === 'labcharts-sync-backup-restore-pending') throw new DOMException('Synthetic full storage', 'QuotaExceededError'); + return original.call(this, key, value); + }; + }); + const rotating = a.page.evaluate(async () => (await import('/js/sync-diagnose-identity-actions.js')).confirmRotateIdentity()); + await a.page.locator('#confirm-ok').click(); await rotating; + await a.page.locator('#rotate-saved-check').check(); + await a.page.locator('#rotate-apply-btn').click(); + const message = 'Could not save sync recovery progress. Your identity and local data are unchanged. Free browser storage, then retry.'; + const error = a.page.getByRole('alert').filter({hasText: message}); + await expect(error).toBeVisible(); + await expect(error).toHaveText('✗ ' + message); + await expect(a.page.locator('#rotate-apply-btn')).toBeEnabled(); + // Successful rotation schedules a reload after 500ms. A failed durable + // preflight must leave the existing owner and snapshot intact instead. + await a.page.waitForTimeout(1000); + expect((await waitForOwner(a.page)).ownerId).toBe(previous.ownerId); + await waitForContext(a.page, BASELINE_CONTEXT); + await waitForNotes(a.page, ['baseline-note']); + expect(a.errors).toEqual([]); + } finally { + await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync()).catch(() => {}); + await a.context.close(); + } +}); + +test('rejected identity rotation restores normal incoming sync with an unavailable local profile', async ({browser}) => { + const a = await createDevice(browser, 'rejected rotation device A'); + const devices = [a]; + try { + await setSyntheticData(a.page, 'seed'); + const previous = await enableNewIdentity(a.page); + const b = await createDevice(browser, 'rejected rotation device B'); devices.push(b); + await joinIdentity(b.page, previous.mnemonic, previous.ownerId); + await waitForContext(b.page, BASELINE_CONTEXT); + await a.page.evaluate(async () => { + (await import('/js/sync.js')).pauseSync(); + await (await import('/js/profile.js')).createProfile('Synthetic unavailable local profile', {skipInitialSync: true}); + const evolu = (await import('/js/sync-runtime.js')).getSyncEvolu(); + if (!evolu) throw new Error('Missing actual fixture SDK'); + // Fail only the owner reset; existing SDK queries and transport remain real. + const rejected = new Proxy(evolu, {get(target, property, receiver) { + if (property === 'restoreAppOwner') return async () => { throw new Error('Synthetic owner reset rejection'); }; + return Reflect.get(target, property, receiver); + }}); + (await import('/js/sync-identity.js')).configureSyncIdentity({getEvolu: () => rejected}); + }); + const rotating = a.page.evaluate(async () => (await import('/js/sync-diagnose-identity-actions.js')).confirmRotateIdentity()); + await a.page.locator('#confirm-ok').click(); await rotating; + await a.page.locator('#rotate-saved-check').check(); + await a.page.locator('#rotate-apply-btn').click(); + await expect(a.page.getByRole('alert').filter({hasText: 'Invalid mnemonic'})).toBeVisible(); + expect((await waitForOwner(a.page)).ownerId).toBe(previous.ownerId); + expect(await a.page.evaluate(async () => (await import('/js/sync-backup-restore-state.js')).getPendingBackupRestoreProfileIds())).toEqual([]); + await setSyntheticData(b.page, 'update-a'); await syncNow(b.page); + await waitForContext(a.page, UPDATED_CONTEXT); await waitForNotes(a.page, ['baseline-note', 'from-device-a']); + for (const device of devices) expect(device.errors).toEqual([]); + } finally { + for (const device of devices.reverse()) await device.context.close(); + } +}); + +test('same-browser old-owner tab cannot acknowledge an in-flight identity handoff', async ({browser}) => { + test.setTimeout(120_000); + const a = await createDevice(browser, 'multi-tab handoff device A'); + try { + await setSyntheticData(a.page, 'seed'); + const previous = await enableNewIdentity(a.page); + const other = await a.context.newPage(); + await other.goto(APP_URL); + await waitForOwner(other, previous.ownerId); + await a.page.evaluate(async () => { + const runtime = await import('/js/sync-runtime.js'); + const actual = runtime.getSyncEvolu(); + if (!actual) throw new Error('Missing real SDK'); + let release!: () => void; + const gate = new Promise(resolve => {release=resolve;}); + const probe=window as unknown as {__handoffRelease:()=>void;__handoffStarted:boolean}; + probe.__handoffRelease=release; probe.__handoffStarted=false; + const held=new Proxy(actual,{get(target,property,receiver){ + if(property==='restoreAppOwner')return async (mnemonic:string,options?:{reload?:boolean})=>{probe.__handoffStarted=true;await gate;return target.restoreAppOwner(mnemonic,options);}; + return Reflect.get(target,property,receiver); + }}); + (await import('/js/sync-identity.js')).configureSyncIdentity({getEvolu:()=>held}); + }); + const rotating=a.page.evaluate(async () => (await import('/js/sync-diagnose-identity-actions.js')).confirmRotateIdentity()); + await a.page.locator('#confirm-ok').click();await rotating; + await a.page.locator('#rotate-saved-check').check();await a.page.locator('#rotate-apply-btn').click(); + await expect.poll(()=>a.page.evaluate(()=>(window as unknown as {__handoffStarted:boolean}).__handoffStarted)).toBe(true); + const queued=()=>other.evaluate(async()=>(await import('/js/sync-backup-restore-state.js')).getPendingBackupRestoreProfileIds()); + expect(await queued()).toEqual(['default']); + expect((await other.evaluate(async()=>(await import('/js/sync-runtime.js')).getSyncAppOwner()))?.id).toBe(previous.ownerId); + await other.evaluate(async()=>(await import('/js/sync.js')).syncNow()); + expect(await queued()).toEqual(['default']); + await other.close(); + const reloaded=a.page.waitForEvent('load');await a.page.evaluate(()=>(window as unknown as {__handoffRelease:()=>void}).__handoffRelease());await reloaded; + await expect.poll(()=>a.page.evaluate(async old=>(await import('/js/sync-runtime.js')).getSyncAppOwner()?.id!==old,previous.ownerId)).toBe(true); + await waitForContext(a.page,BASELINE_CONTEXT); + await waitForNotes(a.page,['baseline-note']); + const peer=await createDevice(browser,'multi-tab fresh peer'); + try{const identity=await waitForOwner(a.page);await joinIdentity(peer.page,identity.mnemonic,identity.ownerId);await waitForContext(peer.page,BASELINE_CONTEXT);await waitForNotes(peer.page,['baseline-note']);}finally{await peer.context.close();} + }finally{await a.context.close();} +}); + + +test('pending recovery follows an explicit join and normal incoming edits resume', async ({browser}) => { + test.setTimeout(180_000); + const a = await createDevice(browser, 'switch-pending device A'); + const devices = [a]; + try { + await setSyntheticData(a.page, 'seed'); await enableNewIdentity(a.page); + await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync()); + const inactive = await a.page.evaluate(async () => { + const profiles = await import('/js/profile.js'); + const id = await profiles.createProfile('Synthetic pending identity switch', {skipInitialSync: true}); + await profiles.switchProfile('default'); + await (await import('/js/crypto.js')).encryptedRemoveItem(profiles.profileStorageKey(id, 'imported')); + return id; + }); + await rotateIdentityWithLocalData(a.page); + const pending = () => a.page.evaluate(async () => (await import('/js/sync-backup-restore-state.js')).getPendingBackupRestoreProfileIds()); + await expect.poll(pending).toEqual([inactive]); + const b = await createDevice(browser, 'switch-pending device B'); devices.push(b); + await setSyntheticData(b.page, 'seed'); const target = await enableNewIdentity(b.page); + await setSyntheticData(b.page, 'update-a'); await syncNow(b.page); + await a.page.evaluate(async id => { + const profiles = await import('/js/profile.js'); + const data = profiles.createDefaultProfileData(); data.contextNotes = 'synthetic pending data retained through join'; + await (await import('/js/crypto.js')).encryptedSetItem(profiles.profileStorageKey(id, 'imported'), JSON.stringify(data)); + (await import('/js/sync-dirty-state.js')).markSyncProfileDirty(id); + }, inactive); + await joinIdentity(a.page, target.mnemonic, target.ownerId); + await expect.poll(pending).toEqual([]); + await waitForContext(a.page, UPDATED_CONTEXT); + await waitForProfilePresence(b.page, inactive, true); + await activateProfile(a.page, inactive); await waitForContext(a.page, 'synthetic pending data retained through join'); + await activateProfile(a.page, 'default'); + await setSyntheticData(b.page, 'offline-context'); expect((await syncNow(b.page))?.ok).toBe(true); + await waitForContext(a.page, OFFLINE_CONTEXT); + for (const device of devices) expect(device.errors).toEqual([]); + } finally {for (const device of devices.reverse()) await device.context.close();} +}); + + +test('deleting an unavailable recovery profile unblocks incoming changes without reviving it', async ({browser}) => { + test.setTimeout(120_000); + const a = await createDevice(browser, 'deleted recovery device A'); + const devices = [a]; + try { + await setSyntheticData(a.page, 'seed'); await enableNewIdentity(a.page); + await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync()); + const inactive = await a.page.evaluate(async () => { + const profiles = await import('/js/profile.js'); + const id = await profiles.createProfile('Synthetic deleted pending recovery', {skipInitialSync: true}); + await profiles.switchProfile('default'); + await (await import('/js/crypto.js')).encryptedRemoveItem(profiles.profileStorageKey(id, 'imported')); + return id; + }); + await rotateIdentityWithLocalData(a.page); + const pending = () => a.page.evaluate(async () => (await import('/js/sync-backup-restore-state.js')).getPendingBackupRestoreProfileIds()); + await expect.poll(pending).toEqual([inactive]); + const identity = await waitForOwner(a.page); + const b = await createDevice(browser, 'deleted recovery device B'); devices.push(b); + await joinIdentity(b.page, identity.mnemonic, identity.ownerId); + const deleting = a.page.evaluate(async id => (await import('/js/profile.js')).deleteProfile(id), inactive); + await a.page.locator('#confirm-ok').click(); await deleting; + expect(await profileIds(a.page)).toEqual(['default']); + await syncNow(a.page); await expect.poll(pending).toEqual([]); + await a.page.reload({waitUntil: 'domcontentloaded'}); await waitForOwner(a.page, identity.ownerId); + expect(await pending()).toEqual([]); expect(await profileIds(a.page)).toEqual(['default']); + await waitForContext(a.page, BASELINE_CONTEXT); await waitForNotes(a.page, ['baseline-note']); + await setSyntheticData(b.page, 'update-a'); expect((await syncNow(b.page))?.ok).toBe(true); + await waitForContext(a.page, UPDATED_CONTEXT); + expect(await profileIds(a.page)).toEqual(['default']); expect(await profileIds(b.page)).toEqual(['default']); + for (const device of devices) expect(device.errors).toEqual([]); + } finally {for (const device of devices.reverse()) await device.context.close();} +}); + + +test('a profile reload during a queued local save retains the edit and newer draft across devices', async ({browser}) => { + test.setTimeout(120_000); + const a = await createDevice(browser, 'save reload device A'); + const devices = [a]; + try { + await setSyntheticData(a.page, 'seed'); const identity = await enableNewIdentity(a.page); + const b = await createDevice(browser, 'save reload device B'); devices.push(b); + await joinIdentity(b.page, identity.mnemonic, identity.ownerId); await waitForContext(b.page, BASELINE_CONTEXT); + expect(await a.page.evaluate(async () => (await import('/js/sync.js')).pauseSync())).toBe(true); + await a.page.evaluate(async text => { + const {state} = await import('/js/state.js'); const data = await import('/js/data.js'); + const probe = window as Window & {__releaseSaveLock?: () => void; __pendingProfileSave?: Promise; __originalImported?: unknown}; + let entered!: () => void, release!: () => void; + const ready = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + void navigator.locks.request('getbased-profile-data:default', async () => { entered(); await gate; }); + await ready; probe.__releaseSaveLock = release; + probe.__originalImported = state.importedData; state.importedData.contextNotes = text; + probe.__pendingProfileSave = data.saveImportedData(); + }, OFFLINE_CONTEXT); + await a.page.evaluate(async () => (await import('/js/profile.js')).loadProfile('default')); + expect(await a.page.evaluate(async () => { + const {state} = await import('/js/state.js'); + return {replaced: state.importedData !== (window as Window & {__originalImported?: unknown}).__originalImported, + profile: state.currentProfile, context: state.importedData.contextNotes}; + })).toEqual({replaced: true, profile: 'default', context: BASELINE_CONTEXT}); + await a.page.evaluate(async () => { + const {state} = await import('/js/state.js'); + state.importedData.notes.push({date: '2026-08-06T10:00:00.000Z', text: 'concurrent-a', updatedAt: '2026-08-06T10:00:00.000Z'}); + }); + expect(await a.page.evaluate(async () => { + const probe = window as Window & {__releaseSaveLock?: () => void; __pendingProfileSave?: Promise}; + probe.__releaseSaveLock!(); return probe.__pendingProfileSave; + })).toBe(true); + expect(await contextNotes(a.page)).toBe(OFFLINE_CONTEXT); + await waitForNotes(a.page, ['baseline-note', 'concurrent-a']); + expect(await a.page.evaluate(async () => (await import('/js/data.js')).saveImportedData())).toBe(true); + expect(await a.page.evaluate(async () => (await import('/js/sync.js')).enableSync())).toBe(true); + await waitForContext(b.page, OFFLINE_CONTEXT); await waitForNotes(b.page, ['baseline-note', 'concurrent-a']); + for (const device of devices) expect(device.errors).toEqual([]); + } finally { + await a.page.evaluate(() => (window as Window & {__releaseSaveLock?: () => void}).__releaseSaveLock?.()).catch(() => {}); + for (const device of devices.reverse()) await device.context.close(); + } +}); + +test('a delayed Force resend cannot roll back a newer committed edit', async ({browser}) => { + test.setTimeout(120_000); + const a = await createDevice(browser, 'ordered push device A'); const devices = [a]; + try { + await setSyntheticData(a.page, 'seed'); const identity = await enableNewIdentity(a.page); + const b = await createDevice(browser, 'ordered push device B'); devices.push(b); + await joinIdentity(b.page, identity.mnemonic, identity.ownerId); await waitForContext(b.page, BASELINE_CONTEXT); + await a.page.evaluate(async () => { + const {state} = await import('/js/state.js'); const data = await import('/js/data.js'); + state.importedData.contextNotes = 'older forced snapshot ' + 'x'.repeat(1024); + if (!await data.saveImportedData()) throw new Error('Could not save synthetic older edit'); + const NativeCompressionStream = CompressionStream; + let release!: () => void; const gate = new Promise(resolve => {release = resolve;}); + const probe = {entered: false, release, older: null as Promise | null, + restore: () => {globalThis.CompressionStream = NativeCompressionStream;}}; + (window as Window & {__orderedPush?: typeof probe}).__orderedPush = probe; + let armed = true; + // Keep the browser's real gzip bytes; delay just one stream completion. + // SQLite, SDK queries, owner proof and relay transport remain untouched. + globalThis.CompressionStream = new Proxy(NativeCompressionStream, {construct(target, args) { + const actual = Reflect.construct(target, args) as CompressionStream; + if (!armed) return actual; + armed = false; probe.entered = true; + return {writable: actual.writable, readable: actual.readable.pipeThrough(new TransformStream({ + transform(chunk, controller) {controller.enqueue(chunk);}, async flush() {await gate;}, + }))}; + }}); + probe.older = (await import('/js/sync.js')).forceResendCurrentProfile(); + }); + await expect.poll(() => a.page.evaluate(() => (window as Window & {__orderedPush?: {entered: boolean}}).__orderedPush?.entered)).toBe(true); + expect(await setSyntheticData(a.page, 'offline-context')).toBe(true); + await a.page.evaluate(async () => (await import('/js/sync.js')).forceResendCurrentProfile()); + await waitForContext(b.page, OFFLINE_CONTEXT); + expect(await b.page.evaluate(async () => (await import('/js/sync.js')).pauseSync())).toBe(true); + await a.page.evaluate(async () => { + const probe = (window as Window & {__orderedPush?: {release(): void; older: Promise; restore(): void}}).__orderedPush; + if (!probe) throw new Error('Missing ordered push fixture'); + probe.release(); await probe.older; probe.restore(); + }); + await expect.poll(async () => { + const diagnostics = await syncDiagnostics(a.page); + return (diagnostics.rows as {arrayName: string; syncedAt: string; value: unknown}[]) + .filter(row => row.arrayName === 'contextNotes').sort((x,y) => y.syncedAt.localeCompare(x.syncedAt))[0]?.value; + }).toBe(OFFLINE_CONTEXT); + expect(await contextNotes(a.page)).toBe(OFFLINE_CONTEXT); + for (const device of devices) expect(device.errors).toEqual([]); + } finally { + await a.page.evaluate(async () => { + const probe = (window as Window & {__orderedPush?: {release(): void; older: Promise; restore(): void}}).__orderedPush; + if (probe) {probe.release(); await probe.older.catch(() => {}); probe.restore();} + }).catch(() => {}); + for (const device of devices.reverse()) await device.context.close(); + } +}); diff --git a/tests/production-build.test.ts b/tests/production-build.test.ts index ada9fb475..3801c6a22 100644 --- a/tests/production-build.test.ts +++ b/tests/production-build.test.ts @@ -364,7 +364,9 @@ describe('shipping SvelteKit production build', () => { it('publishes byte-identical previous workers and current licenses outside the new precache', async () => { for (const url of ONLINE_EVOLU_ASSETS) { expect(shell).not.toContain(url); - expect(await fs.readFile(path.join(outputRoot, url.slice(1))), url).toEqual(await fs.readFile(url.slice(1))); + // Node compares Buffer bytes directly, without a JS property visit for + // every byte under the full-suite V8 profiler. + assert.deepEqual(await fs.readFile(path.join(outputRoot, url.slice(1))), await fs.readFile(url.slice(1)), url); } }); diff --git a/tests/profile-write-concurrency.test.ts b/tests/profile-write-concurrency.test.ts index 0708eeb6b..6a762c5cd 100644 --- a/tests/profile-write-concurrency.test.ts +++ b/tests/profile-write-concurrency.test.ts @@ -102,6 +102,38 @@ it('uses the adopted baseline when another save starts during post-save hooks', expect(state.importedData.notes!).toEqual((await read()).notes); }); +it.each([false, true])('adopts a completed save into a replacement active object without inventing a stale edit (new draft: %s)', async newDraft => { + const baseline = structuredClone(state.importedData); + let started!: () => void, release!: () => void; + const reading = new Promise(resolve => { started = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const originalRead = crypto.encryptedGetItem; + let held = false; + vi.spyOn(crypto, 'encryptedGetItem').mockImplementation(async (...args) => { + if (args[0] === key && !held) { held = true; started(); await gate; } + return originalRead(...args); + }); + state.importedData.contextNotes = 'committed local context edit'; + const saving = saveImportedData(); + await reading; + const replacement = structuredClone(baseline); + rememberProfileData(replacement, baseline); + if (newDraft) replacement.notes!.push({date: '2026-10-31', text: 'new unsaved replacement note'}); + state.importedData = replacement; + release(); + expect(await saving).toBe(true); + expect((await read()).contextNotes).toBe('committed local context edit'); + expect(state.importedData).toBe(replacement); + expect(state.importedData.contextNotes).toBe('committed local context edit'); + if (newDraft) { + expect(state.importedData.notes).toContainEqual({date: '2026-10-31', text: 'new unsaved replacement note'}); + expect((await read()).notes).toEqual([]); + expect(await saveImportedData()).toBe(true); + expect((await read()).contextNotes).toBe('committed local context edit'); + expect((await read()).notes).toContainEqual({date: '2026-10-31', text: 'new unsaved replacement note'}); + } +}); + it('merges independent additions, same-entry marker edits, and delete-versus-edit without resurrection', () => { const base = { entries: [entry('2026-09-01', { a: 1, b: 2 }), entry('2026-09-02', { a: 2 })] }; const a = structuredClone(base), b = structuredClone(base); diff --git a/tests/pwa/settings-display-pilot.spec.ts b/tests/pwa/settings-display-pilot.spec.ts index 4ae2f377a..c5025121b 100644 --- a/tests/pwa/settings-display-pilot.spec.ts +++ b/tests/pwa/settings-display-pilot.spec.ts @@ -12,6 +12,7 @@ test('Display Svelte component opens for the first time offline and retains pref localStorage.setItem('labcharts-analytics-consent-seen', '1'); }); await page.goto(`${server.origin}/app?dev-sw=1`, { waitUntil: 'networkidle' }); + await expect(page.locator('html[data-app-ready]')).toBeAttached(); await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)); await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true); expect(await page.locator('link[data-settings-stylesheet]').count()).toBe(0); @@ -28,6 +29,7 @@ test('Display Svelte component opens for the first time offline and retains pref await panel.locator('[data-alt-units="on"]').click(); await expect(panel.locator('[data-unit="US"]')).toHaveAttribute('aria-pressed', 'true'); await page.reload({ waitUntil: 'load' }); + await expect(page.locator('html[data-app-ready]')).toBeAttached(); await page.evaluate(async () => { await (await import('/js/settings-loader.js')).openSettingsModal('display'); }); await expect(panel.locator('[data-unit="US"]')).toHaveAttribute('aria-pressed', 'true'); await expect(panel.locator('[data-alt-units="on"]')).toHaveAttribute('aria-pressed', 'true'); diff --git a/tests/pwa/sveltekit-biometric-overview.spec.ts b/tests/pwa/sveltekit-biometric-overview.spec.ts index f54223d62..e235d5011 100644 --- a/tests/pwa/sveltekit-biometric-overview.spec.ts +++ b/tests/pwa/sveltekit-biometric-overview.spec.ts @@ -33,8 +33,10 @@ async function keyboardActivate(page:Page,control:Locator){await control.scrollI * deliberately suspends title and adds aria-describedby while hovered. Let its * real pointerout/RAF lifecycle restore the original ordered attributes. */ async function neutralBiometricPointer(page: Page, widget: Locator) { - if (!await page.evaluate(() => matchMedia('(pointer: fine)').matches)) return; + // Mobile WebKit also retargets its virtual mouse after a tap/DOM update. + // Move it outside the widget before dismissing keyboard/hover tooltips. await page.mouse.move(0, 0); + await page.keyboard.press('Escape'); for (const control of await widget.locator('.db-biometric-remove').all()) { await expect(control).toHaveAttribute('title', 'Remove metric'); await expect(control).not.toHaveAttribute('aria-describedby'); diff --git a/tests/pwa/sveltekit-evolu-runtime.spec.ts b/tests/pwa/sveltekit-evolu-runtime.spec.ts index 7f4e31deb..c310ee56a 100644 --- a/tests/pwa/sveltekit-evolu-runtime.spec.ts +++ b/tests/pwa/sveltekit-evolu-runtime.spec.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import {devices, expect, test, type BrowserContext, type Page} from '@playwright/test'; import {startKitServer} from './kit-server.js'; import {artifactInputs} from './kit-native-receipts.js'; +import {captureOfflineLaunchDiagnostics} from '../helpers/offline-launch-diagnostics.js'; import type {SyncRuntimeClient} from '../../js/sync-runtime.js'; const runtime = '/vendor/evolu8/v8.24.1-web3.6.5/'; @@ -19,8 +20,11 @@ async function fixtureRows(page: Page) { } async function ready(page: Page) { await expect(page.locator('html[data-app-ready][data-shell-framework="sveltekit"]')).toBeAttached({timeout: 30_000}); - await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)); - await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true); + const diagnostic = setTimeout(() => { void captureOfflineLaunchDiagnostics(page, test.info()).catch(() => {}); }, 30_000); + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)); + await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true); + } finally { clearTimeout(diagnostic); } } async function openIdentity(page: Page) { await expect(page.locator('body')).not.toHaveClass(/\bchat-autostart-reserved\b/, {timeout: 30_000}); diff --git a/tests/pwa/sveltekit-report-tour.spec.ts b/tests/pwa/sveltekit-report-tour.spec.ts index 8fc6b8ccc..8e2dcea2a 100644 --- a/tests/pwa/sveltekit-report-tour.spec.ts +++ b/tests/pwa/sveltekit-report-tour.spec.ts @@ -5,9 +5,17 @@ import {expect, test, type Locator, type Page} from '@playwright/test'; import {createDefaultProfileData} from '../../js/profile.js'; import {startKitServer} from './kit-server.js'; import {installDiscussionFixture} from '../helpers/chat-discussion-fixture.js'; -import {installOfflineStartupDiagnostics} from '../helpers/offline-launch-diagnostics.js'; +import {installOfflineStartupDiagnostics, captureOfflineLaunchDiagnostics} from '../helpers/offline-launch-diagnostics.js'; import {readComparisonAcceptanceProfile} from '../helpers/nutrition-comparison-fixture.js'; -import {readyLightArtifact as ready} from '../helpers/light-artifact-navigation.js'; +import {readyLightArtifact} from '../helpers/light-artifact-navigation.js'; + +async function ready(page: Page, origin: string) { + // Observe a pending install before the existing test deadline tears it down. + // The shared readiness infrastructure and its success path stay unchanged. + const diagnostic = setTimeout(() => { void captureOfflineLaunchDiagnostics(page, test.info()).catch(() => {}); }, 30_000); + try { return await readyLightArtifact(page, origin); } + finally { clearTimeout(diagnostic); } +} async function keyboardActivate(page: Page, control: Locator) { await page.keyboard.press(nativeTabKey(page)); diff --git a/tests/raw-module-assets.test.ts b/tests/raw-module-assets.test.ts index 23c3f1831..774bb2708 100644 --- a/tests/raw-module-assets.test.ts +++ b/tests/raw-module-assets.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; import { expect, it } from 'vitest'; import { collectRawModuleAssets } from '../scripts/raw-module-assets.mjs'; import { productionRawModuleAssets, pruneSourceModuleAppShell } from '../scripts/build-production.mjs'; @@ -175,7 +176,7 @@ it('keeps precisely the genuine raw graph and public files while pruning the 22 .toEqual(original.filter(url => !url.startsWith('/js/') || !url.endsWith('.js'))); for (const url of previouslyQuoteRetained) expect(pruned).not.toContain(url); const afterFiles = await Promise.all(previouslyQuoteRetained.map(url => fs.readFile(url.slice(1)))); - expect(afterFiles).toEqual(beforeFiles); + assert.deepEqual(afterFiles, beforeFiles); }); diff --git a/tests/sync-actions.test.ts b/tests/sync-actions.test.ts index 3b125cc16..f15ad2603 100644 --- a/tests/sync-actions.test.ts +++ b/tests/sync-actions.test.ts @@ -236,11 +236,11 @@ describe('sync action profile dependencies', () => { } }); - it('waits and retries when a pull-side rebroadcast wins the manual-push race', async () => { + it.each(['in-flight', 'superseded'])('waits and retries when the manual push is %s', async reason => { const previousProfile = state.currentProfile; state.currentProfile = 'edit-during-pull'; const pushProfile = vi.fn() - .mockResolvedValueOnce({ ok: false, skipped: true, reason: 'in-flight' }) + .mockResolvedValueOnce({ ok: false, skipped: true, reason }) .mockResolvedValueOnce({ ok: true }); configureSyncActions({ forcePull: async () => { markSyncProfileDirty(state.currentProfile); }, diff --git a/tests/sync-ai-settings-retry.test.ts b/tests/sync-ai-settings-retry.test.ts new file mode 100644 index 000000000..92e274170 --- /dev/null +++ b/tests/sync-ai-settings-retry.test.ts @@ -0,0 +1,30 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {createSyncSaveHooks} from '../js/sync-save-hooks-core.js'; +import {state} from '../js/state.js'; +import {getSyncDirtyToken} from '../js/sync-dirty-state.js'; + +afterEach(() => {vi.clearAllTimers(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear();}); +for (const {transientFirst, markerUnavailable} of [{transientFirst: false, markerUnavailable: false}, {transientFirst: true, markerUnavailable: false}, {transientFirst: false, markerUnavailable: true}]) { + it(markerUnavailable ? 'retains an AI-setting request when its dirty marker cannot be saved' : `stops AI-setting retries after an unchanged receipt${transientFirst ? ' following an in-flight attempt' : ''}`, async () => { + vi.useFakeTimers(); localStorage.clear(); + const events = new EventTarget(); vi.stubGlobal('window', events); + const previousProfile = state.currentProfile, previousData = state.importedData; + state.currentProfile = 'ai-settings-retry'; (state as {importedData: unknown}).importedData = {entries: []}; + const push = vi.fn(async () => ({ok: true, skipped: true, reason: 'unchanged'})); + if (transientFirst) push.mockResolvedValueOnce({ok: false, skipped: true, reason: 'in-flight'}); + const hooks = createSyncSaveHooks({encryptedGetItem: async () => null, + markChatDataLocal: () => {}, markCustomPersonalityDataLocal: () => {}, pushContextToGateway: () => {}}); + hooks.configureSyncSaveHooks({pushProfile: push, isSyncConfigured: () => true, isSyncEnabled: () => true, + isEvoluReady: () => true, isSyncing: () => false, getProfiles: () => [{id: 'ai-settings-retry'}]}); + try { + if (markerUnavailable) { + const write = localStorage.setItem.bind(localStorage); + vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {if (key.endsWith('-sync-dirty')) throw new Error('Synthetic storage fault'); write(key, value);}); + } + hooks.bindSyncSaveHookEvents(); events.dispatchEvent(new Event('labcharts-ai-settings-local-changed')); + await vi.advanceTimersByTimeAsync(6250); + expect(push).toHaveBeenCalledTimes(transientFirst ? 2 : 1); + if (markerUnavailable) expect(getSyncDirtyToken('ai-settings-retry')).toBeNull(); + } finally {hooks.clearSyncSaveTimers(); state.currentProfile = previousProfile; state.importedData = previousData;} + }); +} diff --git a/tests/sync-identity-restore-owner.test.ts b/tests/sync-identity-restore-owner.test.ts index f547fe78b..0499de510 100644 --- a/tests/sync-identity-restore-owner.test.ts +++ b/tests/sync-identity-restore-owner.test.ts @@ -1,9 +1,49 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { webcrypto } from 'node:crypto'; import { configureSyncIdentity, restoreFromMnemonic, RESTORE_NOTICE_KEY } from '../js/sync-identity.js'; -import { SYNC_STORAGE_KEY } from '../js/sync-settings-state.js'; -beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); -afterEach(() => { configureSyncIdentity({ getAppOwner: () => null, getEvolu: () => null, seedLocalProfiles: async () => {} }); localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); }); +import { isSyncEnabled, isSyncPaused, setSyncEnabled, setSyncPaused, setSyncPublishingSuspended, SYNC_STORAGE_KEY } from '../js/sync-settings-state.js'; +import { fingerprintBackupRestoreOwner, getPendingBackupRestoreProfileIds, republishPendingBackupRestore, stageBackupRestorePending, SYNC_BACKUP_RESTORE_PENDING_KEY } from '../js/sync-backup-restore-state.js'; +import {setSyncEvolu} from '../js/sync-runtime.js'; +beforeEach(() => { vi.stubGlobal('crypto', webcrypto); localStorage.clear(); sessionStorage.clear(); setSyncPublishingSuspended(false); document.body.innerHTML = '
'; }); +afterEach(() => { configureSyncIdentity({ getAppOwner: () => null, getEvolu: () => null, seedLocalProfiles: async () => {} }); setSyncEvolu(null); setSyncPublishingSuspended(false); localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it('carries pending recovery to a deliberately joined identity before resetting the SDK', async () => { + vi.useFakeTimers(); + try { + setSyncEnabled(true); + let owner = {id: 'prior-recovered-owner', mnemonic: 'synthetic prior recovery identity'}; + const next = {id: 'explicitly-joined-owner', mnemonic: 'synthetic next recovery identity'}; + stageBackupRestorePending(['retained'], {ownerFingerprint: await fingerprintBackupRestoreOwner(owner.mnemonic)}); + let atReset = ''; let suspended = false; + const engine = {get appOwner() {return Promise.resolve(owner);}, restoreAppOwner: vi.fn(async () => { + atReset = localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)!; + suspended = !isSyncEnabled(); owner = next; + })}; + const seed = vi.fn(); setSyncEvolu(engine); + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => owner, seedLocalProfiles: seed}); + expect(await restoreFromMnemonic(next.mnemonic)).toBe(true); + expect(JSON.parse(atReset).at(-1).ownerFingerprint).toBe(await fingerprintBackupRestoreOwner(next.mnemonic)); + expect(suspended).toBe(true); expect(seed).not.toHaveBeenCalled(); + const publish = vi.fn(async () => ({succeeded: 1, failed: 0, skipped: 0})); + expect((await republishPendingBackupRestore({runtime: {}, ownerId: next.id, ownerMnemonic: next.mnemonic, + isCurrent: () => true, getProfiles: () => [{id: 'retained'}], pushProfilesById: publish})).complete).toBe(true); + expect(publish).toHaveBeenCalledOnce(); + } finally {vi.clearAllTimers(); vi.useRealTimers();} +}); + +it('restores the prior bound journal when a deliberate join rejects without changing its owner', async () => { + setSyncEnabled(true); + const owner = {id: 'retained-recovery-owner', mnemonic: 'synthetic prior recovery identity'}; + stageBackupRestorePending(['retained'], {ownerFingerprint: await fingerprintBackupRestoreOwner(owner.mnemonic)}); + const prior = localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY); + let suspended = false; + const engine = {appOwner: Promise.resolve(owner), restoreAppOwner: vi.fn(async () => {suspended = !isSyncEnabled(); throw new Error('Synthetic join rejection');})}; + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => owner}); + expect(await restoreFromMnemonic('synthetic different identity')).toBe(false); + expect(suspended).toBe(true); expect(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)).toBe(prior); + expect(isSyncEnabled()).toBe(true); +}); for (const failed of [false, true]) { it(`does not mutate a replaced runtime after an old restore ${failed ? 'fails' : 'succeeds'}`, async () => { let finish!: () => void; let reject!: (error: Error) => void; @@ -45,3 +85,144 @@ it('does not finalize an old seed operation after the runtime changes during per expect(sessionStorage.getItem(RESTORE_NOTICE_KEY)).toBe('replacement-notice'); expect(document.body.textContent).not.toContain('republished'); }); + +it('persists recovery for the new owner while preventing the retiring runtime from publishing it', async () => { + vi.useFakeTimers(); + try { + setSyncEnabled(true); setSyncPaused(true); + const engine = {restoreAppOwner: vi.fn(async () => { + expect(getPendingBackupRestoreProfileIds()).toEqual(['first', 'second']); + expect(isSyncEnabled()).toBe(false); + })}; + setSyncEvolu(engine); + const seed = vi.fn(async (options: {ownerFingerprint: string}) => { + expect(engine.restoreAppOwner).not.toHaveBeenCalled(); + expect(localStorage.getItem(SYNC_STORAGE_KEY)).toBe('true'); + expect(isSyncPaused()).toBe(true); + expect(isSyncEnabled()).toBe(false); + expect(options.ownerFingerprint).toBe(await fingerprintBackupRestoreOwner('new fixture identity')); + return stageBackupRestorePending(['first', 'second'], options); + }); + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => ({id: 'retiring-owner', mnemonic: 'old fixture identity'}), seedLocalProfiles: seed}); + expect(await restoreFromMnemonic('new fixture identity', {seedLocal: true})).toBe(true); + expect(seed).toHaveBeenCalledOnce(); + expect(getPendingBackupRestoreProfileIds()).toEqual(['first', 'second']); + expect(isSyncEnabled()).toBe(false); + expect(localStorage.getItem(SYNC_STORAGE_KEY)).toBe('true'); + setSyncEvolu({}); + expect(isSyncEnabled()).toBe(true); + } finally { vi.clearAllTimers(); vi.useRealTimers(); } +}); + +it('keeps the old owner and journal if its target identity cannot be fingerprinted', async () => { + setSyncEnabled(true); + localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, '["previous"]'); + const engine = {restoreAppOwner: vi.fn()}; + const seed = vi.fn(async () => stageBackupRestorePending(['candidate'])); + vi.spyOn(webcrypto.subtle, 'digest').mockRejectedValueOnce(new Error('Synthetic crypto failure')); + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => ({id: 'retained-owner'}), seedLocalProfiles: seed}); + expect(await restoreFromMnemonic('synthetic replacement identity', {seedLocal: true})).toBe(false); + expect(seed).not.toHaveBeenCalled(); expect(engine.restoreAppOwner).not.toHaveBeenCalled(); + expect(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)).toBe('["previous"]'); + expect(isSyncEnabled()).toBe(true); +}); + +it.each(['write', 'readback'] as const)('keeps the old identity and paused state when recovery journal %s fails', async failure => { + setSyncEnabled(true); setSyncPaused(true); + const engine = {restoreAppOwner: vi.fn(async () => {})}; + localStorage.setItem('synthetic-local-data', 'retained'); + const key = 'labcharts-sync-backup-restore-pending'; + if (failure === 'write') { + const write = localStorage.setItem.bind(localStorage); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((name, value) => { if (name === key) throw new Error('Fixture storage full'); write(name, value); }); + } else { + const read = localStorage.getItem.bind(localStorage); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(name => name === key ? null : read(name)); + } + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => ({id: 'old-fixture-owner', mnemonic: 'old fixture words'}), + seedLocalProfiles: async () => stageBackupRestorePending(['first', 'second'])}); + expect(await restoreFromMnemonic('new fixture words', {seedLocal: true})).toBe(false); + expect(engine.restoreAppOwner).not.toHaveBeenCalled(); + expect(isSyncPaused()).toBe(true); + expect(localStorage.getItem(SYNC_STORAGE_KEY)).toBe('true'); + expect(localStorage.getItem('synthetic-local-data')).toBe('retained'); + expect(sessionStorage.getItem(RESTORE_NOTICE_KEY)).toBeNull(); + expect(document.body.textContent).toContain('Could not save sync recovery progress'); +}); + +it.each([null, '["previous-recovery"]'])('restores the exact prior journal %s after an owner reset rejects without changing identity', async previous => { + setSyncEnabled(true); + if (previous !== null) localStorage.setItem(SYNC_BACKUP_RESTORE_PENDING_KEY, previous); + const owner = {id: 'unchanged-owner', mnemonic: 'original fixture words'}; + const engine = {appOwner: Promise.resolve(owner), restoreAppOwner: vi.fn(async () => { throw new Error('Fixture owner rejection'); })}; + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => owner, + seedLocalProfiles: async () => stageBackupRestorePending(['available', 'unavailable'])}); + expect(await restoreFromMnemonic('replacement fixture words', {seedLocal: true})).toBe(false); + expect(engine.restoreAppOwner).toHaveBeenCalledOnce(); + expect(localStorage.getItem(SYNC_BACKUP_RESTORE_PENDING_KEY)).toBe(previous); + expect(isSyncEnabled()).toBe(true); +}); + +it('keeps recovery when the SDK owner changed before a replacement startup failure', async () => { + setSyncEnabled(true); + const previous = {id: 'old-owner', mnemonic: 'old fixture words'}; + const next = {id: 'new-owner', mnemonic: 'new fixture words'}; + const engine = {appOwner: Promise.resolve(next), restoreAppOwner: async () => { throw new Error('Fixture replacement startup failure'); }}; + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => previous, + seedLocalProfiles: async () => stageBackupRestorePending(['available', 'unavailable'])}); + expect(await restoreFromMnemonic(next.mnemonic, {seedLocal: true})).toBe(false); + expect(getPendingBackupRestoreProfileIds()).toEqual(['available', 'unavailable']); + expect(isSyncEnabled()).toBe(false); + expect(document.body.textContent).toContain('Reload to finish recovery'); +}); + +it('keeps publishing suspended when rollback storage fails and retries rollback before another owner operation', async () => { + setSyncEnabled(true); + const owner = {id: 'unchanged-owner', mnemonic: 'original fixture words'}; + const engine = {appOwner: Promise.resolve(owner), restoreAppOwner: vi.fn(async () => { throw new Error('Fixture owner rejection'); })}; + let failRollback = true; + configureSyncIdentity({getEvolu: () => engine, getAppOwner: () => owner, seedLocalProfiles: async () => { + const staged = stageBackupRestorePending(['available', 'unavailable']); + return {rollback() { if (failRollback) { failRollback = false; return false; } return staged.rollback(); }}; + }}); + expect(await restoreFromMnemonic('replacement fixture words', {seedLocal: true})).toBe(false); + expect(isSyncEnabled()).toBe(false); + expect(getPendingBackupRestoreProfileIds()).toEqual(['available', 'unavailable']); + expect(await restoreFromMnemonic('replacement fixture words', {seedLocal: true})).toBe(false); + expect(engine.restoreAppOwner).toHaveBeenCalledTimes(2); + expect(getPendingBackupRestoreProfileIds()).toEqual([]); + expect(isSyncEnabled()).toBe(true); +}); + +it('rolls back its owned staged journal when the runtime changes before the SDK reset starts', async () => { + const first = {restoreAppOwner: vi.fn(async () => {})}; let engine = first; + let finish!: () => void; + configureSyncIdentity({getEvolu: () => engine, seedLocalProfiles: async () => { + const staged = stageBackupRestorePending(['unavailable']); + await new Promise(resolve => { finish = resolve; }); + return staged; + }}); + const task = restoreFromMnemonic('replacement fixture words', {seedLocal: true}); + await vi.waitFor(() => expect(getPendingBackupRestoreProfileIds()).toEqual(['unavailable'])); + engine = {restoreAppOwner: vi.fn(async () => {})}; setSyncEvolu(engine); + sessionStorage.setItem(RESTORE_NOTICE_KEY, 'new-runtime-notice'); + finish(); expect(await task).toBe(false); + expect(first.restoreAppOwner).not.toHaveBeenCalled(); + expect(getPendingBackupRestoreProfileIds()).toEqual([]); + expect(sessionStorage.getItem(RESTORE_NOTICE_KEY)).toBe('new-runtime-notice'); +}); + +it('does not stage recovery after its runtime changes during identity fingerprinting', async () => { + let finish!: (digest: ArrayBuffer) => void; + vi.spyOn(webcrypto.subtle, 'digest').mockReturnValueOnce(new Promise(resolve => {finish = resolve;})); + const first = {restoreAppOwner: vi.fn()}; let engine = first; + const seed = vi.fn(async () => stageBackupRestorePending(['candidate'])); + configureSyncIdentity({getEvolu: () => engine, seedLocalProfiles: seed}); + const task = restoreFromMnemonic('synthetic target identity', {seedLocal: true}); + engine = {restoreAppOwner: vi.fn()}; setSyncEvolu(engine); + sessionStorage.setItem(RESTORE_NOTICE_KEY, 'replacement-notice'); + finish(new ArrayBuffer(32)); expect(await task).toBe(false); + expect(seed).not.toHaveBeenCalled(); expect(first.restoreAppOwner).not.toHaveBeenCalled(); + expect(getPendingBackupRestoreProfileIds()).toEqual([]); + expect(sessionStorage.getItem(RESTORE_NOTICE_KEY)).toBe('replacement-notice'); +}); diff --git a/tests/sync-push-ordering.test.ts b/tests/sync-push-ordering.test.ts new file mode 100644 index 000000000..22c8090bd --- /dev/null +++ b/tests/sync-push-ordering.test.ts @@ -0,0 +1,162 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {configureSyncPush, pushProfile, isSyncPushInFlight} from '../js/sync-push.js'; +import {configureSyncDelta} from '../js/sync-delta.js'; +import * as payload from '../js/sync-payload.js'; +import {getSyncDirtyToken, markSyncProfileDirty} from '../js/sync-dirty-state.js'; +import {getSyncStatus} from '../js/sync-state.js'; +import {makeEvolu, PROFILE_QUERY, ITEM_ROW_QUERY} from './helpers/sync-runtime-fixture.js'; + +afterEach(() => {vi.restoreAllMocks(); vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear();}); +it.each([false, true])('a delayed forced snapshot cannot overwrite a newer completed edit (initially dirty: %s)', async initiallyDirty => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-31T12:00:00Z')); + const id = `push-order-${initiallyDirty}`; + const fixture = makeEvolu(); + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => [{id}]}); + expect(await pushProfile(id, {contextNotes: 'baseline'})).toMatchObject({ok: true}); + let entered!: () => void, release!: () => void; + const reached = new Promise(resolve => {entered = resolve;}); + const gate = new Promise(resolve => {release = resolve;}); + const actualBuild = payload.buildSyncPayload; + vi.spyOn(payload, 'buildSyncPayload').mockImplementationOnce(async (...args) => { + entered(); await gate; return actualBuild(...args); + }); + if (initiallyDirty) markSyncProfileDirty(id); + vi.setSystemTime(new Date('2026-10-31T12:00:01Z')); + const older = pushProfile(id, {contextNotes: 'older pending snapshot'}, {force: true}); + try { + await reached; + vi.setSystemTime(new Date('2026-10-31T12:00:02Z')); + markSyncProfileDirty(id); + expect(await pushProfile(id, {contextNotes: 'newest local edit'}, {force: true})).toMatchObject({ok: true}); + release(); expect(await older).toMatchObject({ok: false, skipped: true, reason: 'superseded'}); + const row = fixture.itemRows.find(row => row.arrayName === 'contextNotes'); + expect(JSON.parse(String(row!.payload))).toEqual({v: 'newest local edit'}); + expect((await payload.parseSyncPayload(fixture.profileRows[0]!.dataJson)).importedData) + .toMatchObject({contextNotes: 'newest local edit'}); + } finally {release(); await older;} +}); + +it('retains a later dirty generation when an older snapshot finishes planning without another push', async () => { + vi.useFakeTimers(); const id = 'push-order-local-change'; const fixture = makeEvolu(); + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => [{id}]}); + let entered!: () => void, release!: () => void; + const reached = new Promise(resolve => {entered = resolve;}); + const gate = new Promise(resolve => {release = resolve;}); const actualBuild = payload.buildSyncPayload; + vi.spyOn(payload, 'buildSyncPayload').mockImplementationOnce(async (...args) => {entered(); await gate; return actualBuild(...args);}); + const older = pushProfile(id, {contextNotes: 'older snapshot'}, {force: true}); + try { + await reached; const token = markSyncProfileDirty(id); release(); + expect(await older).toMatchObject({ok: false, skipped: true, reason: 'superseded'}); + expect(fixture.profileRows).toEqual([]); expect(fixture.itemRows).toEqual([]); + expect(getSyncDirtyToken(id)).toBe(token); expect(isSyncPushInFlight()).toBe(false); + expect(getSyncStatus().push).not.toBe('pending'); + expect(await pushProfile(id, {contextNotes: 'newest local edit'})).toMatchObject({ok: true}); + expect(getSyncDirtyToken(id)).toBeNull(); + } finally {release(); await older;} +}); + +it('ignores an older SDK completion after the newer forced push has committed', async () => { + vi.useFakeTimers(); const id = 'push-order-late-ack'; const fixture = makeEvolu(); + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => [{id}]}); + expect(await pushProfile(id, {contextNotes: 'baseline'})).toMatchObject({ok: true}); + let entered!: () => void, complete!: () => void; const reached = new Promise(resolve => {entered = resolve;}); + const actualUpdate = fixture.evolu.update; + vi.spyOn(fixture.evolu, 'update').mockImplementationOnce((table, args, options) => { + actualUpdate(table, args, {}); complete = options!.onComplete!; entered(); + }); + const older = pushProfile(id, {contextNotes: 'older pending ack'}, {force: true}); + try { + await reached; + expect(await pushProfile(id, {contextNotes: 'newest local edit'}, {force: true})).toMatchObject({ok: true}); + complete(); expect(await older).toMatchObject({ok: false, skipped: true, reason: 'superseded'}); + const row = fixture.itemRows.find(row => row.arrayName === 'contextNotes'); + expect(JSON.parse(String(row!.payload))).toEqual({v: 'newest local edit'}); + expect(getSyncStatus().push).toBe('confirmed'); expect(isSyncPushInFlight()).toBe(false); + } finally {complete?.(); await older;} +}); + +it.each([false, true])('profile completions retain the other pending push (newest completes first: %s)', async newestFirst => { + vi.useFakeTimers(); const fixture = makeEvolu(); const ids = ['push-order-a', 'push-order-b']; + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => ids.map(id => ({id}))}); + const completions = new Map void>(); + const originalInsert = fixture.evolu.insert; + vi.spyOn(fixture.evolu, 'insert').mockImplementation((table, args, options) => { + if (table === 'profileData') {originalInsert(table, args, {}); completions.set(String(args.profileId), options!.onComplete!);} + else originalInsert(table, args, options); + }); + const a = pushProfile(ids[0], {contextNotes: 'profile A'}, {force: true}); + await vi.waitFor(() => expect(completions.has(ids[0]!)).toBe(true)); + const b = pushProfile(ids[1], {contextNotes: 'profile B'}, {force: true}); + try { + await vi.waitFor(() => expect(completions.has(ids[1]!)).toBe(true)); + const first = newestFirst ? 1 : 0, second = newestFirst ? 0 : 1; + completions.get(ids[first]!)!(); expect(await [a,b][first]).toMatchObject({ok: true}); + expect(isSyncPushInFlight()).toBe(true); expect(getSyncStatus().push).toBe('pending'); + completions.get(ids[second]!)!(); expect(await [a,b][second]).toMatchObject({ok: true}); + expect(isSyncPushInFlight()).toBe(false); expect(getSyncStatus().push).toBe('confirmed'); + } finally {for (const complete of completions.values()) complete(); await Promise.all([a,b]);} +}); + +it('a timed-out SDK callback cannot later clear its error or apply retired deltas', async () => { + vi.useFakeTimers(); const id = 'push-order-timeout'; const fixture = makeEvolu(); + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => [{id}]}); + let entered!: () => void, complete!: () => void; + const reached = new Promise(resolve => {entered = resolve;}); const originalInsert = fixture.evolu.insert; + vi.spyOn(fixture.evolu, 'insert').mockImplementationOnce((table, args, options) => { + originalInsert(table, args, {}); complete = options!.onComplete!; entered(); + }); + const pending = pushProfile(id, {contextNotes: 'retired snapshot'}); + await reached; await vi.advanceTimersByTimeAsync(30_000); + expect(await pending).toMatchObject({ok: false, reason: 'timeout'}); + expect(getSyncStatus().push).toBe('error'); + complete(); await Promise.resolve(); + expect(getSyncStatus().push).toBe('error'); expect(fixture.itemRows).toEqual([]); + expect(isSyncPushInFlight()).toBe(false); +}); + +it.each([false, true])('a superseded plan settles only its own pending status (newest plan delayed: %s)', async newestDelayed => { + vi.useFakeTimers(); const fixture = makeEvolu(); const ids = ['push-plan-a', 'push-plan-b']; + const delayedId = ids[newestDelayed ? 1 : 0]!; + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, + isSyncEnabled: () => true, isPhase2CutoverEnabled: () => false, getProfiles: () => ids.map(id => ({id}))}); + let entered!: () => void, release!: () => void, completeA!: () => void; + const reached = new Promise(resolve => {entered = resolve;}); + const gate = new Promise(resolve => {release = resolve;}); const actualBuild = payload.buildSyncPayload; + vi.spyOn(payload, 'buildSyncPayload').mockImplementation(async (...args) => { + if (args[0] === delayedId) {entered(); await gate;} + return actualBuild(...args); + }); + if (newestDelayed) { + const insert = fixture.evolu.insert; + vi.spyOn(fixture.evolu, 'insert').mockImplementation((table, args, options) => { + if (table === 'profileData' && args.profileId === ids[0]) {insert(table, args, {}); completeA = options!.onComplete!;} + else insert(table, args, options); + }); + } + const a = pushProfile(ids[0], {contextNotes: 'profile A'}, {force: true}); + if (newestDelayed) await vi.waitFor(() => expect(completeA).toBeTypeOf('function')); + else await reached; + const b = pushProfile(ids[1], {contextNotes: 'profile B'}, {force: true}); + try { + if (newestDelayed) await reached; + else expect(await b).toMatchObject({ok: true}); + const token = markSyncProfileDirty(delayedId); release(); + expect(await (newestDelayed ? b : a)).toMatchObject({ok: false, reason: 'superseded'}); + expect(getSyncDirtyToken(delayedId)).toBe(token); + expect(isSyncPushInFlight()).toBe(newestDelayed); + expect(getSyncStatus().push === 'pending').toBe(newestDelayed); + if (newestDelayed) {completeA(); expect(await a).toMatchObject({ok: true});} + expect(getSyncStatus().push).not.toBe('pending'); + } finally {release(); completeA?.(); await Promise.all([a,b]);} +}); diff --git a/tests/sync-reconcile-noop.test.ts b/tests/sync-reconcile-noop.test.ts new file mode 100644 index 000000000..979b01e6d --- /dev/null +++ b/tests/sync-reconcile-noop.test.ts @@ -0,0 +1,57 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {state} from '../js/state.js'; +import {configureSyncReconcile, reconcileLocalStorageWithEvolu} from '../js/sync-reconcile.js'; +import {configureSyncPush, pushProfile} from '../js/sync-push.js'; +import {configureSyncDelta} from '../js/sync-delta.js'; +import {parseSyncPayload} from '../js/sync-payload.js'; +import {localHasRowsRemoteLacks} from '../js/data-merge.js'; +import {chatDataNeedsRebroadcast} from '../js/sync-chat-apply.js'; +import {collectAISettings} from '../js/sync-payload-collectors.js'; +import {getProfileSyncBlockReason} from '../js/profile-sync-policy.js'; +import {isRestoreJoinPending} from '../js/sync-identity.js'; +import type {SyncProfileRow} from '../js/sync-payload.js'; +import type {DeltaItemRow} from '../js/sync-delta-row-codec.js'; +import {makeEvolu, PROFILE_QUERY, ITEM_ROW_QUERY} from './helpers/sync-runtime-fixture.js'; + +afterEach(() => {vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear(); sessionStorage.clear();}); +it('repeated startup reconciliation does not append an equivalent profile, but sends real edits and explicit resends', async () => { + vi.useFakeTimers(); localStorage.clear(); sessionStorage.clear(); + const previousProfile = state.currentProfile, previousData = state.importedData; + const id = 'startup-equivalent-profile'; + const fixture = makeEvolu(); + const profiles = [{id}]; + const deps = {getEvolu: () => fixture.evolu, getProfileQuery: () => PROFILE_QUERY, isSyncEnabled: () => true, getProfiles: () => profiles}; + configureSyncDelta({getEvolu: () => fixture.evolu, getItemRowQuery: () => ITEM_ROW_QUERY}); + configureSyncPush({...deps, isPhase2CutoverEnabled: () => false}); + configureSyncReconcile({...deps, getEvolu: () => ({ + getQueryRows: (query: unknown) => fixture.evolu.getQueryRows(query) as unknown as readonly Row[], + }), pushProfile}); + state.currentProfile = id; + (state as {importedData: unknown}).importedData = {entries: [], customMarkers: {'custom.synthetic': {name: 'Synthetic marker', unit: 'u', group: null}}}; + const writes = () => fixture.calls.insert.length + fixture.calls.update.length; + try { + expect(await pushProfile(id, state.importedData)).toMatchObject({ok: true}); + const row = fixture.profileRows[0]!; + const parsed = await parseSyncPayload(row.dataJson); + const imported = parsed.importedData as typeof state.importedData; + const marker = imported!.customMarkers!['custom.synthetic']!; + // A peer can serialize identical custom-marker fields in another order. + imported!.customMarkers!['custom.synthetic'] = Object.fromEntries(Object.entries(marker).reverse()) as typeof marker; + row.dataJson = JSON.stringify({_v: 3, ...parsed}); + expect(row.profileId).toBe(id); + expect(await chatDataNeedsRebroadcast(id, parsed.chatData)).toBe(false); + expect(await collectAISettings()).toEqual({}); + expect(localHasRowsRemoteLacks(state.importedData, imported)).toBe(true); + expect(getProfileSyncBlockReason(id, profiles)).toBe(''); expect(isRestoreJoinPending()).toBe(false); + expect(await parseSyncPayload(row.dataJson)).toEqual(parsed); + const before = writes(); + for (let reload = 0; reload < 3; reload++) await reconcileLocalStorageWithEvolu(); + expect(writes()).toBe(before); + state.importedData!.customMarkers!['custom.synthetic']!.name = 'Actual local edit'; + await reconcileLocalStorageWithEvolu(); + expect(writes()).toBeGreaterThan(before); + const committed = writes(); + expect(await pushProfile(id, state.importedData, {force: true})).toMatchObject({ok: true}); + expect(writes()).toBeGreaterThan(committed); + } finally {state.currentProfile = previousProfile; state.importedData = previousData;} +}); diff --git a/tests/sync-status-presentation.test.ts b/tests/sync-status-presentation.test.ts index b572e971d..e424aede5 100644 --- a/tests/sync-status-presentation.test.ts +++ b/tests/sync-status-presentation.test.ts @@ -168,6 +168,41 @@ it.each(['Offline', 'Error'] as const)('does not describe replication %s as conn expect(popover.textContent).toContain(replication === 'Offline' ? 'Sync connection offline' : 'Sync needs attention'); expect(popover.querySelector('[role="status"]')?.textContent).toContain(replication === 'Offline' ? 'Offline.' : 'Relay storage is full'); }); + +it('retains open action buttons, keyboard focus and clicks during repeated and changing sync status', async () => { + const {ui, status} = await controller([true, true]); + const diagnose = vi.fn(); + ui.configureSyncUI({isSyncEnabled: () => true, showSyncDiagnose: diagnose}); + status.updateSyncStatus({relay: 'connected', replication: 'Syncing', devicePersistence: 'Persisted'}); + status.logSyncEvent('skip', 'No changes to push'); + document.body.innerHTML = '
'; + ui.renderSyncIndicator(); ui.toggleSyncDetail(); ui.initSyncUIDelegates(); ui.bindSyncUIStatusUpdates(); + const popover = document.getElementById('sync-popover')!; + const buttons = Array.from(popover.querySelectorAll('[data-sync-ui-action]')); + const button = popover.querySelector('[data-sync-ui-action="show-diagnose"]')!; + button.focus(); button.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + for (let i = 0; i < 40; i++) status.updateSyncStatus({replication: 'Syncing'}); + status.updateSyncStatus({push: 'error', lastError: {type: 'PushStuck', at: Date.now()}}); + expect(popover.querySelector('[data-sync-ui-action="reload"]')).not.toBeNull(); + status.logSyncEvent('pull', 'Checked latest data'); + status.updateSyncStatus({push: 'confirmed', pushConfirmedAt: Date.now(), replication: 'Synced', lastError: null}); + expect(document.getElementById('sync-popover')).toBe(popover); + for (const original of buttons) expect(popover.querySelector(`[data-sync-ui-action="${original.dataset.syncUiAction}"]`)).toBe(original); + expect(document.activeElement).toBe(button); + expect(popover.querySelector('[data-sync-ui-action="reload"]')).toBeNull(); + expect(popover.textContent).toContain('Confirmed'); + button.click(); expect(diagnose).toHaveBeenCalledOnce(); +}); + +it('does not emit a new status event when every supplied field is unchanged', async () => { + const {status} = await controller(); + status.updateSyncStatus({replication: 'Syncing'}); + const listener = vi.fn(), unsubscribe = status.subscribeSyncStatus(listener); + for (let i = 0; i < 40; i++) status.updateSyncStatus({replication: 'Syncing'}); + expect(listener).not.toHaveBeenCalled(); + status.updateSyncStatus({replication: 'Synced'}); + expect(listener).toHaveBeenCalledOnce(); unsubscribe(); +}); describe('public Sync indicator lifetime and original no-render boundaries', () => { it.each(fixture.indicators)('retains original public indicator case: $original.name', async ({ original }) => { // Unknown persistence no longer claims durable local storage. Preserve the diff --git a/tests/test-export-import.ts b/tests/test-export-import.ts index f7f1b6a44..7bd42de1e 100644 --- a/tests/test-export-import.ts +++ b/tests/test-export-import.ts @@ -307,7 +307,7 @@ return (async function() { // Database bundle detection assert('Import detects database bundle', exportImportSrc.includes("json.type === 'database' && Array.isArray(json.profiles)")); - assert('Import routes to _importDatabaseBundle', exportImportSrc.includes('_importDatabaseBundle(json)')); + assert('Import routes database bundles with the selected import options', exportImportSrc.includes('_importDatabaseBundle(json, options)')); // Client export detection (v2 with profile metadata) assert('Import detects client profile.name', exportImportSrc.includes('json.profile?.name')); diff --git a/tests/test-light-tools.ts b/tests/test-light-tools.ts index 77c1b0431..3866377fa 100644 --- a/tests/test-light-tools.ts +++ b/tests/test-light-tools.ts @@ -4,6 +4,7 @@ import { readServiceWorkerSource } from '../scripts/service-worker-source.js'; import { sourcePath } from '../scripts/source-files.js'; import { fileURLToPath } from 'node:url'; import { createLegacyAssertions } from './helpers/legacy-assertions.js'; +import { queueProfileDataWrite } from '../js/profile-data-writes.js'; // test-light-tools.js — Pure helpers re-exported by light-tools.js: // computeRowBanding (flicker FFT), cameraLockStatusLine, saveMeasurement // persistence + spectrum auto-fill, getMeasurementsForRoom, deleteMeasurement. @@ -218,6 +219,9 @@ type FixtureMeasurementStream = Omit {}); reset(); const m1 = await saveMeasurement('lux', 350, { roomId: 'r1', label: 'desk' }); diff --git a/tests/test-mobile.ts b/tests/test-mobile.ts index 0ea5ed223..b62772a33 100644 --- a/tests/test-mobile.ts +++ b/tests/test-mobile.ts @@ -91,7 +91,9 @@ ${await fetchWithRetry('css/chat-redesign-open.css')}`; css.includes('position: fixed') && css.includes('z-index: 360')); const dashboardPageViewSrc = (await fetchWithRetry('js/dashboard-page-view.js')) - .replace(/if \(!isDesktopChatOnboardingViewport \|\| getDashboardPageRuntimeValue\('innerWidth'\) <= 768\)\s+return/, "if (!isDesktopChatOnboardingViewport || getDashboardPageRuntimeValue('innerWidth') <= 768) return"); + // The owned early return now releases its reservation first. Require that + // exact guarded sequence before comparing the existing mobile contract. + .replace(/if \(!isDesktopChatOnboardingViewport \|\| getDashboardPageRuntimeValue\('innerWidth'\) <= 768\)\s*\{\s*releaseReservation\(\);\s*return;\s*\}/, "if (!isDesktopChatOnboardingViewport || getDashboardPageRuntimeValue('innerWidth') <= 768) return"); const mobileDashboardSrc = await fetchWithRetry('js/mobile-dashboard.js'); const mobileDashboardPresentationSrc = await fetchWithRetry('js/components/MobileDashboardPage.svelte.html.js'); const dashboardControlsSrc = await fetchWithRetry('js/dashboard-widget-controls.js'); diff --git a/tests/test-sync.ts b/tests/test-sync.ts index 7f781adce..63df43ba2 100644 --- a/tests/test-sync.ts +++ b/tests/test-sync.ts @@ -3,6 +3,7 @@ import { dataModuleSource } from './helpers/data-module-source.js'; import { syncPushSourceContracts, syncCommittedDeltaSourceContracts, syncChatSaveSourceContracts, syncHealthGoalsSourceContracts, syncPullEncryptionSourceContract } from './helpers/sync-push-source-contracts.js'; import { readServiceWorkerSource } from '../scripts/service-worker-source.js'; import { createLegacyAssertions } from './helpers/legacy-assertions.js'; +import {syncLegacyDiagnoseContracts} from './helpers/sync-legacy-diagnose-contracts.js'; // test-sync.js — Verify sync module exports, payload format, settings UI // // Run: node tests/test-sync.js (or via npm test) @@ -15,6 +16,7 @@ import {writeScalarVersion, scalarValueHash} from '../js/sync-scalar-snapshot.js import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mock } from 'node:test'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel.replace(/^\//, '')), 'utf-8'); @@ -41,22 +43,20 @@ console.log('=== Cross-Device Sync Tests ===\n'); // Load the module-only Sync surfaces and Settings Sync panel. const { state } = await import('../js/state.js'); -const syncActions = await import('../js/sync-actions.js'); -const syncSaveHooks = await import('../js/sync-save-hooks.js'); -const syncApply = await import('../js/sync-apply.js'); -const syncChatApply = await import('../js/sync-chat-apply.js'); -const syncChatMerge = await import('../js/sync-chat-merge.js'); -const syncDelta = await import('../js/sync-delta.js'); -const syncCutover = await import('../js/sync-cutover.js'); -const dataMerge = await import('../js/data-merge.js'); -const syncSubscriptions = await import('../js/sync-subscriptions.js'); -await import('../js/sync-payload.js'); -const syncPayloadCollectors = await import('../js/sync-payload-collectors.js'); -const syncStorageCleanup = await import('../js/sync-storage-cleanup.js'); -await import('../js/sync.js'); -const settingsSyncPanel = await import('../js/settings-sync-panel.js'); -await import('../js/settings.js'); -const {syncLegacyDiagnoseContracts} = await import('./helpers/sync-legacy-diagnose-contracts.js'); +// These independent surfaces are all ready before the root wiring below. +// Avoid serial cold transform round trips inside the unchanged case deadline. +const [syncActions, syncSaveHooks, syncApply, syncChatApply, syncChatMerge, syncDelta, + syncCutover, dataMerge, syncSubscriptions, , syncPayloadCollectors, syncStorageCleanup] = await Promise.all([ + import('../js/sync-actions.js'), import('../js/sync-save-hooks.js'), import('../js/sync-apply.js'), + import('../js/sync-chat-apply.js'), import('../js/sync-chat-merge.js'), import('../js/sync-delta.js'), + import('../js/sync-cutover.js'), import('../js/data-merge.js'), import('../js/sync-subscriptions.js'), + import('../js/sync-payload.js'), import('../js/sync-payload-collectors.js'), import('../js/sync-storage-cleanup.js'), +]); +// Settings statically depends on the Sync facade, so the module loader retains +// initialization order while independent graph fetching/compilation overlaps. +const [, settingsSyncPanel] = await Promise.all([ + import('../js/sync.js'), import('../js/settings-sync-panel.js'), import('../js/settings.js'), +]); const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); const syncSrc = await fetchWithRetry('js/sync.js'); @@ -516,7 +516,7 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); && syncIdentitySrc.includes('export function getMnemonicResolutionError') && syncIdentitySrc.includes('export async function restoreFromMnemonic') && syncIdentitySrc.includes('RESTORE_JOIN_PENDING_KEY') - && syncConfigureSrc.includes('seedLocalProfiles: () => pushAllProfiles({ force: true })') + && syncConfigureSrc.includes('seedLocalProfiles: async (options) => stageBackupRestorePending(getProfiles().map(profile => profile.id), options)') && exportBlockIncludes(syncSrc, ['getMnemonic', 'getMnemonicResolutionError', 'restoreFromMnemonic'])); assert('service worker precaches sync-identity.js', serviceWorkerSrc.includes("'/js/sync-identity.js'")); @@ -828,16 +828,17 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); && syncHealthGoalsSourceContracts(healthGoalsViewSrc) && healthGoalsViewSrc.includes("modal.dataset.syncRefreshKind = 'healthGoals'") && /export function refresh\(value: unknown\): void\s*\{\s*goals = value;\s*\}/.test(healthGoalsPresentationSrc)); + const jsSourceCorpus = new Map(listJsFiles(path.join(ROOT, 'js')).map(abs => [abs, fs.readFileSync(abs, 'utf-8')])); { const syncedArraySurfaces = ['entries', 'notes', 'supplements', 'healthGoals', 'chatSummaries', 'changeHistory']; const helperOwnedFiles = new Set(['js/data-merge.js']); const ownerExpr = '(?:state\\.importedData|current|imp)'; const mutationExpr = new RegExp(`${ownerExpr}\\.(${syncedArraySurfaces.join('|')})(?:\\s*=|\\.(?:push|splice|filter|sort|unshift|shift|pop|reverse)\\b)`); const violations: string[] = []; - for (const abs of listJsFiles(path.join(ROOT, 'js'))) { + for (const [abs, src] of jsSourceCorpus) { const rel = path.relative(ROOT, abs).replace(/\\/g, '/'); if (helperOwnedFiles.has(rel)) continue; - const lines = fs.readFileSync(abs, 'utf-8').split('\n'); + const lines = src.split('\n'); lines.forEach((line, i) => { if (mutationExpr.test(line)) violations.push(`${rel}:${i + 1}: ${line.trim()}`); }); @@ -857,10 +858,9 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); && !chatSummariesSrc.includes("window.addEventListener('labcharts-sync-applied', refreshOpenSummaryModalOnSync)")); { const violations: string[] = []; - for (const abs of listJsFiles(path.join(ROOT, 'js'))) { + for (const [abs, src] of jsSourceCorpus) { const rel = path.relative(ROOT, abs).replace(/\\/g, '/'); if (rel === 'js/utils.js') continue; - const src = fs.readFileSync(abs, 'utf-8'); if (src.includes("window.addEventListener('labcharts-sync-applied'")) violations.push(rel); } assert('sync-applied modal listeners are centralized in utils helpers', @@ -1048,18 +1048,26 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); onSyncReceived: () => { received++; }, }); (syncSubscriptions.bindSyncSubscriptions as unknown as (options: Omit[0]>, "evolu"> & {evolu: {subscribeQuery(query: unknown): (callback: () => void) => unknown; subscribeError(): void; getQueryRows(): unknown[]}}) => ReturnType)({ evolu: fakeEvolu, profileQuery, tombstoneQuery, itemRowQuery }); - callbacks.get(profileQuery)?.(); - assert('subscription receive is not dropped while syncing', received === 0); - syncing = false; - await new Promise(r => setTimeout(r, 650)); - assert('deferred subscription receive runs after syncing clears', received === 1); - pulling = true; - callbacks.get(itemRowQuery)?.(); - assert('itemRow receive is deferred while pulling', received === 1); - pulling = false; - await new Promise(r => setTimeout(r, 650)); - assert('deferred itemRow receive runs after pulling clears', received === 2); - syncSubscriptions.clearSyncSubscriptionTimers(); + // Run the actual production retry callbacks through the same 650ms + // boundaries without spending 1.3s of the unchanged cold-case deadline. + // No asynchronous work runs while the scoped timer replacement is active. + mock.timers.enable({apis: ['setTimeout']}); + try { + callbacks.get(profileQuery)?.(); + assert('subscription receive is not dropped while syncing', received === 0); + syncing = false; + mock.timers.tick(650); + assert('deferred subscription receive runs after syncing clears', received === 1); + pulling = true; + callbacks.get(itemRowQuery)?.(); + assert('itemRow receive is deferred while pulling', received === 1); + pulling = false; + mock.timers.tick(650); + assert('deferred itemRow receive runs after pulling clears', received === 2); + } finally { + syncSubscriptions.clearSyncSubscriptionTimers(); + mock.timers.reset(); + } } assert('Sync diagnose includes tombstone rows and deleted-state column', /tombstoneRows[\s\S]{0,300}isDeleted:\s*true/.test(syncDiagnosticsSnapshotSrc) @@ -1516,6 +1524,7 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); // Verify timestamps are cleared AFTER restoreAppOwner within restoreFromMnemonic (not before) const restoreIdx = syncIdentitySrc.indexOf('evolu.restoreAppOwner(normalizedMnemonic, { reload: false })'); const clearTsInRestore = syncIdentitySrc.indexOf('clearSyncDisableStorage();', restoreIdx); + const restoreCleanupAfterOwner = restoreIdx > 0 && clearTsInRestore > restoreIdx; assert('Sync-ts cleared after restoreAppOwner (not before)', restoreIdx > 0 && clearTsInRestore > restoreIdx, `restoreAppOwner at ${restoreIdx}, sync-ts clear at ${clearTsInRestore}`); @@ -1584,7 +1593,7 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); && syncPushSrc.includes('_syncing = true') && syncPushSrc.includes('export function isSyncPushInFlight')); assert('pushProfile await resolves only from onComplete/watchdog paths', - syncPushSourceContracts(syncPushSrc).successOwnedByComplete + syncPushContracts.successOwnedByComplete && /Push NOT committed after 30s[\s\S]{0,800}finish\(\{ ok: false, reason: 'timeout' \}\)/.test(syncPushSrc)); assert('pushProfile uses insert/update pattern', syncPushSrc.includes('evolu.insert(') && syncPushSrc.includes('evolu.update(')); // v1.6.3: debounce bumped 2s → 10s. Each push is the full importedData @@ -1628,9 +1637,14 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); syncActionsSrc.includes('createDefaultProfileData()') && /pushAllProfiles[\s\S]{0,1200}readProfileImportedData\(p\.id\)/.test(syncActionsSrc) && /pushAllProfiles[\s\S]{0,1200}if \(!dataJson\)/.test(syncActionsSrc)); - assert('restore-as-source seeds the new owner before reload', + const sourceRestoreStart = syncIdentitySrc.indexOf('if (shouldStageRecovery)'); + const sourceRestoreBranch = syncIdentitySrc.slice(sourceRestoreStart, syncIdentitySrc.indexOf('await evolu.restoreAppOwner', sourceRestoreStart)); + assert('restore-as-source confirms recovery with retiring runtime suspended before changing owner', /restoreFromMnemonic\(mnemonic,\s*options = \{\}\)/.test(syncIdentitySrc) - && /options\?\.seedLocal[\s\S]{0,400}await _seedLocalProfiles\(\)/.test(syncIdentitySrc) + && sourceRestoreBranch.includes('setSyncPublishingSuspended(true)') + && sourceRestoreBranch.indexOf('setSyncPublishingSuspended(true)') < sourceRestoreBranch.indexOf('await _seedLocalProfiles({ ownerFingerprint })') + && sourceRestoreBranch.includes('await _seedLocalProfiles({ ownerFingerprint })') + && sourceRestoreBranch.includes('await fingerprintBackupRestoreOwner(normalizedMnemonic)') && /restoreMnemonicForDiagnose\(words\.join\(' '\),\s*\{ seedLocal: true \}\)/.test(syncDiagnoseIdentityActionsSrc)); assert('restore-as-joiner marks first pull to ignore old local tombstones', /setRestoreJoinPending\(true\)/.test(syncIdentitySrc) @@ -2683,10 +2697,10 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); disableSyncSrc.includes('clearSyncDisableStorage();') && /isSyncDisableCleanupKey[\s\S]{0,300}-sync-cutover-v2/.test(syncDisableCleanupSrc)); assert('restoreFromMnemonic clears delta snapshots', - /restoreFromMnemonic[\s\S]{0,2200}clearSyncDisableStorage\(\)/.test(syncIdentitySrc) + restoreCleanupAfterOwner && /isSyncDisableCleanupKey[\s\S]{0,300}key\.includes\('-delta-'\)/.test(syncDisableCleanupSrc)); assert('restoreFromMnemonic clears cutover flag', - /restoreFromMnemonic[\s\S]{0,2200}clearSyncDisableStorage\(\)/.test(syncIdentitySrc) + restoreCleanupAfterOwner && /isSyncDisableCleanupKey[\s\S]{0,300}-sync-cutover-v2/.test(syncDisableCleanupSrc)); // Live: proto-pollution defence — verify a malicious key is rejected @@ -2809,10 +2823,10 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); disableSyncSrc.includes('clearSyncDisableStorage();') && /isSyncDisableCleanupKey[\s\S]{0,300}key\s*===\s*'labcharts-relay-quota-warned'/.test(syncDisableCleanupSrc)); assert('restoreFromMnemonic clears -relay-bytes- keys', - /restoreFromMnemonic[\s\S]{0,2200}clearSyncDisableStorage\(\)/.test(syncIdentitySrc) + restoreCleanupAfterOwner && /isSyncDisableCleanupKey[\s\S]{0,300}key\.includes\('-relay-bytes-'\)/.test(syncDisableCleanupSrc)); assert('restoreFromMnemonic clears legacy global quota-warned key', - /restoreFromMnemonic[\s\S]{0,2200}clearSyncDisableStorage\(\)/.test(syncIdentitySrc) + restoreCleanupAfterOwner && /isSyncDisableCleanupKey[\s\S]{0,300}key\s*===\s*'labcharts-relay-quota-warned'/.test(syncDisableCleanupSrc)); // P2: warned-marker key now owner-scoped @@ -3513,7 +3527,7 @@ const diagnosePresentationContracts = syncLegacyDiagnoseContracts(); assert('Reconciliation routes through localHasRowsRemoteLacks (catches same-id timestamp drift)', /reconcileLocalStorageWithEvolu[\s\S]{0,2500}localHasRowsRemoteLacks\(state\.importedData,\s*remoteImported\)/.test(syncReconcileSrc)); assert('Reconciliation force-pushes when local has unsynced rows', - /reconcileLocalStorageWithEvolu[\s\S]{0,4000}_pushProfile\(state\.currentProfile,\s*state\.importedData,\s*\{\s*force:\s*true\s*\}\)/.test(syncReconcileSrc)); + /reconcileLocalStorageWithEvolu[\s\S]{0,4000}_pushProfile\(state\.currentProfile,\s*state\.importedData,\s*\{\s*force:\s*true,\s*skipUnchanged:\s*true\s*\}\)/.test(syncReconcileSrc)); // Defence-in-depth: the regression we're guarding against was id-only // comparison, so explicitly assert the OLD shape is gone. Hard to write // without false-positives — this regex matches the v1.7.x id-set diff