From 60e16bba4fdcd728bb6fde16cf05a2c610694cd3 Mon Sep 17 00:00:00 2001 From: Real1ty Date: Tue, 9 Jun 2026 10:58:36 +0200 Subject: [PATCH] chore: full resync of prisma-calendar from monorepo --- e2e/README.md | 55 -- e2e/fixtures/analytics-helpers.ts | 29 - e2e/fixtures/api-helpers.ts | 91 -- e2e/fixtures/calendar-helpers.ts | 157 ---- e2e/fixtures/color-assertions.ts | 15 - e2e/fixtures/commands.ts | 45 - e2e/fixtures/constants.ts | 12 - e2e/fixtures/dates.ts | 108 --- e2e/fixtures/dsl/batch.ts | 60 -- e2e/fixtures/dsl/bulk.ts | 49 - e2e/fixtures/dsl/calendar.ts | 814 ----------------- e2e/fixtures/dsl/categories-settings.ts | 77 -- e2e/fixtures/dsl/drag.ts | 140 --- e2e/fixtures/dsl/event.ts | 221 ----- e2e/fixtures/dsl/events-modal.ts | 545 ----------- e2e/fixtures/dsl/index.ts | 67 -- e2e/fixtures/dsl/shared.ts | 380 -------- e2e/fixtures/dsl/templates.ts | 78 -- e2e/fixtures/electron.ts | 553 ------------ e2e/fixtures/frontmatter-assertions.ts | 62 -- e2e/fixtures/helpers.ts | 572 ------------ e2e/fixtures/history-helpers.ts | 213 ----- e2e/fixtures/ics-server.ts | 172 ---- e2e/fixtures/ics-subscription.ts | 182 ---- e2e/fixtures/plugin-data.ts | 38 - e2e/fixtures/resilience-helpers.ts | 288 ------ e2e/fixtures/seed-events.ts | 195 ---- e2e/fixtures/selectors.ts | 39 - e2e/fixtures/stress-helpers.ts | 258 ------ e2e/fixtures/testids.ts | 414 --------- e2e/fixtures/tour.ts | 83 -- e2e/fixtures/vault-seed/.gitkeep | 0 e2e/fixtures/vault-seed/Welcome.md | 4 - e2e/fixtures/viewport.ts | 239 ----- e2e/fixtures/window-types.ts | 156 ---- e2e/obsidian-version.json | 4 - e2e/playwright.config.ts | 99 -- e2e/preview/collect-artifacts.mjs | 58 -- e2e/preview/tutorial-walkthrough.spec.ts | 50 - e2e/setup/bootstrap.spec.ts | 36 - e2e/setup/global-setup.ts | 0 .../analytics/calendar-connections.spec.ts | 67 -- .../analytics/capacity-indicator.spec.ts | 28 - .../analytics/categories-assignment.spec.ts | 43 - e2e/specs/analytics/color-rules.spec.ts | 37 - e2e/specs/analytics/dashboard-charts.spec.ts | 63 -- e2e/specs/analytics/event-presets.spec.ts | 70 -- e2e/specs/analytics/event-preview.spec.ts | 52 -- .../event-series-modal-controls.spec.ts | 159 ---- e2e/specs/analytics/events-list-modal.spec.ts | 206 ----- .../analytics/events-modal-actions.spec.ts | 221 ----- .../events-modal-recurring-controls.spec.ts | 154 ---- .../analytics/filtered-events-modal.spec.ts | 43 - e2e/specs/analytics/gantt.spec.ts | 209 ----- e2e/specs/analytics/global-search.spec.ts | 35 - e2e/specs/analytics/heatmap-populated.spec.ts | 28 - .../analytics/monthly-calendar-stats.spec.ts | 74 -- e2e/specs/analytics/multi-color-views.spec.ts | 118 --- e2e/specs/analytics/pro-gates.spec.ts | 22 - .../analytics/skipped-events-modal.spec.ts | 63 -- .../statistics-populated-numeric.spec.ts | 93 -- .../analytics/statistics-populated.spec.ts | 35 - .../analytics/timeline-populated.spec.ts | 26 - .../analytics/title-autocomplete.spec.ts | 102 --- .../analytics/untracked-dropdown.spec.ts | 28 - .../analytics/view-switching-smoke.spec.ts | 29 - e2e/specs/api/window-api-ai.spec.ts | 40 - e2e/specs/api/window-api-batch.spec.ts | 130 --- .../api/window-api-cross-calendar.spec.ts | 141 --- e2e/specs/api/window-api-crud.spec.ts | 184 ---- e2e/specs/api/window-api-license.spec.ts | 62 -- e2e/specs/api/window-api-lifecycle.spec.ts | 264 ------ .../api/window-api-modal-actions.spec.ts | 149 --- e2e/specs/api/window-api-navigation.spec.ts | 78 -- e2e/specs/api/window-api-readonly.spec.ts | 177 ---- e2e/specs/api/window-api-settings.spec.ts | 100 -- e2e/specs/api/window-api-statistics.spec.ts | 142 --- .../calendar/bases-calendar-view.spec.ts | 41 - e2e/specs/calendar/batch-categories.spec.ts | 63 -- e2e/specs/calendar/batch-move-by.spec.ts | 57 -- e2e/specs/calendar/batch-selection.spec.ts | 173 ---- e2e/specs/calendar/context-menu.spec.ts | 140 --- e2e/specs/calendar/drag-reschedule.spec.ts | 32 - e2e/specs/calendar/dual-daily.spec.ts | 26 - e2e/specs/calendar/list-view.spec.ts | 45 - e2e/specs/calendar/month-view.spec.ts | 30 - e2e/specs/calendar/navigation-history.spec.ts | 113 --- e2e/specs/calendar/navigation.spec.ts | 105 --- e2e/specs/calendar/resize.spec.ts | 39 - e2e/specs/calendar/search-and-filter.spec.ts | 53 -- e2e/specs/calendar/week-view.spec.ts | 48 - e2e/specs/calendar/year-view.spec.ts | 69 -- .../cross-view-category-change.spec.ts | 26 - .../cross-view-drag-reschedule.spec.ts | 56 -- .../cross-view-event-create.spec.ts | 45 - .../cross-view-event-delete.spec.ts | 46 - .../cross-view/cross-view-event-edit.spec.ts | 38 - e2e/specs/cross-view/cross-view-gantt.spec.ts | 118 --- .../empty-state-across-views.spec.ts | 68 -- .../empty-state-no-calendar.spec.ts | 70 -- .../cross-view/gantt-to-calendar.spec.ts | 82 -- .../cross-view/invisible-mutation.spec.ts | 97 -- .../cross-view/lateral-propagation.spec.ts | 123 --- e2e/specs/cross-view/mobile-gallery.spec.ts | 217 ----- .../multi-calendar-isolation.spec.ts | 95 -- .../cross-view/multi-view-fanout.spec.ts | 104 --- .../settings-to-view-reactivity.spec.ts | 92 -- .../cross-view/subscription-cleanup.spec.ts | 77 -- e2e/specs/edge-cases/dst-transition.spec.ts | 15 - .../edge-cases/large-dataset-smoke.spec.ts | 54 -- .../edge-cases/leap-year-recurring.spec.ts | 20 - e2e/specs/edge-cases/long-title.spec.ts | 16 - .../edge-cases/midnight-crossing.spec.ts | 19 - e2e/specs/edge-cases/unicode-title.spec.ts | 17 - e2e/specs/edge-cases/year-boundary.spec.ts | 12 - .../events/batch-frontmatter-modal.spec.ts | 126 --- e2e/specs/events/cancel-modal.spec.ts | 82 -- e2e/specs/events/context-menu-breadth.spec.ts | 250 ----- .../context-menu-series-shortcuts.spec.ts | 129 --- e2e/specs/events/create-allday.spec.ts | 40 - .../events/create-from-calendar-click.spec.ts | 45 - .../events/create-modal-all-fields.spec.ts | 110 --- e2e/specs/events/creation-paths.spec.ts | 78 -- .../events/custom-property-delete.spec.ts | 149 --- e2e/specs/events/data-integrity.spec.ts | 149 --- e2e/specs/events/delete-event.spec.ts | 22 - e2e/specs/events/drag-move.spec.ts | 85 -- e2e/specs/events/drag-untracked.spec.ts | 156 ---- e2e/specs/events/drag-zone-conversion.spec.ts | 110 --- .../events/edit-modal-all-fields.spec.ts | 162 ---- e2e/specs/events/edit-modal-minimize.spec.ts | 102 --- e2e/specs/events/events-helpers.ts | 432 --------- e2e/specs/events/fill-event-modal.ts | 284 ------ e2e/specs/events/frontmatter-schema.spec.ts | 61 -- e2e/specs/events/modal-ux.spec.ts | 104 --- e2e/specs/events/move-by-modal.spec.ts | 64 -- .../events/multiple-events-isolation.spec.ts | 32 - .../events/prerequisites-context-menu.spec.ts | 115 --- .../prerequisites-dependency-graph.spec.ts | 96 -- e2e/specs/events/preset-apply.spec.ts | 55 -- .../events/recurring-custom-interval.spec.ts | 79 -- .../events/recurring-delete-contract.spec.ts | 93 -- .../events/recurring-drag-instance.spec.ts | 94 -- .../events/recurring-propagation.spec.ts | 65 -- .../events/recurring-rrule-change.spec.ts | 74 -- .../recurring-virtual-instances.spec.ts | 265 ------ e2e/specs/events/recurring.spec.ts | 118 --- e2e/specs/events/robustness-helpers.ts | 169 ---- e2e/specs/events/save-as-preset.spec.ts | 107 --- e2e/specs/events/untracked-lifecycle.spec.ts | 80 -- e2e/specs/events/validation.spec.ts | 21 - e2e/specs/events/virtual-toggle.spec.ts | 78 -- .../history/batch-toggle-selection.spec.ts | 100 -- .../history/context-menu-every-item.spec.ts | 46 - .../context-menu-per-item-undo.spec.ts | 97 -- e2e/specs/history/cross-calendar-undo.spec.ts | 140 --- e2e/specs/history/multi-step-undo.spec.ts | 141 --- .../history/undo-boundary-reload.spec.ts | 39 - .../undo-from-non-calendar-focus.spec.ts | 104 --- e2e/specs/history/undo-redo-batch.spec.ts | 156 ---- .../history/undo-redo-single-event.spec.ts | 151 ---- .../integrations/caldav-add-modal.spec.ts | 110 --- .../integrations/cross-calendar-move.spec.ts | 174 ---- .../integrations/expression-filter.spec.ts | 41 - .../external-file-mutation.spec.ts | 81 -- e2e/specs/integrations/filter-presets.spec.ts | 48 - .../integrations/highlight-commands.spec.ts | 40 - e2e/specs/integrations/ics-export.spec.ts | 82 -- e2e/specs/integrations/ics-roundtrip.spec.ts | 334 ------- .../ics-subscription-refresh.spec.ts | 143 --- .../integrations/ics-subscription.spec.ts | 176 ---- .../integrations/notifications-firing.spec.ts | 76 -- .../integrations/stopwatch-lifecycle.spec.ts | 268 ------ .../virtual-events-block-render.spec.ts | 107 --- .../integrations/virtual-events-file.spec.ts | 36 - e2e/specs/integrations/zettel-id.spec.ts | 47 - e2e/specs/onboarding/tutorial.spec.ts | 54 -- .../resilience/corrupt-data-json.spec.ts | 50 - .../resilience/duplicate-zettel-ids.spec.ts | 39 - .../resilience/malformed-frontmatter.spec.ts | 92 -- .../resilience/mid-operation-reload.spec.ts | 26 - .../plugin-disable-reenable.spec.ts | 25 - .../reload-preserves-events.spec.ts | 138 --- .../reload-preserves-settings.spec.ts | 61 -- .../reload-reopens-active-calendar.spec.ts | 30 - .../resilience/unreadable-event-file.spec.ts | 50 - e2e/specs/settings/bases-tab.spec.ts | 24 - e2e/specs/settings/calendar-crud.spec.ts | 128 --- e2e/specs/settings/categories-rename.spec.ts | 89 -- .../settings/categories-undo-redo.spec.ts | 88 -- .../settings/categories-untracked.spec.ts | 109 --- .../settings/category-color-picker.spec.ts | 64 -- e2e/specs/settings/configuration-tab.spec.ts | 52 -- e2e/specs/settings/configure-calendar.spec.ts | 295 ------ e2e/specs/settings/general-tab.spec.ts | 76 -- .../settings/index-subdirectories.spec.ts | 54 -- e2e/specs/settings/indexing-stats.spec.ts | 36 - e2e/specs/settings/input-debounce.spec.ts | 84 -- .../settings/multi-calendar-lifecycle.spec.ts | 395 -------- .../settings/normalization-conflict.spec.ts | 206 ----- e2e/specs/settings/notifications-tab.spec.ts | 37 - e2e/specs/settings/performance-tab.spec.ts | 23 - .../settings/pro-license-activation.spec.ts | 125 --- e2e/specs/settings/properties-tab.spec.ts | 88 -- e2e/specs/settings/search.spec.ts | 43 - e2e/specs/settings/settings-transfer.spec.ts | 192 ---- .../shared-directory-isolation.spec.ts | 205 ----- .../settings/subscription-button.spec.ts | 46 - e2e/specs/settings/title-property.spec.ts | 224 ----- e2e/specs/shared/collapsible-section.spec.ts | 35 - e2e/specs/shared/confirmation-modal.spec.ts | 83 -- e2e/specs/shared/context-menu-manager.spec.ts | 57 -- .../manager-customization-persistence.spec.ts | 220 ----- e2e/specs/shared/notice-toasts.spec.ts | 89 -- e2e/specs/shared/page-header-manager.spec.ts | 30 - e2e/specs/shared/page-header-overflow.spec.ts | 257 ------ .../page-header-reorder-and-persist.spec.ts | 91 -- e2e/specs/shared/progress-modal.spec.ts | 65 -- .../shared/tabbed-container-rename.spec.ts | 0 .../shared/tabbed-container-reorder.spec.ts | 0 .../bulk-batch-delete-undo-redo.spec.ts | 115 --- .../stress/bulk-batch-move-reactivity.spec.ts | 160 ---- e2e/specs/stress/bulk-skip-clone.spec.ts | 116 --- e2e/specs/stress/color-reactivity.spec.ts | 115 --- .../stress/huge-vault-reactivity.spec.ts | 227 ----- e2e/specs/stress/large-vault-render.spec.ts | 61 -- e2e/specs/stress/rapid-fire-mutations.spec.ts | 141 --- e2e/tsconfig.json | 12 - shared-react/package.json | 2 +- .../src/hooks/settings/use-schema-field.ts | 11 +- shared/package.json | 2 +- shared/src/testing/e2e/bootstrap.ts | 851 ------------------ shared/src/testing/e2e/build.ts | 158 ---- shared/src/testing/e2e/dom.ts | 52 -- shared/src/testing/e2e/forms.ts | 149 --- shared/src/testing/e2e/frontmatter.ts | 101 --- shared/src/testing/e2e/global-setup.ts | 138 --- shared/src/testing/e2e/helpers.ts | 78 -- shared/src/testing/e2e/index.ts | 58 -- .../src/testing/e2e/infra-error-reporter.ts | 54 -- shared/src/testing/e2e/infra-errors.ts | 67 -- shared/src/testing/e2e/plugin-bootstrap.ts | 249 ----- shared/src/testing/e2e/seed.ts | 54 -- shared/src/testing/e2e/types.ts | 64 -- shared/src/testing/e2e/vault-prefix.ts | 51 -- shared/src/testing/stress/artifact-paths.ts | 27 - shared/src/testing/stress/budgets.ts | 41 - .../testing/stress/collectors/cdp-metrics.ts | 54 -- .../testing/stress/collectors/cpu-profiler.ts | 45 - .../stress/collectors/heap-profiler.ts | 44 - .../testing/stress/collectors/perf-bridge.ts | 30 - shared/src/testing/stress/environment.ts | 40 - shared/src/testing/stress/heap-digest.ts | 183 ---- shared/src/testing/stress/index.ts | 103 --- shared/src/testing/stress/metrics.ts | 44 - shared/src/testing/stress/profile-digest.ts | 186 ---- shared/src/testing/stress/profile-tree.ts | 109 --- shared/src/testing/stress/regression.ts | 84 -- shared/src/testing/stress/reporters/html.ts | 399 -------- shared/src/testing/stress/reporters/json.ts | 66 -- .../src/testing/stress/reporters/markdown.ts | 163 ---- shared/src/testing/stress/runner/repeat.ts | 25 - shared/src/testing/stress/seeded-random.ts | 70 -- shared/src/testing/stress/source-map.ts | 71 -- shared/src/testing/stress/types.ts | 255 ------ shared/src/testing/stress/vault/generate.ts | 78 -- shared/tests/stress/artifact-paths.test.ts | 31 - shared/tests/stress/budgets.test.ts | 69 -- shared/tests/stress/heap-digest.test.ts | 186 ---- shared/tests/stress/html-report.test.ts | 55 -- shared/tests/stress/markdown-report.test.ts | 85 -- shared/tests/stress/metrics.test.ts | 37 - shared/tests/stress/profile-digest.test.ts | 99 -- shared/tests/stress/profile-tree.test.ts | 66 -- shared/tests/stress/regression.test.ts | 76 -- shared/tests/stress/repeat.test.ts | 29 - shared/tests/stress/seeded-random.test.ts | 56 -- shared/tests/stress/source-map.test.ts | 109 --- shared/tests/stress/stress-fixtures.ts | 44 - shared/tests/stress/vault-generate.test.ts | 82 -- stress/README.md | 56 -- stress/baselines/.gitkeep | 0 stress/baselines/README.md | 12 - stress/playwright.config.ts | 35 - stress/report.ts | 135 --- stress/scenarios/edit-storm.ts | 25 - stress/scenarios/navigate-months.ts | 70 -- stress/scenarios/open-close-view-loop.ts | 37 - stress/scenarios/render-views.ts | 56 -- stress/specs/calendar-navigation.perf.spec.ts | 117 --- stress/specs/event-edit-storm.perf.spec.ts | 105 --- stress/specs/memory-leak.perf.spec.ts | 144 --- stress/specs/startup.perf.spec.ts | 94 -- stress/specs/view-render.perf.spec.ts | 102 --- stress/specs/year-view.perf.spec.ts | 113 --- stress/stress.config.ts | 75 -- stress/tsconfig.json | 12 - stress/vaults/event-builder.ts | 82 -- stress/vaults/generate-vault.ts | 49 - stress/vaults/profiles.ts | 39 - 300 files changed, 5 insertions(+), 31444 deletions(-) delete mode 100644 e2e/README.md delete mode 100644 e2e/fixtures/analytics-helpers.ts delete mode 100644 e2e/fixtures/api-helpers.ts delete mode 100644 e2e/fixtures/calendar-helpers.ts delete mode 100644 e2e/fixtures/color-assertions.ts delete mode 100644 e2e/fixtures/commands.ts delete mode 100644 e2e/fixtures/constants.ts delete mode 100644 e2e/fixtures/dates.ts delete mode 100644 e2e/fixtures/dsl/batch.ts delete mode 100644 e2e/fixtures/dsl/bulk.ts delete mode 100644 e2e/fixtures/dsl/calendar.ts delete mode 100644 e2e/fixtures/dsl/categories-settings.ts delete mode 100644 e2e/fixtures/dsl/drag.ts delete mode 100644 e2e/fixtures/dsl/event.ts delete mode 100644 e2e/fixtures/dsl/events-modal.ts delete mode 100644 e2e/fixtures/dsl/index.ts delete mode 100644 e2e/fixtures/dsl/shared.ts delete mode 100644 e2e/fixtures/dsl/templates.ts delete mode 100644 e2e/fixtures/electron.ts delete mode 100644 e2e/fixtures/frontmatter-assertions.ts delete mode 100644 e2e/fixtures/helpers.ts delete mode 100644 e2e/fixtures/history-helpers.ts delete mode 100644 e2e/fixtures/ics-server.ts delete mode 100644 e2e/fixtures/ics-subscription.ts delete mode 100644 e2e/fixtures/plugin-data.ts delete mode 100644 e2e/fixtures/resilience-helpers.ts delete mode 100644 e2e/fixtures/seed-events.ts delete mode 100644 e2e/fixtures/selectors.ts delete mode 100644 e2e/fixtures/stress-helpers.ts delete mode 100644 e2e/fixtures/testids.ts delete mode 100644 e2e/fixtures/tour.ts delete mode 100644 e2e/fixtures/vault-seed/.gitkeep delete mode 100644 e2e/fixtures/vault-seed/Welcome.md delete mode 100644 e2e/fixtures/viewport.ts delete mode 100644 e2e/fixtures/window-types.ts delete mode 100644 e2e/obsidian-version.json delete mode 100644 e2e/playwright.config.ts delete mode 100644 e2e/preview/collect-artifacts.mjs delete mode 100644 e2e/preview/tutorial-walkthrough.spec.ts delete mode 100644 e2e/setup/bootstrap.spec.ts delete mode 100644 e2e/setup/global-setup.ts delete mode 100644 e2e/specs/analytics/calendar-connections.spec.ts delete mode 100644 e2e/specs/analytics/capacity-indicator.spec.ts delete mode 100644 e2e/specs/analytics/categories-assignment.spec.ts delete mode 100644 e2e/specs/analytics/color-rules.spec.ts delete mode 100644 e2e/specs/analytics/dashboard-charts.spec.ts delete mode 100644 e2e/specs/analytics/event-presets.spec.ts delete mode 100644 e2e/specs/analytics/event-preview.spec.ts delete mode 100644 e2e/specs/analytics/event-series-modal-controls.spec.ts delete mode 100644 e2e/specs/analytics/events-list-modal.spec.ts delete mode 100644 e2e/specs/analytics/events-modal-actions.spec.ts delete mode 100644 e2e/specs/analytics/events-modal-recurring-controls.spec.ts delete mode 100644 e2e/specs/analytics/filtered-events-modal.spec.ts delete mode 100644 e2e/specs/analytics/gantt.spec.ts delete mode 100644 e2e/specs/analytics/global-search.spec.ts delete mode 100644 e2e/specs/analytics/heatmap-populated.spec.ts delete mode 100644 e2e/specs/analytics/monthly-calendar-stats.spec.ts delete mode 100644 e2e/specs/analytics/multi-color-views.spec.ts delete mode 100644 e2e/specs/analytics/pro-gates.spec.ts delete mode 100644 e2e/specs/analytics/skipped-events-modal.spec.ts delete mode 100644 e2e/specs/analytics/statistics-populated-numeric.spec.ts delete mode 100644 e2e/specs/analytics/statistics-populated.spec.ts delete mode 100644 e2e/specs/analytics/timeline-populated.spec.ts delete mode 100644 e2e/specs/analytics/title-autocomplete.spec.ts delete mode 100644 e2e/specs/analytics/untracked-dropdown.spec.ts delete mode 100644 e2e/specs/analytics/view-switching-smoke.spec.ts delete mode 100644 e2e/specs/api/window-api-ai.spec.ts delete mode 100644 e2e/specs/api/window-api-batch.spec.ts delete mode 100644 e2e/specs/api/window-api-cross-calendar.spec.ts delete mode 100644 e2e/specs/api/window-api-crud.spec.ts delete mode 100644 e2e/specs/api/window-api-license.spec.ts delete mode 100644 e2e/specs/api/window-api-lifecycle.spec.ts delete mode 100644 e2e/specs/api/window-api-modal-actions.spec.ts delete mode 100644 e2e/specs/api/window-api-navigation.spec.ts delete mode 100644 e2e/specs/api/window-api-readonly.spec.ts delete mode 100644 e2e/specs/api/window-api-settings.spec.ts delete mode 100644 e2e/specs/api/window-api-statistics.spec.ts delete mode 100644 e2e/specs/calendar/bases-calendar-view.spec.ts delete mode 100644 e2e/specs/calendar/batch-categories.spec.ts delete mode 100644 e2e/specs/calendar/batch-move-by.spec.ts delete mode 100644 e2e/specs/calendar/batch-selection.spec.ts delete mode 100644 e2e/specs/calendar/context-menu.spec.ts delete mode 100644 e2e/specs/calendar/drag-reschedule.spec.ts delete mode 100644 e2e/specs/calendar/dual-daily.spec.ts delete mode 100644 e2e/specs/calendar/list-view.spec.ts delete mode 100644 e2e/specs/calendar/month-view.spec.ts delete mode 100644 e2e/specs/calendar/navigation-history.spec.ts delete mode 100644 e2e/specs/calendar/navigation.spec.ts delete mode 100644 e2e/specs/calendar/resize.spec.ts delete mode 100644 e2e/specs/calendar/search-and-filter.spec.ts delete mode 100644 e2e/specs/calendar/week-view.spec.ts delete mode 100644 e2e/specs/calendar/year-view.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-category-change.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-drag-reschedule.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-event-create.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-event-delete.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-event-edit.spec.ts delete mode 100644 e2e/specs/cross-view/cross-view-gantt.spec.ts delete mode 100644 e2e/specs/cross-view/empty-state-across-views.spec.ts delete mode 100644 e2e/specs/cross-view/empty-state-no-calendar.spec.ts delete mode 100644 e2e/specs/cross-view/gantt-to-calendar.spec.ts delete mode 100644 e2e/specs/cross-view/invisible-mutation.spec.ts delete mode 100644 e2e/specs/cross-view/lateral-propagation.spec.ts delete mode 100644 e2e/specs/cross-view/mobile-gallery.spec.ts delete mode 100644 e2e/specs/cross-view/multi-calendar-isolation.spec.ts delete mode 100644 e2e/specs/cross-view/multi-view-fanout.spec.ts delete mode 100644 e2e/specs/cross-view/settings-to-view-reactivity.spec.ts delete mode 100644 e2e/specs/cross-view/subscription-cleanup.spec.ts delete mode 100644 e2e/specs/edge-cases/dst-transition.spec.ts delete mode 100644 e2e/specs/edge-cases/large-dataset-smoke.spec.ts delete mode 100644 e2e/specs/edge-cases/leap-year-recurring.spec.ts delete mode 100644 e2e/specs/edge-cases/long-title.spec.ts delete mode 100644 e2e/specs/edge-cases/midnight-crossing.spec.ts delete mode 100644 e2e/specs/edge-cases/unicode-title.spec.ts delete mode 100644 e2e/specs/edge-cases/year-boundary.spec.ts delete mode 100644 e2e/specs/events/batch-frontmatter-modal.spec.ts delete mode 100644 e2e/specs/events/cancel-modal.spec.ts delete mode 100644 e2e/specs/events/context-menu-breadth.spec.ts delete mode 100644 e2e/specs/events/context-menu-series-shortcuts.spec.ts delete mode 100644 e2e/specs/events/create-allday.spec.ts delete mode 100644 e2e/specs/events/create-from-calendar-click.spec.ts delete mode 100644 e2e/specs/events/create-modal-all-fields.spec.ts delete mode 100644 e2e/specs/events/creation-paths.spec.ts delete mode 100644 e2e/specs/events/custom-property-delete.spec.ts delete mode 100644 e2e/specs/events/data-integrity.spec.ts delete mode 100644 e2e/specs/events/delete-event.spec.ts delete mode 100644 e2e/specs/events/drag-move.spec.ts delete mode 100644 e2e/specs/events/drag-untracked.spec.ts delete mode 100644 e2e/specs/events/drag-zone-conversion.spec.ts delete mode 100644 e2e/specs/events/edit-modal-all-fields.spec.ts delete mode 100644 e2e/specs/events/edit-modal-minimize.spec.ts delete mode 100644 e2e/specs/events/events-helpers.ts delete mode 100644 e2e/specs/events/fill-event-modal.ts delete mode 100644 e2e/specs/events/frontmatter-schema.spec.ts delete mode 100644 e2e/specs/events/modal-ux.spec.ts delete mode 100644 e2e/specs/events/move-by-modal.spec.ts delete mode 100644 e2e/specs/events/multiple-events-isolation.spec.ts delete mode 100644 e2e/specs/events/prerequisites-context-menu.spec.ts delete mode 100644 e2e/specs/events/prerequisites-dependency-graph.spec.ts delete mode 100644 e2e/specs/events/preset-apply.spec.ts delete mode 100644 e2e/specs/events/recurring-custom-interval.spec.ts delete mode 100644 e2e/specs/events/recurring-delete-contract.spec.ts delete mode 100644 e2e/specs/events/recurring-drag-instance.spec.ts delete mode 100644 e2e/specs/events/recurring-propagation.spec.ts delete mode 100644 e2e/specs/events/recurring-rrule-change.spec.ts delete mode 100644 e2e/specs/events/recurring-virtual-instances.spec.ts delete mode 100644 e2e/specs/events/recurring.spec.ts delete mode 100644 e2e/specs/events/robustness-helpers.ts delete mode 100644 e2e/specs/events/save-as-preset.spec.ts delete mode 100644 e2e/specs/events/untracked-lifecycle.spec.ts delete mode 100644 e2e/specs/events/validation.spec.ts delete mode 100644 e2e/specs/events/virtual-toggle.spec.ts delete mode 100644 e2e/specs/history/batch-toggle-selection.spec.ts delete mode 100644 e2e/specs/history/context-menu-every-item.spec.ts delete mode 100644 e2e/specs/history/context-menu-per-item-undo.spec.ts delete mode 100644 e2e/specs/history/cross-calendar-undo.spec.ts delete mode 100644 e2e/specs/history/multi-step-undo.spec.ts delete mode 100644 e2e/specs/history/undo-boundary-reload.spec.ts delete mode 100644 e2e/specs/history/undo-from-non-calendar-focus.spec.ts delete mode 100644 e2e/specs/history/undo-redo-batch.spec.ts delete mode 100644 e2e/specs/history/undo-redo-single-event.spec.ts delete mode 100644 e2e/specs/integrations/caldav-add-modal.spec.ts delete mode 100644 e2e/specs/integrations/cross-calendar-move.spec.ts delete mode 100644 e2e/specs/integrations/expression-filter.spec.ts delete mode 100644 e2e/specs/integrations/external-file-mutation.spec.ts delete mode 100644 e2e/specs/integrations/filter-presets.spec.ts delete mode 100644 e2e/specs/integrations/highlight-commands.spec.ts delete mode 100644 e2e/specs/integrations/ics-export.spec.ts delete mode 100644 e2e/specs/integrations/ics-roundtrip.spec.ts delete mode 100644 e2e/specs/integrations/ics-subscription-refresh.spec.ts delete mode 100644 e2e/specs/integrations/ics-subscription.spec.ts delete mode 100644 e2e/specs/integrations/notifications-firing.spec.ts delete mode 100644 e2e/specs/integrations/stopwatch-lifecycle.spec.ts delete mode 100644 e2e/specs/integrations/virtual-events-block-render.spec.ts delete mode 100644 e2e/specs/integrations/virtual-events-file.spec.ts delete mode 100644 e2e/specs/integrations/zettel-id.spec.ts delete mode 100644 e2e/specs/onboarding/tutorial.spec.ts delete mode 100644 e2e/specs/resilience/corrupt-data-json.spec.ts delete mode 100644 e2e/specs/resilience/duplicate-zettel-ids.spec.ts delete mode 100644 e2e/specs/resilience/malformed-frontmatter.spec.ts delete mode 100644 e2e/specs/resilience/mid-operation-reload.spec.ts delete mode 100644 e2e/specs/resilience/plugin-disable-reenable.spec.ts delete mode 100644 e2e/specs/resilience/reload-preserves-events.spec.ts delete mode 100644 e2e/specs/resilience/reload-preserves-settings.spec.ts delete mode 100644 e2e/specs/resilience/reload-reopens-active-calendar.spec.ts delete mode 100644 e2e/specs/resilience/unreadable-event-file.spec.ts delete mode 100644 e2e/specs/settings/bases-tab.spec.ts delete mode 100644 e2e/specs/settings/calendar-crud.spec.ts delete mode 100644 e2e/specs/settings/categories-rename.spec.ts delete mode 100644 e2e/specs/settings/categories-undo-redo.spec.ts delete mode 100644 e2e/specs/settings/categories-untracked.spec.ts delete mode 100644 e2e/specs/settings/category-color-picker.spec.ts delete mode 100644 e2e/specs/settings/configuration-tab.spec.ts delete mode 100644 e2e/specs/settings/configure-calendar.spec.ts delete mode 100644 e2e/specs/settings/general-tab.spec.ts delete mode 100644 e2e/specs/settings/index-subdirectories.spec.ts delete mode 100644 e2e/specs/settings/indexing-stats.spec.ts delete mode 100644 e2e/specs/settings/input-debounce.spec.ts delete mode 100644 e2e/specs/settings/multi-calendar-lifecycle.spec.ts delete mode 100644 e2e/specs/settings/normalization-conflict.spec.ts delete mode 100644 e2e/specs/settings/notifications-tab.spec.ts delete mode 100644 e2e/specs/settings/performance-tab.spec.ts delete mode 100644 e2e/specs/settings/pro-license-activation.spec.ts delete mode 100644 e2e/specs/settings/properties-tab.spec.ts delete mode 100644 e2e/specs/settings/search.spec.ts delete mode 100644 e2e/specs/settings/settings-transfer.spec.ts delete mode 100644 e2e/specs/settings/shared-directory-isolation.spec.ts delete mode 100644 e2e/specs/settings/subscription-button.spec.ts delete mode 100644 e2e/specs/settings/title-property.spec.ts delete mode 100644 e2e/specs/shared/collapsible-section.spec.ts delete mode 100644 e2e/specs/shared/confirmation-modal.spec.ts delete mode 100644 e2e/specs/shared/context-menu-manager.spec.ts delete mode 100644 e2e/specs/shared/manager-customization-persistence.spec.ts delete mode 100644 e2e/specs/shared/notice-toasts.spec.ts delete mode 100644 e2e/specs/shared/page-header-manager.spec.ts delete mode 100644 e2e/specs/shared/page-header-overflow.spec.ts delete mode 100644 e2e/specs/shared/page-header-reorder-and-persist.spec.ts delete mode 100644 e2e/specs/shared/progress-modal.spec.ts delete mode 100644 e2e/specs/shared/tabbed-container-rename.spec.ts delete mode 100644 e2e/specs/shared/tabbed-container-reorder.spec.ts delete mode 100644 e2e/specs/stress/bulk-batch-delete-undo-redo.spec.ts delete mode 100644 e2e/specs/stress/bulk-batch-move-reactivity.spec.ts delete mode 100644 e2e/specs/stress/bulk-skip-clone.spec.ts delete mode 100644 e2e/specs/stress/color-reactivity.spec.ts delete mode 100644 e2e/specs/stress/huge-vault-reactivity.spec.ts delete mode 100644 e2e/specs/stress/large-vault-render.spec.ts delete mode 100644 e2e/specs/stress/rapid-fire-mutations.spec.ts delete mode 100644 e2e/tsconfig.json delete mode 100644 shared/src/testing/e2e/bootstrap.ts delete mode 100644 shared/src/testing/e2e/build.ts delete mode 100644 shared/src/testing/e2e/dom.ts delete mode 100644 shared/src/testing/e2e/forms.ts delete mode 100644 shared/src/testing/e2e/frontmatter.ts delete mode 100644 shared/src/testing/e2e/global-setup.ts delete mode 100644 shared/src/testing/e2e/helpers.ts delete mode 100644 shared/src/testing/e2e/index.ts delete mode 100644 shared/src/testing/e2e/infra-error-reporter.ts delete mode 100644 shared/src/testing/e2e/infra-errors.ts delete mode 100644 shared/src/testing/e2e/plugin-bootstrap.ts delete mode 100644 shared/src/testing/e2e/seed.ts delete mode 100644 shared/src/testing/e2e/types.ts delete mode 100644 shared/src/testing/e2e/vault-prefix.ts delete mode 100644 shared/src/testing/stress/artifact-paths.ts delete mode 100644 shared/src/testing/stress/budgets.ts delete mode 100644 shared/src/testing/stress/collectors/cdp-metrics.ts delete mode 100644 shared/src/testing/stress/collectors/cpu-profiler.ts delete mode 100644 shared/src/testing/stress/collectors/heap-profiler.ts delete mode 100644 shared/src/testing/stress/collectors/perf-bridge.ts delete mode 100644 shared/src/testing/stress/environment.ts delete mode 100644 shared/src/testing/stress/heap-digest.ts delete mode 100644 shared/src/testing/stress/index.ts delete mode 100644 shared/src/testing/stress/metrics.ts delete mode 100644 shared/src/testing/stress/profile-digest.ts delete mode 100644 shared/src/testing/stress/profile-tree.ts delete mode 100644 shared/src/testing/stress/regression.ts delete mode 100644 shared/src/testing/stress/reporters/html.ts delete mode 100644 shared/src/testing/stress/reporters/json.ts delete mode 100644 shared/src/testing/stress/reporters/markdown.ts delete mode 100644 shared/src/testing/stress/runner/repeat.ts delete mode 100644 shared/src/testing/stress/seeded-random.ts delete mode 100644 shared/src/testing/stress/source-map.ts delete mode 100644 shared/src/testing/stress/types.ts delete mode 100644 shared/src/testing/stress/vault/generate.ts delete mode 100644 shared/tests/stress/artifact-paths.test.ts delete mode 100644 shared/tests/stress/budgets.test.ts delete mode 100644 shared/tests/stress/heap-digest.test.ts delete mode 100644 shared/tests/stress/html-report.test.ts delete mode 100644 shared/tests/stress/markdown-report.test.ts delete mode 100644 shared/tests/stress/metrics.test.ts delete mode 100644 shared/tests/stress/profile-digest.test.ts delete mode 100644 shared/tests/stress/profile-tree.test.ts delete mode 100644 shared/tests/stress/regression.test.ts delete mode 100644 shared/tests/stress/repeat.test.ts delete mode 100644 shared/tests/stress/seeded-random.test.ts delete mode 100644 shared/tests/stress/source-map.test.ts delete mode 100644 shared/tests/stress/stress-fixtures.ts delete mode 100644 shared/tests/stress/vault-generate.test.ts delete mode 100644 stress/README.md delete mode 100644 stress/baselines/.gitkeep delete mode 100644 stress/baselines/README.md delete mode 100644 stress/playwright.config.ts delete mode 100644 stress/report.ts delete mode 100644 stress/scenarios/edit-storm.ts delete mode 100644 stress/scenarios/navigate-months.ts delete mode 100644 stress/scenarios/open-close-view-loop.ts delete mode 100644 stress/scenarios/render-views.ts delete mode 100644 stress/specs/calendar-navigation.perf.spec.ts delete mode 100644 stress/specs/event-edit-storm.perf.spec.ts delete mode 100644 stress/specs/memory-leak.perf.spec.ts delete mode 100644 stress/specs/startup.perf.spec.ts delete mode 100644 stress/specs/view-render.perf.spec.ts delete mode 100644 stress/specs/year-view.perf.spec.ts delete mode 100644 stress/stress.config.ts delete mode 100644 stress/tsconfig.json delete mode 100644 stress/vaults/event-builder.ts delete mode 100644 stress/vaults/generate-vault.ts delete mode 100644 stress/vaults/profiles.ts diff --git a/e2e/README.md b/e2e/README.md deleted file mode 100644 index 8654b1a5..00000000 --- a/e2e/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Prisma-Calendar — End-to-End Tests - -This directory contains Prisma Calendar's Playwright specs plus the small amount -of Prisma-specific fixture wiring that sits on top of the shared E2E runtime. - -## Run locally - -```bash -# default headless run -pnpm --filter Prisma-Calendar run test:e2e - -# verbose bootstrap + renderer logs -pnpm --filter Prisma-Calendar run test:e2e:verbose -pnpm --filter Prisma-Calendar run test:e2e -- --debug - -# headed / interactive debugging -pnpm --filter Prisma-Calendar run test:e2e:headed -pnpm --filter Prisma-Calendar run test:e2e:ui -pnpm --filter Prisma-Calendar run test:e2e:debug -- specs/plugin-load.spec.ts - -# demo mode -pnpm --filter Prisma-Calendar run test:e2e:demo -- specs/events/create-allday.spec.ts - -# single spec -pnpm --filter Prisma-Calendar run test:e2e -- specs/events/create-allday.spec.ts - -# feature-folder shortcut -pnpm --filter Prisma-Calendar run test:e2e -- --events -``` - -## Local notes - -- First run downloads the pinned Obsidian binary via `obsidian-launcher` unless `OBSIDIAN_BIN` is set. -- Linux headless runs use `xvfb-run`; install `xvfb` if missing. -- Full run logs always land in `e2e/.cache/last-run.log`. -- Failure traces and screenshots land in `e2e/playwright-report/`. - -## Directory shape - -```text -e2e/ -├── fixtures/ # Prisma-specific helpers and selectors -├── specs/ # User journeys grouped by area -├── obsidian-version.json # Pinned Obsidian version for the harness -└── README.md -``` - -The generic runtime lives in [`shared/src/testing/e2e/`](../../shared/src/testing/e2e). - -## Adding a spec - -1. Add a `*.spec.ts` file under `e2e/specs//`. -2. Import `{ expect, test }` from `../fixtures/electron`. -3. Reuse stable locators from `../fixtures/selectors.ts` instead of inlining selectors. -4. Assert persisted state as well as UI state. diff --git a/e2e/fixtures/analytics-helpers.ts b/e2e/fixtures/analytics-helpers.ts deleted file mode 100644 index 9c432e7d..00000000 --- a/e2e/fixtures/analytics-helpers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Page } from "@playwright/test"; - -export { todayStamp } from "./dates"; - -/** - * Dismiss the topmost modal via Escape and wait for the modal count to drop. - * Robust against stacked modals — `first()`-based waits can resolve on a - * sibling that was already open before the call. - */ -export async function closeOpenModal(page: Page): Promise { - const before = await page.locator(".modal").count(); - await page.keyboard.press("Escape"); - await page.waitForFunction((prev) => document.querySelectorAll(".modal").length < prev, before, { - timeout: 5_000, - }); -} - -/** - * Stats views default to "Event Name" aggregation. Flip to "Category" so entry - * rows are keyed by category name — required for deterministic assertions on - * category-aggregated stats. - */ -export async function switchAggregationToCategory(page: Page): Promise { - const button = page.locator('[data-testid="prisma-stats-mode-button"]').first(); - await button.waitFor({ state: "visible", timeout: 5_000 }); - if ((await button.innerText()).trim() === "Event Name") { - await button.click(); - } -} diff --git a/e2e/fixtures/api-helpers.ts b/e2e/fixtures/api-helpers.ts deleted file mode 100644 index 570ddbed..00000000 --- a/e2e/fixtures/api-helpers.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, type Page } from "@playwright/test"; -import type { PrismaCalendarApi } from "@real1ty-obsidian-plugins/external-apis/prisma-calendar"; -import { createTypedApi, pageEvaluateInvoker, type Invoker } from "@real1ty-obsidian-plugins/testing/api-contract"; - -/** - * Shared helpers for the `window.PrismaCalendar.*` contract specs. Two layers: - * - * 1. `createPrismaApi(page)` — thin Prisma-typed wrapper over the shared - * `createTypedApi` factory in `shared/src/testing/api-contract/`. - * Returns a `PrismaCalendarApi`-shaped proxy so specs call - * `api.createEvent({...})` with full type inference instead of - * `(await invoke(...)) as string`. The generated `PrismaCalendarApi` - * interface from `@real1ty-obsidian-plugins/external-apis/prisma-calendar` - * is the authoritative shape — drift between contract and runtime - * surfaces as a compile error in the spec, not a runtime cast surprise. - * - * 2. Poll helpers — wait for indexer / window-api / active-file readiness - * before the next action. The metadata cache + event repository need a - * tick to ingest a freshly created file, and pro-gated actions only - * attach to the window after `licenseManager` fires `expose()`. - */ - -/** Build a typed `PrismaCalendarApi` proxy backed by `pageEvaluateInvoker`. */ -export function createPrismaApi(page: Page): PrismaCalendarApi { - return createTypedApi(page, "PrismaCalendar"); -} - -/** Wait until a single file is indexed by the event repository. */ -export async function waitForApiIndex(api: PrismaCalendarApi, filePath: string): Promise { - await expect - .poll(async () => (await api.getEventByPath({ filePath })) !== null, { - message: `event ${filePath} never appeared in the indexed event repository`, - }) - .toBe(true); -} - -/** Wait until every file in the list is indexed by the event repository. */ -export async function waitForAllIndexed(api: PrismaCalendarApi, filePaths: readonly string[]): Promise { - await expect - .poll(async () => { - const results = await Promise.all(filePaths.map((p) => api.getEventByPath({ filePath: p }))); - return results.every((r) => r !== null); - }) - .toBe(true); -} - -/** - * Wait until a specific action surfaces as a function on `window[globalKey]`. - * Pro-gated actions (anything beyond `isPro`) are only attached after the - * licenseManager subscription fires expose() — `__setProForTesting(true)` - * triggers that synchronously but the test invocation may still arrive - * before Obsidian's event loop flushes. Poll the window directly to gate - * the next invocation. - */ -export async function waitForApiAction(page: Page, globalKey: string, action: string): Promise { - await expect - .poll( - async () => - page.evaluate( - ({ key, name }) => { - const api = (window as unknown as Record)[key] as Record | undefined; - return typeof api?.[name] === "function"; - }, - { key: globalKey, name: action } - ), - { message: `window.${globalKey}.${action} was never exposed` } - ) - .toBe(true); -} - -/** - * Poll `app.workspace.getActiveFile()?.path` until it matches `expectedPath`. - * Used by active-note action specs that need the workspace to settle on the - * opened event before invoking `openEditActiveNoteModal` / `duplicateCurrentEvent`. - */ -export async function waitForActiveFile(page: Page, expectedPath: string): Promise { - await expect - .poll(() => - page.evaluate(() => { - const w = window as unknown as { - app: { workspace: { getActiveFile: () => { path: string } | null } }; - }; - return w.app.workspace.getActiveFile()?.path ?? null; - }) - ) - .toBe(expectedPath); -} - -// Re-export the loose Invoker for the rare callsite that needs the untyped -// surface (drift suites that walk the contract programmatically). -export { type Invoker, pageEvaluateInvoker }; diff --git a/e2e/fixtures/calendar-helpers.ts b/e2e/fixtures/calendar-helpers.ts deleted file mode 100644 index 16df3c11..00000000 --- a/e2e/fixtures/calendar-helpers.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF } from "./constants"; -import { anchorISO, todayISO } from "./dates"; -import type { SeedEventInput } from "./seed-events"; -import { sel, TID, type ViewMode } from "./testids"; -import type { PrismaWindow } from "./window-types"; - -// Prisma-calendar-specific helpers — thin UI wrappers that don't belong in the -// generic `helpers.ts`. Disk-level event seeding + refresh live in -// `seed-events.ts`; this file only adds helpers that touch the rendered -// FullCalendar DOM or drive vault-backed APIs. - -const EVENT_IN_LEAF = `${ACTIVE_CALENDAR_LEAF} ${sel(TID.block)}`; - -// Re-exported from `./dates` (single source of truth for local-TZ builders) -// so existing consumers importing `todayISO` from here keep working. -export { todayISO }; -export { anchorISO }; - -/** - * Convenience builder for seeding a timed event anchored to today. Produces a - * `SeedEventInput` compatible with `seedEvent()` from `seed-events.ts`. - */ -export function todayTimedEvent(title: string, startHour: number, endHour: number): SeedEventInput { - const date = todayISO(); - return { - title, - startDate: `${date}T${String(startHour).padStart(2, "0")}:00`, - endDate: `${date}T${String(endHour).padStart(2, "0")}:00`, - }; -} - -/** - * Convenience builder for seeding a timed event on the anchor Wednesday. - * Prefer over `todayTimedEvent` — the anchor stays mid-week/mid-month, - * preventing flakes near week/month boundaries. - */ -export function anchorTimedEvent(title: string, startHour: number, endHour: number): SeedEventInput { - const date = anchorISO(); - return { - title, - startDate: `${date}T${String(startHour).padStart(2, "0")}:00`, - endDate: `${date}T${String(endHour).padStart(2, "0")}:00`, - }; -} - -/** - * Create an event through Obsidian's own vault API so `app.vault` and the - * metadataCache treat it as a first-class file. Use this instead of - * `seedEvent` (which writes to disk directly) whenever a command needs the - * TFile handle — notably `batch-duplicate-selection` and `batch-clone-*`, - * which call `getTFileOrThrow` on the source path. - */ -export async function seedEventViaVault( - page: Page, - event: { title: string; date: string; startTime?: string; endTime?: string; subdir?: string } -): Promise { - const subdir = event.subdir ?? "Events"; - const relativePath = `${subdir}/${event.title}.md`; - const contents = - `---\n` + - `Start Date: ${event.date}T${event.startTime ?? "10:00"}\n` + - `End Date: ${event.date}T${event.endTime ?? "11:00"}\n` + - `---\n\n# ${event.title}\n`; - await page.evaluate( - async ({ path, body }) => { - const w = window as unknown as PrismaWindow; - const parts = path.split("/"); - parts.pop(); - if (parts.length > 0) { - const folderPath = parts.join("/"); - if (!(await w.app.vault.adapter?.exists(folderPath))) { - await w.app.vault.createFolder(folderPath).catch(() => {}); - } - } - const existing = w.app.vault.getAbstractFileByPath(path); - if (existing) await w.app.vault.modify(existing, body); - else await w.app.vault.create(path, body); - }, - { path: relativePath, body: contents } - ); - return relativePath; -} - -/** Locator for a calendar event in the active leaf, matched by its title. */ -export function eventByTitle(page: Page, title: string): Locator { - return page.locator(`${EVENT_IN_LEAF}[data-event-title="${title}"]`).first(); -} - -/** Wait for an event with the given title to render in the active leaf. */ -export async function waitForEvent(page: Page, title: string, timeout = 10_000): Promise { - await eventByTitle(page, title).waitFor({ state: "visible", timeout }); -} - -// Re-exported from the canonical location in `fixtures/dsl/drag.ts`. -export { dragByDelta } from "./dsl/drag"; - -/** - * Click the Day view button in the calendar toolbar so events seeded for today - * are on screen. FullCalendar auto-disables Today when the visible date is - * already today, so we only click it when it's enabled. - */ -export async function gotoToday(page: Page): Promise { - const dayBtn = page.locator(sel(TID.toolbar("view-day"))).first(); - await dayBtn.waitFor({ state: "visible" }); - await dayBtn.click(); - await page.locator(".fc-timegrid").first().waitFor({ state: "visible" }); - const todayBtn = page.locator(sel(TID.toolbar("today"))).first(); - if (await todayBtn.isEnabled().catch(() => false)) { - await todayBtn.click(); - } -} - -/** Right-click an event by title and return the opened menu locator. */ -export async function rightClickEventByTitle(page: Page, title: string): Promise { - const block = eventByTitle(page, title); - await block.waitFor({ state: "visible" }); - await block.click({ button: "right" }); - return page.locator(".menu").last(); -} - -// FullCalendar applies `.fc-{viewType}-view` on the outer view container. We -// also accept the generic `.fc-timegrid` / `.fc-daygrid` / `.fc-list` fallback -// because the specific class isn't always stamped until the first paint cycle. -const VIEW_READY_SELECTOR: Record = { - day: ".fc-timeGridDay-view, .fc-timegrid", - week: ".fc-timeGridWeek-view, .fc-timegrid", - month: ".fc-dayGridMonth-view, .fc-daygrid", - list: ".fc-listWeek-view, .fc-list", -}; - -/** - * Click the toolbar button for the given FullCalendar view and wait for the - * view container to render. Mirrors the four registered view types in - * `calendar-view.ts` (`dayGridMonth`, `timeGridWeek`, `timeGridDay`, `listWeek`). - */ -export async function switchToView(page: Page, view: ViewMode): Promise { - const btn = page.locator(sel(TID.toolbar(`view-${view}`))).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - await page.locator(VIEW_READY_SELECTOR[view]).first().waitFor({ state: "visible" }); -} - -/** - * Locator for a list-view row matched by its title. FullCalendar renders list - * events as `.fc-list-event` rows with a `.fc-list-event-title` cell; the row - * itself (not the title cell) is what `hasText` filters. - */ -export function listEventRow(page: Page, title: string): Locator { - return page.locator(`${ACTIVE_CALENDAR_LEAF} .fc-list-event`).filter({ hasText: title }).first(); -} - -/** Locator for a `dayGridMonth` cell keyed by ISO date (`YYYY-MM-DD`). */ -export function monthCellForDate(page: Page, isoDate: string): Locator { - return page.locator(`${ACTIVE_CALENDAR_LEAF} .fc-daygrid-day[data-date="${isoDate}"]`).first(); -} diff --git a/e2e/fixtures/color-assertions.ts b/e2e/fixtures/color-assertions.ts deleted file mode 100644 index 4de66f08..00000000 --- a/e2e/fixtures/color-assertions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, type Locator } from "@playwright/test"; - -export async function expectBackgroundColor(locator: Locator, hex: string): Promise { - await expect - .poll( - () => - locator.evaluate((el, target) => { - const probe = document.createElement("div"); - probe.style.backgroundColor = target; - return getComputedStyle(el).backgroundColor === probe.style.backgroundColor; - }, hex), - { message: `background should match ${hex}` } - ) - .toBe(true); -} diff --git a/e2e/fixtures/commands.ts b/e2e/fixtures/commands.ts deleted file mode 100644 index f989bd00..00000000 --- a/e2e/fixtures/commands.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { PLUGIN_ID } from "./constants"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -// Run a registered command via Obsidian's command palette — Ctrl/Cmd+P, type, -// Enter. Exercises the real user path: many Prisma commands expose no toolbar -// button at all (ICS export/import, focus expression filter, open filter -// preset selector, highlight commands, add zettel id) and the palette is the -// only UI entry point. See feedback_e2e_click_buttons.md — palette typing is -// the sanctioned fallback for commands that genuinely lack a button. - -export async function runCommand(page: Page, commandName: string): Promise { - const isMac = process.platform === "darwin"; - await page.keyboard.press(isMac ? "Meta+P" : "Control+P"); - const input = page.locator(".prompt-input").first(); - await input.waitFor({ state: "visible" }); - await input.fill(commandName); - await page.locator(".suggestion-item").first().waitFor({ state: "visible" }); - await page.keyboard.press("Enter"); - await page - .locator(".prompt-input") - .first() - .waitFor({ state: "hidden" }) - .catch(() => {}); -} - -/** - * Wait until the default bundle's command manager has no more queued or - * in-flight work. Use after firing the "Prisma Calendar: Undo" / "...: Redo" - * palette command — those callbacks are registered as `void undo(plugin)` so - * the palette closes (and `runCommand` resolves) before the undo actually - * completes. Counts can also be invariant across the mutation (a batch move - * doesn't change `eventStore.length`), so `waitForIndexerToReach` alone is - * not enough of a gate. `CommandManager.whenIdle()` drains both kinds of - * pending work. - */ -export async function waitForCommandManagerIdle(page: Page): Promise { - await page.evaluate(async (pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundle = plugin?.calendarBundles?.[0]; - await bundle?.commandManager.whenIdle(); - }, PLUGIN_ID); -} diff --git a/e2e/fixtures/constants.ts b/e2e/fixtures/constants.ts deleted file mode 100644 index de11c784..00000000 --- a/e2e/fixtures/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Central constants used across every e2e module. Importing from here — -// instead of redeclaring — keeps refactors of the plugin id or leaf-scoping -// selector a one-line change. - -export const PLUGIN_ID = "prisma-calendar"; -export const DEFAULT_CALENDAR_ID = "default"; - -// Multiple calendar views can be open in parallel tabs. Obsidian renders the -// inactive leaves' DOM but keeps them visually hidden, so a bare `.first()` -// match can land on an inactive tab's button. Scoping locators to the active -// workspace leaf keeps the click pointed at the view the user is looking at. -export const ACTIVE_CALENDAR_LEAF = ".workspace-leaf.mod-active"; diff --git a/e2e/fixtures/dates.ts b/e2e/fixtures/dates.ts deleted file mode 100644 index f593f353..00000000 --- a/e2e/fixtures/dates.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Deterministic date-string builders used across specs. Centralised here so -// every suite shares one implementation — prior fragmentation had today-ISO -// logic duplicated across history-helpers, analytics-helpers, and -// calendar-helpers. All output is local-time, matching the "local-time-as-UTC" -// convention Prisma stores in frontmatter (see ics-export.ts). -// -// TWO families of date helpers — pick the right one for your view: -// -// `todayStamp` / `todayISO` / `isoLocal` -// Use for specs that assert on NON-CALENDAR views: timeline, dashboard, -// heatmap, stats, list. These views default to "today" and have no -// equivalent of `goToAnchor()` — anchor-dated events fall outside their -// default viewport and assertions fail silently. -// -// `fromAnchor` / `anchorISO` / `anchorDayISO` -// Use ONLY for specs that operate exclusively within the FullCalendar -// week/day view AND call `calendar.goToAnchor()`. The anchor (most recent -// Wednesday) keeps events mid-week regardless of when the suite runs, -// preventing week-boundary flakes. Do NOT use for cross-view specs that -// switch to timeline, dashboard, heatmap, or stats. - -const pad = (n: number): string => String(n).padStart(2, "0"); - -/** `YYYY-MM-DD` for today in local TZ. */ -export function todayISO(): string { - const d = new Date(); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -} - -/** `YYYY-MM-DDTHH:mm` for today at the given hour/minute in local TZ. */ -export function todayStamp(hours: number, minutes = 0): string { - return `${todayISO()}T${pad(hours)}:${pad(minutes)}`; -} - -/** `YYYY-MM-DDTHH:mm` for a date N days from today at the given hour/minute. */ -export function isoLocal(daysFromToday: number, hh = 10, mm = 0): string { - const d = new Date(); - d.setDate(d.getDate() + daysFromToday); - d.setHours(hh, mm, 0, 0); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -/** `YYYY-MM-DDTHH:mm:ss` for the given Date in local TZ — second-precision variant of `isoLocal`. */ -export function localISOWithSeconds(d: Date): string { - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad( - d.getMinutes() - )}:${pad(d.getSeconds())}`; -} - -/** A Date `minutes` minutes from now (negative values for the past). */ -export function dateOffsetMinutes(minutes: number): Date { - return new Date(Date.now() + minutes * 60_000); -} - -// ─── Anchor helpers ────────────────────────────────────────────────────── -// -// The test suite runs on a moving wall clock. "Today" varies day-of-week, -// which pushes `isoLocal(1, …)` across a week boundary on Saturdays and -// produces flaky failures (see `docs/specs/e2e-date-anchor-robustness.md`). -// -// `anchorDate()` returns the most recent Wednesday at-or-before today. Every -// non-recurring spec seeds relative to the anchor and calls -// `calendar.goToAnchor()` so the viewport shows the anchor's week. Offsets -// in [-3, +3] stay inside that week regardless of the configured -// `firstDayOfWeek`. - -/** JS `getDay()` code for Wednesday. 0 = Sunday, 3 = Wednesday. */ -const ANCHOR_DOW = 3; - -/** - * A stable mid-week reference day for seeding events — the most recent - * Wednesday at-or-before today, midnight local. Wednesday is chosen because - * it sits in the middle of the visible week under every week-start setting, - * so offsets of ±3 days remain inside the same rendered week. - * - * Pair with `calendar.goToAnchor()` so the viewport is deterministic. - */ -export function anchorDate(): Date { - const d = new Date(); - d.setHours(0, 0, 0, 0); - const offset = (d.getDay() - ANCHOR_DOW + 7) % 7; - d.setDate(d.getDate() - offset); - return d; -} - -/** `YYYY-MM-DD` form of `anchorDate()`. */ -export function anchorISO(): string { - return formatISODate(anchorDate()); -} - -/** `YYYY-MM-DD` for anchor + N days. Negative values return dates before the anchor. */ -export function anchorDayISO(daysFromAnchor: number): string { - const d = anchorDate(); - d.setDate(d.getDate() + daysFromAnchor); - return formatISODate(d); -} - -/** `YYYY-MM-DDTHH:mm` for anchor + N days at the given hour/minute. */ -export function fromAnchor(daysFromAnchor: number, hh = 10, mm = 0): string { - const d = anchorDate(); - d.setDate(d.getDate() + daysFromAnchor); - d.setHours(hh, mm, 0, 0); - return `${formatISODate(d)}T${pad(hh)}:${pad(mm)}`; -} - -function formatISODate(d: Date): string { - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -} diff --git a/e2e/fixtures/dsl/batch.ts b/e2e/fixtures/dsl/batch.ts deleted file mode 100644 index 82dc83ef..00000000 --- a/e2e/fixtures/dsl/batch.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF } from "../constants"; -import { sel, TID, type BatchBtnKey } from "../testids"; -import type { EventHandle } from "./event"; - -// BatchHandle — a short-lived wrapper around a batch-selection session. -// Instantiated with a list of EventHandles, enters batch mode, toggles each -// into selection. `.do(action)` fires a batch button; `.confirm()` accepts -// the destructive-action modal; `.exit()` returns to normal mode. -// -// Lifecycle is manual (rather than try/finally) so specs can interleave -// assertions before exiting. `batchActionRoundTrip` in templates.ts wraps the -// full enter → do → exit cycle for the common case. - -export interface BatchHandle { - do(action: BatchBtnKey): Promise; - confirm(): Promise; - exit(): Promise; -} - -const BATCH_SELECT_BTN = `${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar("batch-select"))}`; -const BATCH_EXIT_BTN = `${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar("batch-exit"))}`; -const BATCH_COUNTER = `${ACTIVE_CALENDAR_LEAF} ${sel(TID.batchCounter)}`; - -/** - * Enter batch mode, toggle each given event into selection, return a handle - * on the active session. Assumes every event is currently rendered in the - * active calendar leaf. - */ -export async function openBatch(page: Page, events: readonly EventHandle[]): Promise { - await page.locator(BATCH_SELECT_BTN).first().click(); - await page.locator(BATCH_COUNTER).first().waitFor({ state: "visible" }); - - for (const e of events) { - const block = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.block)}[data-event-title="${e.title}"]`).first(); - await block.waitFor({ state: "visible" }); - await block.click(); - } - - return { - async do(action) { - const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.batch(action))}`).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - - async confirm() { - const confirm = page.locator(sel(TID.batchConfirm)).first(); - await confirm.waitFor({ state: "visible" }); - await confirm.click(); - await confirm.waitFor({ state: "hidden" }); - }, - - async exit() { - const exit = page.locator(BATCH_EXIT_BTN).first(); - if (await exit.isVisible().catch(() => false)) await exit.click(); - }, - }; -} diff --git a/e2e/fixtures/dsl/bulk.ts b/e2e/fixtures/dsl/bulk.ts deleted file mode 100644 index 2fd2a78f..00000000 --- a/e2e/fixtures/dsl/bulk.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { expectEventsNotVisibleByTitle, expectEventsVisibleByTitle, expectTitleCount } from "../history-helpers"; -import type { EventHandle } from "./event"; - -// Bulk operations over a list of EventHandles. Specs that exercise batch ops -// used to repeat `for (const e of events) await e.expectX(...)` 4–6× per test; -// these helpers collapse that to one call. All of them are thin loops around -// the single-event primitives — no new semantics, just less noise. - -export async function expectAllExist(events: readonly EventHandle[], yes: boolean): Promise { - for (const e of events) await e.expectExists(yes); -} - -export async function expectAllFrontmatter( - events: readonly EventHandle[], - key: string, - matcher: (v: unknown) => boolean -): Promise { - for (const e of events) await e.expectFrontmatter(key, matcher); -} - -/** - * Assert every event's rendered tile carries the given `--event-color`. - * Runs the polled reads in parallel so a full-batch repaint check stays - * quick even at 20+ tiles. - */ -export async function expectAllColors(events: readonly EventHandle[], color: string): Promise { - await Promise.all(events.map((e) => e.expectColor(color))); -} - -export async function expectAllVisible(page: Page, events: readonly EventHandle[]): Promise { - await expectEventsVisibleByTitle( - page, - events.map((e) => e.title) - ); -} - -export async function expectAllHidden(page: Page, events: readonly EventHandle[]): Promise { - await expectEventsNotVisibleByTitle( - page, - events.map((e) => e.title) - ); -} - -/** Assert each event's title is currently rendered `n` times in the active leaf. */ -export async function expectAllTitleCount(page: Page, events: readonly EventHandle[], n: number): Promise { - for (const e of events) await expectTitleCount(page, e.title, n); -} diff --git a/e2e/fixtures/dsl/calendar.ts b/e2e/fixtures/dsl/calendar.ts deleted file mode 100644 index 5f176c26..00000000 --- a/e2e/fixtures/dsl/calendar.ts +++ /dev/null @@ -1,814 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; -import type { BootstrappedObsidian } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { - listEventFiles, - openCreateModal, - seedEventFile, - snapshotEventFiles, - waitForNewEventFiles, -} from "../../specs/events/events-helpers"; -import { fillEventModal, saveEventModal, type EventModalInput } from "../../specs/events/fill-event-modal"; -import { runCommand, waitForCommandManagerIdle } from "../commands"; -import { ACTIVE_CALENDAR_LEAF, PLUGIN_ID } from "../constants"; -import { anchorDate, anchorISO, isoLocal } from "../dates"; -import { - getEventCount, - getUntrackedEventCount, - waitForEventCount, - waitForUntrackedEventCount, - type SeedEventInput, -} from "../seed-events"; -import { - DASHBOARD_RANKING_TID, - dashboardItemByTitle, - HEATMAP_CELL_TID, - HEATMAP_CONTAINER_TID, - overflowMenuItem, - PAGE_HEADER_OVERFLOW_TID, - sel, - STATS_DATE_LABEL_TID, - TID, - TIMELINE_CONTAINER_TID, - TIMELINE_ITEM_CLASS, - type ToolbarActionKey, - type ViewMode, - type ViewTabKey, -} from "../testids"; -import type { PrismaPlugin, PrismaWindow } from "../window-types"; -import { openBatch, type BatchHandle } from "./batch"; -import { createEventHandle, type EventHandle } from "./event"; -import { createEventsModalHandle, type EventsModalHandle } from "./events-modal"; -import { expectConfirmationModal } from "./shared"; - -type DashboardGroup = "dashboard-by-name" | "dashboard-by-category"; - -/** - * Mirror of the production `LicenseStatus` shape. Reproduced here (instead of - * imported from `src/`) so the e2e fixtures stay decoupled from the plugin's - * internal type layout — only the wire-shape `licenseManager.status$.next` - * accepts matters for the test seam. - */ -export interface LicenseStatusInput { - state: "none" | "valid" | "expired" | "invalid" | "device_limit" | "error"; - activationsCurrent?: number; - activationsLimit?: number; - expiresAt?: string | null; - errorMessage?: string | null; -} - -// CalendarHandle — root of the E2E DSL. Represents "a live calendar view in -// an Obsidian session". Exposes every operation specs currently reach for -// via a grab-bag of page-level helpers: create + seed events, batch, undo / -// redo, toolbar buttons, view switches, disk assertions. Produced by the -// `calendar` Playwright fixture in `electron.ts` after the calendar view is -// open. -// -// The handle closes over `page` + `vaultDir`; calls always re-query the -// renderer / disk. Dropping the handle between specs is safe — state lives -// in the DOM / vault, not on the handle. - -export interface CalendarHandle { - readonly page: Page; - readonly vaultDir: string; - - /** - * Create a timed event via the toolbar → modal flow. Returns a handle - * pinned to the new path. - * - * Pass `subdir` when driving a calendar whose bundle writes to a directory - * other than the default `Events/` — multi-calendar specs need this so - * the snapshot / wait-for-new-file loop looks in the right folder. - */ - createEvent(input: EventCreate, options?: { subdir?: string }): Promise; - - /** Seed N events with auto-generated titles. Stable titles: ` 1` … ` N`. */ - seedEvents(count: number, options?: SeedOptions): Promise; - - /** Seed arbitrary-titled events via the UI and drain the success-notice stack. */ - seedMany(inputs: readonly EventCreate[]): Promise; - - /** - * Write an event directly to disk (bypassing the create modal) and wait - * for the indexer to ingest it via `vault.on("modify")`. Prefer this over - * hand-rolled `writeFileSync` + `openCalendarReady` sequences — the - * event-count gate guarantees the plugin's reactive store has picked the - * file up before the caller proceeds. - * - * Pass `awaitRender: true` to wait for the seeded block to paint in the - * active calendar leaf before returning — only safe when the seeded date - * is actually inside the currently-visible FC viewport. - */ - seedOnDisk( - title: string, - frontmatter: Record, - options?: { awaitRender?: boolean } - ): Promise; - - /** - * Write a dateless ("untracked") event directly to disk and wait for the - * untracked store to ingest it. Dateless events (no Start/End/Date) never - * enter the tracked `eventStore` — the indexer routes them to - * `untrackedEventStore` — so `seedOnDisk`'s event-count gate would never - * resolve for them. There is no `awaitRender` option: an event with no date - * never paints on the calendar grid. - */ - seedOnDiskUntracked(title: string, frontmatter: Record): Promise; - - /** - * Bulk version of `seedOnDisk` for the common "seed N events, assert on - * downstream rendering" shape (analytics tabs, stats modals, search, etc.). - * Writes every markdown file, then gates on the indexer reaching - * `baseline + N` so the caller proceeds only once every file has been - * ingested. Input mirrors `seedMany`'s `{title, start, end, allDay, date, - * category}` shape so specs don't have to spell out `"Start Date"` / - * `"End Date"` frontmatter keys. Richer fields (prerequisites, - * participants, recurring, custom properties) still require the full - * modal flow — use `seedMany` for those. Duplicate titles within a - * single batch are safe — each gets a unique zettel-ID suffix. - * - * Like `seedOnDisk`, pass `awaitRender: true` to wait for every seeded - * tile to paint in the active calendar leaf — only safe when every - * seeded date sits inside the currently-visible FC viewport. - */ - seedOnDiskMany(events: readonly EventOnDisk[], options?: { awaitRender?: boolean }): Promise; - - /** - * Bulk-seed events to disk with a double-refresh to flush reactive trackers - * (name series, category, etc.). Use this instead of raw `seedEvent` loops - * when the spec opens a modal that reads tracker data (e.g. Event Series Modal). - */ - seedAndStabilize(events: readonly SeedEventInput[]): Promise; - - batch(events: readonly EventHandle[]): Promise; - - undo(times?: number): Promise; - redo(times?: number): Promise; - - /** Wait until the on-disk Events/ tree holds exactly `n` files. */ - expectEventCount(n: number): Promise; - - /** - * Pin a handle on an already-rendered event by title. The underlying file - * is resolved lazily from the block's `data-event-file-path` attribute. - * Use this to right-click / assert on events seeded outside the DSL. - */ - eventByTitle(title: string): Promise; - - /** Click a page-header toolbar action (create-event / daily-stats / refresh / …). */ - clickToolbar(action: ToolbarActionKey): Promise; - - /** - * Click the toolbar "show-recurring" button and resolve to a handle on the - * resulting EventsModal (Recurring / By Category / By Name tabs). Wraps - * the toolbar click + modal-visible wait that every analytics spec repeats. - */ - openEventsModal(): Promise; - - /** Switch the analytics view tab. For group tabs use `switchToGroupChild`. */ - switchView(tab: ViewTabKey): Promise; - - /** Drill into a child inside a group tab (e.g. dashboard → dashboard-by-name). */ - switchToGroupChild(group: ViewTabKey, child: ViewTabKey): Promise; - - /** Click the FullCalendar view-mode button (month/week/day/list). */ - switchMode(mode: ViewMode): Promise; - - /** - * Navigate the active calendar view to `iso` (`YYYY-MM-DD`) via - * FullCalendar's `gotoDate`. Boot-time viewport is whatever FC picks - * from `new Date()` at mount — specs that seed relative to a fixed - * reference should call this to pin the viewport. - */ - goToDate(iso: string): Promise; - - /** - * Shorthand for `goToDate(anchorISO())`. Non-recurring specs should call - * this after `switchMode(...)` so the rendered week contains the - * `fromAnchor(...)` seeds regardless of what day-of-week the suite runs - * on — see `docs/specs/e2e-date-anchor-robustness.md`. - */ - goToAnchor(): Promise; - - /** - * Navigate an embedded FullCalendar (e.g. inside monthly-calendar-stats) to - * the anchor month. `goToAnchor()` drives the main calendar component — - * this method clicks the embedded calendar's own prev button to reach the - * anchor month, then asserts the stats date label updated. - * - * @param gridCellSelector CSS selector for the grid cell that contains the embedded calendar. - */ - goToEmbeddedAnchor(gridCellSelector: string): Promise; - - /** Flip the license to Pro via the `__setProForTesting` seam (guarded by `window.E2E`). */ - unlockPro(): Promise; - - /** - * Drive the licenseManager's `status$` BehaviorSubject (same path the - * verified-activation flow flips) and toggle the testing isPro flag. Used - * by license-surface specs that need to walk free/valid/expired transitions - * without hitting the network. The status keys mirror `LicenseStatusInput` - * in the plugin source. - */ - setLicenseStatus(input: LicenseStatusInput, options: { isPro: boolean }): Promise; - - /** - * Read the count of events held by the currently-active bundle's event - * store. Returns -1 when the bundle is missing — caller asserts on the - * count to surface that case rather than swallowing it. - */ - eventStoreCount(): Promise; - - /** - * Read each bundle's event store keyed by `calendarId`. Used by multi- - * calendar isolation specs to prove that a mutation in calendar A does - * not bleed into calendar B's reactive store. - */ - readPerBundleEventStores(): Promise>; - - /** - * For each title, ask the active bundle's `prerequisiteTracker` whether - * the corresponding event file is `isConnected`. Resolves to a record - * keyed by title; titles that don't match any event resolve to `false`. - */ - isPrereqConnectedByTitle(titles: readonly string[]): Promise>; - - /** - * Seed a virtual event through the production code path - * (`VirtualEventStore.add`) so the auto-created "Virtual Events.md" file - * gets a new row. Returns the created event id. - */ - seedVirtualEvent(event: { title: string; start: string; end: string | null }): Promise; - - /** Open a new workspace leaf hosting the active bundle's calendar view. */ - openInNewLeaf(): Promise; - - /** Count workspace leaves currently rendering the active bundle's view type. */ - leafCount(): Promise; - - /** - * Close every leaf rendering the active bundle's view type beyond the - * first `keep`. Used by subscription-cleanup specs that walk open/close - * cycles to surface stale subscribers. - */ - detachExtraLeaves(keep: number): Promise; - - /** Collapse the left workspace sidebar — used by visual specs that need maximum canvas width. */ - collapseLeftSidebar(): Promise; - - /** Open a markdown file in reading (preview) mode — required for code-block processors to mount. */ - openFileInReadingMode(path: string): Promise; - - /** Click the untracked-events dropdown toggle in the toolbar. */ - openUntrackedDropdown(): Promise; - - /** Fire a registered Obsidian command via the command palette. */ - runCommand(name: string): Promise; - - /** Wait for the shared confirmation modal and click Confirm. Used by destructive flows. */ - confirmDeletion(): Promise; - - /** - * Path of the currently-active workspace file, or `null` if no markdown leaf - * is focused. Use with `expect.poll` to assert which file an action opened — - * Obsidian's link-open APIs don't bubble through DOM, so workspace state is - * the only reliable probe. - */ - activeFilePath(): Promise; - - /** Assert a timeline item rendering `title` is present (or absent) in the timeline view. */ - expectTimelineItem(title: string, present?: boolean): Promise; - - /** Assert the heatmap cell for `iso` carries `data-count=""`. */ - expectHeatmapCount(iso: string, count: number): Promise; - - /** Assert a dashboard ranking row for `title` is present (or absent) in the active group. */ - expectDashboardItem(group: DashboardGroup, title: string, present?: boolean): Promise; -} - -/** - * Shape accepted by `createEvent` / `seedMany`. A direct subset of - * `EventModalInput` — every field is typed into the modal via `fillEventModal`. - * `start` / `end` are `YYYY-MM-DDTHH:mm`; `date` is `YYYY-MM-DD` for all-day. - */ -export type EventCreate = Pick< - EventModalInput, - | "title" - | "start" - | "end" - | "allDay" - | "date" - | "categories" - | "prerequisites" - | "participants" - | "location" - | "icon" - | "skip" - | "markAsDone" - | "breakMinutes" - | "minutesBefore" - | "daysBefore" - | "customProperties" - | "recurring" -> & { - title: string; -}; - -/** - * Minimal event shape supported by `seedOnDiskMany`. Maps straight onto - * Prisma's frontmatter keys — nothing goes through the modal or Zod roundtrip. - * Use `seedMany` (which accepts the full `EventCreate`) for events that need - * prerequisites, participants, or recurring rules. - */ -export interface EventOnDisk { - title: string; - /** Timed start — `YYYY-MM-DDTHH:mm`. Maps to the `Start Date` frontmatter key. */ - start?: string; - /** Timed end — `YYYY-MM-DDTHH:mm`. Maps to the `End Date` frontmatter key. */ - end?: string; - /** All-day date — `YYYY-MM-DD`. Maps to the `Date` frontmatter key. */ - date?: string; - /** Maps to the `All Day` frontmatter key. */ - allDay?: boolean; - /** Single-category assignment — maps to the `Category` frontmatter key. */ - category?: string; - /** Multi-category assignment — maps to the `Category` frontmatter key as a YAML list. */ - categories?: string[]; -} - -export interface SeedOptions { - prefix?: string; - /** First event's start hour (defaults to 8). Each subsequent event +1h. */ - startHour?: number; - /** Days from today for the seeded events (defaults to 1). */ - daysFromToday?: number; -} - -interface CalendarHandleDeps { - obsidian: BootstrappedObsidian; -} - -/** - * Locate the block for `title` in the active calendar leaf. Used by handle - * methods that don't have a cached path (e.g. `eventByTitle`). - */ -function blockByTitle(page: Page, title: string): Locator { - return page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.block)}[data-event-title="${title}"]`).first(); -} - -/** - * Drive FullCalendar's `gotoDate(iso)` on every registered calendar bundle. - * Reaches the FC instance through `plugin.calendarBundles[].viewRef.calendarComponent.calendar` - * — the old `leaf.view.calendar` path broke when the CalendarView was wrapped - * in a tabbed ComponentView (see `prisma-view.ts`). Throws if no bundle has a - * live calendar component. - */ -async function navigateCalendarTo(page: Page, iso: string): Promise { - await page.evaluate( - ({ dateIso, pid }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundles = plugin?.calendarBundles ?? []; - let navigated = false; - for (const bundle of bundles) { - const cal = bundle.viewRef?.calendarComponent?.calendar; - if (cal) { - cal.gotoDate(dateIso); - navigated = true; - } - } - if (!navigated) throw new Error("goToDate: no active calendar component to navigate"); - }, - { dateIso: iso, pid: PLUGIN_ID } - ); -} - -export function createCalendarHandle(deps: CalendarHandleDeps): CalendarHandle { - const page = deps.obsidian.page; - const vaultDir = deps.obsidian.vaultDir; - - const createEvent: CalendarHandle["createEvent"] = async (input, options = {}) => { - const subdir = options.subdir ?? "Events"; - const baseline = snapshotEventFiles(vaultDir, subdir); - await openCreateModal(page); - await fillEventModal(page, input); - await saveEventModal(page); - const [newPath] = await waitForNewEventFiles(vaultDir, baseline, 1, undefined, subdir); - if (!newPath) throw new Error(`createEvent(${input.title}): no new event file appeared in ${subdir}`); - return createEventHandle({ page, vaultDir }, newPath, input.title); - }; - - const switchToGroupChild: CalendarHandle["switchToGroupChild"] = async (group, child) => { - const groupTab = page.locator(sel(TID.viewTab(group))).first(); - await groupTab.waitFor({ state: "visible" }); - await groupTab.click(); - const childTab = page.locator(sel(TID.viewTab(child))).first(); - await childTab.waitFor({ state: "visible" }); - await childTab.click(); - }; - - // Click a page-header toolbar action, transparently reaching it through the - // overflow menu when the responsive header has trimmed it off the bar. Specs - // don't care whether an action is directly visible or in the overflow at the - // current pane width — they just want it invoked. - const clickToolbarAction = async (action: ToolbarActionKey): Promise => { - const direct = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.pageHeader(action))}`).first(); - await direct.waitFor({ state: "attached" }); - if (await direct.isVisible()) { - await direct.click(); - return; - } - // Trimmed off the bar — reach it the way a narrow-pane user would. - const trigger = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(PAGE_HEADER_OVERFLOW_TID)}`).first(); - await trigger.waitFor({ state: "visible" }); - await trigger.click(); - const item = page.locator(sel(overflowMenuItem(action))).first(); - await item.waitFor({ state: "visible" }); - await item.click(); - }; - - return { - page, - vaultDir, - - createEvent, - - async seedEvents(count, options = {}) { - const prefix = options.prefix ?? "Event"; - const startHour = options.startHour ?? 8; - const days = options.daysFromToday ?? 0; - const out: EventHandle[] = []; - for (let i = 0; i < count; i++) { - const handle = await createEvent({ - title: `${prefix} ${i + 1}`, - start: isoLocal(days, startHour + i), - end: isoLocal(days, startHour + i + 1), - }); - out.push(handle); - } - return out; - }, - - async seedMany(inputs) { - const out: EventHandle[] = []; - for (const input of inputs) out.push(await createEvent(input)); - return out; - }, - - async seedOnDisk(title, frontmatter, options = {}) { - const baseline = await getEventCount(page); - const relPath = seedEventFile(vaultDir, title, frontmatter); - await waitForEventCount(page, baseline + 1); - const handle = createEventHandle({ page, vaultDir }, relPath, title); - if (options.awaitRender === true) await handle.expectVisible(); - return handle; - }, - - async seedOnDiskUntracked(title, frontmatter) { - const baseline = await getUntrackedEventCount(page); - const relPath = seedEventFile(vaultDir, title, frontmatter); - await waitForUntrackedEventCount(page, baseline + 1); - return createEventHandle({ page, vaultDir }, relPath, title); - }, - - async seedOnDiskMany(events, options = {}) { - const out: EventHandle[] = []; - const titleCounts = new Map(); - const baseline = await getEventCount(page); - for (const input of events) { - const fm: Record = {}; - if (input.start !== undefined) fm["Start Date"] = input.start; - if (input.end !== undefined) fm["End Date"] = input.end; - if (input.date !== undefined) fm["Date"] = input.date; - if (input.allDay !== undefined) fm["All Day"] = input.allDay; - if (input.categories !== undefined) fm["Category"] = input.categories; - else if (input.category !== undefined) fm["Category"] = input.category; - const count = titleCounts.get(input.title) ?? 0; - titleCounts.set(input.title, count + 1); - const suffix = count === 0 ? "" : String(count).padStart(2, "0"); - const relPath = seedEventFile(vaultDir, input.title, fm, suffix); - out.push(createEventHandle({ page, vaultDir }, relPath, input.title)); - } - await waitForEventCount(page, baseline + events.length); - if (options.awaitRender === true) { - for (const handle of out) await handle.expectVisible(); - } - return out; - }, - - async seedAndStabilize(events) { - const baseline = await getEventCount(page); - // Build file content from SeedEventInput, then create via Obsidian's - // vault API so the metadata cache fires events and downstream reactive - // trackers (name series, category) pick up the new rows. Raw - // writeFileSync bypasses the cache → trackers stay empty. - const files = events.map((event) => { - const subdir = event.subdir ?? "Events"; - const filename = `${event.title.replace(/[/\\:*?"<>|]/g, "-")}.md`; - const fm: Record = {}; - if (event.startDate) fm["Start Date"] = event.startDate; - if (event.endDate) fm["End Date"] = event.endDate; - if (event.date) fm["Date"] = event.date; - if (event.allDay) fm["All Day"] = true; - if (event.category) fm["Category"] = event.category; - if (event.location) fm["Location"] = event.location; - if (event.participants?.length) fm["Participants"] = event.participants; - if (event.rrule) fm["RRule"] = event.rrule; - if (event.rruleSpec) fm["RRuleSpec"] = event.rruleSpec; - if (event.extra) { - for (const [k, v] of Object.entries(event.extra)) fm[k] = v; - } - const fmLines = Object.entries(fm) - .map(([k, v]) => { - if (Array.isArray(v)) - return v.length === 0 ? `${k}: []` : `${k}:\n${v.map((item) => ` - ${String(item)}`).join("\n")}`; - if (typeof v === "string" && (v.includes(":") || v.includes("#"))) - return `${k}: "${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; - return `${k}: ${String(v)}`; - }) - .join("\n"); - return { path: `${subdir}/${filename}`, content: `---\n${fmLines}\n---\n\n# ${event.title}\n` }; - }); - await page.evaluate(async (fileList) => { - const w = window as unknown as PrismaWindow; - for (const f of fileList) await w.app.vault.create(f.path, f.content); - }, files); - await waitForEventCount(page, baseline + events.length); - }, - - async batch(events) { - return openBatch(page, events); - }, - - async undo(times = 1) { - // Palette callbacks for undo/redo register as `void undo(plugin)` — - // Obsidian ignores the return value, so `runCommand` resolves the - // moment the palette closes, NOT when the underlying CommandManager - // has finished. The gate goes once at the end: CommandManager.undo - // is serialized through PromiseQueue, so back-to-back dispatches - // can't race each other in flight, and a single whenIdle() drain - // at the loop's tail is enough for follow-up assertions to see a - // fully settled state. - for (let i = 0; i < times; i++) { - await runCommand(page, "Prisma Calendar: Undo"); - } - await waitForCommandManagerIdle(page); - }, - - async redo(times = 1) { - for (let i = 0; i < times; i++) { - await runCommand(page, "Prisma Calendar: Redo"); - } - await waitForCommandManagerIdle(page); - }, - - async expectEventCount(n) { - await expect.poll(() => listEventFiles(vaultDir).length, { message: `expected ${n} event files` }).toBe(n); - }, - - async eventByTitle(title) { - const block = blockByTitle(page, title); - await block.waitFor({ state: "visible" }); - const relPath = await block.getAttribute("data-event-file-path"); - if (!relPath) throw new Error(`eventByTitle("${title}"): block has no data-event-file-path`); - return createEventHandle({ page, vaultDir }, relPath, title); - }, - - async clickToolbar(action) { - await clickToolbarAction(action); - }, - - async openEventsModal() { - await clickToolbarAction("show-recurring"); - const modal = page.locator(".modal").first(); - await modal.waitFor({ state: "visible" }); - return createEventsModalHandle(page, modal); - }, - - async switchView(tab) { - const el = page.locator(sel(TID.viewTab(tab))).first(); - await el.waitFor({ state: "visible" }); - await el.click(); - }, - - switchToGroupChild, - - async switchMode(mode) { - const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar(`view-${mode}`))}`).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - - async goToDate(iso) { - await navigateCalendarTo(page, iso); - }, - - async goToAnchor() { - await navigateCalendarTo(page, anchorISO()); - }, - - async goToEmbeddedAnchor(gridCellSelector) { - const anchor = anchorDate(); - const now = new Date(); - const monthDiff = (now.getFullYear() - anchor.getFullYear()) * 12 + (now.getMonth() - anchor.getMonth()); - if (monthDiff === 0) return; - const cell = page.locator(gridCellSelector).first(); - for (let i = 0; i < monthDiff; i++) { - await cell.locator(".fc-prev-button").click(); - } - const targetLabel = anchor.toLocaleDateString("en-US", { month: "long", year: "numeric" }); - await expect(page.locator(sel(STATS_DATE_LABEL_TID)).first()).toHaveText(targetLabel); - }, - - async unlockPro() { - await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const lm = plugin?.licenseManager; - if (!lm?.__setProForTesting) { - throw new Error(`licenseManager.__setProForTesting missing on ${pid}`); - } - lm.__setProForTesting(true); - }, PLUGIN_ID); - }, - - async openUntrackedDropdown() { - const toggle = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-untracked-dropdown-button"]`).first(); - await toggle.waitFor({ state: "visible" }); - await toggle.click(); - }, - - async runCommand(name) { - await runCommand(page, name); - }, - - async confirmDeletion() { - const modal = await expectConfirmationModal(page); - await modal.confirm(); - }, - - async activeFilePath() { - return page.evaluate(() => { - const w = window as unknown as PrismaWindow; - return w.app.workspace.getActiveFile()?.path ?? null; - }); - }, - - async expectTimelineItem(title, present = true) { - const container = page.locator(sel(TIMELINE_CONTAINER_TID)).first(); - await container.waitFor({ state: "visible" }); - const item = container.locator(TIMELINE_ITEM_CLASS).filter({ hasText: title }); - if (present) await expect(item.first()).toBeVisible(); - else await expect(item).toHaveCount(0); - }, - - async expectHeatmapCount(iso, count) { - const container = page.locator(sel(HEATMAP_CONTAINER_TID)).first(); - await container.waitFor({ state: "visible" }); - const cell = container.locator(`${sel(HEATMAP_CELL_TID)}[data-date="${iso}"]`).first(); - await expect(cell).toHaveAttribute("data-count", String(count)); - }, - - async expectDashboardItem(group, title, present = true) { - await switchToGroupChild("dashboard", group); - const ranking = page.locator(`${sel(DASHBOARD_RANKING_TID)}:visible`).first(); - const item = ranking.locator(dashboardItemByTitle(title)); - if (present) await expect(item).toBeVisible(); - else await expect(item).toHaveCount(0); - }, - - async setLicenseStatus(input, options) { - await page.evaluate( - ({ pid, status, pro }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const lm = plugin?.licenseManager; - if (!lm) throw new Error("licenseManager missing"); - lm.status$.next({ - state: status.state, - activationsCurrent: status.activationsCurrent ?? 0, - activationsLimit: status.activationsLimit ?? 5, - expiresAt: status.expiresAt ?? null, - errorMessage: status.errorMessage ?? null, - }); - lm.__setProForTesting?.(pro); - }, - { pid: PLUGIN_ID, status: input, pro: options.isPro } - ); - }, - - async eventStoreCount() { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - return bundle?.eventStore.getAllEvents().length ?? -1; - }, PLUGIN_ID); - }, - - async readPerBundleEventStores() { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundles = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles ?? []; - const out: Record = {}; - for (const b of bundles) { - const events = b.eventStore.getAllEvents(); - out[b.calendarId] = { - count: events.length, - titles: events.map((e) => e.title).sort(), - }; - } - return out; - }, PLUGIN_ID); - }, - - async isPrereqConnectedByTitle(titles) { - return page.evaluate( - ({ pid, wanted }) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("bundle missing"); - const events = bundle.eventStore.getAllEvents(); - const result: Record = {}; - for (const title of wanted) { - const found = events.find((e) => e.title === title); - result[title] = found ? bundle.prerequisiteTracker.isConnected(found.ref.filePath) : false; - } - return result; - }, - { pid: PLUGIN_ID, wanted: [...titles] } - ); - }, - - async seedVirtualEvent(event) { - return page.evaluate( - async ({ pid, data }) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("bundle missing"); - const added = await bundle.virtualEventStore.add({ - title: data.title, - start: data.start, - end: data.end, - allDay: false, - properties: {}, - }); - return added.id; - }, - { pid: PLUGIN_ID, data: event } - ); - }, - - async openInNewLeaf() { - await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("bundle missing"); - const leaf = w.app.workspace.getLeaf("tab"); - return leaf.setViewState({ type: bundle.viewType, active: true }); - }, PLUGIN_ID); - }, - - async leafCount() { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) return 0; - return w.app.workspace.getLeavesOfType(bundle.viewType).length; - }, PLUGIN_ID); - }, - - async detachExtraLeaves(keep) { - await page.evaluate( - ({ pid, keepCount }) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) return; - const leaves = w.app.workspace.getLeavesOfType(bundle.viewType); - for (let i = keepCount; i < leaves.length; i++) { - leaves[i].detach(); - } - }, - { pid: PLUGIN_ID, keepCount: keep } - ); - }, - - async collapseLeftSidebar() { - await page.evaluate(() => { - const w = window as unknown as PrismaWindow; - if (w.app.workspace.leftSplit && !w.app.workspace.leftSplit.collapsed) { - w.app.workspace.leftSplit.collapse(); - } - document.querySelector(".workspace-ribbon.mod-left")?.remove(); - }); - }, - - async openFileInReadingMode(path) { - await page.evaluate(async (filePath) => { - const w = window as unknown as PrismaWindow; - await w.app.workspace.openLinkText(filePath, "", false, { state: { mode: "preview" } }); - const leaf = w.app.workspace.getLeaf(false); - await leaf.setViewState({ type: "markdown", state: { file: filePath, mode: "preview" }, active: true }); - }, path); - }, - }; -} diff --git a/e2e/fixtures/dsl/categories-settings.ts b/e2e/fixtures/dsl/categories-settings.ts deleted file mode 100644 index 036f1855..00000000 --- a/e2e/fixtures/dsl/categories-settings.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Locator, type Page } from "@playwright/test"; - -import { - CATEGORY_COUNT_CLASS, - CATEGORY_DELETE_BTN_TID, - CATEGORY_DELETE_PREFIX, - CATEGORY_INCLUDE_UNTRACKED_TOGGLE_TID, - CATEGORY_RENAME_BTN_TID, - CATEGORY_RENAME_PREFIX, - CATEGORY_ROW_TID, - sel, -} from "../testids"; -import { - expectConfirmationModal, - expectRenameModal, - type ConfirmationModalHandle, - type RenameModalHandle, -} from "./shared"; - -export interface CategoryRowHandle { - readonly row: Locator; - countText(): Promise; - openRename(): Promise; - openDelete(): Promise; -} - -export interface CategoryRenameModalHandle extends RenameModalHandle { - readonly toggleUntracked: Locator; - setIncludeUntracked(checked: boolean): Promise; -} - -export interface CategoryDeleteModalHandle extends ConfirmationModalHandle { - readonly toggleUntracked: Locator; - setIncludeUntracked(checked: boolean): Promise; -} - -export function categoryRow(page: Page, category: string): CategoryRowHandle { - const row = page.locator(`${sel(CATEGORY_ROW_TID)}[data-category="${category}"]`); - const includeUntrackedLocator = page.locator(sel(CATEGORY_INCLUDE_UNTRACKED_TOGGLE_TID)); - - return { - row, - async countText() { - await row.waitFor({ state: "visible" }); - return (await row.locator(`.${CATEGORY_COUNT_CLASS}`).textContent()) ?? ""; - }, - async openRename() { - await row.waitFor({ state: "visible" }); - await row.locator(sel(CATEGORY_RENAME_BTN_TID)).click(); - const modal = await expectRenameModal(page, { testIdPrefix: CATEGORY_RENAME_PREFIX }); - return decorateUntracked(modal, includeUntrackedLocator); - }, - async openDelete() { - await row.waitFor({ state: "visible" }); - await row.locator(sel(CATEGORY_DELETE_BTN_TID)).click(); - const modal = await expectConfirmationModal(page, { testIdPrefix: CATEGORY_DELETE_PREFIX }); - return decorateUntracked(modal, includeUntrackedLocator); - }, - }; -} - -function decorateUntracked( - modal: T, - toggleUntracked: Locator -): T & { - readonly toggleUntracked: Locator; - setIncludeUntracked(checked: boolean): Promise; -} { - return { - ...modal, - toggleUntracked, - async setIncludeUntracked(checked: boolean) { - if (checked) await toggleUntracked.check(); - else await toggleUntracked.uncheck(); - }, - }; -} diff --git a/e2e/fixtures/dsl/drag.ts b/e2e/fixtures/dsl/drag.ts deleted file mode 100644 index 818254c0..00000000 --- a/e2e/fixtures/dsl/drag.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; - -// Drag primitives for FullCalendar + Prisma drag flows. Three shapes exist in -// the wild: -// -// 1. Straight drag (drag-select on empty time grid) — mousedown → move → -// mouseup. FC's own range-select handler runs; no dragstart pacing needed. -// 2. Jittered drag (block → drop-target) — adds an initial ~8px move so FC's -// `eventDragMinDistance` threshold is crossed and `dragstart` fires. Without -// the jitter the interaction degrades to a click and `eventDrop` never runs. -// 3. Stepped drag by delta (drag-reschedule) — many small 1-frame moves so -// FC's per-frame debouncer observes every intermediate position. -// -// All three paths used to be hand-rolled or scattered across `events-helpers` -// and `calendar-helpers`. This module is the single entry point: pick the -// variant via the `mode` option. - -export interface Point { - readonly x: number; - readonly y: number; -} - -export interface BoundingBox { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface DragOptions { - /** - * Drag flavour: - * - `"plain"` (default) — mousedown → move → mouseup, with a single `steps` - * parameter on the move. Good for range-select on empty slots. - * - `"jitter"` — adds an initial orthogonal nudge past FC's drag-min-distance - * before the main move. Required for any event-block drag. - * - `"stepped"` — breaks the move into N frames with a `waitForTimeout(15)` - * between each so FC's per-frame handler sees every intermediate position. - * Use for drag-to-resize + FC-native drag-reschedule gestures. - */ - readonly mode?: "plain" | "jitter" | "stepped"; - /** Main-drag interpolation steps. Default depends on mode. */ - readonly steps?: number; - /** Pause after mousedown, before the first move (ms). Default `50` in jitter mode, `0` otherwise. */ - readonly preDragPauseMs?: number; - /** Pause between final move and mouseup (ms). Default `100` in jitter mode, `0` otherwise. */ - readonly preReleasePauseMs?: number; - /** Jitter nudge magnitude (only used in `"jitter"` mode). */ - readonly jitterDx?: number; - readonly jitterDy?: number; - readonly jitterSteps?: number; -} - -/** - * Drag from `from` to `to`. The single entry point for every Prisma drag flow. - * Pick the flavour via `opts.mode`; defaults cover the vast majority of - * FC-native range-selects. - */ -export async function drag(page: Page, from: Point, to: Point, opts: DragOptions = {}): Promise { - const { mode = "plain" } = opts; - const isJitter = mode === "jitter"; - const preDragPauseMs = opts.preDragPauseMs ?? (isJitter ? 50 : 0); - const preReleasePauseMs = opts.preReleasePauseMs ?? (isJitter ? 100 : 0); - const steps = opts.steps ?? (mode === "stepped" ? 15 : 25); - - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - if (preDragPauseMs > 0) await page.waitForTimeout(preDragPauseMs); - - if (isJitter) { - const jitterDx = opts.jitterDx ?? 8; - const jitterDy = opts.jitterDy ?? 0; - const jitterSteps = opts.jitterSteps ?? 4; - await page.mouse.move(from.x + jitterDx, from.y + jitterDy, { steps: jitterSteps }); - } - - if (mode === "stepped") { - const dx = to.x - from.x; - const dy = to.y - from.y; - for (let i = 1; i <= steps; i++) { - await page.mouse.move(from.x + (dx * i) / steps, from.y + (dy * i) / steps); - // 15 ms ≥ 1 frame at 60 Hz so FC's per-frame handler observes every step. - await page.waitForTimeout(15); - } - await page.mouse.move(to.x, to.y); - } else { - await page.mouse.move(to.x, to.y, { steps }); - } - - if (preReleasePauseMs > 0) await page.waitForTimeout(preReleasePauseMs); - await page.mouse.up(); -} - -/** - * Drag a source locator by a delta (dx, dy) in pixels from its bounding-box - * center. Uses the `stepped` gesture by default so FC observes every frame. - */ -export async function dragByDelta( - page: Page, - source: Locator, - dx: number, - dy: number, - opts: DragOptions = {} -): Promise { - const box = await boundingBoxOrThrow(source, "drag source"); - const from = centerOf(box); - const to = { x: from.x + dx, y: from.y + dy }; - await drag(page, from, to, { mode: "stepped", ...opts }); -} - -/** - * Drag from a source locator's center to a target locator's center. Uses - * `jitter` mode by default — that's what event-block drags need. - */ -export async function dragLocatorToLocator( - page: Page, - from: Locator, - to: Locator, - opts: DragOptions = {} -): Promise { - const fromBox = await boundingBoxOrThrow(from, "drag source"); - const toBox = await boundingBoxOrThrow(to, "drag target"); - await drag(page, centerOf(fromBox), centerOf(toBox), { mode: "jitter", ...opts }); -} - -/** - * Resolve a locator's bounding box or throw — saves callers from the - * `expect(box).not.toBeNull(); if (!box) return;` dance Playwright's nullable - * return type forces. - */ -export async function boundingBoxOrThrow(locator: Locator, name = "locator"): Promise { - const box = await locator.boundingBox(); - if (!box) throw new Error(`${name} has no bounding box (detached or not visible)`); - return box; -} - -/** Return the center point of a bounding box. */ -export function centerOf(box: BoundingBox): Point { - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; -} diff --git a/e2e/fixtures/dsl/event.ts b/e2e/fixtures/dsl/event.ts deleted file mode 100644 index 46029b50..00000000 --- a/e2e/fixtures/dsl/event.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { collectInstanceFiles, listEventFiles } from "../../specs/events/events-helpers"; -import { fillEventModal, saveEventModal, type EventModalInput } from "../../specs/events/fill-event-modal"; -import { ACTIVE_CALENDAR_LEAF } from "../constants"; -import { EVENT_BLOCK_TID, sel, TID, type ContextMenuItemKey } from "../testids"; -import type { PrismaWindow } from "../window-types"; - -const MOVE_TO_CALENDAR_MODAL_TID = "prisma-modal-move-to-calendar"; -const MOVE_TO_CALENDAR_CONTROL_TID = "prisma-move-to-calendar-control-calendarId"; -const MOVE_TO_CALENDAR_SUBMIT_TID = "prisma-move-to-calendar-submit"; - -// EventHandle — an on-disk event pinned by vault-relative path, with fluent -// methods for the operations every history / events spec performs: right- -// click → context menu, edit via modal, expect frontmatter, expect existence. -// -// The handle remembers the original path and title from creation. Operations -// that rename the underlying file (title change + zettel regen on redo) are -// out of scope — specs that exercise those paths should drop to the page-level -// helpers. For the common case where the path is stable, this surface -// eliminates the "lookup-file, compare, assert" boilerplate that saturates -// every undo/redo spec. - -export interface EventHandle { - readonly path: string; - readonly title: string; - - edit(changes: EventModalInput): Promise; - rightClick(item: ContextMenuItemKey): Promise; - - /** - * Right-click → "Move to planning system…" → pick `targetCalendarId` → - * submit. The destination modal is driven by `openMoveToCalendarModal` - * (ModalSchemaForm). Returns once the modal has unmounted; callers should - * still poll the disk for the moved file because the rename + frontmatter - * rewrite settle across two `processFrontMatter` calls. - */ - moveToCalendar(targetCalendarId: string): Promise; - - readFrontmatter(key: string): T; - - /** - * Read the `Category` frontmatter key normalised to a string array — - * Obsidian stores single-value assignments as scalars and multi-value - * as YAML lists, and downstream callers always want the list shape. - * Returns an empty array when the key is absent. - */ - readCategory(): string[]; - - /** - * Read the `--event-color` CSS variable the renderer paints on the tile. - * Empty string means "no color rule matched and no inline color override" - * — the tile falls back to the default theme colour. - */ - readColor(): Promise; - - expectExists(yes: boolean): Promise; - expectFrontmatter(key: string, matcher: (v: unknown) => boolean, message?: string): Promise; - - /** - * Polled assertion that `--event-color` equals `expected` (typically a hex - * string from a color rule). The DSL owns the colour read so specs don't - * rethread `style.getPropertyValue` and the locator query themselves. - */ - expectColor(expected: string, message?: string): Promise; - - /** - * Replace the note body (everything after frontmatter) via `app.vault.modify`. - * Preserves frontmatter — only the markdown body is swapped. - */ - writeNoteBody(body: string): Promise; - - /** Hover over the event block in the active calendar leaf. */ - hover(): Promise; - - /** Read the native `title` attribute (tooltip text) from the event block. */ - getTooltip(): Promise; - - /** Wait for (or assert the absence of) the event block in the active calendar leaf. */ - expectVisible(yes?: boolean): Promise; - - /** - * Polled assertion that exactly `count` physical instance files exist on - * disk for this event's title. Use after creating a recurring source to - * wait for the generator to materialise its instances — replaces inline - * `collectInstanceFiles(...).length` polling and any need to force a - * full indexer rescan to observe convergence. - */ - expectInstanceCount(count: number, message?: string): Promise; -} - -interface EventHandleDeps { - page: Page; - vaultDir: string; -} - -/** - * Pin an on-disk event to a handle so later calls don't need to rethread path - * / title / vaultDir. The handle is stateless beyond the stored path — disk - * reads and DOM operations always re-query. - */ -export function createEventHandle(deps: EventHandleDeps, path: string, title: string): EventHandle { - const { page, vaultDir } = deps; - - const block = (): Locator => - page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(EVENT_BLOCK_TID)}[data-event-title="${title}"]`).first(); - - return { - path, - title, - - async edit(changes) { - await this.rightClick("editEvent"); - await fillEventModal(page, changes); - await saveEventModal(page); - }, - - async rightClick(item) { - const el = block(); - await el.waitFor({ state: "visible" }); - await el.click({ button: "right" }); - const menuItem = page.locator(sel(TID.ctxMenu(item))).first(); - await menuItem.waitFor({ state: "visible" }); - // Dispatch a synthetic click: Obsidian's `Menu` shifts the - // `.selected` class onto whichever item the cursor passes through, - // and that hover state intermittently expands the item's hit-box - // enough to intercept pointer events at the target item's center - // — surfacing as a flaky "intercepts pointer events" timeout. - // The handler is wired via Obsidian's `onClick` (a plain click - // listener) so a synthetic click triggers it without the pointer - // stack. - await menuItem.dispatchEvent("click"); - }, - - async moveToCalendar(targetCalendarId) { - await this.rightClick("moveToCalendar"); - const modal = page.locator(sel(MOVE_TO_CALENDAR_MODAL_TID)).first(); - await modal.waitFor({ state: "visible" }); - await modal.locator(sel(MOVE_TO_CALENDAR_CONTROL_TID)).first().selectOption(targetCalendarId); - await modal.locator(sel(MOVE_TO_CALENDAR_SUBMIT_TID)).first().click(); - await modal.waitFor({ state: "hidden" }); - }, - - readFrontmatter(key: string): T { - return readEventFrontmatter(vaultDir, path)[key] as T; - }, - - readCategory() { - const raw = readEventFrontmatter(vaultDir, path).Category; - if (raw == null) return []; - if (Array.isArray(raw)) return raw.map((v) => String(v)); - return [String(raw)]; - }, - - async readColor() { - return block().evaluate((el) => (el as HTMLElement).style.getPropertyValue("--event-color").trim()); - }, - - async expectColor(expected, message) { - await expect - .poll(() => this.readColor(), { message: message ?? `${title}: --event-color != ${expected}` }) - .toBe(expected); - }, - - async expectExists(yes) { - await expect - .poll(() => listEventFiles(vaultDir).some((abs) => abs.endsWith(`/${path}`)), { - message: `${path} existence != ${yes}`, - }) - .toBe(yes); - }, - - async expectFrontmatter(key, matcher, message) { - await expect - .poll(() => matcher(readEventFrontmatter(vaultDir, path)[key]), { - message: message ?? `frontmatter ${key} did not match in ${path}`, - }) - .toBe(true); - }, - - async writeNoteBody(body) { - await page.evaluate( - async ({ filePath, newBody }) => { - const w = window as unknown as PrismaWindow; - const file = w.app.vault.getAbstractFileByPath(filePath); - if (!file) throw new Error(`writeNoteBody: no file at ${filePath}`); - const content = await w.app.vault.read(file); - const fmEnd = content.indexOf("\n---\n"); - if (fmEnd === -1) throw new Error(`writeNoteBody: no frontmatter in ${filePath}`); - const frontmatter = content.slice(0, fmEnd + 5); - await w.app.vault.modify(file, frontmatter + "\n" + newBody + "\n"); - }, - { filePath: path, newBody: body } - ); - }, - - async hover() { - const el = block(); - await el.waitFor({ state: "visible" }); - await el.hover(); - }, - - async getTooltip() { - return block().getAttribute("title"); - }, - - async expectVisible(yes = true) { - if (yes) await block().waitFor({ state: "visible" }); - else await expect(block()).toHaveCount(0); - }, - - async expectInstanceCount(count, message) { - await expect - .poll(() => collectInstanceFiles(vaultDir, title).length, { - message: message ?? `expected ${count} instance files for ${title}`, - }) - .toBe(count); - }, - }; -} diff --git a/e2e/fixtures/dsl/events-modal.ts b/e2e/fixtures/dsl/events-modal.ts deleted file mode 100644 index 20e51abb..00000000 --- a/e2e/fixtures/dsl/events-modal.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; - -import { sel } from "../testids"; - -// Handles for the multi-tab EventsModal (`prisma-cal-toolbar-show-recurring`) -// and the EventSeriesModal it drills into. Both screens are saturated with -// repeating Locator boilerplate (`page.locator(".modal")…first()`, repeated -// SERIES_*_SEL constants, manual `allTextContents().every(...)` checks), -// which the handles collapse so analytics specs can read like a flow: -// -// const events = await calendar.openEventsModal(); -// await events.switchTab("by-category"); -// const series = await events.drillInto("Work"); -// await series.expectRowCount(2); -// -// Selectors live here once — every events-modal / series-modal class string -// in spec code is a smell. - -const EVENTS_MODAL_BODY = ".prisma-events-modal-content"; -const EVENTS_MODAL_GROUP_COUNT = `${EVENTS_MODAL_BODY} .prisma-generic-event-list-count`; -const EVENTS_MODAL_LIST_ITEM = `${EVENTS_MODAL_BODY} .prisma-generic-event-list .prisma-generic-event-list-item`; - -const SERIES_MODAL = ".modal.prisma-recurring-events-list-modal"; -const SERIES_ROW = ".prisma-recurring-event-row"; -const SERIES_TITLE = ".prisma-recurring-event-title"; -const SERIES_STATS = ".prisma-recurring-events-stats-text"; -const SERIES_TAB_TESTID_PREFIX = "prisma-event-series-tab-"; -const SERIES_PAST_ROW_CLASS = "prisma-recurring-event-past"; -const SERIES_SKIPPED_ROW_CLASS = "prisma-recurring-event-skipped"; -const PICKER_ROW = ".prisma-generic-event-list-item"; -const PICKER_HEADING_TEXT = "Select a category"; -const SERIES_BACK_BTN = ".prisma-event-series-back-btn"; - -async function clickWhenVisible(loc: Locator): Promise { - await loc.waitFor({ state: "visible" }); - await loc.click(); -} - -export type EventsModalTab = "recurring" | "by-category" | "by-name"; -export type EventsModalSortMode = "count-desc" | "count-asc" | "name-asc" | "name-desc"; -export type SeriesModalTab = "recurring" | "category" | "name"; -export type RecurringTypeFilter = - | "all" - | "daily" - | "bi-daily" - | "weekdays" - | "weekends" - | "weekly" - | "bi-weekly" - | "monthly" - | "bi-monthly" - | "quarterly" - | "semi-annual" - | "yearly" - | "custom"; -export type SeriesBasesView = "table" | "list" | "cards" | "timeline" | "heatmap"; - -export interface EventsModalHandle { - readonly modal: Locator; - - /** Click a tab and wait for it to become active. */ - switchTab(tab: EventsModalTab): Promise; - - /** Assert the active tab and (optionally) pin its label text — e.g. `"Recurring (1)"`. */ - expectTabActive(tab: EventsModalTab, label?: string): Promise; - - /** Assert the count chip text (`"2 category groups"`, `"1 event"`, …). */ - expectGroupCountText(text: string): Promise; - - /** Locator for all top-level group items in the active tab. */ - groupItems(): Locator; - - /** - * Single group item by its title — uses the `prisma-event-list-item-{title}` - * testid stamped by `simple-event-group-list.tsx`. The Recurring tab does - * not stamp this testid; use `recurringRow(title)` there instead. - */ - groupItem(title: string): Locator; - - /** Locator for the generic list rows (used by the Recurring tab). */ - listRows(): Locator; - - /** - * Click a group item by title and resolve to a series-modal handle once - * the EventSeriesModal is visible. The drill-in path goes through the - * `prisma-event-list-item-{title}` testid. - */ - drillInto(title: string): Promise; - - /** - * Click an already-resolved row locator (Recurring tab uses this — no - * per-row testid path) and resolve to the resulting series-modal handle. - */ - drillIntoRow(row: Locator): Promise; - - /** Type into the events-modal search input (filters group items by title). */ - search(query: string): Promise; - - /** Set the sort dropdown to one of the four supported modes. */ - setSort(mode: EventsModalSortMode): Promise; - - /** Recurring-tab only: set the type filter dropdown. */ - setRecurringTypeFilter(filter: RecurringTypeFilter): Promise; - - /** - * Recurring-tab only: toggle the "Show disabled only" checkbox. The toggle - * is only rendered when at least one disabled recurring event exists, so - * this throws if the toggle isn't visible. - */ - toggleShowDisabledOnly(): Promise; - - /** Whether the "Show disabled only" toggle is currently rendered. */ - hasShowDisabledOnlyToggle(): Promise; - - /** Locator pinned to a recurring row by its title — uses the testid stamped on the row. */ - recurringRow(title: string): RecurringRowHandle; -} - -/** Per-row affordances on the Recurring tab — Category / Nav / Disable. */ -export interface RecurringRowHandle { - readonly row: Locator; - - /** Open the category-assign modal. Caller drives the modal afterwards. */ - clickCategory(): Promise; - - /** Trigger the Nav button — modal closes, calendar navigates to source. */ - clickNav(): Promise; - - /** - * Click the disable/enable toggle. Same button regardless of pool — its - * label flips between "Disable" and "Enable" based on the active pool. - */ - clickToggle(): Promise; - - /** Ctrl/Cmd+click the row to open the source file in a new workspace tab. */ - openInNewTab(): Promise; - - /** Plain click — opens the EventSeriesModal for this recurring source. */ - open(): Promise; - - /** Pin the recurrence type stamped on `data-recurring-type`. */ - expectType(type: string): Promise; - - /** Pin the visible badge text (e.g. "Weekly", "Daily", "Custom Interval"). */ - expectBadgeLabel(label: string): Promise; - - /** Pin the "N instance(s)" subtitle. */ - expectInstanceCountText(count: number): Promise; -} - -export interface SeriesModalHandle { - readonly modal: Locator; - - /** All event rows in the series modal. */ - rows(): Locator; - - /** Exact row-count assertion. */ - expectRowCount(n: number): Promise; - - /** Stats-footer assertion: `Total: N`. */ - expectTotal(n: number): Promise; - - /** - * Pin specific fields of the primary stats line. Any omitted field is - * skipped — useful for asserting only what the spec cares about. - */ - expectStats(parts: { total?: number; past?: number; skipped?: number; completedPercent?: number }): Promise; - - /** Pin the recurrence "extra info" banner text (only present on the recurring tab). */ - expectRecurrenceInfo(text: string | RegExp): Promise; - - /** Every visible row's title equals `title`. */ - expectAllTitles(title: string): Promise; - - /** No tab bar rendered — `tabs.length < 2` in `event-series-modal-content.tsx`. */ - expectNoTabBar(): Promise; - - /** Pin a series-modal tab as visible and active (carries `is-active`). */ - expectTabActive(tab: SeriesModalTab): Promise; - - /** Pin a series-modal tab as visible but not active. */ - expectTabInactive(tab: SeriesModalTab): Promise; - - /** Pin the modal root's `--source-category-color` CSS var (set when drilling a colored category). */ - expectCategoryColorVar(color: string): Promise; - - /** A Bases-footer button for `view` is visible. */ - expectBasesAction(view: SeriesBasesView): Promise; - - /** Click a Bases-footer button. Caller drives whatever child modal opens. */ - pickBasesView(view: SeriesBasesView): Promise; - - /** Click the source-title heading — opens the source markdown file. */ - clickTitle(): Promise; - - /** Toggle the "Hide past events" checkbox. */ - toggleHidePast(): Promise; - - /** Toggle the "Hide skipped events" checkbox. */ - toggleHideSkipped(): Promise; - - /** Type into the per-tab search input (only rendered on tabs that pass `showSearch`). */ - search(query: string): Promise; - - /** Pin a row by its zero-based index in the rendered list (post-filter, post-sort). */ - row(index: number): SeriesRowHandle; - - /** Pin a row by its `data-event-date` (`YYYY-MM-DD`). */ - rowByDate(iso: string): SeriesRowHandle; - - /** Assert no row exists for the given `data-event-date`. */ - expectRowAbsent(iso: string): Promise; - - /** Visible row titles in document order — useful when asserting a `startsWith` set. */ - titles(): Promise; - - /** Locator for picker rows shown when a multi-category event drills in. */ - pickerRows(): Locator; - - /** Assert the picker heading ("Select a category") is visible. */ - expectPickerVisible(): Promise; - - /** Multi-category picker: click the option for `value`. */ - pickCategory(value: string): Promise; - - /** "← Back to categories" returns to the picker. */ - backToCategories(): Promise; -} - -/** Per-row affordances inside the EventSeriesModal — past/skipped probes, click-to-open. */ -export interface SeriesRowHandle { - readonly row: Locator; - - /** Click the row to open its underlying file. */ - click(): Promise; - - /** Assert the row carries (or does not carry) the `prisma-recurring-event-past` class. */ - expectPast(yes?: boolean): Promise; - - /** Assert the row carries (or does not carry) the `prisma-recurring-event-skipped` class. */ - expectSkipped(yes?: boolean): Promise; - - /** Pin the row's `data-event-file-path` attribute. */ - expectFilePath(path: string | RegExp): Promise; - - /** Pin the row's title text. */ - expectTitle(title: string): Promise; -} - -export function createEventsModalHandle(page: Page, modal: Locator): EventsModalHandle { - const groupItem = (title: string): Locator => page.locator(sel(`prisma-event-list-item-${title}`)).first(); - - const drillIntoLocator = async (target: Locator): Promise => { - await clickWhenVisible(target); - const seriesModal = page.locator(SERIES_MODAL).first(); - await expect(seriesModal).toBeVisible(); - return createSeriesModalHandle(page, seriesModal); - }; - - const showDisabledToggle = (): Locator => page.locator(sel("prisma-recurring-show-disabled")).first(); - - return { - modal, - - async switchTab(tab) { - const button = page.locator(sel(`prisma-events-modal-tab-${tab}`)).first(); - await clickWhenVisible(button); - await expect(button).toHaveClass(/is-active/); - }, - - async expectTabActive(tab, label) { - const button = page.locator(sel(`prisma-events-modal-tab-${tab}`)).first(); - await expect(button).toHaveClass(/is-active/); - if (label !== undefined) await expect(button).toContainText(label); - }, - - async expectGroupCountText(text) { - await expect(page.locator(EVENTS_MODAL_GROUP_COUNT).first()).toHaveText(text); - }, - - groupItems() { - return page.locator('[data-testid^="prisma-event-list-item-"]'); - }, - - groupItem, - - listRows() { - return page.locator(EVENTS_MODAL_LIST_ITEM); - }, - - async drillInto(title) { - return drillIntoLocator(groupItem(title)); - }, - - async drillIntoRow(row) { - return drillIntoLocator(row); - }, - - async search(query) { - const input = page.locator(sel("prisma-events-modal-search")).first(); - await input.waitFor({ state: "visible" }); - await input.fill(query); - }, - - async setSort(mode) { - const select = page.locator(sel("prisma-events-modal-sort")).first(); - await select.waitFor({ state: "visible" }); - await select.selectOption(mode); - }, - - async setRecurringTypeFilter(filter) { - const select = page.locator(sel("prisma-recurring-type-filter")).first(); - await select.waitFor({ state: "visible" }); - await select.selectOption(filter); - }, - - async toggleShowDisabledOnly() { - await clickWhenVisible(showDisabledToggle()); - }, - - async hasShowDisabledOnlyToggle() { - return (await showDisabledToggle().count()) > 0; - }, - - recurringRow(title) { - const row = page.locator(sel(`prisma-recurring-row-${title}`)).first(); - return createRecurringRowHandle(page, row); - }, - }; -} - -function createRecurringRowHandle(page: Page, row: Locator): RecurringRowHandle { - const buttonByAction = (action: "category" | "nav" | "toggle"): Locator => - row.locator(sel(`prisma-recurring-row-${action}`)).first(); - - return { - row, - - async clickCategory() { - await clickWhenVisible(buttonByAction("category")); - }, - - async clickNav() { - await clickWhenVisible(buttonByAction("nav")); - }, - - async clickToggle() { - await clickWhenVisible(buttonByAction("toggle")); - }, - - async openInNewTab() { - await row.waitFor({ state: "visible" }); - const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await row.click({ modifiers: [modifier] }); - }, - - async open() { - await clickWhenVisible(row); - const seriesModal = page.locator(SERIES_MODAL).first(); - await expect(seriesModal).toBeVisible(); - return createSeriesModalHandle(page, seriesModal); - }, - - async expectType(type) { - await expect(row).toHaveAttribute("data-recurring-type", type); - }, - - async expectBadgeLabel(label) { - await expect(row.locator(sel("prisma-recurring-type-badge")).first()).toHaveText(label); - }, - - async expectInstanceCountText(count) { - const subtitle = row.locator(".prisma-generic-event-subtitle").first(); - await expect(subtitle).toHaveText(`${count} instance${count === 1 ? "" : "s"}`); - }, - }; -} - -/** - * Pin a `SeriesModalHandle` to whichever EventSeriesModal is currently open. - * Use this when the modal was spawned by something other than - * `EventsModalHandle.drillInto` — e.g. a context-menu shortcut - * (`viewNameSeries` / `viewCategorySeries` / `viewRecurringSeries`). - */ -export async function expectSeriesModalOpen(page: Page): Promise { - const modal = page.locator(SERIES_MODAL).first(); - await expect(modal).toBeVisible(); - return createSeriesModalHandle(page, modal); -} - -export function createSeriesModalHandle(page: Page, modal: Locator): SeriesModalHandle { - const rows = (): Locator => modal.locator(SERIES_ROW); - - return { - modal, - - rows, - - async expectRowCount(n) { - await expect(rows()).toHaveCount(n); - }, - - async expectTotal(n) { - await expect(modal.locator(SERIES_STATS).first()).toContainText(`Total: ${n}`); - }, - - async expectStats(parts) { - const stats = modal.locator(sel("prisma-series-stats-primary")).first(); - await expect(stats).toBeVisible(); - if (parts.total !== undefined) await expect(stats).toContainText(`Total: ${parts.total}`); - if (parts.past !== undefined) await expect(stats).toContainText(`Past: ${parts.past}`); - if (parts.skipped !== undefined) await expect(stats).toContainText(`Skipped: ${parts.skipped}`); - if (parts.completedPercent !== undefined) - await expect(stats).toContainText(`Completed: ${parts.completedPercent}%`); - }, - - async expectRecurrenceInfo(text) { - const banner = modal.locator(".prisma-recurring-events-info-text").first(); - await expect(banner).toBeVisible(); - if (typeof text === "string") await expect(banner).toContainText(text); - else await expect(banner).toHaveText(text); - }, - - async expectAllTitles(title) { - const titles = await modal.locator(SERIES_TITLE).allTextContents(); - expect(titles.every((t) => t === title)).toBe(true); - }, - - async expectNoTabBar() { - await expect(modal.locator(`[data-testid^="${SERIES_TAB_TESTID_PREFIX}"]`)).toHaveCount(0); - }, - - async expectTabActive(tab) { - const button = modal.locator(sel(`${SERIES_TAB_TESTID_PREFIX}${tab}`)).first(); - await expect(button).toBeVisible(); - await expect(button).toHaveClass(/is-active/); - }, - - async expectTabInactive(tab) { - const button = modal.locator(sel(`${SERIES_TAB_TESTID_PREFIX}${tab}`)).first(); - await expect(button).toBeVisible(); - await expect(button).not.toHaveClass(/is-active/); - }, - - async expectCategoryColorVar(color) { - const target = modal.locator(".prisma-recurring-events-list-modal-categorized").first(); - await expect(target).toBeVisible(); - const actual = await target.evaluate((el) => - (el as HTMLElement).style.getPropertyValue("--source-category-color").trim() - ); - expect(actual).toBe(color); - }, - - async expectBasesAction(view) { - await expect(modal.locator(sel(`prisma-event-series-bases-${view}`)).first()).toBeVisible(); - }, - - async pickBasesView(view) { - await clickWhenVisible(modal.locator(sel(`prisma-event-series-bases-${view}`)).first()); - }, - - async clickTitle() { - await clickWhenVisible(modal.locator(sel("prisma-series-title")).first()); - }, - - async toggleHidePast() { - await clickWhenVisible(modal.locator(sel("prisma-series-hide-past")).first()); - }, - - async toggleHideSkipped() { - await clickWhenVisible(modal.locator(sel("prisma-series-hide-skipped")).first()); - }, - - async search(query) { - const input = modal.locator(sel("prisma-series-search")).first(); - await input.waitFor({ state: "visible" }); - await input.fill(query); - }, - - row(index) { - return createSeriesRowHandle(rows().nth(index)); - }, - - rowByDate(iso) { - return createSeriesRowHandle(modal.locator(`${SERIES_ROW}[data-event-date="${iso}"]`).first()); - }, - - async expectRowAbsent(iso) { - await expect(modal.locator(`${SERIES_ROW}[data-event-date="${iso}"]`)).toHaveCount(0); - }, - - async titles() { - return modal.locator(SERIES_TITLE).allTextContents(); - }, - - pickerRows() { - return modal.locator(PICKER_ROW); - }, - - async expectPickerVisible() { - await expect(modal.locator("h3", { hasText: PICKER_HEADING_TEXT })).toBeVisible(); - }, - - async pickCategory(value) { - const item = modal.locator(`${PICKER_ROW}:has(.prisma-generic-event-title:text-is("${value}"))`).first(); - await clickWhenVisible(item); - }, - - async backToCategories() { - await clickWhenVisible(modal.locator(SERIES_BACK_BTN).first()); - }, - }; -} - -function createSeriesRowHandle(row: Locator): SeriesRowHandle { - return { - row, - - async click() { - await clickWhenVisible(row); - }, - - async expectPast(yes = true) { - const matcher = new RegExp(`(?:^|\\s)${SERIES_PAST_ROW_CLASS}(?:\\s|$)`); - if (yes) await expect(row).toHaveClass(matcher); - else await expect(row).not.toHaveClass(matcher); - }, - - async expectSkipped(yes = true) { - await expect(row).toHaveAttribute("data-event-skipped", yes ? "true" : "false"); - const titleMatcher = new RegExp(`(?:^|\\s)${SERIES_SKIPPED_ROW_CLASS}(?:\\s|$)`); - const title = row.locator(SERIES_TITLE).first(); - if (yes) await expect(title).toHaveClass(titleMatcher); - else await expect(title).not.toHaveClass(titleMatcher); - }, - - async expectFilePath(path) { - await expect(row).toHaveAttribute("data-event-file-path", path); - }, - - async expectTitle(title) { - await expect(row.locator(SERIES_TITLE).first()).toHaveText(title); - }, - }; -} diff --git a/e2e/fixtures/dsl/index.ts b/e2e/fixtures/dsl/index.ts deleted file mode 100644 index be16fef4..00000000 --- a/e2e/fixtures/dsl/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -export { type BatchHandle, openBatch } from "./batch"; -export { - expectAllColors, - expectAllExist, - expectAllFrontmatter, - expectAllHidden, - expectAllTitleCount, - expectAllVisible, -} from "./bulk"; -export { - type CalendarHandle, - createCalendarHandle, - type EventCreate, - type EventOnDisk, - type LicenseStatusInput, - type SeedOptions, -} from "./calendar"; -export { - type CategoryDeleteModalHandle, - type CategoryRenameModalHandle, - categoryRow, - type CategoryRowHandle, -} from "./categories-settings"; -export { - type BoundingBox, - boundingBoxOrThrow, - centerOf, - drag, - dragByDelta, - dragLocatorToLocator, - type DragOptions, - type Point, -} from "./drag"; -export { createEventHandle, type EventHandle } from "./event"; -export { - createEventsModalHandle, - createSeriesModalHandle, - type EventsModalHandle, - type EventsModalSortMode, - type EventsModalTab, - expectSeriesModalOpen, - type RecurringRowHandle, - type RecurringTypeFilter, - type SeriesBasesView, - type SeriesModalHandle, - type SeriesModalTab, - type SeriesRowHandle, -} from "./events-modal"; -export { - type ActionManagerHandle, - type AssignmentModalHandle, - collapsibleSection, - type CollapsibleSectionHandle, - type ConfirmationModalHandle, - expectAssignmentModal, - expectConfirmationModal, - expectItemManagerOpen, - expectProgressModal, - expectRenameModal, - type ItemManagerHandle, - openActionManager, - openTabManager, - type ProgressModalHandle, - type RenameModalHandle, - type TabManagerHandle, -} from "./shared"; -export { batchActionRoundTrip, type BatchRoundTripHooks, type UndoRedoHooks, undoRedoRoundTrip } from "./templates"; diff --git a/e2e/fixtures/dsl/shared.ts b/e2e/fixtures/dsl/shared.ts deleted file mode 100644 index 1a4eb3c6..00000000 --- a/e2e/fixtures/dsl/shared.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; - -import { - ACTION_MANAGER_MODAL, - ACTION_MANAGER_RESET_BTN, - ASSIGN_MODAL_ROOT, - CONFIRMATION_MODAL_CANCEL_TID, - CONFIRMATION_MODAL_CONFIRM_TID, - CONFIRMATION_MODAL_TID, - ITEM_MANAGER_MODAL, - ITEM_MANAGER_RESET_BTN, - PAGE_HEADER_MANAGE_BTN, - PROGRESS_DETAILS_TID, - PROGRESS_MODAL_TID, - PROGRESS_STATUS_TID, - RENAME_CANCEL_TID, - RENAME_INPUT_TID, - RENAME_MODAL_TID, - RENAME_SUBMIT_TID, - sel, - SHARED_ROW_PREFIX, - sharedTID, - TAB_MANAGER_MODAL, - TAB_MANAGER_RESET_BTN, - TABBED_CONTAINER_MANAGE_BTN, -} from "../testids"; - -// Handles for shared-library components (page-header action-manager, -// tabbed-container tab-manager, context-menu item-manager, assignment picker, -// confirmation modal, progress modal, collapsible section). -// -// These wrap the raw testids exposed by `shared/` so specs don't need to -// remember modal names or row-id conventions. Each handle represents "a -// modal/component that is currently on screen"; specs construct one via the -// corresponding `open...` function. - -// ── Page header: action manager ──────────────────────────────────────────── - -export interface ActionManagerHandle { - readonly modal: Locator; - /** Reset-to-defaults button at the top of the modal. */ - readonly resetBtn: Locator; - /** Row for an action id (e.g. `create-event`). */ - row(id: string): Locator; - /** Click the chevron-up for `id` to move it one position up. */ - moveUp(id: string): Promise; - /** Click the visibility toggle for `id`. */ - toggle(id: string): Promise; - /** Click the Reset-to-defaults button (does NOT confirm — caller drives confirmation modal). */ - clickReset(): Promise; - /** Dismiss via Escape and wait for the modal to unmount. */ - close(): Promise; -} - -/** Open the page-header action manager modal. Requires a calendar view visible. */ -export async function openActionManager(page: Page): Promise { - const manageBtn = page.locator(sel(PAGE_HEADER_MANAGE_BTN)).first(); - await manageBtn.waitFor({ state: "visible" }); - await manageBtn.click(); - const modal = page.locator(sel(ACTION_MANAGER_MODAL)); - await modal.waitFor({ state: "visible" }); - const resetBtn = modal.locator(sel(ACTION_MANAGER_RESET_BTN)).first(); - - return { - modal, - resetBtn, - row: (id) => modal.locator(sel(sharedTID.actionRow(id))).first(), - async moveUp(id) { - const btn = page.locator(sel(sharedTID.actionUp(id))).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - async toggle(id) { - const btn = page.locator(sel(sharedTID.actionToggle(id))).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - async clickReset() { - await resetBtn.waitFor({ state: "visible" }); - await resetBtn.click(); - }, - async close() { - await page.keyboard.press("Escape"); - await modal.waitFor({ state: "hidden" }); - }, - }; -} - -// ── Tabbed container: tab manager ─────────────────────────────────────────── - -export interface TabManagerHandle { - readonly modal: Locator; - readonly resetBtn: Locator; - moveUp(id: string): Promise; - toggle(id: string): Promise; - rename(id: string): Promise; - clickReset(): Promise; - close(): Promise; -} - -export async function openTabManager(page: Page): Promise { - const manageBtn = page.locator(sel(TABBED_CONTAINER_MANAGE_BTN)).first(); - await manageBtn.waitFor({ state: "visible" }); - await manageBtn.click(); - const modal = page.locator(sel(TAB_MANAGER_MODAL)); - await modal.waitFor({ state: "visible" }); - const resetBtn = modal.locator(sel(TAB_MANAGER_RESET_BTN)).first(); - - return { - modal, - resetBtn, - async moveUp(id) { - const srcRow = modal.locator(sel(sharedTID.tabManagerRow(id))).first(); - await srcRow.waitFor({ state: "visible" }); - const allRows = modal.locator(`[data-testid^="${SHARED_ROW_PREFIX.tabManagerRow}"]`); - const count = await allRows.count(); - let targetRow: typeof srcRow | null = null; - for (let i = 1; i < count; i++) { - const row = allRows.nth(i); - const tid = await row.getAttribute("data-testid"); - if (tid === `${SHARED_ROW_PREFIX.tabManagerRow}${id}`) { - targetRow = allRows.nth(i - 1); - break; - } - } - if (!targetRow) throw new Error(`moveUp: cannot find row above ${id}`); - await srcRow.dragTo(targetRow); - }, - async toggle(id) { - const btn = page.locator(sel(sharedTID.tabManagerToggle(id))).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - async rename(id) { - const editBtn = page.locator(sel(sharedTID.tabManagerEdit(id))).first(); - await editBtn.waitFor({ state: "visible" }); - await editBtn.click(); - }, - async clickReset() { - await resetBtn.waitFor({ state: "visible" }); - await resetBtn.click(); - }, - async close() { - await page.keyboard.press("Escape"); - await modal.waitFor({ state: "hidden" }); - }, - }; -} - -// ── Context-menu item manager ─────────────────────────────────────────────── - -export interface ItemManagerHandle { - readonly modal: Locator; - readonly resetBtn: Locator; - toggle(id: string): Promise; - clickReset(): Promise; - close(): Promise; -} - -/** - * Assumes the context menu is already open and the caller will click the - * `manage` entry to reach the manager. Returns a handle for interacting with - * the modal rows. - */ -export async function expectItemManagerOpen(page: Page): Promise { - const modal = page.locator(sel(ITEM_MANAGER_MODAL)); - await modal.waitFor({ state: "visible" }); - const resetBtn = modal.locator(sel(ITEM_MANAGER_RESET_BTN)).first(); - return { - modal, - resetBtn, - async toggle(id) { - const btn = modal.locator(sel(sharedTID.itemManagerToggle(id))).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); - }, - async clickReset() { - await resetBtn.waitFor({ state: "visible" }); - await resetBtn.click(); - }, - async close() { - await page.keyboard.press("Escape"); - await modal.waitFor({ state: "hidden" }); - }, - }; -} - -// ── Confirmation modal ────────────────────────────────────────────────────── - -export interface ConfirmationModalHandle { - readonly root: Locator; - readonly confirmBtn: Locator; - readonly cancelBtn: Locator; - confirm(): Promise; - cancel(): Promise; -} - -/** - * Wait for the shared confirmation modal to appear and return a handle. - * - * Pass `testIdPrefix` for prefixed instances (e.g. `"prisma-delete-recurring-"` - * for the recurring-delete modal opened via `openConfirmation({ testIdPrefix })`) - * — the prefix is concatenated with the standard `confirmation-modal-*` suffixes. - */ -export async function expectConfirmationModal( - page: Page, - options: { testIdPrefix?: string } = {} -): Promise { - const prefix = options.testIdPrefix ?? ""; - const root = page.locator(sel(`${prefix}${CONFIRMATION_MODAL_TID}`)).first(); - await root.waitFor({ state: "visible" }); - const confirmBtn = root.locator(sel(`${prefix}${CONFIRMATION_MODAL_CONFIRM_TID}`)); - const cancelBtn = root.locator(sel(`${prefix}${CONFIRMATION_MODAL_CANCEL_TID}`)); - return { - root, - confirmBtn, - cancelBtn, - async confirm() { - await confirmBtn.click(); - await root.waitFor({ state: "detached" }); - }, - async cancel() { - await cancelBtn.click(); - await root.waitFor({ state: "detached" }); - }, - }; -} - -// ── Rename modal ──────────────────────────────────────────────────────────── - -export interface RenameModalHandle { - readonly root: Locator; - readonly input: Locator; - readonly submitBtn: Locator; - readonly cancelBtn: Locator; - fill(value: string): Promise; - submit(): Promise; - cancel(): Promise; -} - -/** - * Wait for the shared rename modal to appear and return a handle. - * - * Pass `testIdPrefix` for prefixed instances (e.g. `"prisma-category-"` for the - * category rename modal opened via `openRenameModal({ testIdPrefix })`) — the - * prefix is concatenated with the standard `rename-*` suffixes. - */ -export async function expectRenameModal( - page: Page, - options: { testIdPrefix?: string } = {} -): Promise { - const prefix = options.testIdPrefix ?? ""; - const root = page.locator(sel(`${prefix}${RENAME_MODAL_TID}`)).first(); - await root.waitFor({ state: "visible" }); - const input = page.locator(sel(`${prefix}${RENAME_INPUT_TID}`)); - const submitBtn = page.locator(sel(`${prefix}${RENAME_SUBMIT_TID}`)); - const cancelBtn = page.locator(sel(`${prefix}${RENAME_CANCEL_TID}`)); - return { - root, - input, - submitBtn, - cancelBtn, - async fill(value) { - await input.fill(value); - }, - async submit() { - await submitBtn.click(); - await root.waitFor({ state: "detached" }); - }, - async cancel() { - await cancelBtn.click(); - await root.waitFor({ state: "detached" }); - }, - }; -} - -// ── Progress modal ────────────────────────────────────────────────────────── - -export interface ProgressModalHandle { - readonly modal: Locator; - readonly status: Locator; - readonly details: Locator; - /** Wait for the modal to auto-close on success (via `successCloseDelay`). */ - waitForClose(): Promise; -} - -export async function expectProgressModal(page: Page): Promise { - const modal = page.locator(sel(PROGRESS_MODAL_TID)).first(); - await modal.waitFor({ state: "visible" }); - return { - modal, - status: page.locator(sel(PROGRESS_STATUS_TID)).first(), - details: page.locator(sel(PROGRESS_DETAILS_TID)).first(), - async waitForClose() { - await modal.waitFor({ state: "detached" }); - }, - }; -} - -// ── Assignment picker (categories / prerequisites) ────────────────────────── - -export interface AssignmentModalHandle { - readonly modal: Locator; - pick(value: string, options?: { createIfMissing?: boolean }): Promise; - submit(): Promise; -} - -/** - * Wait for the shared assignment picker (categories / prerequisites / - * Bases search) to appear and return a handle. The caller is expected to have - * just clicked the button that opens it (Assign Categories, etc.). - */ -export async function expectAssignmentModal(page: Page): Promise { - const modal = page.locator(ASSIGN_MODAL_ROOT).first(); - await modal.locator(sel(sharedTID.assignSearch())).waitFor({ state: "visible" }); - - return { - modal, - async pick(value, options = {}) { - const createIfMissing = options.createIfMissing ?? true; - const search = modal.locator(sel(sharedTID.assignSearch())); - await search.fill(value); - const existing = modal.locator(`${sel(sharedTID.assignItem())}[data-assign-name="${value}"]`).first(); - if ((await existing.count()) > 0) { - await existing.click(); - } else if (createIfMissing) { - const create = modal.locator(sel(sharedTID.assignCreateNew())).first(); - await create.waitFor({ state: "visible" }); - await create.click(); - } else { - throw new Error(`assignment modal: "${value}" not found and createIfMissing is false`); - } - await search.fill(""); - }, - async submit() { - await modal.locator(sel(sharedTID.assignSubmit())).click(); - await modal.waitFor({ state: "hidden" }); - }, - }; -} - -// ── Collapsible section ───────────────────────────────────────────────────── - -export interface CollapsibleSectionHandle { - readonly header: Locator; - readonly body: Locator; - readonly toggle: Locator; - isExpanded(): Promise; - expand(): Promise; - collapse(): Promise; - expectExpanded(yes: boolean): Promise; -} - -/** Handle on a shared `renderCollapsibleSection` instance identified by `id`. */ -export function collapsibleSection(page: Page, id: string): CollapsibleSectionHandle { - const header = page.locator(sel(sharedTID.collapsibleHeader(id))).first(); - const body = page.locator(sel(sharedTID.collapsibleBody(id))).first(); - const toggle = page.locator(sel(sharedTID.collapsibleToggle(id))).first(); - - const isExpanded = async (): Promise => { - const cls = (await body.getAttribute("class")) ?? ""; - return !cls.includes("prisma-collapsible-hidden"); - }; - - return { - header, - body, - toggle, - isExpanded, - async expand() { - if (!(await isExpanded())) await header.click(); - }, - async collapse() { - if (await isExpanded()) await header.click(); - }, - async expectExpanded(yes) { - await expect.poll(isExpanded).toBe(yes); - }, - }; -} diff --git a/e2e/fixtures/dsl/templates.ts b/e2e/fixtures/dsl/templates.ts deleted file mode 100644 index a46d1659..00000000 --- a/e2e/fixtures/dsl/templates.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { BatchBtnKey } from "../testids"; -import type { CalendarHandle } from "./calendar"; -import type { EventHandle } from "./event"; - -// Template patterns for the workflows that repeat across the history suite. -// Each is a thin wrapper around `CalendarHandle` that lets the spec body -// describe WHAT changes (via closures) instead of repeating HOW the full -// undo/redo dance is driven. -// -// See feedback_terse_changelog_and_replies.md — specs should read as one -// sentence of intent. These helpers exist to deliver on that. - -export interface UndoRedoHooks { - /** The action whose undo/redo symmetry we're verifying. */ - mutate: () => Promise; - /** Assert the post-mutate state — called after mutate AND after redo. */ - mutated: () => Promise; - /** Assert the pre-mutate state — called after undo. */ - baseline: () => Promise; -} - -/** - * Drive the canonical undo/redo symmetry check: mutate → assert mutated → - * undo → assert baseline → redo → assert mutated. Collapses the 5-step - * repeating skeleton that saturates every single-event undo/redo spec. - */ -export async function undoRedoRoundTrip(calendar: CalendarHandle, hooks: UndoRedoHooks): Promise { - await hooks.mutate(); - await hooks.mutated(); - - await calendar.undo(); - await hooks.baseline(); - - await calendar.redo(); - await hooks.mutated(); -} - -export interface BatchRoundTripHooks { - /** Assert the post-action state — called after the batch action AND after redo. */ - mutated: () => Promise; - /** Assert the baseline state — called after undo. */ - baseline: () => Promise; - /** True for destructive actions (delete) that require confirming a modal. */ - destructive?: boolean; - /** - * Skip the redo leg. Use when the action is asymmetric or when a follow-up - * round-trip in the same test will exercise redo itself. Undo still runs - * and `baseline` still fires. - */ - skipRedo?: boolean; -} - -/** - * Drive a batch-selection action + undo + redo cycle. The caller owns the - * seeded events and the assertions; this helper glues the enter → select → - * do → [confirm] → exit → undo → redo skeleton together. `mutated` fires - * twice (after action, after redo) — if the two states genuinely differ, - * drop to `calendar.batch(events)` + manual undo/redo. - */ -export async function batchActionRoundTrip( - calendar: CalendarHandle, - events: readonly EventHandle[], - action: BatchBtnKey, - hooks: BatchRoundTripHooks -): Promise { - const batch = await calendar.batch(events); - await batch.do(action); - if (hooks.destructive) await batch.confirm(); - await hooks.mutated(); - await batch.exit(); - - await calendar.undo(); - await hooks.baseline(); - - if (hooks.skipRedo) return; - await calendar.redo(); - await hooks.mutated(); -} diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts deleted file mode 100644 index e0a19e19..00000000 --- a/e2e/fixtures/electron.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -import { test as base, type Page } from "@playwright/test"; -import { - applyStandardRendererBoilerplate, - createConsoleErrorGuard, - createPluginE2eHarness, - bootstrapObsidian as sharedBootstrap, - writeStandardAppJson, - type BootstrappedObsidian, - type ObsidianWindow, -} from "@real1ty-obsidian-plugins/testing/e2e"; - -import { CONTEXT_MENU_ITEM_IDS } from "../../src/context-menu-items"; -import { openCalendarReady } from "../specs/events/events-helpers"; -import { PLUGIN_ID } from "./constants"; -import { createCalendarHandle, type CalendarHandle } from "./dsl/calendar"; - -const E2E_ROOT = resolve(__dirname, ".."); -const PLUGIN_ROOT = resolve(E2E_ROOT, ".."); -const harness = createPluginE2eHarness({ e2eRoot: E2E_ROOT }); - -// Batch buttons seeded into every calendar so specs can exercise ones the -// production default hides (batchCloneNext / batchMoveNext / etc. — see -// DEFAULT_BATCH_ACTION_BUTTONS in src/constants.ts). Kept inline rather than -// imported from src to keep the fixture build-free. -const ALL_BATCH_BUTTONS = [ - "batchSelectAll", - "batchClear", - "batchDuplicate", - "batchMoveBy", - "batchMarkAsDone", - "batchMarkAsNotDone", - "batchCategories", - "batchFrontmatter", - "batchCloneNext", - "batchClonePrev", - "batchMoveNext", - "batchMovePrev", - "batchOpenAll", - "batchSkip", - "batchMakeVirtual", - "batchMakeReal", - "batchDelete", -]; - -// Every context menu item seeded as visible so specs can exercise items the -// production default hides (see DEFAULT_CONTEXT_MENU_ITEMS in -// src/context-menu-items.ts). -const DEFAULT_CALENDAR = { - id: "default", - name: "Main Calendar", - enabled: true, - directory: "Events", - enableNotifications: false, - autoAssignCategoryByName: false, - autoAssignCategoryByIncludes: false, - batchActionButtons: ALL_BATCH_BUTTONS, - contextMenuItems: [...CONTEXT_MENU_ITEM_IDS], -}; - -const DEFAULT_PAGE_HEADER_STATE = { - visibleActionIds: [ - "create-event", - "create-untracked", - "go-to-today", - "scroll-to-now", - "navigate-back", - "navigate-forward", - "global-search", - "toggle-batch", - "daily-stats", - "weekly-stats", - "monthly-stats", - "alltime-stats", - "toggle-prerequisites", - "refresh", - ], -}; - -export interface BootstrapOverrides { - calendars?: Array>; - keepDirs?: string[]; - /** - * Top-level data.json keys merged over the default seed (e.g. `caldav`, - * `icsSubscriptions`). Per-calendar fields still go under `calendars`. - */ - settings?: Record; -} - -// Demo mode: `PW_DEMO=1` (or any positive int value, interpreted as ms) slows -// every Playwright operation so you can watch what the suite does. The wrapper -// script also forces headed mode when PW_DEMO is set, so a visible Obsidian -// window shows up. -// -// slowMo only paces Playwright's own input primitives (click/fill/press). Most -// of `fill-event-modal.ts` drives fields via `page.evaluate()` against the -// exposed `__prismaActiveEventModal` — those calls bypass slowMo entirely, so -// the visible pacing came out much faster than the raw slowMo value suggested. -// The `demoPause` helper is sprinkled between those evaluate-driven steps to -// restore the intended pacing. -const DEMO_DEFAULT_SLOW_MO_MS = 500; -const DEMO_DEFAULT_HOLD_SECONDS = 10; - -function resolveDemoSlowMo(): number { - const raw = process.env["PW_DEMO"]; - if (!raw || raw === "0" || raw === "false") return 0; - if (raw === "1" || raw === "true") return DEMO_DEFAULT_SLOW_MO_MS; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEMO_DEFAULT_SLOW_MO_MS; -} - -function resolveDemoHoldMs(slowMoMs: number): number { - // Hold only makes sense when demo is active; otherwise CI would block 10s per - // spec for no reason. - if (slowMoMs <= 0) return 0; - const raw = process.env["PW_DEMO_HOLD"]; - if (raw === undefined || raw === "") return DEMO_DEFAULT_HOLD_SECONDS * 1000; - if (raw === "0" || raw === "false") return 0; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : DEMO_DEFAULT_HOLD_SECONDS * 1000; -} - -const DEMO_SLOW_MO_MS = resolveDemoSlowMo(); -const DEMO_HOLD_MS = resolveDemoHoldMs(DEMO_SLOW_MO_MS); -// Any mode where a human is watching the window — demo implies it, and plain -// `E2E_HEADED=1` (set by `pnpm test:e2e:headed`) means the real Obsidian window -// is shown. Both should get maximize + sidebar-collapse polish for readability. -const HEADED_VISIBLE = process.env["E2E_HEADED"] === "1" || DEMO_SLOW_MO_MS > 0; - -/** - * Pause between evaluate-driven field sets in demo mode so glass-box steps - * (chip lists, schema-form fields, custom properties, recurring widgets) pace - * at the same visible speed as slowMo'd clicks. No-op outside demo mode. - */ -export async function demoPause(page: Page): Promise { - if (DEMO_SLOW_MO_MS <= 0) return; - await page.waitForTimeout(DEMO_SLOW_MO_MS); -} - -export const demoMode = { - slowMoMs: DEMO_SLOW_MO_MS, - holdMs: DEMO_HOLD_MS, - enabled: DEMO_SLOW_MO_MS > 0, -}; - -export async function bootstrapObsidian( - options: { prefix?: string; overrides?: BootstrapOverrides } = {} -): Promise { - const calendars = options.overrides?.calendars ?? [DEFAULT_CALENDAR]; - const keepDirs = options.overrides?.keepDirs ?? ["Events"]; - const extraSettings = options.overrides?.settings ?? {}; - - return sharedBootstrap({ - version: harness.readVersion(), - slowMoMs: DEMO_SLOW_MO_MS, - polishVisibleWindow: HEADED_VISIBLE, - vaultSeedDir: harness.vaultSeedDir, - vaultsRoot: harness.vaultsRoot, - prefix: options.prefix ?? "run", - plugin: { id: PLUGIN_ID, rootDir: PLUGIN_ROOT }, - logger: harness.log, - // Retained vaults are trimmed to just the events folder(s) and the - // plugin's data.json on close — everything else (seeded Obsidian - // config, staged plugin artifacts) is regeneratable and bloats the - // cache. - leanVaultOnClose: { keep: keepDirs }, - env: { - PRISMA_LOG_LEVEL: harness.verbose ? "debug" : "warn", - }, - onRendererReady: (page: Page) => applyStandardRendererBoilerplate(page, { verbose: harness.verbose }), - seedPluginData: (pluginDir, { manifest }) => { - // Pre-seed Prisma data.json so the calendar points at Events/, AND - // suppress the "What's new" modal by pre-setting `version` to the - // current plugin version (the modal fires when stored version differs). - // - // `pageHeaderState.visibleActionIds` is seeded so every toolbar button - // an analytics E2E spec might click is present on first paint — the - // production defaults hide several (including the plain "Create" - // action) behind the gear menu. - // - // `writeStandardAppJson` writes `.obsidian/app.json` with - // `alwaysUpdateLinks: true` so the "Update links" modal never appears - // when the plugin renames an event file (e.g. on zettel-id assignment - // or title change). That modal blocks subsequent test clicks. - writeStandardAppJson(pluginDir); - - // Notifications default to ON in production. Tests seed events at wall- - // clock-relative times (e.g. `today T09:00`) which fire the - // notification-manager modal whenever the test runs within - // MAX_PAST_NOTIFICATION_THRESHOLD (5h timed / 1d all-day) of the start. - // That modal steals pointer events and breaks any right-click / click - // flow the spec tries next. Flipping `enableNotifications: false` here - // mutes the whole feature by default. Specs that specifically exercise - // notifications must re-enable it (e.g. via the settings tab or by - // rewriting data.json in the spec). - writeFileSync( - join(pluginDir, "data.json"), - JSON.stringify( - { - version: manifest["version"] as string, - calendars, - pageHeaderState: DEFAULT_PAGE_HEADER_STATE, - // Mark onboarding done by default so the tour (which now auto-starts - // whenever tutorialCompleted is false) doesn't cover every spec's UI. - // The tutorial specs opt back in with `settings: { tutorialCompleted: false }`. - tutorialCompleted: true, - ...extraSettings, - }, - null, - 2 - ), - "utf8" - ); - }, - afterPluginLoaded: async (page: Page) => { - // CalendarBundles initialize lazily via workspace.onLayoutReady → - // waitForCacheReady. Force them to resolve before tests run so the - // plugin is fully usable (commands registered, views activatable). - type PrismaPlugin = { - calendarBundles?: Array<{ calendarId: string; initialize: () => Promise }>; - ensureCalendarBundlesReady?: () => Promise; - }; - await page.waitForFunction( - (id) => { - const w = window as unknown as ObsidianWindow; - - return Boolean((w.app.plugins.plugins[id] as PrismaPlugin | undefined)?.calendarBundles?.length); - }, - PLUGIN_ID, - { timeout: 60_000 } - ); - await page.evaluate(async (id) => { - const w = window as unknown as ObsidianWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - - if (!plugin) return; - if (typeof plugin.ensureCalendarBundlesReady === "function") { - await plugin.ensureCalendarBundlesReady(); - } else { - for (const bundle of plugin.calendarBundles ?? []) { - await bundle.initialize(); - } - } - }, PLUGIN_ID); - harness.log.debug(`afterPluginLoaded: calendarBundles ready`); - }, - }); -} - -type UseObsidian = (handle: BootstrappedObsidian) => Promise; - -// Plugin-specific transient pattern: some flows (undo-of-create / undo-of-clone) -// delete a file the metadata cache still has a pending read for. Obsidian -// surfaces that race as an `ENOENT … .md` console.error at teardown time — -// assertions on the actual disk state pass, but the console-error guard would -// otherwise fail the spec. -const PRISMA_TRANSIENT_PATTERNS: readonly RegExp[] = [/ENOENT.*\/Events\/[^/]+\.md/]; - -async function runWithObsidianHandle( - options: { prefix: string; overrides?: BootstrapOverrides; expectedErrorPatterns?: readonly RegExp[] }, - use: UseObsidian -): Promise { - const handle = await bootstrapObsidian(options); - const guard = createConsoleErrorGuard({ - extraTransientPatterns: PRISMA_TRANSIENT_PATTERNS, - expectedErrorPatterns: options.expectedErrorPatterns ?? [], - }); - guard.attach(handle.page); - - try { - await use(handle); - } finally { - // Demo mode: hold the window open so a human can poke around the vault - // state before teardown. If the user closes Obsidian manually during the - // hold the subsequent `handle.close()` no-ops (browser/process `close()` - // already tolerates a dead target). - if (DEMO_HOLD_MS > 0) { - harness.log.info( - `demo hold: keeping Obsidian open for ${DEMO_HOLD_MS / 1000}s — inspect the vault, close manually to skip` - ); - await handle.page.waitForTimeout(DEMO_HOLD_MS).catch(() => {}); - } - guard.detach(handle.page); - await handle.close(); - } - - guard.throwIfErrors(); -} - -// Opt-in DSL fixture shared across every `test*` variant. Factored out so each -// variant can mount the same `calendar` handle without duplicating the body. -// Each variant still owns its own `obsidian` fixture (different prefixes, -// overrides, error whitelists). -const calendarFixture = async ( - { obsidian }: { obsidian: BootstrappedObsidian }, - use: (handle: CalendarHandle) => Promise -): Promise => { - const handle = createCalendarHandle({ obsidian }); - // Headless runs keep Obsidian's default ~1280px window with the file-explorer - // open, leaving the calendar leaf too narrow for the full page-header toolbar — - // the responsive header then trims most actions into the overflow menu, where - // specs that click them can't reach them directly. Emulate a wide desktop window - // and collapse the sidebar *before* the view mounts so the header packs the whole - // toolbar onto the bar from the first paint (no post-mount re-pack race), matching - // how the plugin is actually used on desktop. Specs that exercise narrow layouts - // (page-header overflow, mobile) override these metrics themselves afterwards. - const cdp = await obsidian.page.context().newCDPSession(obsidian.page); - await cdp.send("Emulation.setDeviceMetricsOverride", { - width: 1600, - height: 900, - deviceScaleFactor: 1, - mobile: false, - screenWidth: 1600, - screenHeight: 900, - }); - await handle.collapseLeftSidebar(); - await openCalendarReady(obsidian.page); - await use(handle); -}; - -export const test = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "spec" }, use); - }, - // Opt-in DSL fixture: destructure `{ calendar }` instead of (or alongside) - // `{ obsidian }` to get a CalendarHandle with the calendar view already - // open. Classic `{ obsidian }`-only specs are unaffected — this fixture - // only runs when a spec actually references `calendar`. - calendar: calendarFixture, -}); - -/** - * Patterns the resilience suite expects to see in the renderer console during - * recovery paths. Kept as module-level consts so the list stays explicit — - * every entry needs a matching spec scenario, or it should be deleted. - */ -/** - * Specs that bulk-seed files via writeFileSync while Obsidian runs may trigger - * a transient "File already exists" pageerror from the vault watcher. The files - * are still created correctly — the error is a harmless race. - */ -export const FILE_SEED_EXPECTED_ERRORS: readonly RegExp[] = [/File already exists/]; - -export const testWithSeededFiles = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "seed-spec", expectedErrorPatterns: FILE_SEED_EXPECTED_ERRORS }, use); - }, - calendar: calendarFixture, -}); - -export const RESILIENCE_EXPECTED_ERRORS: readonly RegExp[] = [ - // corrupt-data-json.spec.ts: truncated / schema-incompatible data.json - /failed to read JSON.*prisma-calendar\/data\.json/, - // unreadable-event-file.spec.ts: chmod 000 under Events/ - /EACCES.*\/Events\/.*\.md/, -]; - -/** - * Variant of `test` that whitelists the resilience suite's expected recovery- - * path renderer errors. Every spec under `specs/resilience/` should import - * from here instead of the default `test`. - */ -export const testResilience = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "resilience-spec", expectedErrorPatterns: RESILIENCE_EXPECTED_ERRORS }, use); - }, - calendar: calendarFixture, -}); - -/** - * Patterns the ICS subscription E2E suite expects to see. The subscription - * sync service logs `[ICS Subscription] Sync failed: …` on every error path - * it handles (403, malformed ICS body, network failure). The whole point of - * those specs is to prove the *plugin* stays up when those errors fire, so - * the renderer error must not fail the test harness. - */ -export const ICS_SUBSCRIPTION_EXPECTED_ERRORS: readonly RegExp[] = [ - /\[ICS Subscription\].*Sync failed/, - /\[ICSImport\] Failed to import event/, -]; - -/** - * Variant of `test` for integration specs that exercise network/error paths - * the plugin legitimately logs to the renderer console. Use for ICS - * subscription and (future) CalDAV specs. - */ -export const testIntegrations = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle( - { prefix: "integration-spec", expectedErrorPatterns: ICS_SUBSCRIPTION_EXPECTED_ERRORS }, - use - ); - }, - calendar: calendarFixture, -}); - -const NOTIFICATIONS_ON_OVERRIDES: BootstrapOverrides = { - calendars: [{ ...DEFAULT_CALENDAR, enableNotifications: true }], -}; - -export const SEEDED_ICS_SUBSCRIPTION_ID = "seeded-sub"; -export const SEEDED_ICS_SUBSCRIPTION_NAME = "Team Holidays"; - -const SEEDED_ICS_SUBSCRIPTION_OVERRIDES: BootstrapOverrides = { - settings: { - // `icsSubscriptions` lives at the data.json top level (alongside - // `calendars`), not per-calendar — settings-store reads it via - // mainSettingsStore.currentSettings.icsSubscriptions. See - // CustomCalendarSettingsSchema in src/types/settings.ts. - icsSubscriptions: { - subscriptions: [ - { - id: SEEDED_ICS_SUBSCRIPTION_ID, - name: SEEDED_ICS_SUBSCRIPTION_NAME, - urlSecretName: "", - enabled: true, - calendarId: DEFAULT_CALENDAR.id, - syncIntervalMinutes: 1440, - timezone: "UTC", - createdAt: 1_700_000_000_000, - }, - ], - // Every sync flag off so bootstrap doesn't attempt network I/O - // for the seeded subscription. The delete path we exercise runs - // entirely in-process. - enableAutoSync: false, - syncOnStartup: false, - notifyOnSync: false, - integrationEventColor: "#8b5cf6", - }, - }, -}; - -/** - * Variant of `test` that seeds `enableNotifications: true` in data.json - * before the plugin loads. Use for specs that exercise notification-bound UI - * (e.g. "Notify minutes before" field in the event modal, or the notification - * settings tab). Bypasses the pointer-event-stealing notification modal by - * setting the toggle at bootstrap time rather than via the settings UI. - * - * Exposes the same opt-in `calendar` DSL fixture as the default `test`. - */ -export const testWithNotifications = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "notif-spec", overrides: NOTIFICATIONS_ON_OVERRIDES }, use); - }, - calendar: calendarFixture, -}); - -const ONBOARDING_INCOMPLETE_OVERRIDES: BootstrapOverrides = { - settings: { tutorialCompleted: false }, -}; - -/** - * Variant of `test` that seeds `tutorialCompleted: false` so the onboarding tour - * auto-starts on launch. The default `test` seeds it `true` to keep the tour out - * of every other spec, so this is the only fixture that exercises the auto-trigger. - */ -export const testOnboarding = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "onboarding-spec", overrides: ONBOARDING_INCOMPLETE_OVERRIDES }, use); - }, - calendar: calendarFixture, -}); - -/** - * Variant of `test` that seeds a single ICS subscription on the default - * calendar so specs can exercise the subscription-list UI without going - * through the (network-bound) add-subscription modal. Sync flags are all off - * so bootstrap doesn't try to fetch the URL. - */ -export const testWithSeededICSSubscription = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "ics-sub-spec", overrides: SEEDED_ICS_SUBSCRIPTION_OVERRIDES }, use); - }, - calendar: calendarFixture, -}); - -export const MULTI_CALENDAR_PRIMARY_ID = "primary"; -export const MULTI_CALENDAR_SECONDARY_ID = "secondary"; -export const MULTI_CALENDAR_PRIMARY_DIR = "EventsPrimary"; -export const MULTI_CALENDAR_SECONDARY_DIR = "EventsSecondary"; - -const MULTI_CALENDAR_OVERRIDES: BootstrapOverrides = { - calendars: [ - { - ...DEFAULT_CALENDAR, - id: MULTI_CALENDAR_PRIMARY_ID, - name: "Primary Calendar", - directory: MULTI_CALENDAR_PRIMARY_DIR, - }, - { - ...DEFAULT_CALENDAR, - id: MULTI_CALENDAR_SECONDARY_ID, - name: "Secondary Calendar", - directory: MULTI_CALENDAR_SECONDARY_DIR, - }, - ], - keepDirs: [MULTI_CALENDAR_PRIMARY_DIR, MULTI_CALENDAR_SECONDARY_DIR], -}; - -/** - * Variant of `test` that seeds two independent calendar bundles so specs can - * exercise cross-calendar semantics — undo-stack isolation, last-used bundle - * resolution, etc. Use `openCalendarView(page, "primary"|"secondary")` to - * switch between them. - */ -export const testMultiCalendar = base.extend<{ - obsidian: BootstrappedObsidian; - calendar: CalendarHandle; -}>({ - // eslint-disable-next-line no-empty-pattern -- Playwright fixture API requires destructuring even when no fixtures are needed - obsidian: async ({}, use) => { - await runWithObsidianHandle({ prefix: "multi-cal-spec", overrides: MULTI_CALENDAR_OVERRIDES }, use); - }, - // Auto-opens the primary bundle (the `openCalendarView` fallback path picks - // `calendarBundles[0]` when the default "default" id isn't present). Specs - // that need to act on the secondary calendar call `openCalendarView(page, - // "secondary")` themselves before using the handle. - calendar: calendarFixture, -}); - -export const expect = test.expect; diff --git a/e2e/fixtures/frontmatter-assertions.ts b/e2e/fixtures/frontmatter-assertions.ts deleted file mode 100644 index 8784ef2c..00000000 --- a/e2e/fixtures/frontmatter-assertions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, type Page } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { seedEvent, waitForEventCount, type SeedEventInput } from "./seed-events"; - -// Template-pattern helper for the edge-case + integrations suites: every spec -// that verifies a single-event frontmatter round-trip does the same three -// steps (seed → assert frontmatter equals input literals → assert the -// indexer picked it up). Keeping the shape here collapses ~5 near-identical -// specs to a single factory call each. - -export interface RoundTripAssertion { - seed: SeedEventInput; - /** Frontmatter keys to compare literal-for-literal (byte preservation). */ - expectFrontmatter: Record; - /** Expected event count once the indexer ingests the seed (defaults to 1). */ - expectedEvents?: number; -} - -export async function assertFrontmatterRoundTrip( - page: Page, - vaultDir: string, - spec: RoundTripAssertion -): Promise { - const relative = seedEvent(vaultDir, spec.seed); - - const fm = readEventFrontmatter(vaultDir, relative); - for (const [key, value] of Object.entries(spec.expectFrontmatter)) { - expect(fm[key], `frontmatter ${key} must round-trip byte-for-byte`).toBe(value); - } - - const expectedEvents = spec.expectedEvents ?? 1; - await waitForEventCount(page, expectedEvents); -} - -/** - * The Start/End Date pair — by far the most common subset of frontmatter the - * recurring suite snapshots before a drag/skip/revert operation and asserts - * unchanged after. - */ -export const EVENT_DATE_FRONTMATTER_FIELDS = ["Start Date", "End Date"] as const; - -/** - * Read the file fresh from disk and assert each of `fields` is unchanged - * relative to the `before` snapshot. Both sides are coerced via `String(...)` - * so YAML-typed dates (which `parseYaml` may resolve to a `Date`) compare - * the same way they print in the file. - * - * Defaults to `EVENT_DATE_FRONTMATTER_FIELDS` — pass an explicit list when - * asserting on other keys. - */ -export function expectFrontmatterFieldsUnchanged( - vaultDir: string, - relativePath: string, - before: Record, - fields: readonly string[] = EVENT_DATE_FRONTMATTER_FIELDS -): void { - const after = readEventFrontmatter(vaultDir, relativePath); - for (const field of fields) { - expect(String(after[field]), `${relativePath}: ${field} must not change`).toBe(String(before[field])); - } -} diff --git a/e2e/fixtures/helpers.ts b/e2e/fixtures/helpers.ts deleted file mode 100644 index 94cc65df..00000000 --- a/e2e/fixtures/helpers.ts +++ /dev/null @@ -1,572 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF, DEFAULT_CALENDAR_ID, PLUGIN_ID } from "./constants"; -import { getCalendars } from "./plugin-data"; -import { waitForCalendarCount } from "./seed-events"; -import { NOTICE_SELECTOR } from "./testids"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -// ── Schema-field accessors ────────────────────────────────────────────────── -// Fields rendered via SchemaSection stamp the outer `.setting-item` wrapper -// with `data-testid="prisma-settings-field-"` but not the inner control. -// These helpers drill into the wrapper and interact with the control directly. -// Controls rendered outside SchemaSection (e.g. in general-settings.tsx) DO -// get `prisma-settings-control-` — for those, use `setToggle` / -// `setTextInput` / `setDropdown` from the shared `@real1ty-obsidian-plugins/testing/e2e` package. - -function schemaField(page: Page, key: string): Locator { - return page.locator(`[data-testid="prisma-settings-field-${key}"]`).first(); -} - -async function ensureVisible(locator: Locator): Promise { - await locator.waitFor({ state: "visible", timeout: 10_000 }); - await locator.scrollIntoViewIfNeeded(); -} - -export async function setSchemaToggle(page: Page, key: string, on: boolean): Promise { - const toggle = schemaField(page, key).locator(".checkbox-container").first(); - await ensureVisible(toggle); - const cls = (await toggle.getAttribute("class")) ?? ""; - if (cls.includes("is-enabled") !== on) { - await toggle.click(); - } -} - -export async function setSchemaTextInput(page: Page, key: string, value: string): Promise { - const input = schemaField(page, key).locator('input[type="text"], textarea').first(); - await ensureVisible(input); - await input.fill(value); - await input.dispatchEvent("change"); - await input.blur(); -} - -export async function setSchemaNumberInput(page: Page, key: string, value: number): Promise { - // Bounded integer fields (`z.number().int().min().max()`) auto-infer to the - // slider widget (``). Unbounded fields render a native - // ``. Accept either so specs don't need to know which - // widget the schema resolved to. - // - // For number inputs, Playwright's `fill()` handles React's value tracker - // correctly (it uses the native HTMLInputElement.value setter, which React - // observes). For range inputs, `fill()` no-ops — we fall back to invoking - // the native setter directly so React's onChange still fires. - const field = schemaField(page, key); - const input = field.locator('input[type="number"], input[type="range"]').first(); - await ensureVisible(input); - const inputType = await input.getAttribute("type"); - if (inputType === "range") { - await input.evaluate((el, v) => { - const inp = el as HTMLInputElement; - // prototype setter is intentionally extracted for explicit .call() binding on inp - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; - setter?.call(inp, String(v)); - inp.dispatchEvent(new Event("input", { bubbles: true })); - inp.dispatchEvent(new Event("change", { bubbles: true })); - }, value); - } else { - await input.fill(String(value)); - await input.dispatchEvent("change"); - } - await input.blur(); -} - -export async function setSchemaDropdown(page: Page, key: string, value: string): Promise { - const select = schemaField(page, key).locator("select").first(); - await ensureVisible(select); - await select.selectOption(value); -} - -// ── UI-level navigation ───────────────────────────────────────────────────── -// Every helper below drives the plugin the same way a user does: clicking -// buttons, pressing keyboard shortcuts, right-clicking events. No `page.evaluate` -// into `app.*`, no `executeCommandById`. The point of E2E is to exercise real -// user interactions — API shortcuts belong in unit tests. - -function isMac(): boolean { - return process.platform === "darwin"; -} - -/** - * Open Obsidian Settings via the user keyboard shortcut, then click the - * "Prisma Calendar" entry in the settings-modal sidebar. Closes any existing - * settings modal first so invocations are idempotent. - */ -export async function openPrismaSettings(page: Page): Promise { - if (await page.locator(".modal-container .mod-settings").count()) { - await closeSettings(page); - } - await page.keyboard.press(isMac() ? "Meta+," : "Control+,"); - await page - .locator(".progress-bar-container") - .waitFor({ state: "hidden" }) - .catch(() => {}); - const sidebarTab = page.locator('.vertical-tab-nav-item:has-text("Prisma Calendar")').first(); - await sidebarTab.waitFor({ state: "visible", timeout: 10_000 }); - await sidebarTab.click(); -} - -/** - * Dismiss the Obsidian settings modal by clicking its close (×) button. - * Obsidian attaches this button itself; we rely on its stable class. - */ -export async function closeSettings(page: Page): Promise { - const closeBtn = page.locator(".modal-container .mod-settings .modal-close-button").first(); - if (await closeBtn.count()) { - await closeBtn.click(); - await page.locator(".modal-container .mod-settings").waitFor({ state: "detached", timeout: 5_000 }); - return; - } - await page.keyboard.press("Escape"); -} - -/** - * Click a tab inside the Prisma settings pane (general, properties, bases, …). - * Waits until the tab is visible first — some tabs are below the fold on - * smaller viewports and need scrolling. - */ -export async function switchSettingsTab(page: Page, tabId: string): Promise { - const tab = page.locator(`[data-testid="prisma-settings-nav-${tabId}"]`).first(); - await tab.waitFor({ state: "visible", timeout: 10_000 }); - await tab.click(); -} - -/** - * Open a Prisma Calendar view by clicking the ribbon icon the plugin adds to - * the left sidebar. Each calendar bundle stamps its ribbon entry with a - * deterministic `prisma-ribbon-open-` testid. Exercises the real - * user-click path; prefer this over the API-based `openCalendarView`. - */ -export async function openCalendarViewViaRibbon(page: Page, calendarId = DEFAULT_CALENDAR_ID): Promise { - const ribbon = page.locator(`[data-testid="prisma-ribbon-open-${calendarId}"]`).first(); - await ribbon.waitFor({ state: "visible", timeout: 10_000 }); - await ribbon.click(); - await page.locator(".fc-header-toolbar.fc-toolbar").first().waitFor({ state: "visible", timeout: 10_000 }); -} - -/** - * Activate Prisma-Calendar's calendar view for the given calendar ID. Uses the - * runtime API instead of the command palette to avoid palette-UI races. - * Preserved for pre-existing specs (events/*.spec.ts, events-helpers.ts). - * New specs should prefer `openCalendarViewViaRibbon` to exercise the real - * user interaction. - */ -export async function openCalendarView(page: Page, calendarId = DEFAULT_CALENDAR_ID): Promise { - await page.evaluate( - async ({ id, pid }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundle = plugin?.calendarBundles?.find((b) => b.calendarId === id) ?? plugin?.calendarBundles?.[0]; - if (!bundle || typeof bundle.activateCalendarView !== "function") { - throw new Error(`No CalendarBundle for id=${id} (bundles: ${plugin?.calendarBundles?.length ?? 0})`); - } - await bundle.activateCalendarView(); - }, - { id: calendarId, pid: PLUGIN_ID } - ); -} - -/** Back-compat alias; older specs imported `openCalendar`. */ -export const openCalendar = openCalendarView; - -/** - * Click the calendar toolbar's "Create Event" button and wait for the event - * modal to appear. Requires the calendar view to be open first. - */ -export async function createEventViaToolbar(page: Page): Promise { - const createBtn = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-toolbar-create"]`).first(); - await createBtn.waitFor({ state: "visible", timeout: 10_000 }); - await createBtn.click(); - await page.locator(".modal").first().waitFor({ state: "visible", timeout: 5_000 }); -} - -/** - * Switch the calendar view mode by clicking the year/month/week/day/list toolbar - * button. Pass "year" | "month" | "week" | "day" | "list". - */ -export async function switchCalendarViewMode( - page: Page, - mode: "year" | "month" | "week" | "day" | "list" -): Promise { - const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-toolbar-view-${mode}"]`).first(); - await btn.waitFor({ state: "visible", timeout: 10_000 }); - await btn.click(); -} - -/** - * Right-click the first rendered calendar event matching the given title to - * open its context menu. If no title is passed, right-clicks whatever event - * is first in the DOM. Scoped to the active leaf so parallel calendar tabs - * don't steal the click. - */ -export async function rightClickEvent(page: Page, options: { title?: string } = {}): Promise { - const selector = options.title - ? `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"][data-event-title="${options.title}"]` - : `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"]`; - const event = page.locator(selector).first(); - await event.waitFor({ state: "visible", timeout: 10_000 }); - await event.click({ button: "right" }); - await page.locator(".menu").first().waitFor({ state: "visible", timeout: 5_000 }); -} - -/** - * Click an item in the currently-open context menu by its Prisma item id - * (e.g. `editEvent`, `duplicateEvent`, `makeUntracked`). Relies on the testids - * stamped by the shared `createContextMenu` wrapper. - * - * Dispatches a synthetic click rather than a real mouse click: Obsidian's - * native `Menu` shifts the `.selected` class onto whichever item the cursor - * passes through, and that hover state intermittently expands the item's - * hit-box enough to intercept pointer events at the target item's center, - * surfacing as a flaky "intercepts pointer events" timeout. The handler is - * wired via Obsidian's `onClick` (a plain `click` listener), so a synthetic - * click triggers it without going through the pointer stack. - */ -export async function clickContextMenuItem(page: Page, itemId: string): Promise { - const item = page.locator(`[data-testid="prisma-context-menu-item-${itemId}"]`).first(); - await item.waitFor({ state: "visible", timeout: 5_000 }); - await item.dispatchEvent("click"); -} - -interface EventModalMinimalValues { - title?: string; -} - -/** - * Fill in the minimum required fields in the Create/Edit event modal so it - * can be saved. Currently only the title — start/end fields auto-populate. - */ -export async function fillEventModalMinimal(page: Page, values: EventModalMinimalValues = {}): Promise { - if (values.title !== undefined) { - const titleInput = page.locator('.modal [data-testid="prisma-event-control-title"]').first(); - await titleInput.waitFor({ state: "visible", timeout: 5_000 }); - await titleInput.fill(values.title); - await titleInput.dispatchEvent("change"); - } -} - -/** Click the Save button inside the open event modal and wait for it to close. */ -export async function saveEventModal(page: Page): Promise { - await page.locator('.modal [data-testid="prisma-event-btn-save"]').first().click(); - await page.locator(".modal").first().waitFor({ state: "detached", timeout: 10_000 }); -} - -/** - * Click a Prisma view tab. Supported ids: calendar, stats, timeline, heatmap, - * gantt, dashboard — matches the `prisma-view-tab-` testid stamped by the - * view renderer. - * - * For group tabs (like `dashboard` which has `dashboard-by-name` etc. as - * children), clicking the group button opens a dropdown but does NOT activate - * any child panel. Use `switchToGroupChild` to drill in. - */ -export async function switchView(page: Page, viewId: string): Promise { - const tab = page.locator(`[data-testid="prisma-view-tab-${viewId}"]`).first(); - await tab.waitFor({ state: "visible", timeout: 10_000 }); - await tab.click(); -} - -/** - * Activate a child tab inside a group tab (e.g. `dashboard` → `dashboard-by-name`). - * Clicks the group's tab button, waits for the dropdown, clicks the child row. - * The child rows are stamped with the same `prisma-view-tab-` testid - * as leaf tabs, so the test locator is stable. - */ -export async function switchToGroupChild(page: Page, groupId: string, childId: string): Promise { - const group = page.locator(`[data-testid="prisma-view-tab-${groupId}"]`).first(); - await group.waitFor({ state: "visible", timeout: 10_000 }); - await group.click(); - const child = page.locator(`[data-testid="prisma-view-tab-${childId}"]`).first(); - await child.waitFor({ state: "visible", timeout: 5_000 }); - await child.click(); -} - -/** - * After a `page.reload()` the plugin needs to boot again. The cleanest - * user-visible signal that it's alive is the ribbon icon it installs in the - * left sidebar — wait for that to reappear before driving the UI. - */ -export async function waitForPluginReady(page: Page, calendarId = DEFAULT_CALENDAR_ID): Promise { - await page - .locator(`[data-testid="prisma-ribbon-open-${calendarId}"]`) - .first() - .waitFor({ state: "visible", timeout: 60_000 }); -} - -// ── Analytics helpers ─────────────────────────────────────────────────────── -// Added for the analytics E2E suite. Extends the click-only contract above -// to page-header toolbar actions, the Pro-unlock test seam, and the Gantt -// renderer. - -/** - * Click a button in the Prisma page header toolbar. `actionId` matches an - * entry in `buildPageHeaderActions()` (e.g. `create-event`, `daily-stats`, - * `refresh`). Relies on `data-testid="prisma-toolbar-"` stamped once - * at the shared `createPageHeader` render site. - * - * If the button isn't currently visible in the toolbar (user has it stashed - * behind the "+" overflow), this will fail — the bootstrap seed in - * `electron.ts` pre-populates a visible set that covers every toolbar id - * the analytics suite clicks. - */ -export async function clickToolbar(page: Page, actionId: string): Promise { - const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-toolbar-${actionId}"]`).first(); - await btn.waitFor({ state: "visible", timeout: 5_000 }); - await btn.click(); -} - -export interface CreateEventInput { - title: string; - /** - * For timed events: `YYYY-MM-DDTHH:mm` — typed into the Start Date - * datetime-local input. For all-day events: `YYYY-MM-DD` — typed into - * the Date input. - */ - start?: string; - /** For timed events only: `YYYY-MM-DDTHH:mm` typed into the End Date input. */ - end?: string; - allDay?: boolean; - /** - * Category names to attach via the "Assign categories" picker modal. Each - * is created on the fly if it doesn't already exist in the vault. - */ - categories?: string[]; -} - -/** - * End-to-end create flow: click `create-event` → fill modal → Save. Composes - * the existing helpers (`createEventViaToolbar` + `fillEventModalMinimal` + - * `saveEventModal`) with additional fields so analytics specs can seed - * deterministic data through the real UI. - * - * Date-field routing matches the modal's mutually-exclusive containers: - * - Timed events: Start Date + End Date datetime-local inputs are visible, - * the Date input is hidden behind `.prisma-hidden`. - * - All-day events: the reverse — Date input is visible, Start/End aren't. - */ -export async function createEventViaUI(page: Page, input: CreateEventInput): Promise { - await createEventViaToolbar(page); - await fillEventModalMinimal(page, { title: input.title }); - - if (input.allDay) { - const allDayToggle = page.locator('.modal [data-testid="prisma-event-control-all-day"]').first(); - await allDayToggle.check({ force: true }); - if (input.start) { - const dateOnly = input.start.includes("T") ? input.start.slice(0, 10) : input.start; - const dateControl = page.locator('.modal [data-testid="prisma-event-control-date"]').first(); - await dateControl.waitFor({ state: "visible", timeout: 5_000 }); - await dateControl.fill(dateOnly); - await dateControl.dispatchEvent("change"); - } - } else { - if (input.start) { - const startControl = page.locator('.modal [data-testid="prisma-event-control-start"]').first(); - await startControl.waitFor({ state: "visible", timeout: 5_000 }); - await startControl.fill(input.start); - await startControl.dispatchEvent("change"); - } - if (input.end) { - const endControl = page.locator('.modal [data-testid="prisma-event-control-end"]').first(); - await endControl.waitFor({ state: "visible", timeout: 5_000 }); - await endControl.fill(input.end); - await endControl.dispatchEvent("change"); - } - } - - if (input.categories && input.categories.length > 0) { - await assignCategoriesViaModal(page, input.categories); - } - - await saveEventModal(page); -} - -export async function seedEvents(page: Page, events: CreateEventInput[]): Promise { - for (const input of events) { - await createEventViaUI(page, input); - } -} - -/** - * Drive the full "assign prerequisite" flow through the calendar UI: right-click - * the dependant, open its context menu, hit "Assign prerequisites", then click - * the prerequisite tile in the calendar. Both tiles must be visible in the - * current view at the same time, so the caller usually switches to month view - * before calling. - */ -export async function assignPrerequisiteViaUI(page: Page, dependant: string, prerequisite: string): Promise { - await rightClickEvent(page, { title: dependant }); - await clickContextMenuItem(page, "assignPrerequisites"); - const prereqTile = page - .locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"][data-event-title="${prerequisite}"]`) - .first(); - await prereqTile.waitFor({ state: "visible", timeout: 5_000 }); - await prereqTile.click(); - await page - .locator(".prisma-prereq-selection-banner") - .first() - .waitFor({ state: "detached", timeout: 5_000 }) - .catch(() => {}); -} - -/** - * Flip the Prisma-Calendar license into Pro mode for the current session via - * the license-manager's `__setProForTesting` public seam (guarded by - * `window.E2E === true`, which the bootstrap sets). No user path for this — - * license validation is network-backed. - */ -export async function unlockPro(page: Page): Promise { - await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const lm = plugin?.licenseManager; - if (!lm?.__setProForTesting) { - throw new Error(`licenseManager.__setProForTesting missing on ${pid}`); - } - lm.__setProForTesting(true); - }, PLUGIN_ID); -} - -/** Locate a Gantt bar whose label matches `title`, scoped to the active calendar leaf. */ -export function ganttBarLocator(page: Page, title: string): Locator { - return page - .locator( - `${ACTIVE_CALENDAR_LEAF} .prisma-gantt-bar:has(.prisma-gantt-bar-label:text-is("${title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"))` - ) - .first(); -} - -export async function rightClickGanttBar(page: Page, title: string): Promise { - const bar = ganttBarLocator(page, title); - await bar.waitFor({ state: "visible", timeout: 5_000 }); - await bar.click({ button: "right" }); -} - -// ── Phase 2: category assignment + list modals + untracked dropdown ───────── - -/** - * Drive the "Assign categories" picker modal from inside the open event modal. - * Clicks the Assign button, ticks each named category checkbox, and saves. - * The picker is `openCategoryAssignModal` in `modals/category/assignment.ts`; - * rows are stamped `data-testid="prisma-assign-item"` + `data-assign-name=""`. - */ -export async function assignCategoriesViaModal(page: Page, categories: string[]): Promise { - const modalsBefore = await page.locator(".modal").count(); - await page.locator('.modal [data-testid="prisma-event-btn-assign-categories"]').first().click(); - await page.waitForFunction((n) => document.querySelectorAll(".modal").length > n, modalsBefore, { timeout: 5_000 }); - - // The assign modal is the top one (highest index). Obsidian stacks them. - const assignModalIdx = modalsBefore; // zero-based index of the new modal - const assignModal = page.locator(".modal").nth(assignModalIdx); - - for (const name of categories) { - const row = assignModal.locator(`[data-testid="prisma-assign-item"][data-assign-name="${name}"]`).first(); - if ((await row.count()) === 0) { - // Category doesn't exist — type the name into the search input to - // surface the "Create new" button, then click it. `createNewItem` - // adds the entry AND checks it automatically (see assignment.ts). - const searchInput = assignModal.locator('[data-testid="prisma-assign-search"]').first(); - await searchInput.fill(name); - await searchInput.dispatchEvent("input"); - const createBtn = assignModal.locator('[data-testid="prisma-assign-create-new"]').first(); - await createBtn.waitFor({ state: "visible", timeout: 5_000 }); - await createBtn.click(); - await row.waitFor({ state: "visible", timeout: 5_000 }); - } else { - const checkbox = row.locator('input[type="checkbox"]').first(); - if (!(await checkbox.isChecked())) await row.click(); - } - } - - await assignModal.locator('[data-testid="prisma-assign-submit"]').first().click(); - await page.waitForFunction((n) => document.querySelectorAll(".modal").length <= n, modalsBefore, { - timeout: 5_000, - }); -} - -/** - * Click the "Events" group button (recurring events list) via its toolbar - * action id. Pairs with the `show-recurring` page-header action. - */ -export async function openEventsModal(page: Page): Promise { - await clickToolbar(page, "show-recurring"); - await page.locator(".modal").first().waitFor({ state: "visible", timeout: 5_000 }); -} - -/** Click a tab inside the multi-tab EventsModal (recurring / byCategory / byName). */ -export async function switchEventsModalTab(page: Page, tabId: "recurring" | "byCategory" | "byName"): Promise { - const tab = page.locator(`[data-testid="prisma-events-modal-tab-${tabId}"]`).first(); - await tab.waitFor({ state: "visible", timeout: 5_000 }); - await tab.click(); -} - -/** Click a list item inside any event-list modal by its title. Drills into series/details. */ -export async function clickEventListItem(page: Page, title: string): Promise { - const item = page.locator(`[data-testid="prisma-event-list-item-${title}"]`).first(); - await item.waitFor({ state: "visible", timeout: 5_000 }); - await item.click(); -} - -/** - * Inside an event-series modal, click one of the Bases-footer visualisation - * buttons: table / list / cards / timeline / heatmap. Each opens its own - * visualisation modal on top of the series modal. - */ -export async function pickSeriesBasesView( - page: Page, - viewType: "table" | "list" | "cards" | "timeline" | "heatmap" -): Promise { - const btn = page.locator(`[data-testid="prisma-event-series-bases-${viewType}"]`).first(); - await btn.waitFor({ state: "visible", timeout: 5_000 }); - await btn.click(); -} - -/** Click the calendar toolbar's Untracked-events "⋮" button to open the dropdown. */ -export async function openUntrackedDropdown(page: Page): Promise { - const toggle = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-untracked-dropdown-button"]`).first(); - await toggle.waitFor({ state: "visible", timeout: 10_000 }); - await toggle.click(); -} - -// ── Calendar management helpers ───────────────────────────────────────────── -// Shared helpers for multi-calendar settings interactions. The "add calendar -// and get its ID" pattern appears in every multi-calendar spec — this is the -// single source of truth. - -/** - * Click the "Add calendar" button, wait for the new bundle to register, and - * return the new calendar's id. Settings must already be open before calling. - */ -export async function addCalendar(page: Page, vaultDir: string): Promise { - const before = getCalendars(vaultDir); - await page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await waitForCalendarCount(page, before.length + 1); - const after = getCalendars(vaultDir); - const id = after.find((c) => !before.some((b) => b.id === c.id))?.id; - if (!id) throw new Error("addCalendar: new calendar id not found"); - return id; -} - -/** - * Select a calendar from the calendar-management dropdown in Prisma settings. - * Requires the settings modal to be open. - */ -export async function selectCalendarInSettings(page: Page, calendarId: string): Promise { - await page.locator(".prisma-calendar-management select.dropdown").selectOption(calendarId); -} - -/** - * Assert that the newest `.notice-container .notice` carries `text`. Pins the - * `.last()` match so older notices from setup don't satisfy the matcher. - * Used by every spec that asserts on a user-facing toast wording. - */ -export async function expectNoticeText(page: Page, text: string): Promise { - await expect(page.locator(NOTICE_SELECTOR, { hasText: text }).last()).toBeVisible(); -} - -/** - * Frontmatter matcher that succeeds when the stored value starts with the - * given `YYYY-MM-DDTHH:mm` short form. Prisma normalises datetimes to - * `YYYY-MM-DDTHH:mm:ss.SSSZ`, so spec inputs need to be compared on prefix. - */ -export function startsWithStamp(expected: string): (v: unknown) => boolean { - return (v) => typeof v === "string" && v.startsWith(expected); -} diff --git a/e2e/fixtures/history-helpers.ts b/e2e/fixtures/history-helpers.ts deleted file mode 100644 index deaac791..00000000 --- a/e2e/fixtures/history-helpers.ts +++ /dev/null @@ -1,213 +0,0 @@ -// Additive helpers for the undo/redo history specs. Main's -// `fixtures/helpers.ts` already covers open-ribbon / create-event / right-click -// / context-menu / modal-save flows — this file only adds the pieces those -// helpers don't have yet: -// -// - batch-selection toolbar (enter, exit, toggle event, click action button, -// confirm destructive modal) -// - undo / redo via the command palette (no ribbon button exists for these) -// - disk-level waiters for frontmatter / existence / count assertions -// -// If any of these grow to be reused outside `specs/history/`, hoist them into -// `fixtures/helpers.ts` and delete the duplicate here. - -import { expect, type Locator, type Page } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { - listEventFiles, - openCreateModal, - snapshotEventFiles, - waitForNewEventFiles, -} from "../specs/events/events-helpers"; -import { fillEventModal, saveEventModal } from "../specs/events/fill-event-modal"; -import { eventByTitle } from "./calendar-helpers"; -import { runCommand } from "./commands"; -import { ACTIVE_CALENDAR_LEAF } from "./constants"; - -const BATCH_SELECT_BUTTON = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-toolbar-batch-select"]`; -const BATCH_EXIT_BUTTON = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-toolbar-batch-exit"]`; -const BATCH_COUNTER = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-batch-counter"]`; -const BATCH_CONFIRM_SUBMIT = '[data-testid="prisma-batch-confirm-submit"]'; - -const EVENT_IN_LEAF = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"]`; -const SELECTED_EVENT_IN_LEAF = `${EVENT_IN_LEAF}.prisma-batch-selected`; - -// ── DOM locators / assertions ───────────────────────────────────────────── -// Every batch op changes two things at once: the vault (frontmatter / file -// existence) AND the calendar's rendered events. Specs assert against both — -// a regression that writes correct frontmatter but fails to refresh the DOM -// (or vice versa) is the kind of partial failure these helpers catch. - -// Re-exported so history specs can import from one module; canonical -// definition lives in `./calendar-helpers`. -export { eventByTitle }; - -/** Locator for a calendar event in the active leaf, matched by its vault-relative path. */ -export function eventByPath(page: Page, vaultRelativePath: string): Locator { - return page.locator(`${EVENT_IN_LEAF}[data-event-file-path="${vaultRelativePath}"]`).first(); -} - -/** Locator for the batch-mode counter button ("N selected"). */ -export function batchCounter(page: Page): Locator { - return page.locator(BATCH_COUNTER).first(); -} - -/** Count of events currently marked with the `prisma-batch-selected` class. */ -export function selectedCount(page: Page): Promise { - return page.locator(SELECTED_EVENT_IN_LEAF).count(); -} - -/** Count of all visible events in the active calendar leaf. */ -export function visibleEventCount(page: Page): Promise { - return page.locator(EVENT_IN_LEAF).count(); -} - -/** Assert the batch counter reads `N selected` AND the DOM has exactly N `batch-selected` events. */ -export async function expectSelectedCount(page: Page, n: number): Promise { - await expect(batchCounter(page)).toHaveText(new RegExp(`${n} selected`)); - await expect.poll(() => selectedCount(page), { message: `expected ${n} events in batch selection` }).toBe(n); -} - -/** Assert every given title is currently rendered in the active calendar leaf. */ -export async function expectEventsVisibleByTitle(page: Page, titles: readonly string[]): Promise { - for (const title of titles) { - await expect(eventByTitle(page, title)).toBeVisible(); - } -} - -/** Assert none of the given titles are rendered in the active calendar leaf. */ -export async function expectEventsNotVisibleByTitle(page: Page, titles: readonly string[]): Promise { - for (const title of titles) { - await expect(eventByTitle(page, title)).toHaveCount(0); - } -} - -/** Assert the active leaf shows exactly `n` events (any title). */ -export async function expectVisibleEventCount(page: Page, n: number): Promise { - await expect.poll(() => visibleEventCount(page), { message: `expected ${n} visible events` }).toBe(n); -} - -/** - * Assert exactly `n` events with the given title are rendered in the active - * leaf. Per-title counts are more stable than aggregate counts: FC month-view - * injects overflow / "+N more" clones that inflate `[data-testid="prisma-cal-event"]` - * totals, but each clone still carries its original `data-event-title`. - */ -export async function expectTitleCount(page: Page, title: string, n: number): Promise { - await expect - .poll(() => page.locator(`${EVENT_IN_LEAF}[data-event-title="${title}"]`).count(), { - message: `expected ${n} events titled "${title}"`, - }) - .toBe(n); -} - -/** - * Create a single event via the toolbar → modal flow and return its - * vault-relative path. Consolidates the snapshot+fill+save+diff pattern that - * every history spec needs. Each call seeds exactly one file with a unique - * title — passing duplicate titles across calls confuses the right-click - * locators in follow-up assertions. - */ -export async function createEventViaToolbar( - page: Page, - vaultDir: string, - input: { title: string; start: string; end: string; allDay?: boolean } -): Promise { - const baseline = snapshotEventFiles(vaultDir); - await openCreateModal(page); - await fillEventModal(page, input); - await saveEventModal(page); - const [newPath] = await waitForNewEventFiles(vaultDir, baseline); - return newPath; -} - -export async function enterBatchMode(page: Page): Promise { - await page.locator(BATCH_SELECT_BUTTON).first().click(); - await page.locator(BATCH_COUNTER).first().waitFor({ state: "visible" }); -} - -/** - * Wait until every given title is not just rendered but flagged as - * batch-selectable (the `.prisma-batch-selectable` class is added during - * `addSelectionStylingToEvents`, which means the event is registered with the - * BatchSelectionManager AND has `data-event-id`). Useful right after - * `enterBatchMode` when a spec is about to click "Select All" — without this - * guard, the click can race the mount of the most-recently-created event and - * leave it unselected. - */ -export async function waitForBatchSelectable(page: Page, titles: readonly string[]): Promise { - for (const title of titles) { - await expect(eventByTitle(page, title)).toHaveClass(/prisma-batch-selectable/); - } -} - -export async function exitBatchMode(page: Page): Promise { - const exit = page.locator(BATCH_EXIT_BUTTON).first(); - if (await exit.isVisible().catch(() => false)) { - await exit.click(); - } -} - -/** Click an event (by title) while in batch mode — toggles its selection. */ -export async function toggleEventInBatch(page: Page, title: string): Promise { - const event = page - .locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"][data-event-title="${title}"]`) - .first(); - await event.waitFor({ state: "visible" }); - await event.click(); -} - -/** Click a batch-mode toolbar action button by its stamped suffix. */ -export async function clickBatchButton(page: Page, suffix: string): Promise { - const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-batch-${suffix}"]`).first(); - await btn.waitFor({ state: "visible" }); - await btn.click(); -} - -/** Confirm the destructive-action modal (currently only batch delete). */ -export async function confirmBatchAction(page: Page): Promise { - const confirm = page.locator(BATCH_CONFIRM_SUBMIT).first(); - await confirm.waitFor({ state: "visible" }); - await confirm.click(); - await confirm.waitFor({ state: "hidden" }); -} - -export async function undoViaPalette(page: Page, times = 1): Promise { - for (let i = 0; i < times; i++) await runCommand(page, "Prisma Calendar: Undo"); -} - -export async function redoViaPalette(page: Page, times = 1): Promise { - for (let i = 0; i < times; i++) await runCommand(page, "Prisma Calendar: Redo"); -} - -// ── Disk waiters ───────────────────────────────────────────────────────── -// Assertions against persisted state must read the vault, not the DOM: the -// metadata cache lags file writes and FC event state is not authoritative. - -export async function waitForEventFileCount(vaultDir: string, n: number): Promise { - await expect.poll(() => listEventFiles(vaultDir).length, { message: `expected ${n} event files` }).toBe(n); -} - -export async function waitForFrontmatter( - vaultDir: string, - filePath: string, - key: string, - matcher: (v: unknown) => boolean -): Promise { - await expect - .poll(() => matcher(readEventFrontmatter(vaultDir, filePath)[key]), { - message: `frontmatter ${key} did not match in ${filePath}`, - }) - .toBe(true); -} - -export async function waitForFileExists(vaultDir: string, filePath: string, shouldExist: boolean): Promise { - await expect - .poll(() => listEventFiles(vaultDir).some((abs) => abs.endsWith(`/${filePath}`)), { - message: `${filePath} existence != ${shouldExist}`, - }) - .toBe(shouldExist); -} - -export { isoLocal } from "./dates"; diff --git a/e2e/fixtures/ics-server.ts b/e2e/fixtures/ics-server.ts deleted file mode 100644 index ad58a101..00000000 --- a/e2e/fixtures/ics-server.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; - -import ICAL from "ical.js"; - -// Local HTTP server that mimics a remote ICS feed. Binds to 127.0.0.1 on an -// OS-assigned port, serves whatever body the spec last `setBody`'d, records -// every request for assertion, and lets the spec swap the status code to -// exercise error paths (403 for auth failures, 500 for server errors, etc.). -// Obsidian's `requestUrl` API (used by ics-subscription/sync.ts) hits this -// over plain HTTP on the loopback interface — no CORS, no cert trust, no -// docker required. - -const DEFAULT_CONTENT_TYPE = "text/calendar; charset=utf-8"; -const OK = 200; - -export interface RecordedRequest { - method: string; - path: string; - headers: Readonly>; -} - -export interface IcsServerState { - body: string; - status: number; - contentType: string; -} - -export interface IcsServer { - /** Full URL the plugin should subscribe to. */ - readonly url: string; - /** All requests observed since start(). Cleared by `resetRequests()`. */ - readonly requests: readonly RecordedRequest[]; - /** Replace the served body; takes effect on the next request. */ - setBody(ics: string): void; - /** Swap the HTTP status code. Handy for 403/500 error-path specs. */ - setStatus(status: number): void; - resetRequests(): void; - close(): Promise; -} - -/** - * Start a mock ICS server. Call `close()` in a `test.afterEach` / - * `test.afterAll` hook to release the port — Playwright's serial runner will - * otherwise leak a listener per spec. - */ -export async function startIcsServer(initialBody: string): Promise { - const state: IcsServerState = { - body: initialBody, - status: OK, - contentType: DEFAULT_CONTENT_TYPE, - }; - const recorded: RecordedRequest[] = []; - - const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { - recorded.push({ - method: req.method ?? "GET", - path: req.url ?? "/", - headers: req.headers, - }); - res.writeHead(state.status, { "Content-Type": state.contentType }); - res.end(state.body); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - - const addr = server.address() as AddressInfo; - const url = `http://127.0.0.1:${addr.port}/calendar.ics`; - - return { - url, - get requests() { - return recorded; - }, - setBody(ics) { - state.body = ics; - }, - setStatus(status) { - state.status = status; - }, - resetRequests() { - recorded.length = 0; - }, - close: () => - new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }), - }; -} - -// ── ICS body builders ─────────────────────────────────────────────────── -// Delegate RFC 5545 serialization to `ical.js` — the same library the plugin -// uses for export/import. Hand-rolling line protocol in tests would just -// duplicate that library's edge cases (line folding, value escaping, TZID -// emission) and eventually drift from what real clients produce. - -export interface VEventInput { - uid: string; - summary: string; - /** Timed events: `YYYYMMDDTHHmmssZ`. All-day events: `YYYYMMDD`. */ - dtstart: string; - dtend?: string; - /** Emit `DTSTART;VALUE=DATE` instead of a timestamp. `dtstart` should be `YYYYMMDD`. */ - allDay?: boolean; - categories?: string; - location?: string; - description?: string; -} - -function toICALTime(stamp: string, allDay: boolean): ICAL.Time { - // `YYYYMMDD[THHmmss[Z]]` — the compact form used in ICS wire output. - const year = Number(stamp.slice(0, 4)); - const month = Number(stamp.slice(4, 6)); - const day = Number(stamp.slice(6, 8)); - if (allDay) { - return new ICAL.Time({ year, month, day, isDate: true }, ICAL.Timezone.utcTimezone); - } - const hour = Number(stamp.slice(9, 11)); - const minute = Number(stamp.slice(11, 13)); - const second = Number(stamp.slice(13, 15)); - return new ICAL.Time({ year, month, day, hour, minute, second, isDate: false }, ICAL.Timezone.utcTimezone); -} - -// Tracks the last LAST-MODIFIED ms emitted across all buildIcs() calls so each -// successive call is guaranteed to be strictly greater than every prior one, -// even when fired in the same wall-clock second. Without this monotonic -// counter the sync planner can't tell two consecutive ICS bodies apart and -// treats real changes as "skip-unchanged" — see ICSSubscriptionSyncPlan. -let lastModifiedMs = 0; -function nextLastModifiedTime(): ICAL.Time { - const ms = Math.max(Date.now(), lastModifiedMs + 1000); - lastModifiedMs = ms; - return ICAL.Time.fromJSDate(new Date(ms), true); -} - -function buildVEvent(v: VEventInput): ICAL.Component { - const vevent = new ICAL.Component("vevent"); - vevent.addPropertyWithValue("uid", v.uid); - vevent.addPropertyWithValue("summary", v.summary); - vevent.addPropertyWithValue("dtstart", toICALTime(v.dtstart, Boolean(v.allDay))); - if (v.dtend) { - vevent.addPropertyWithValue("dtend", toICALTime(v.dtend, Boolean(v.allDay))); - } - vevent.addPropertyWithValue("last-modified", nextLastModifiedTime()); - if (v.categories) vevent.addPropertyWithValue("categories", v.categories); - if (v.location) vevent.addPropertyWithValue("location", v.location); - if (v.description) vevent.addPropertyWithValue("description", v.description); - return vevent; -} - -/** - * Wrap VEVENTs in a minimal VCALENDAR envelope. `ICAL.Component#toString()` - * produces byte-for-byte-correct RFC 5545 output (folded lines, escaped - * commas/semicolons, canonical property order) — the same output path the - * plugin's own exporter uses. - */ -export function buildIcs(events: readonly VEventInput[]): string { - const vcalendar = new ICAL.Component("vcalendar"); - vcalendar.addPropertyWithValue("version", "2.0"); - vcalendar.addPropertyWithValue("prodid", "-//Prisma-Calendar-E2E//EN"); - vcalendar.addPropertyWithValue("calscale", "GREGORIAN"); - for (const event of events) { - vcalendar.addSubcomponent(buildVEvent(event)); - } - return vcalendar.toString() + "\r\n"; -} diff --git a/e2e/fixtures/ics-subscription.ts b/e2e/fixtures/ics-subscription.ts deleted file mode 100644 index 3d7e969f..00000000 --- a/e2e/fixtures/ics-subscription.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { existsSync, readdirSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; - -import type { Page } from "@playwright/test"; -import { readEventFrontmatter, type BootstrappedObsidian } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { runCommand } from "./commands"; -import { expect } from "./electron"; -import { unlockPro } from "./helpers"; -import { buildIcs, startIcsServer, type IcsServer, type VEventInput } from "./ics-server"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -// High-level DSL for ICS subscription specs. Owns the mock HTTP server, -// secret-storage stub, subscription seed, vault clean-up, and exposes polling -// helpers so specs read as a sequence of remote-state changes and on-disk -// assertions instead of repeating the same 100-line boilerplate per file. - -const EVENTS_DIR = "Events"; -const PLUGIN_ID = "prisma-calendar"; -const SYNC_COMMAND = "Prisma Calendar: Sync ICS subscriptions"; -const DEFAULT_SUBSCRIPTION_ID = "e2e-subscription"; -const DEFAULT_SUBSCRIPTION_NAME = "E2E Feed"; -const DEFAULT_CALENDAR_ID = "default"; -const DEFAULT_URL_SECRET_NAME = "e2e-ics-subscription-url"; -const DEFAULT_TIMEZONE = "UTC"; -const DEFAULT_SYNC_INTERVAL_MINUTES = 60; - -export interface IcsSubscriptionConfig { - id?: string; - name?: string; - calendarId?: string; - urlSecretName?: string; - timezone?: string; - enabled?: boolean; -} - -export interface SetupIcsOptions { - /** Initial VEVENTs to serve from the mock server. Default: empty feed. */ - initial?: readonly VEventInput[]; - /** Override any subscription field; defaults serve a single enabled UTC sub. */ - subscription?: IcsSubscriptionConfig; -} - -export interface IcsSubscriptionHandle { - readonly server: IcsServer; - /** Replace the served feed body with a fresh VEVENT set. */ - setRemoteEvents(events: readonly VEventInput[]): void; - /** Serve an arbitrary body — used by malformed-input error-path specs. */ - setRawBody(body: string): void; - /** Swap the HTTP status code (e.g., 403 / 500). */ - setStatus(status: number): void; - /** Clear the recorded request log on the mock server. */ - resetRequests(): void; - /** Trigger the sync command via the palette and wait for it to dismiss. */ - sync(): Promise; - /** Poll until the server has observed at least `minRequests` requests. */ - waitForRequest(minRequests?: number): Promise; - /** Snapshot of `.md` filenames under the Events dir, minus virtual-event files. */ - listEventFiles(): string[]; - findEventFile(summarySubstring: string): string | undefined; - /** Like `findEventFile` but throws with a helpful diff if no match exists. */ - expectEventFile(summarySubstring: string): string; - readFrontmatter(summarySubstring: string): Record | undefined; - expectFileCount(expected: number): Promise; - close(): Promise; -} - -export async function setupIcsSubscription( - obsidian: BootstrappedObsidian, - options: SetupIcsOptions = {} -): Promise { - const config: Required = { - id: options.subscription?.id ?? DEFAULT_SUBSCRIPTION_ID, - name: options.subscription?.name ?? DEFAULT_SUBSCRIPTION_NAME, - calendarId: options.subscription?.calendarId ?? DEFAULT_CALENDAR_ID, - urlSecretName: options.subscription?.urlSecretName ?? DEFAULT_URL_SECRET_NAME, - timezone: options.subscription?.timezone ?? DEFAULT_TIMEZONE, - enabled: options.subscription?.enabled ?? true, - }; - - const server = await startIcsServer(buildIcs(options.initial ?? [])); - deleteAllEventFiles(obsidian.vaultDir); - await stubSecretStorage(obsidian.page, config.urlSecretName, server.url); - await unlockPro(obsidian.page); - await addSubscription(obsidian.page, config); - - return { - server, - setRemoteEvents: (events) => server.setBody(buildIcs(events)), - setRawBody: (body) => server.setBody(body), - setStatus: (status) => server.setStatus(status), - resetRequests: () => server.resetRequests(), - sync: () => triggerSync(obsidian.page), - waitForRequest: (minRequests = 1) => waitForRequest(server, minRequests), - listEventFiles: () => listEventFiles(obsidian.vaultDir), - findEventFile: (summarySubstring) => findEventFile(obsidian.vaultDir, summarySubstring), - expectEventFile(summarySubstring) { - const files = listEventFiles(obsidian.vaultDir); - const match = files.find((f) => f.includes(summarySubstring)); - expect(match, `expected an event file containing "${summarySubstring}" in [${files.join(", ")}]`).toBeDefined(); - return match!; - }, - readFrontmatter(summarySubstring) { - const file = findEventFile(obsidian.vaultDir, summarySubstring); - if (!file) return undefined; - return readEventFrontmatter(obsidian.vaultDir, `${EVENTS_DIR}/${file}`); - }, - expectFileCount: (expected) => expect.poll(() => listEventFiles(obsidian.vaultDir).length).toBe(expected), - close: () => server.close(), - }; -} - -function listEventFiles(vaultDir: string): string[] { - const dir = join(vaultDir, EVENTS_DIR); - if (!existsSync(dir)) return []; - return readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("Virtual Events")); -} - -function deleteAllEventFiles(vaultDir: string): void { - const dir = join(vaultDir, EVENTS_DIR); - if (!existsSync(dir)) return; - for (const f of readdirSync(dir).filter((x) => x.endsWith(".md"))) { - unlinkSync(join(dir, f)); - } -} - -function findEventFile(vaultDir: string, summarySubstring: string): string | undefined { - return listEventFiles(vaultDir).find((f) => f.includes(summarySubstring)); -} - -// Replace `app.secretStorage.getSecret` in the renderer so the subscription -// reads our test URL without touching the OS keychain. Renderer-scoped — dies -// with the page; can't leak across runs. -async function stubSecretStorage(page: Page, secretName: string, secretValue: string): Promise { - await page.evaluate( - ({ name, value }) => { - const w = window as unknown as PrismaWindow; - const original = w.app.secretStorage.getSecret.bind(w.app.secretStorage); - w.app.secretStorage.getSecret = (requested: string) => (requested === name ? value : original(requested)); - }, - { name: secretName, value: secretValue } - ); -} - -// `syncICSSubscription` re-reads from the settings store on every invocation, -// so inserting after boot is safe — no re-bootstrap needed. -async function addSubscription(page: Page, sub: Required): Promise { - await page.evaluate( - ({ pid, sub, syncIntervalMinutes }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const store = plugin?.settingsStore; - if (!store?.updateSettings) throw new Error("settingsStore.updateSettings missing"); - return store.updateSettings((current) => { - const icsSubs = (current["icsSubscriptions"] as Record | undefined) ?? {}; - const existing = (icsSubs["subscriptions"] as Array> | undefined) ?? []; - return { - ...current, - icsSubscriptions: { - ...icsSubs, - subscriptions: [ - ...existing.filter((s) => s["id"] !== sub.id), - { ...sub, syncIntervalMinutes, createdAt: Date.now() }, - ], - }, - }; - }); - }, - { pid: PLUGIN_ID, sub, syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES } - ); -} - -async function triggerSync(page: Page): Promise { - await runCommand(page, SYNC_COMMAND); -} - -// Polling the request log is more robust than polling the file system: a sync -// that fails (auth, parse error, network hiccup) still lands a request, so -// error-path specs can assert regardless of whether files appeared. -async function waitForRequest(server: IcsServer, minRequests: number): Promise { - await expect.poll(() => server.requests.length).toBeGreaterThanOrEqual(minRequests); -} diff --git a/e2e/fixtures/plugin-data.ts b/e2e/fixtures/plugin-data.ts deleted file mode 100644 index 24cf4494..00000000 --- a/e2e/fixtures/plugin-data.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { readPluginData } from "@real1ty-obsidian-plugins/testing/e2e"; - -// Prisma persists per-calendar UI state (pageHeaderState, activeTab, -// contextMenuState, …) under `data.calendars[N]`, keyed by id. Every shared -// spec needs to read "the default calendar entry" after `settleSettings`; -// this helper centralises the `find(id === "default") ?? [0]` fallback so the -// specs can describe what they're asserting, not how to get there. - -const PLUGIN_ID = "prisma-calendar"; -const DEFAULT_CALENDAR_ID = "default"; - -type CalendarEntry = Record> = { id: string } & Extra; - -export type { CalendarEntry }; - -/** - * Read `data.json`, then return the default calendar entry (by id, falling - * back to the first). The generic narrows the return type so callers get the - * plugin-specific shape they care about without casting at every call site. - */ -export function readDefaultCalendar>( - vaultDir: string -): CalendarEntry | undefined { - const data = readPluginData(vaultDir, PLUGIN_ID) as { - calendars?: Array>; - }; - const calendars = data.calendars; - return calendars?.find((c) => c.id === DEFAULT_CALENDAR_ID) ?? calendars?.[0]; -} - -export function getCalendars = Record>( - vaultDir: string -): Array> { - const data = readPluginData(vaultDir, PLUGIN_ID) as { - calendars?: Array>; - }; - return data.calendars ?? []; -} diff --git a/e2e/fixtures/resilience-helpers.ts b/e2e/fixtures/resilience-helpers.ts deleted file mode 100644 index d596aef5..00000000 --- a/e2e/fixtures/resilience-helpers.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import type { Page } from "@playwright/test"; - -import { PLUGIN_ID } from "./constants"; -import { seedEvent, type SeedEventInput } from "./seed-events"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -export function dataJsonPath(vaultDir: string): string { - // test fixture intentionally seeds the Obsidian config directory by path - return join(vaultDir, ".obsidian", "plugins", PLUGIN_ID, "data.json"); -} - -/** - * Write raw bytes into `/Events/` without running them through - * `seedEvent`'s structured frontmatter builder. Use only when the spec needs - * an intentionally malformed file (invalid YAML, truncated blocks) — all - * well-formed seeding should go through `seedEvent`. - */ -export function writeRawEventFile(vaultDir: string, fileName: string, content: string): string { - const relative = join("Events", fileName); - writeFileSync(join(vaultDir, relative), content, "utf8"); - return relative; -} - -/** - * Reload Obsidian's renderer and wait until prisma-calendar is fully ready - * again: plugin loaded, calendar bundles initialized, layout ready. Use this - * after mutating files on disk (data.json, event frontmatter) to observe how - * the plugin reacts to the new on-disk state from a cold boot. - * - * Repeats the bootstrap dance (`setEnable` → `loadManifests` → `enablePlugin`) - * because `community-plugins.json` persists the enabled set, but some Obsidian - * builds still start with `setEnable(false)` on a fresh renderer. - */ -export async function reloadAndWaitForPrisma(page: Page): Promise { - // Obsidian debounces `workspace.requestSaveLayout`; if we reload before it - // fires, leaves opened via `setViewState` never reach `workspace.json` and - // the renderer reboots without them. Flush the debouncer synchronously - // before tearing the renderer down. - await page.evaluate(async () => { - await (window as unknown as PrismaWindow).app.workspace.requestSaveLayout.run(); - }); - - await page.reload({ waitUntil: "domcontentloaded" }); - - await page.waitForFunction(() => Boolean((window as unknown as { app?: { plugins?: unknown } }).app?.plugins)); - - await page.evaluate(async (id) => { - const w = window as unknown as PrismaWindow; - const plugins = w.app.plugins; - try { - if (typeof plugins.setEnable === "function") await plugins.setEnable(true); - if (typeof plugins.loadManifests === "function") await plugins.loadManifests(); - if (!plugins.plugins[id]) { - if (typeof plugins.enablePluginAndSave === "function") await plugins.enablePluginAndSave(id); - else if (typeof plugins.enablePlugin === "function") await plugins.enablePlugin(id); - } - } catch { - // best-effort; the waitForFunction below is the real gate - } - }, PLUGIN_ID); - - await page.waitForFunction((id) => Boolean((window as unknown as PrismaWindow).app.plugins.plugins[id]), PLUGIN_ID); - - // Some malformed-state tests may end up with zero bundles — that's a - // valid assertion target rather than a harness failure. - await page - .waitForFunction((id) => { - const plugin = (window as unknown as PrismaWindow).app.plugins.plugins[id] as PrismaPlugin | undefined; - return Boolean(plugin?.calendarBundles?.length); - }, PLUGIN_ID) - .catch(() => {}); - - await page.evaluate(async (id) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - if (plugin && typeof plugin.ensureCalendarBundlesReady === "function") { - await plugin.ensureCalendarBundlesReady(); - } - }, PLUGIN_ID); -} - -/** Disable the plugin via the community-plugins runtime API. */ -export async function disablePrisma(page: Page): Promise { - await page.evaluate(async (id) => { - const w = window as unknown as PrismaWindow; - const plugins = w.app.plugins; - if (typeof plugins.disablePluginAndSave === "function") await plugins.disablePluginAndSave(id); - else if (typeof plugins.disablePlugin === "function") await plugins.disablePlugin(id); - else throw new Error("no disable fn"); - }, PLUGIN_ID); -} - -/** Re-enable the plugin via the community-plugins runtime API and wait until bundles are back. */ -export async function enablePrisma(page: Page): Promise { - await page.evaluate(async (id) => { - const w = window as unknown as PrismaWindow; - const plugins = w.app.plugins; - if (typeof plugins.enablePluginAndSave === "function") await plugins.enablePluginAndSave(id); - else if (typeof plugins.enablePlugin === "function") await plugins.enablePlugin(id); - else throw new Error("no enable fn"); - }, PLUGIN_ID); - - await page.waitForFunction( - (id) => { - const plugin = (window as unknown as PrismaWindow).app.plugins.plugins[id] as PrismaPlugin | undefined; - return Boolean(plugin?.calendarBundles?.length); - }, - PLUGIN_ID, - { timeout: 30_000 } - ); -} - -/** - * Shallow-merge `patch` into the default calendar entry (index 0 by default). - * Every top-level key in `patch` overrides the corresponding key on the - * calendar record. Use for toggling boolean/string/dropdown fields before a - * reload-barrier assertion — see reload-preserves-settings.spec.ts. - */ -export async function patchDefaultCalendar( - page: Page, - patch: Record, - calendarIndex = 0 -): Promise { - await page.evaluate( - async ({ id, p, i }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - if (!plugin?.settingsStore?.updateSettings) throw new Error("settingsStore.updateSettings missing"); - await plugin.settingsStore.updateSettings((current) => { - const calendars = (current["calendars"] as Array> | undefined) ?? []; - if (!calendars[i]) throw new Error(`no calendar at index ${i}`); - const next = [...calendars]; - next[i] = { ...next[i], ...p }; - return { ...current, calendars: next }; - }); - if (typeof plugin.ensureCalendarBundlesReady === "function") { - await plugin.ensureCalendarBundlesReady(); - } - }, - { id: PLUGIN_ID, p: patch, i: calendarIndex } - ); -} - -/** - * Append a calendar record to the settings store, cloning the first calendar's - * fields as a base so defaults are carried over. `overrides` wins. - * - * Default behaviour runs `refreshCalendarBundles` so the new calendar gets a - * live bundle — `ensureCalendarBundlesReady` only initializes existing bundles, - * not new ones. - * - * Pass `{ preserveActiveView: true }` to spin up ONLY the new bundle via - * `plugin.addCalendarBundle`. The primary's mounted view and painted events - * stay untouched — required when a spec has already rendered a tile in the - * primary calendar and is about to interact with it (e.g. right-click + move). - */ -export async function addCalendar( - page: Page, - overrides: Record, - options: { preserveActiveView?: boolean } = {} -): Promise { - const preserveActiveView = options.preserveActiveView ?? false; - await page.evaluate( - async ({ id, o, preserve }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - if (!plugin?.settingsStore?.updateSettings) throw new Error("settingsStore.updateSettings missing"); - const targetId = String(o["id"]); - await plugin.settingsStore.updateSettings((current) => { - const calendars = (current["calendars"] as Array> | undefined) ?? []; - if (calendars.some((c) => c["id"] === targetId)) return current; - const base = calendars[0] ?? {}; - return { ...current, calendars: [...calendars, { ...base, ...o }] }; - }); - if (preserve) { - if (typeof plugin.addCalendarBundle !== "function") throw new Error("plugin.addCalendarBundle missing"); - await plugin.addCalendarBundle(targetId); - } else if (typeof plugin.refreshCalendarBundles === "function") { - await plugin.refreshCalendarBundles(); - } else if (typeof plugin.ensureCalendarBundlesReady === "function") { - await plugin.ensureCalendarBundlesReady(); - } - }, - { id: PLUGIN_ID, o: overrides, preserve: preserveActiveView } - ); -} - -/** - * Read the currently-active view tab id from the live settings store. - * Returns `null` when the settings haven't been written yet (e.g. first boot - * before any tab switch). - */ -export async function readActiveTab(page: Page): Promise { - return page.evaluate((id) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - const settings = plugin?.settingsStore?.currentSettings; - if (!settings) return null; - const activeTab = settings["activeTab"] as { visibleTabIds?: string[]; activeTabIndex?: number } | undefined; - if (!activeTab?.visibleTabIds || activeTab.activeTabIndex === undefined) return null; - return activeTab.visibleTabIds[activeTab.activeTabIndex] ?? null; - }, PLUGIN_ID); -} - -/** Read the Nth (default: 0) calendar entry from the live settings store. */ -export async function readLiveCalendar(page: Page, index = 0): Promise | null> { - return page.evaluate( - ({ id, i }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - const calendars = plugin?.settingsStore?.currentSettings?.["calendars"] as - | Array> - | undefined; - return calendars?.[i] ?? null; - }, - { id: PLUGIN_ID, i: index } - ); -} - -export async function activateCalendar(page: Page, calendarId: string): Promise { - // `refreshCalendarBundles` resolves the promise before the new bundle's - // `initialize()` runs; the bundle array updates synchronously but the - // `activateCalendarView` path isn't wired up until init completes. Poll - // rather than relying on the prior await to finish everything. - await page.waitForFunction( - ({ id, pid }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - return Boolean(plugin?.calendarBundles?.some((b) => b.calendarId === id)); - }, - { id: calendarId, pid: PLUGIN_ID } - ); - await page.evaluate( - async ({ id, pid }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundle = plugin?.calendarBundles?.find((b) => b.calendarId === id); - if (!bundle?.activateCalendarView) throw new Error(`no bundle for ${id}`); - await bundle.activateCalendarView(); - }, - { id: calendarId, pid: PLUGIN_ID } - ); -} - -export async function countLeavesOfType(page: Page, viewType: string): Promise { - return page.evaluate((t) => { - const w = window as unknown as PrismaWindow; - return w.app.workspace.getLeavesOfType(t).length; - }, viewType); -} - -/** - * Return vault paths whose frontmatter has `ZettelID` matching `zettelId`. - * Compares after `String()` coercion so an unquoted-numeric ZettelID that - * YAML parsed as a number still matches a string-shaped query. - */ -export async function resolveByZettelId(page: Page, zettelId: string): Promise { - return page.evaluate((id) => { - const w = window as unknown as PrismaWindow; - const winners: string[] = []; - for (const f of w.app.vault.getFiles()) { - // frontmatter is untyped in Obsidian API - const fm = w.app.metadataCache.getFileCache(f)?.frontmatter; - // frontmatter fields are dynamically keyed - if (fm && String(fm["ZettelID"]) === id) winners.push(f.path); - } - return winners; - }, zettelId); -} - -/** For each `endsWith` needle, return whether any vault path ends with it. */ -export async function vaultHasFilesEndingWith(page: Page, needles: readonly string[]): Promise { - return page.evaluate((ends) => { - const w = window as unknown as PrismaWindow; - const haystack = w.app.vault.getFiles().map((f) => f.path); - return ends.map((n) => haystack.some((h) => h.endsWith(n))); - }, needles); -} - -/** Seed a batch of events on disk via `seedEvent` and return the relative paths. */ -export function seedEventFiles(vaultDir: string, events: readonly SeedEventInput[]): string[] { - return events.map((e) => seedEvent(vaultDir, e)); -} - -export { PLUGIN_ID }; diff --git a/e2e/fixtures/seed-events.ts b/e2e/fixtures/seed-events.ts deleted file mode 100644 index 7c68196a..00000000 --- a/e2e/fixtures/seed-events.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import { expect, type Page } from "@playwright/test"; - -import { PLUGIN_ID } from "./constants"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -// Disk-level event seeding + runtime introspection. These utilities bypass -// the create-event modal so specs that focus on integrations (ICS export, -// filter presets) or edge cases (DST, year boundary, 500-event stress) can -// arrive at a known event set without clicking through the UI. The modal -// flow itself is already covered by the specs under `specs/events/`. - -export interface SeedEventInput { - title: string; - startDate?: string; - endDate?: string; - date?: string; - allDay?: boolean; - category?: string; - location?: string; - participants?: string[]; - rrule?: string; - rruleSpec?: string; - extra?: Record; - body?: string; - subdir?: string; -} - -function frontmatterValue(value: unknown): string { - if (value === null || value === undefined) return '""'; - if (Array.isArray(value)) { - if (value.length === 0) return "[]"; - return `\n${value.map((v) => ` - ${String(v)}`).join("\n")}`; - } - if (typeof value === "string") { - return value.includes(":") || value.includes("#") - ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` - : value; - } - return String(value); -} - -export function seedEvent(vaultDir: string, event: SeedEventInput): string { - const subdir = event.subdir ?? "Events"; - const filename = `${event.title.replace(/[/\\:*?"<>|]/g, "-")}.md`; - const relative = join(subdir, filename); - const absolute = join(vaultDir, relative); - - mkdirSync(dirname(absolute), { recursive: true }); - writeFileSync(absolute, buildEventMarkdown(event), "utf8"); - return relative; -} - -/** - * Serialize a `SeedEventInput` into the on-disk markdown an event file needs — - * frontmatter (`Start Date`, `End Date`, …) plus body. Extracted from - * `seedEvent` so the deterministic stress vault generator produces byte-for-byte - * the same shape without duplicating the frontmatter contract. - */ -export function buildEventMarkdown(event: SeedEventInput): string { - const fm: Record = {}; - if (event.startDate) fm["Start Date"] = event.startDate; - if (event.endDate) fm["End Date"] = event.endDate; - if (event.date) fm["Date"] = event.date; - if (event.allDay) fm["All Day"] = true; - if (event.category) fm["Category"] = event.category; - if (event.location) fm["Location"] = event.location; - if (event.participants?.length) fm["Participants"] = event.participants; - if (event.rrule) fm["RRule"] = event.rrule; - if (event.rruleSpec) fm["RRuleSpec"] = event.rruleSpec; - if (event.extra) { - for (const [k, v] of Object.entries(event.extra)) fm[k] = v; - } - - const fmLines = Object.entries(fm) - .map(([k, v]) => `${k}: ${frontmatterValue(v)}`) - .join("\n"); - const body = event.body ?? `# ${event.title}\n`; - return `---\n${fmLines}\n---\n\n${body}`; -} - -export function seedEvents(vaultDir: string, events: readonly SeedEventInput[]): string[] { - return events.map((e) => seedEvent(vaultDir, e)); -} - -/** - * Mutate a single frontmatter field on an already-indexed vault file through - * Obsidian's `processFrontMatter`. This is the ONLY write path that atomically - * updates bytes AND the metadata cache in one step. - * - * Do NOT replace this with `writeFileSync` + a refresh/nudge. That older - * pattern raced: if the vault watcher hadn't ingested the raw write yet, the - * plugin's own background writes (or a "nudge" `processFrontMatter` call) - * would read stale cached YAML and rewrite the file from the stale cache — - * silently clobbering the change. Use raw `writeFileSync` only for SEEDING - * (before the plugin indexes the file) where cache can't be stale. - */ -export async function setFrontmatterField( - page: Page, - relativePath: string, - field: string, - value: unknown -): Promise { - await page.evaluate( - async ({ path, key, val }) => { - const w = window as unknown as PrismaWindow; - const file = w.app.vault.getAbstractFileByPath(path); - if (!file) throw new Error(`setFrontmatterField: no file at ${path}`); - await w.app.fileManager.processFrontMatter(file, (fm) => { - fm[key] = val; - }); - }, - { path: relativePath, key: field, val: value } - ); -} - -/** Read the default-calendar bundle's live settings snapshot. */ -export async function readCalendarSettings(page: Page): Promise> { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("No calendar bundle"); - return bundle.settingsStore.currentSettings; - }, PLUGIN_ID); -} - -/** Shallow-merge a settings patch into the default-calendar bundle. */ -export async function updateCalendarSettings(page: Page, patch: Record): Promise { - await page.evaluate( - async ({ p, pid }) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("No calendar bundle"); - await bundle.settingsStore.updateSettings((current) => ({ ...current, ...p })); - }, - { p: patch, pid: PLUGIN_ID } - ); -} - -export async function waitForEventCount(page: Page, count: number, timeout = 30_000): Promise { - await expect - .poll(() => getEventCount(page), { - timeout, - message: `indexer never reached ${count} events`, - }) - .toBe(count); -} - -/** Count events the plugin currently sees via the event store. */ -export async function getEventCount(page: Page): Promise { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("No calendar bundle"); - return bundle.eventStore.getAllEvents().length; - }, PLUGIN_ID); -} - -export async function waitForUntrackedEventCount(page: Page, count: number, timeout = 30_000): Promise { - await expect - .poll(() => getUntrackedEventCount(page), { - timeout, - message: `untracked store never reached ${count} events`, - }) - .toBe(count); -} - -/** - * Count dateless events the plugin tracks in the untracked store. Dateless - * events (no Start/End/Date) never land in `eventStore` — the indexer routes - * them here — so this is the readiness signal when seeding an untracked event. - */ -export async function getUntrackedEventCount(page: Page): Promise { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0]; - if (!bundle) throw new Error("No calendar bundle"); - return bundle.untrackedEventStore.getUntrackedEvents().length; - }, PLUGIN_ID); -} - -export async function waitForCalendarCount(page: Page, count: number): Promise { - await expect - .poll( - () => - page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - return (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.length ?? 0; - }, PLUGIN_ID), - { message: `waiting for ${count} calendar bundles` } - ) - .toBe(count); -} diff --git a/e2e/fixtures/selectors.ts b/e2e/fixtures/selectors.ts deleted file mode 100644 index 342ad24b..00000000 --- a/e2e/fixtures/selectors.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Centralised locators. React migration will churn the DOM; keeping every -// selector in one file limits the blast radius of those churns. -// -// Prefer role-based selectors where possible. Fall back to Obsidian's own -// class names (workspace-*, modal-*, setting-*) — they're stable across plugin -// versions. - -export const SELECTORS = { - workspace: ".workspace", - workspaceLeaf: ".workspace-leaf", - - commandPalette: { - prompt: ".prompt", - input: ".prompt-input", - suggestions: ".suggestion-item", - }, - - settings: { - modal: ".modal-container .mod-settings", - sidebarTab: ".vertical-tab-header-group .vertical-tab-nav-item", - prismaTabName: "Prisma Calendar", - content: ".vertical-tab-content", - heading: ".setting-item-heading", - toggle: ".checkbox-container", - }, - - modal: { - root: ".modal", - title: ".modal-title", - closeButton: ".modal-close-button", - }, - - notice: ".notice", -} as const; - -export const PRISMA_COMMANDS = { - createEvent: "Prisma Calendar: Create event", - openCalendar: "Prisma Calendar: Open calendar", -} as const; diff --git a/e2e/fixtures/stress-helpers.ts b/e2e/fixtures/stress-helpers.ts deleted file mode 100644 index 5ec54f98..00000000 --- a/e2e/fixtures/stress-helpers.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - -import { expect, type Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF, PLUGIN_ID } from "./constants"; -import { seedEvent, updateCalendarSettings, type SeedEventInput } from "./seed-events"; -import type { PrismaPlugin, PrismaWindow } from "./window-types"; - -// Helpers for high-volume / reactivity stress specs. The premise: the calendar -// must be *eventually consistent* — after any batch mutation (move, clone, -// skip, delete, undo/redo) the DOM-rendered event set must converge to match -// the on-disk event set, regardless of how many files changed at once. A -// "partial refresh" (some events move, some stay stale) is a prod-breaking -// bug. Every helper here polls on both the indexer state and the DOM so -// assertions catch divergence rather than race against it. - -const EVENT_IN_LEAF = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"]`; - -export interface BulkSeedOptions { - /** Prefix for auto-generated titles (default "Bulk"). */ - prefix?: string; - /** First event's day offset from today (0 = today). Default 0. */ - startDayOffset?: number; - /** Number of distinct days the events span. Events loop around via modulo. Default 7. */ - spreadDays?: number; - /** First slot's hour-of-day (default 9). Subsequent slots stack +1h within each day. */ - startHour?: number; - /** Optional subdir under vaultDir (default "Events"). */ - subdir?: string; -} - -/** - * Days-from-today offset pointing at the Sunday that starts the current week. - * FullCalendar's default `firstDay` is Sunday. Seeds anchored here land events - * inside the current `listWeek` / `timeGridWeek` window regardless of what day - * of the week the test happens to run on. - */ -export function currentWeekStartOffset(): number { - const today = new Date(); - today.setHours(0, 0, 0, 0); - return -today.getDay(); -} - -/** - * Bump per-slot stacking / per-day caps to the max allowed values so stress - * tests can render 10+ events in one time cell without FullCalendar - * collapsing them into a "+more" link. Does NOT affect correctness — only - * DOM visibility — so specs can count `[data-testid="prisma-cal-event"]` - * nodes directly. - */ -export async function raiseRenderCapsForStress(page: Page): Promise { - await updateCalendarSettings(page, { - eventMaxStack: 10, - desktopMaxEventsPerDay: 0, - }); -} - -/** - * Race-safe event-file count. The shared `listEventFiles` helper walks the - * Events/ tree with a `readdirSync` + per-entry `statSync`; under bulk undo - * or batch delete the plugin deletes files in quick succession, and a stat - * on an entry that vanished between readdir and stat throws ENOENT. This - * wrapper tolerates that race by retrying once after a short pause — - * callers are already polling via `expect.poll` so an occasional retry is - * cheap. - */ -export function safeEventFileCount(vaultDir: string, subdir = "Events"): number { - for (let attempt = 0; attempt < 3; attempt++) { - try { - return countMdFilesRecursive(join(vaultDir, subdir)); - } catch (err) { - if (!isEnoent(err)) throw err; - // tight retry — the race window is sub-ms; poll caller provides the outer timing - } - } - // Final attempt — let the error propagate if the vault is truly broken. - return countMdFilesRecursive(join(vaultDir, subdir)); -} - -function countMdFilesRecursive(dir: string): number { - let total = 0; - let entries: string[]; - try { - entries = readdirSync(dir); - } catch (err) { - if (isEnoent(err)) return 0; - throw err; - } - for (const entry of entries) { - const full = join(dir, entry); - let stat; - try { - stat = statSync(full); - } catch (err) { - if (isEnoent(err)) continue; - throw err; - } - if (stat.isDirectory()) { - total += countMdFilesRecursive(full); - } else if (stat.isFile() && entry.endsWith(".md") && entry !== "Virtual Events.md") { - total += 1; - } - } - return total; -} - -function isEnoent(err: unknown): boolean { - return typeof err === "object" && err !== null && "code" in err && (err as { code: unknown }).code === "ENOENT"; -} - -/** - * Seed `count` timed events to disk, spread across `spreadDays` consecutive - * days. Titles are deterministic and zero-padded (` 001` … ` N`) - * so lexical ordering matches numeric ordering — handy when per-title - * assertions loop a slice of the batch. - * - * Writes bypass the create-event modal: this is a disk-level seed, indexed the - * same way any on-disk event file is. Pair with `waitForIndexerToReach` to - * gate the test on indexer convergence before the first mutation. - */ -export function seedBulkEvents(vaultDir: string, count: number, options: BulkSeedOptions = {}): string[] { - const prefix = options.prefix ?? "Bulk"; - const spread = Math.max(1, options.spreadDays ?? 7); - const startOffset = options.startDayOffset ?? 0; - const baseHour = options.startHour ?? 9; - const subdir = options.subdir ?? "Events"; - const width = Math.max(3, String(count).length); - const paths: string[] = []; - - const base = new Date(); - base.setHours(0, 0, 0, 0); - - for (let i = 0; i < count; i++) { - const dayIndex = i % spread; - const slotInDay = Math.floor(i / spread); - // 8 slots per day cap keeps events within 09:00–17:00 and avoids - // crossing midnight when spread is small relative to count. - const hour = baseHour + (slotInDay % 8); - const day = new Date(base); - day.setDate(base.getDate() + startOffset + dayIndex); - const ymd = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`; - - const title = `${prefix} ${String(i + 1).padStart(width, "0")}`; - const input: SeedEventInput = { - title, - startDate: `${ymd}T${String(hour).padStart(2, "0")}:00`, - endDate: `${ymd}T${String(hour + 1).padStart(2, "0")}:00`, - subdir, - }; - paths.push(seedEvent(vaultDir, input)); - } - return paths; -} - -/** - * Count unique events rendered in the active calendar leaf. Dedups FullCalendar's - * overflow / multi-day clones by `data-event-file-path`: month view injects - * extra DOM nodes with the same path for "+N more" popovers and multi-day - * spans, inflating the raw `[data-testid="prisma-cal-event"]` count. - * - * In week / day / list views this matches the intuitive count (one DOM node - * per event). In month view it counts distinct events, ignoring overflow - * clone-rendering. - */ -export async function uniqueVisibleEventCount(page: Page): Promise { - return page.evaluate((selector) => { - const elements = Array.from(document.querySelectorAll(selector)); - const paths = new Set(); - let untitledWithoutPath = 0; - for (const el of elements) { - const fp = (el as HTMLElement).getAttribute("data-event-file-path"); - if (fp) { - paths.add(fp); - } else { - untitledWithoutPath += 1; - } - } - return paths.size + untitledWithoutPath; - }, EVENT_IN_LEAF); -} - -/** Count events the plugin's indexer currently holds for the default bundle. */ -export async function indexerEventCount(page: Page): Promise { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundle = plugin?.calendarBundles?.[0]; - return bundle?.eventStore.getAllEvents().length ?? 0; - }, PLUGIN_ID); -} - -/** - * Poll until the indexer reaches exactly `expected` events. Large seeded vaults - * take noticeably longer to ingest than the 500ms refresh window bakes in, so - * the tolerance here is generous by default. - */ -export async function waitForIndexerToReach(page: Page, expected: number, timeoutMs?: number): Promise { - await expect - .poll(() => indexerEventCount(page), { - message: `indexer never reached ${expected} events`, - ...(timeoutMs ? { timeout: timeoutMs } : {}), - }) - .toBe(expected); -} - -/** - * Poll until the indexer reaches **at least** `atLeast` events AND the count holds - * steady across two consecutive reads. For recurrence scenarios the source ingest - * is followed by physical-instance materialization that pushes the count *past* the - * source total, so the exact-count gate (`waitForIndexerToReach`) can't settle — - * this waits for "all sources in + materialization quiesced" instead. - */ -export async function waitForIndexerToSettle(page: Page, atLeast: number): Promise { - let previous = -1; - await expect - .poll( - async () => { - const count = await indexerEventCount(page); - const settled = count >= atLeast && count === previous; - previous = count; - return settled; - }, - { message: `indexer never settled at >= ${atLeast} events` } - ) - .toBe(true); -} - -/** - * Assert the rendered DOM converges to exactly `expected` unique events in - * the active calendar leaf. Uses `expect.poll` so a momentarily stale render - * (diff in progress, RAF not yet fired) doesn't fail the spec — but a - * persistently stale render does. Prefer this over `visibleEventCount` - * followed by `toBe`, which races the DOM and flakes. - */ -export async function expectUniqueVisibleEventCount(page: Page, expected: number): Promise { - await expect - .poll(() => uniqueVisibleEventCount(page), { - message: `calendar DOM never converged to ${expected} unique visible events`, - }) - .toBe(expected); -} - -/** - * Joint assertion: indexer AND DOM converge on the expected counts. The whole - * point of the stress suite — if either diverges we've found a reactivity - * regression. `indexer` is always the source of truth for "how many events - * the plugin thinks exist". `visible` is the DOM-rendered subset within the - * currently-visible view range; for an all-today seed in week/day/list view - * the two should match, for events scattered across months `visible` < `indexer`. - */ -export async function expectCalendarConsistent( - page: Page, - options: { indexer: number; visible: number } -): Promise { - await waitForIndexerToReach(page, options.indexer); - await expectUniqueVisibleEventCount(page, options.visible); -} diff --git a/e2e/fixtures/testids.ts b/e2e/fixtures/testids.ts deleted file mode 100644 index e27bd8b7..00000000 --- a/e2e/fixtures/testids.ts +++ /dev/null @@ -1,414 +0,0 @@ -// Single source of truth for every `data-testid` stamped by plugin source. -// Specs import `TID.*` instead of splicing `prisma-*` strings by hand. When -// the plugin renames a test-id, updating the matching entry here recompiles -// every consumer — TypeScript catches the breakage at build time, not as a -// flaky runtime selector miss. -// -// Key types are string-literal unions. New ids get added here and the plugin -// source simultaneously; if a spec tries to address an unknown id it fails -// to compile. - -export type ContextMenuItemKey = - | "enlarge" - | "preview" - | "editEvent" - | "assignCategories" - | "assignPrerequisites" - | "duplicateEvent" - | "moveBy" - | "moveToCalendar" - | "markDone" - | "moveToNextWeek" - | "cloneToNextWeek" - | "moveToPreviousWeek" - | "cloneToPreviousWeek" - | "deleteEvent" - | "skipEvent" - | "openFile" - | "openFileNewWindow" - | "viewNameSeries" - | "viewCategorySeries" - | "viewRecurringSeries"; - -export type EventControlKey = - | "title" - | "all-day" - | "start" - | "end" - | "date" - | "duration" - | "location" - | "icon" - | "skip" - | "markAsDone" - | "breakMinutes" - | "notify-before" - | "rrule" - | "rrule-type" - | "custom-freq" - | "custom-interval" - | "rrule-until" - | "future-instances-count" - | "generate-past-events" - | "preset" - | "participants" - | "virtual"; - -export type EventFieldKey = "title" | "categories" | "prerequisites" | "participants"; - -export type EventBtnKey = - | "save" - | "cancel" - | "minimize" - | "save-preset" - | "assign-categories" - | "assign-prerequisites" - | "add-participant" - | "add-custom-prop-other" - | "add-custom-prop-display" - | "remove-custom-prop-other" - | "remove-custom-prop-display"; - -export type ToolbarKey = - | "create" - | "view-year" - | "view-month" - | "view-week" - | "view-day" - | "view-list" - | "next" - | "prev" - | "today" - | "goto-now" - | "batch-select" - | "batch-exit"; - -/** Calendar "view mode" for FullCalendar — picks the toolbar view- button. */ -export type ViewMode = "year" | "month" | "week" | "day" | "list"; - -/** - * Page-header toolbar action ids. Every entry in `buildPageHeaderActions()` - * stamps a `data-testid="prisma-toolbar-"` on its button. Specs reach for - * these via `calendar.clickToolbar(id)`. - */ -export type ToolbarActionKey = - | "create-event" - | "create-untracked" - | "go-to-today" - | "scroll-to-now" - | "navigate-back" - | "navigate-forward" - | "global-search" - | "toggle-batch" - | "daily-stats" - | "weekly-stats" - | "monthly-stats" - | "alltime-stats" - | "toggle-prerequisites" - | "refresh" - | "show-recurring"; - -/** - * Analytics view-tab ids stamped as `data-testid="prisma-view-tab-"`. - * Leaf tabs (calendar / timeline / heatmap / gantt / …) activate directly; - * group tabs (`dashboard`) open a dropdown whose children use the same - * testid pattern — see `switchToGroupChild`. - */ -export type ViewTabKey = - | "calendar" - | "timeline" - | "heatmap" - | "gantt" - | "dashboard" - | "dashboard-by-name" - | "dashboard-by-category" - | "dashboard-recurring" - | "daily-stats" - | "dual-daily" - | "heatmap-monthly-stats" - | "monthly-calendar-stats"; - -export type BatchBtnKey = - | "select-all" - | "clear" - | "duplicate" - | "move-by" - | "mark-done" - | "mark-not-done" - | "categories" - | "frontmatter" - | "clone-next" - | "clone-prev" - | "move-next" - | "move-prev" - | "open-all" - | "skip" - | "make-virtual" - | "make-real" - | "delete"; - -export const EVENT_BLOCK_TID = "prisma-cal-event"; -export const BATCH_COUNTER_TID = "prisma-cal-batch-counter"; -export const BATCH_CONFIRM_TID = "prisma-batch-confirm-submit"; - -// ── Shared-library testids ───────────────────────────────────────────────── -// The shared/ package stamps its own testids, distinct from the Prisma-Calendar -// family above. Every `prisma-` here was added by the shared component, not -// by plugin code — renaming any of them would require a shared-library update -// and an audit across every plugin that consumes it. - -/** Page header manage-actions modal — sub-parts keyed off the host `prisma-page-header-*` root. */ -export const PAGE_HEADER_MANAGE_BTN = "prisma-page-header-manage"; -export const ACTION_MANAGER_MODAL = "prisma-action-manager-modal"; -export const ACTION_MANAGER_RESET_BTN = "prisma-action-manager-reset"; -/** - * Page-header overflow trigger (the "More actions" button) + the menu it opens. - * When the responsive header trims actions off the bar they're reachable here. - * Menu items are the shared `ContextMenu`'s `prisma-ctx-item-` entries. - */ -export const PAGE_HEADER_OVERFLOW_TID = "prisma-page-header-overflow"; -export const PAGE_HEADER_OVERFLOW_MENU_TID = "prisma-ctx-menu"; -export const overflowMenuItem = (key: ToolbarActionKey): string => `prisma-ctx-item-${key}`; -/** Tabbed-container tab-manager modal parts. */ -export const TABBED_CONTAINER_MANAGE_BTN = "prisma-tabbed-container-manage"; -export const TAB_MANAGER_MODAL = "prisma-tab-manager-modal"; -export const TAB_MANAGER_RESET_BTN = "prisma-tab-manager-reset"; -/** Context-menu item-manager modal (shown via the menu's "Manage items…" entry). */ -export const ITEM_MANAGER_MODAL = "prisma-item-manager-modal"; -export const ITEM_MANAGER_RESET_BTN = "prisma-item-manager-reset"; -/** Shared reset-to-defaults confirmation modal testIdPrefix (matches `ResetToDefaultsButton`). */ -export const RESET_CONFIRMATION_TID_PREFIX = "prisma-reset-to-defaults-"; -/** Generic assignment picker (used by Assign Categories / Assign Prerequisites). */ -export const ASSIGN_MODAL_ROOT = ".prisma-assignment-modal"; -/** Shared visual icon picker modal — opened when editing an icon in any manager. */ -export const ICON_PICKER_GRID_TID = "shared-icon-picker-grid"; -export const ICON_PICKER_SEARCH_TID = "shared-icon-picker-search"; -export const ICON_PICKER_NO_ICON_TID = "shared-icon-picker-no-icon"; -/** Plugin-agnostic shared confirmation modal (unprefixed by design). */ -export const CONFIRMATION_MODAL_TID = "confirmation-modal-container"; -export const CONFIRMATION_MODAL_CONFIRM_TID = "confirmation-modal-confirm"; -export const CONFIRMATION_MODAL_CANCEL_TID = "confirmation-modal-cancel"; -/** Plugin-agnostic shared rename modal (unprefixed by design). */ -export const RENAME_MODAL_TID = "rename-modal-container"; -export const RENAME_INPUT_TID = "rename-input"; -export const RENAME_SUBMIT_TID = "rename-submit"; -export const RENAME_CANCEL_TID = "rename-cancel"; -/** Onboarding tour (shared-react react-joyride engine). The popover renders into - * a body-level portal, so these are addressed off `page`, not the calendar root. */ -export const TOUR_TOOLTIP_TID = "prisma-tour-tooltip"; -export const TOUR_NEXT_TID = "prisma-tour-next"; -export const TOUR_BACK_TID = "prisma-tour-back"; -export const TOUR_SKIP_TID = "prisma-tour-skip"; -export const TOUR_CLOSE_TID = "prisma-tour-close"; -export const TOUR_PROGRESS_TID = "prisma-tour-progress"; - -/** Shared progress modal used by ICS import and other batched flows. */ -export const PROGRESS_MODAL_TID = "prisma-progress-modal"; -export const PROGRESS_STATUS_TID = "prisma-progress-status"; -export const PROGRESS_BAR_TID = "prisma-progress-bar"; -export const PROGRESS_DETAILS_TID = "prisma-progress-details"; - -/** - * Row-ID-scoped shared testids. Each function returns the full testid for a - * specific row inside its manager/component. Using these instead of inlining - * template strings means a shared-library rename only needs one edit here. - */ -/** Prefix constants for shared-library row testids — useful for "list every row" queries. */ -export const SHARED_ROW_PREFIX = { - actionRow: "prisma-action-manager-row-", - actionUp: "prisma-action-manager-up-", - actionToggle: "prisma-action-manager-toggle-", - tabManagerRow: "prisma-tab-manager-row-", - tabManagerUp: "prisma-tab-manager-up-", - tabManagerToggle: "prisma-tab-manager-toggle-", - tabManagerEdit: "prisma-tab-manager-edit-", - tabManagerIconBtn: "prisma-tab-manager-icon-btn-", - tabManagerRename: "prisma-tab-manager-rename-", - itemManagerToggle: "prisma-item-manager-toggle-", - itemManagerEdit: "prisma-item-manager-edit-", - itemManagerIconBtn: "prisma-item-manager-icon-btn-", - collapsibleHeader: "prisma-collapsible-header-", - collapsibleBody: "prisma-collapsible-body-", - collapsibleToggle: "prisma-collapsible-toggle-", - pageHeaderToolbar: "prisma-toolbar-", - viewTab: "prisma-view-tab-", -} as const; - -export const sharedTID = { - actionRow: (id: string): string => `${SHARED_ROW_PREFIX.actionRow}${id}`, - actionUp: (id: string): string => `${SHARED_ROW_PREFIX.actionUp}${id}`, - actionToggle: (id: string): string => `${SHARED_ROW_PREFIX.actionToggle}${id}`, - tabManagerRow: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerRow}${id}`, - tabManagerUp: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerUp}${id}`, - tabManagerToggle: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerToggle}${id}`, - tabManagerEdit: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerEdit}${id}`, - tabManagerIconBtn: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerIconBtn}${id}`, - tabManagerRename: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerRename}${id}`, - itemManagerToggle: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerToggle}${id}`, - itemManagerEdit: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerEdit}${id}`, - itemManagerIconBtn: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerIconBtn}${id}`, - collapsibleHeader: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleHeader}${id}`, - collapsibleBody: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleBody}${id}`, - collapsibleToggle: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleToggle}${id}`, - assignSearch: (): string => "prisma-assign-search", - assignItem: (): string => "prisma-assign-item", - assignCreateNew: (): string => "prisma-assign-create-new", - assignSubmit: (): string => "prisma-assign-submit", -} as const; - -// ── Plugin-specific integration + filter + stopwatch + ICS testids ────────── -// These aren't "shared" — they live in Prisma source — but they're referenced -// outside the existing TID families, so collect them here so specs don't -// scatter raw literals across suites. - -export const FILTER_SEARCH_TID = "prisma-filter-search"; -export const FILTER_EXPRESSION_TID = "prisma-filter-expression"; -export const FILTER_PRESET_TID = "prisma-filter-preset"; - -export const STOPWATCH_TIME_TID = "prisma-stopwatch-time"; - -/** Stopwatch action buttons (start/continue/pause/stop/resume). */ -export type StopwatchBtnKey = "start" | "continue" | "pause" | "stop" | "resume"; -export const stopwatchBtn = (key: StopwatchBtnKey): string => `prisma-stopwatch-btn-${key}`; - -/** Slug used by the stopwatch's CollapsibleSection — passed to `collapsibleSection(page, ...)`. */ -export const STOPWATCH_COLLAPSIBLE_SLUG = "time-tracker"; - -export const ICS_EXPORT_SUBMIT_TID = "prisma-ics-export-submit"; -export const ICS_IMPORT_FILE_TID = "prisma-ics-import-file"; -export const ICS_IMPORT_SUBMIT_TID = "prisma-ics-import-submit"; - -// ── Categories settings ──────────────────────────────────────────────────── - -export const CATEGORY_ROW_TID = "prisma-category-settings-item"; -export const CATEGORY_RENAME_BTN_TID = "prisma-category-settings-rename-button"; -export const CATEGORY_DELETE_BTN_TID = "prisma-category-settings-delete-button"; -export const CATEGORY_COUNT_CLASS = "prisma-category-settings-count"; - -/** Test-id prefixes passed to shared rename / confirmation modals from the categories tab. */ -export const CATEGORY_RENAME_PREFIX = "prisma-category-"; -export const CATEGORY_DELETE_PREFIX = "prisma-category-delete-"; - -export const CATEGORY_INCLUDE_UNTRACKED_TOGGLE_TID = "prisma-category-include-untracked-toggle"; - -export const UNTRACKED_BUTTON_TID = "prisma-untracked-dropdown-button"; -export const UNTRACKED_DROPDOWN_TID = "prisma-untracked-dropdown"; -export const UNTRACKED_ITEM_TID = "prisma-untracked-dropdown-item"; -export const UNTRACKED_ITEM_START_TID = "prisma-untracked-dropdown-item-start"; -export const UNTRACKED_CREATE_BTN_TID = "prisma-untracked-create"; -export const UNTRACKED_SEARCH_TID = "prisma-untracked-search"; -export const UNTRACKED_CREATE_MODAL_TID = "prisma-modal-untracked-event-create"; -export const UNTRACKED_CREATE_NAME_TID = "prisma-untracked-event-control-name"; - -// ── Cross-view + analytics surfaces ──────────────────────────────────────── -// CSS-class anchors and `data-testid`s that show up across more than one -// spec. Keep raw selectors out of spec bodies — when the plugin renames a -// CSS hook these constants give us one edit point. - -/** Timeline tab — individual item element. CSS class because the renderer doesn't stamp a testid. */ -export const TIMELINE_ITEM_CLASS = ".prisma-timeline-item"; -/** Timeline tab — root container `data-testid`. */ -export const TIMELINE_CONTAINER_TID = "prisma-timeline-container"; -/** Heatmap tab — container + cell testids. */ -export const HEATMAP_CONTAINER_TID = "prisma-heatmap-container"; -export const HEATMAP_CELL_TID = "prisma-heatmap-cell"; -/** Dashboard ranking cell that hosts the per-title rows. */ -export const DASHBOARD_RANKING_TID = "prisma-dashboard-cell-ranking"; -/** Stats / placeholder containers. */ -export const STATS_EMPTY_TID = "prisma-stats-empty"; -export const STATS_DATE_LABEL_TID = "prisma-stats-date-label"; - -/** FullCalendar toolbar title (week-of label, etc.). */ -export const FC_TOOLBAR_TITLE = ".fc-toolbar-title"; -/** Prisma right-click context menu wrapper used by Obsidian. */ -export const OBSIDIAN_MENU_ROOT = ".menu"; - -/** Connection-arrow overlay drawn between connected events on the calendar. */ -export const CONNECTION_ARROW_TID = "prisma-connection-arrow"; -/** Banner that appears while the prereq-selection workflow is active. */ -export const PREREQ_SELECTION_BANNER_CLASS = ".prisma-prereq-selection-banner"; - -/** Pro-gate component testid template — call `proGate(area)` for the full id. */ -export const proGate = (area: string): string => `prisma-pro-gate-${area}`; - -/** Notice / toast container injected by Obsidian's Notice API. */ -export const NOTICE_SELECTOR = ".notice-container .notice"; - -/** License-status row sub-elements (settings → general tab). */ -export const LICENSE_ACTIVATIONS_BADGE_CLASS = ".prisma-license-activations-badge"; - -/** Virtual-events code-block renderer. */ -export const VIRTUAL_EVENTS_BLOCK_CLASS = ".prisma-virtual-events-block"; -export const VIRTUAL_EVENTS_TABLE_CLASS = ".prisma-virtual-events-table"; - -/** CalDAV integrations tab surfaces. */ -export const CALDAV_MODAL_TID = "prisma-modal-caldav-add"; -export const CALDAV_TEST_CONNECTION_TID = "prisma-caldav-test-connection"; -export const CALDAV_PRESET_TID = "prisma-caldav-preset"; -export const CALDAV_ADD_ACCOUNT_BTN_CLASS = ".prisma-caldav-add-account-button"; -export const caldavField = (key: string): string => `prisma-caldav-add-control-${key}`; - -/** Clickable "Guide ↗" doc link on the CalDAV / ICS section headings. */ -export const integrationHeadingDoc = (section: "caldav" | "ics"): string => `prisma-settings-${section}-heading-doc`; - -/** Inline "Google Calendar setup guide" link inside the CalDAV / ICS sections. */ -export const integrationDocsLink = (section: "caldav" | "ics"): string => - `prisma-settings-${section}-docs-google-calendar`; - -/** Move-by modal sub-elements. */ -export const MOVE_BY_MODAL_TID = "prisma-modal-move-by"; -export const MOVE_BY_VALUE_TID = "prisma-move-by-value"; -export const MOVE_BY_INCREMENT_TID = "prisma-move-by-increment"; -export const MOVE_BY_DECREMENT_TID = "prisma-move-by-decrement"; -export const MOVE_BY_TOGGLE_SIGN_TID = "prisma-move-by-toggle-sign"; -export const moveByUnit = (unit: string): string => `prisma-move-by-unit-${unit}`; - -/** Batch-frontmatter modal sub-elements. */ -export const BATCH_FM_MODAL_TID = "prisma-modal-batch-frontmatter"; -export const BATCH_FM_ROW_TID = "prisma-batch-property-row"; -export const BATCH_FM_KEY_TID = "prisma-batch-property-key"; -export const BATCH_FM_VALUE_TID = "prisma-batch-property-value"; -export const BATCH_FM_REMOVE_TID = "prisma-batch-property-remove"; -export const BATCH_FM_ADD_TID = "prisma-batch-add-property"; -export const BATCH_FM_DELETION_MARKED_CLASS = "prisma-batch-frontmatter-marked-deletion"; - -/** Generic SchemaForm submit button used by every modal-form (move-by, batch-frontmatter, …). */ -export const FORM_SUBMIT_TID = "prisma-form-submit"; - -/** Shared event-list modal — used by Show skipped / filtered / global-search / recurring. */ -export const LIST_MODAL_TID = "prisma-list-modal"; -export const LIST_EMPTY_TID = "prisma-list-empty"; -export const LIST_SEARCH_TID = "prisma-list-search"; - -/** Selector helpers — keep `data-event-title="…"` and friends out of spec bodies. */ -export const eventTileByTitle = (title: string): string => `${sel(EVENT_BLOCK_TID)}[data-event-title="${title}"]`; -export const dashboardItemByTitle = (title: string): string => `[data-item-title="${title}"]`; - -export const TID = { - event: { - control: (key: EventControlKey): string => `prisma-event-control-${key}`, - field: (key: EventFieldKey): string => `prisma-event-field-${key}`, - btn: (key: EventBtnKey): string => `prisma-event-btn-${key}`, - }, - stopwatch: { - time: STOPWATCH_TIME_TID, - btn: stopwatchBtn, - collapsibleSlug: STOPWATCH_COLLAPSIBLE_SLUG, - }, - toolbar: (key: ToolbarKey): string => `prisma-cal-toolbar-${key}`, - /** Page-header toolbar action (create-event, daily-stats, refresh, …). */ - pageHeader: (key: ToolbarActionKey): string => `prisma-toolbar-${key}`, - /** Analytics view tab — leaf or group child. */ - viewTab: (key: ViewTabKey): string => `prisma-view-tab-${key}`, - batch: (key: BatchBtnKey): string => `prisma-cal-batch-${key}`, - batchCounter: BATCH_COUNTER_TID, - batchConfirm: BATCH_CONFIRM_TID, - ctxMenu: (key: ContextMenuItemKey): string => `prisma-context-menu-item-${key}`, - ribbon: (calendarId: string): string => `prisma-ribbon-open-${calendarId}`, - block: EVENT_BLOCK_TID, -} as const; - -/** CSS attribute selector for a testid, e.g. `sel("prisma-cal-event")` → `[data-testid="prisma-cal-event"]`. */ -export function sel(testId: string): string { - return `[data-testid="${testId}"]`; -} diff --git a/e2e/fixtures/tour.ts b/e2e/fixtures/tour.ts deleted file mode 100644 index c7796807..00000000 --- a/e2e/fixtures/tour.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Page } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PRISMA_TOUR_STEP_IDS, SAMPLE_EVENT_TITLE } from "../../src/react/onboarding/tour-constants"; -import { expect } from "./electron"; -import { eventTileByTitle, sel, TOUR_NEXT_TID, TOUR_PROGRESS_TID, TOUR_TOOLTIP_TID } from "./testids"; - -/** Step ids + title come straight from src so the DSL can never drift from the tour. */ -export { SAMPLE_EVENT_TITLE }; -export const TOUR_STEP_IDS = PRISMA_TOUR_STEP_IDS; -export const TOUR_STEP_COUNT = TOUR_STEP_IDS.length; -export const SAMPLE_EVENT_TILE = eventTileByTitle(SAMPLE_EVENT_TITLE); - -/** Click Next from the current step until the tour shows `step / TOUR_STEP_COUNT`. */ -export async function advanceTourTo(page: Page, step: number): Promise { - const next = page.locator(sel(TOUR_NEXT_TID)); - for (let s = 2; s <= step; s++) { - await next.click(); - await expect(page.locator(sel(TOUR_PROGRESS_TID))).toHaveText(`${s} / ${TOUR_STEP_COUNT}`); - } -} - -// Candidate hours to drop the event on, in preference order. All are within the -// default-scrolled visible range of the week view and away from the 10:00 seed. -const DROP_TIME_CANDIDATES = ["14:00:00", "08:00:00", "15:00:00", "07:00:00", "16:00:00"]; - -/** - * Drag the highlighted sample event to a different time *within its own day-column* - * and return the file + the Start-Date substring it should now contain. Dragging - * vertically (same column) avoids FullCalendar's drag-to-edge week navigation that - * a horizontal cross-day drag triggers, and the target slot is picked clear of the - * floating tour tooltip. The drag only reaches the grid because the "drag-and-drop" - * step removed the blocking overlay (interaction: "page"). Caller asserts the - * resulting on-disk Start Date. - */ -export async function dragSampleEventToNewTime( - page: Page, - vaultDir: string -): Promise<{ filePath: string; expectedStart: string }> { - const tile = page.locator(SAMPLE_EVENT_TILE).first(); - await expect(tile).toBeVisible(); - const filePath = await tile.getAttribute("data-event-file-path"); - if (!filePath) throw new Error("sample event tile is missing data-event-file-path"); - const dayPart = String(readEventFrontmatter(vaultDir, filePath)["Start Date"]).slice(0, 10); - - const blockBox = await tile.boundingBox(); - if (!blockBox) throw new Error("could not resolve the sample event geometry"); - const tooltipBox = await page.locator(sel(TOUR_TOOLTIP_TID)).boundingBox(); - const pickupX = blockBox.x + blockBox.width / 2; - const pickupY = blockBox.y + 5; - - const clearsTooltip = (y: number): boolean => - !tooltipBox || - pickupX < tooltipBox.x || - pickupX > tooltipBox.x + tooltipBox.width || - y < tooltipBox.y || - y > tooltipBox.y + tooltipBox.height; - - let dropY: number | undefined; - let targetTime: string | undefined; - for (const time of DROP_TIME_CANDIDATES) { - const lane = await page.locator(`.fc-timegrid-slot-lane[data-time="${time}"]`).first().boundingBox(); - if (!lane) continue; - const y = lane.y + lane.height / 2; - if (clearsTooltip(y)) { - dropY = y; - targetTime = time.slice(0, 5); - break; - } - } - if (dropY === undefined || !targetTime) throw new Error("no time slot clear of the tour tooltip to drag onto"); - - await page.mouse.move(pickupX, pickupY); - await page.mouse.down(); - await page.waitForTimeout(50); - // Jitter past FC's eventDragMinDistance so dragstart fires instead of a click. - await page.mouse.move(pickupX, pickupY + 8, { steps: 4 }); - await page.mouse.move(pickupX, dropY, { steps: 25 }); - await page.waitForTimeout(100); - await page.mouse.up(); - - return { filePath, expectedStart: `${dayPart}T${targetTime}` }; -} diff --git a/e2e/fixtures/vault-seed/.gitkeep b/e2e/fixtures/vault-seed/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/fixtures/vault-seed/Welcome.md b/e2e/fixtures/vault-seed/Welcome.md deleted file mode 100644 index ecd0ab99..00000000 --- a/e2e/fixtures/vault-seed/Welcome.md +++ /dev/null @@ -1,4 +0,0 @@ -# Welcome - -This is the seed vault used by Prisma-Calendar end-to-end tests. Do not edit -by hand — regenerate by updating `Prisma-Calendar/e2e/fixtures/vault-seed/`. diff --git a/e2e/fixtures/viewport.ts b/e2e/fixtures/viewport.ts deleted file mode 100644 index bbd191cf..00000000 --- a/e2e/fixtures/viewport.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF } from "./constants"; - -/** A typical phone portrait viewport (iPhone-class logical px). */ -export const MOBILE_VIEWPORT = { width: 390, height: 844 } as const; - -interface ObsidianSplit { - collapse?: () => void; -} -interface ObsidianAppWindow { - app?: { workspace?: { leftSplit?: ObsidianSplit; rightSplit?: ObsidianSplit } }; -} - -/** - * Put the renderer into a phone layout. - * - * Playwright's `page.setViewportSize` is a no-op against a CDP-connected - * Electron page, so we drive the renderer's reported metrics directly via - * `Emulation.setDeviceMetricsOverride`. That shrinks `window.innerWidth`, which - * makes both `shouldUseMobileLayout` (JS) and every `@media (width<=…)` rule - * (CSS) activate against the *real* plugin. Then collapse both sidebars so the - * plugin pane fills the device width the way it does on an actual phone (where - * Obsidian auto-collapses them). - */ -export async function enterMobileLayout( - page: Page, - size: { width: number; height: number } = MOBILE_VIEWPORT -): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send("Emulation.setDeviceMetricsOverride", { - width: size.width, - height: size.height, - deviceScaleFactor: 1, - mobile: false, - screenWidth: size.width, - screenHeight: size.height, - }); - await page.evaluate(() => { - const w = window as unknown as ObsidianAppWindow; - w.app?.workspace?.leftSplit?.collapse?.(); - w.app?.workspace?.rightSplit?.collapse?.(); - }); -} - -export interface MobileOverflow { - /** Pane width (px). */ - paneWidth: number; - /** Horizontal overflow of the `.view-content` scroll box. */ - viewContentPx: number; - /** - * The largest px any view-tab extends beyond the pane's edges (left or right). - * `> 0` means at least one tab is cropped — laid out outside the visible pane, - * so a real user can't see or tap it. This is measured by bounding rect at the - * RESTING scroll position, deliberately: Playwright's `click()` auto-scrolls a - * cropped tab into view, so a "scrollable" strip passes a click test while a - * human still can't reach the tab. Wrapping keeps every tab within the pane, so - * this stays 0. See docs/specs/2026-05-22-mobile-responsiveness-findings-and-plan.md. - */ - tabCroppedPx: number; - /** Number of view-tabs found in the strip (sanity: a real strip has several). */ - tabCount: number; - /** - * Px the toolbar's "Search events…" filter input extends beyond the pane's right - * (or left) edge, or `null` if this view has no filter bar. `> 0` means the - * search is laid out off-screen — the toolbar clipped it with `overflow: hidden` - * instead of wrapping it onto its own row. Same resting-rect rationale as - * `tabCroppedPx`. Timeline / Heatmap / Gantt render the filter bar, so a toolbar - * that stops wrapping re-breaks here. - */ - filterSearchCroppedPx: number | null; - /** - * Px the page-header "Manage" button extends beyond the pane edges, or `null` if - * it isn't rendered. `> 0` means the header actions overflowed and pushed Manage - * off-screen — unreachable. The action bar trims to what fits and keeps Manage, - * so this stays 0 even with more actions configured than fit at phone width. - */ - headerManageCroppedPx: number | null; -} - -/** - * Measure the mobile-layout symptoms that matter on a phone: the pane content - * escaping its scroll box, and any view-tab cropped outside the visible pane - * (unreachable without programmatic scroll). - */ -export async function measureMobileOverflow(page: Page): Promise { - return page.evaluate((leafSelector) => { - const leaf = document.querySelector(leafSelector); - if (!leaf) return null; - const paneRect = leaf.getBoundingClientRect(); - - const content = leaf.querySelector(".view-content"); - const viewContentPx = content ? Math.max(0, content.scrollWidth - content.clientWidth) : 0; - - const tabs = Array.from(leaf.querySelectorAll('[data-testid^="prisma-view-tab-"]')); - let tabCroppedPx = 0; - for (const t of tabs) { - const r = t.getBoundingClientRect(); - tabCroppedPx = Math.max(tabCroppedPx, r.right - paneRect.right, paneRect.left - r.left); - } - - // Inactive view tabs stay mounted (hidden) in the same leaf, so there can be - // several "prisma-filter-search" inputs; measure the one that's actually - // displayed (the active view's). A search with a 0×0 rect is hidden and - // skipped — that covers both inactive tabs and the calendar toolbar, which - // intentionally collapses its filter controls behind a "Filters" toggle on - // mobile. `null` therefore means "no visible search here" (calendar / split / - // dashboard views); Timeline / Heatmap / Gantt show it inline and get measured. - const searches = Array.from(leaf.querySelectorAll('[data-testid="prisma-filter-search"]')); - let filterSearchCroppedPx: number | null = null; - for (const s of searches) { - const r = s.getBoundingClientRect(); - if (r.width > 0 && r.height > 0) { - filterSearchCroppedPx = Math.max(0, Math.round(Math.max(r.right - paneRect.right, paneRect.left - r.left))); - break; - } - } - - const manage = leaf.querySelector('[data-testid="prisma-page-header-manage"]'); - const manageRect = manage?.getBoundingClientRect(); - const headerManageCroppedPx = - manageRect && manageRect.width > 0 && manageRect.height > 0 - ? Math.max(0, Math.round(Math.max(manageRect.right - paneRect.right, paneRect.left - manageRect.left))) - : null; - - return { - paneWidth: Math.round(paneRect.width), - viewContentPx, - tabCroppedPx: Math.max(0, Math.round(tabCroppedPx)), - tabCount: tabs.length, - filterSearchCroppedPx, - headerManageCroppedPx, - }; - }, ACTIVE_CALENDAR_LEAF); -} - -export interface HeatmapScroll { - /** - * The grid SVG's intrinsic width (its `width` attribute) — the full extent the - * 12 months are drawn to. The reference the rendered width must match. - */ - contentWidth: number; - /** - * The SVG's actually-rendered width. `< contentWidth` means a `max-width` cap - * (Obsidian's global `svg { max-width: 100% }`) shrank the element below its - * content — and since there's no viewBox, the trailing months are hard-clipped - * with no scroll to reach them. Must equal `contentWidth`. - */ - renderedWidth: number; - /** Visible width of the scroll container. */ - clientWidth: number; - /** Computed `overflow-x` is `auto`/`scroll` — i.e. any overflow is scrollable. */ - scrollable: boolean; - /** - * Px the grid's left edge sits OUTSIDE the container at the resting scroll - * position. `> 0` means the start (January) is stranded off the left with no way - * back — `scrollLeft` can't go negative, so `justify-content: center` on an - * overflowing grid clips the first months. `safe center`/`flex-start` anchors them. - */ - startClippedPx: number; -} - -/** - * Measure whether the heatmap's yearly grid is reachable on a phone. `.view-content` - * overflow can't see this — the grid clips inside its own scroll container — so this - * inspects that container (the `.prisma-heatmap-container` scroll box) and the SVG. - */ -export async function measureHeatmapScroll(page: Page): Promise { - return page.evaluate((leafSelector) => { - const leaf = document.querySelector(leafSelector); - const container = leaf?.querySelector('[data-testid="prisma-heatmap-container"]') as HTMLElement | null; - const svg = container?.querySelector("svg"); - if (!container || !svg) return null; - - const cRect = container.getBoundingClientRect(); - const svgRect = svg.getBoundingClientRect(); - const overflowX = getComputedStyle(container).overflowX; - - return { - contentWidth: Math.round(parseFloat(svg.getAttribute("width") ?? "0")), - renderedWidth: Math.round(svgRect.width), - clientWidth: Math.round(container.clientWidth), - scrollable: overflowX === "auto" || overflowX === "scroll", - startClippedPx: Math.max(0, Math.round(cRect.left - svgRect.left)), - }; - }, ACTIVE_CALENDAR_LEAF); -} - -export interface StatsChartReach { - /** Whether a visible distribution-chart container was found in this view. */ - found: boolean; - /** - * Px of the distribution chart that sit below the clip edge of a non-scrollable - * `overflow:hidden` ancestor — i.e. cropped with no way to scroll to them. `> 0` - * is the bug: in a stacked ~50vh grid cell the chart was hidden past the cell's - * bottom. The walk stops at the first scrollable ancestor (which CAN reveal the - * overflow), so a normally-scrolling tab reads 0. Stays 0 once the stats panel - * flows to full height with the clipping `overflow:hidden` lifted on mobile. - */ - clippedPx: number; -} - -/** - * Measure whether a stats tab's distribution chart is reachable on a phone. The - * chart lives in a stacked grid cell only ~50vh tall; if an `overflow:hidden` - * ancestor between it and the nearest scroll container crops it, the bottom of the - * pie is unreachable. Returns `found:false` for views without a stats chart. - */ -export async function measureStatsChartReach(page: Page): Promise { - return page.evaluate((leafSelector) => { - const leaf = document.querySelector(leafSelector); - const views = Array.from(leaf?.querySelectorAll(".prisma-interval-stats-view") ?? []); - let container: HTMLElement | null = null; - for (const v of views) { - // querySelector returns Element | null; HTMLElement narrowing is required to call getBoundingClientRect - const c = v.querySelector(".prisma-stats-chart-container") as HTMLElement | null; - if (c && c.getBoundingClientRect().height > 0) { - container = c; - break; - } - } - if (!container) return { found: false, clippedPx: 0 }; - - const chartBottom = container.getBoundingClientRect().bottom; - let clippedPx = 0; - let el = container.parentElement; - while (el && el !== document.body) { - const cs = getComputedStyle(el); - // A scrollable ancestor can reveal anything below it — reachability is safe - // from here up, so stop. - if (cs.overflowY === "auto" || cs.overflowY === "scroll") break; - if (cs.overflowY === "hidden" || cs.overflowY === "clip") { - clippedPx = Math.max(clippedPx, chartBottom - el.getBoundingClientRect().bottom); - } - el = el.parentElement; - } - return { found: true, clippedPx: Math.max(0, Math.round(clippedPx)) }; - }, ACTIVE_CALENDAR_LEAF); -} diff --git a/e2e/fixtures/window-types.ts b/e2e/fixtures/window-types.ts deleted file mode 100644 index c47a6a8d..00000000 --- a/e2e/fixtures/window-types.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Typed surfaces for Prisma's renderer-side runtime objects, used inside -// `page.evaluate(...)` callbacks. The base `ObsidianWindow` comes from the -// shared library; `PrismaWindow` extends it with the workspace + vault fields -// the calendar DSL touches, so consumers cast once instead of re-typing -// `getLeaf` / `getLeavesOfType` / `leftSplit` per evaluate call. The plugin -// entry is still cast to `PrismaPlugin` because `plugins.plugins` upstream is -// `Record`. -// -// Usage inside an evaluate callback: -// -// page.evaluate((pid) => { -// const w = window as unknown as PrismaWindow; -// const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; -// const bundle = plugin?.calendarBundles?.[0]; -// ... -// }, PLUGIN_ID); -// -// Playwright serialises the callback source so the types live on the Node -// side only — they're erased at runtime. -// -// Maintenance contract: every renderer-side shape an e2e helper needs lives -// here. If a helper needs a new field, add it to the appropriate type below — -// do NOT re-declare a local `RendererWindow` / `SettingsStoreWindow` / etc. -// next to the evaluate call. A hookify rule enforces this on edit (see -// `.claude/hookify/rules/e2e-window-types.json`). - -import type { ObsidianWindow as BaseObsidianWindow } from "@real1ty-obsidian-plugins/testing/e2e"; - -export type { BaseObsidianWindow as ObsidianWindow }; - -export interface WorkspaceLeaf { - setViewState: (state: unknown) => Promise; - detach: () => void; -} - -export interface PrismaWindow extends Omit { - app: Omit & { - workspace: Omit & { - openLinkText: ( - link: string, - src: string, - newLeaf?: boolean, - state?: { state?: { mode?: string } } - ) => Promise; - getLeaf: (newLeaf: boolean | "tab" | "split") => WorkspaceLeaf; - getLeavesOfType: (type: string) => WorkspaceLeaf[]; - getActiveFile: () => { path: string } | null; - leftSplit?: { collapse: () => void; collapsed?: boolean }; - onLayoutReady: (cb: () => void) => void; - // Obsidian `Debouncer`'s `.run()` — flushes any pending workspace-layout - // save synchronously. Needed before reloads so leaves persist to - // `workspace.json` instead of being lost to the debounce window. - requestSaveLayout: { run: () => Promise }; - }; - vault: { - adapter?: { - basePath?: string; - exists: (path: string) => Promise; - }; - getMarkdownFiles: () => Array<{ path: string }>; - getFiles: () => Array<{ path: string }>; - getAbstractFileByPath: (path: string) => unknown; - create: (path: string, content: string) => Promise; - createFolder: (path: string) => Promise; - read: (file: unknown) => Promise; - modify: (file: unknown, content: string) => Promise; - }; - plugins: BaseObsidianWindow["app"]["plugins"] & { - disablePlugin?: (id: string) => Promise; - disablePluginAndSave?: (id: string) => Promise; - }; - commands: Omit & { - commands: Record; - }; - metadataCache: { - getFileCache: (file: { path: string }) => { frontmatter?: Record } | null; - }; - fileManager: { - processFrontMatter: (file: unknown, fn: (fm: Record) => void) => Promise; - }; - secretStorage: { - getSecret: (name: string) => unknown; - }; - }; -} - -export interface EventRef { - title: string; - ref: { filePath: string }; -} - -export interface VirtualEventInput { - title: string; - start: string; - end: string | null; - allDay: boolean; - properties: Record; -} - -export interface PluginSettingsStore { - currentSettings?: Record; - updateSettings?: (updater: (current: Record) => Record) => Promise; -} - -export interface CalendarBundle { - calendarId: string; - viewType: string; - initialize: () => Promise; - activateCalendarView?: () => Promise; - eventStore: { getAllEvents: () => EventRef[] }; - untrackedEventStore: { getUntrackedEvents: () => EventRef[] }; - settingsStore: { - currentSettings: Record; - updateSettings: (updater: (current: Record) => Record) => Promise; - }; - prerequisiteTracker: { isConnected: (filePath: string) => boolean }; - virtualEventStore: { - add: (e: VirtualEventInput) => Promise<{ id: string }>; - getAll: () => Array<{ id: string; title: string; start: string; end: string | null }>; - }; - commandManager: { - whenIdle: () => Promise; - }; - viewRef?: { - calendarComponent?: { - calendar?: { - gotoDate: (d: string) => void; - getDate?: () => Date; - }; - } | null; - }; -} - -export interface LicenseStatusPayload { - state: string; - activationsCurrent: number; - activationsLimit: number; - expiresAt: string | null; - errorMessage: string | null; -} - -export interface LicenseManager { - status$: { next: (v: LicenseStatusPayload) => void }; - __setProForTesting?: (v: boolean) => void; -} - -export interface PrismaPlugin { - manifest?: { version?: string }; - calendarBundles?: CalendarBundle[]; - ensureCalendarBundlesReady?: () => Promise; - refreshCalendarBundles?: () => Promise; - addCalendarBundle?: (calendarId: string) => Promise; - settingsStore?: PluginSettingsStore; - licenseManager?: LicenseManager; - lastUsedCalendarId?: string | null; -} diff --git a/e2e/obsidian-version.json b/e2e/obsidian-version.json deleted file mode 100644 index ccf89b0d..00000000 --- a/e2e/obsidian-version.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "appVersion": "1.12.7", - "installerVersion": "1.8.10" -} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts deleted file mode 100644 index 0120b395..00000000 --- a/e2e/playwright.config.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -// Two-project split, matching the incremental-reading-obsidian pattern: -// - "bootstrap" runs first, a single test that proves Obsidian launches -// with prisma-calendar loaded and configured. This is the gate. -// - "specs" is the actual suite. It depends on "bootstrap" — Playwright -// will not run specs unless bootstrap passes first. - -// Timeouts are mode-dependent: -// - Debug modes (PWDEBUG=1 / --ui via E2E_UI_MODE) disable all timeouts so a -// human-at-the-keyboard can pause without getting cut off. -// - Demo mode (PW_DEMO=1) slows every action and holds the window open after -// each spec. A 15-field fill at 250ms slowMo + matching demoPause + hold -// easily crosses the normal envelope, so triple it. -// - Default headless runs (CI + dev `pnpm test:e2e`) get a 90s budget. Each -// test spawns a fresh Obsidian process — bootstrap is typically 1-3s but -// can spike to 30-35s under disk/memory pressure during long serial runs. -// 90s leaves ample room for spike + test body while still failing fast on -// genuine hangs. -const DEMO_ON = !!process.env.PW_DEMO && process.env.PW_DEMO !== "0" && process.env.PW_DEMO !== "false"; -// --ui and --debug put a human at the keyboard; disable timeouts in both. -// PWDEBUG is the official env var Playwright sets for the Inspector; argv -// catches `--ui` and `--debug` passed directly to `playwright test`. -const DEBUG_ON = !!process.env.PWDEBUG || process.argv.includes("--ui") || process.argv.includes("--debug"); -// PRISMA_PREVIEW=1 turns on the onboarding-walkthrough preview project: it records -// video + per-step screenshots of the full tour into e2e/.preview/ so humans and -// agents can review it. Off by default, the preview project is omitted entirely — -// it never runs in CI, the full suite, or test:e2e:changed (which only scans -// e2e/specs/, not e2e/preview/). -const PREVIEW_ON = - !!process.env.PRISMA_PREVIEW && process.env.PRISMA_PREVIEW !== "0" && process.env.PRISMA_PREVIEW !== "false"; - -const TEST_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 1_800_000 : 45_000; -// 5s assertion budget. If a UI element is genuinely going to appear, it does -// so well within 5s under the test harness — the indexer is fast, debounces -// are flipped to 0 by `window.E2E` (see `debounceMsForEnv`), and there are no -// network calls. The previous 10s default just made failures take twice as -// long without catching any real-world flake. -const EXPECT_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 120_000 : 5_000; -// `actionTimeout` caps every `waitFor` / `click` / `fill` that doesn't pass its -// own `timeout`. Mirrors EXPECT_TIMEOUT so specs can omit per-call timeouts -// entirely — see feedback_e2e_no_timeout_overrides. Without this, actions fall -// back to testTimeout and a single hung wait eats the whole spec. -const ACTION_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 120_000 : 5_000; - -export default defineConfig({ - outputDir: "./test-results", - fullyParallel: !DEBUG_ON, - workers: DEBUG_ON ? 1 : 3, - timeout: TEST_TIMEOUT, - expect: { timeout: EXPECT_TIMEOUT }, - forbidOnly: !!process.env.CI, - // One retry locally / two on CI absorbs residual bootstrap timing spikes - // that the 45s budget doesn't cover (extreme disk pressure, OOM thrashing). - retries: process.env.CI ? 2 : 1, - // `line` keeps output to one line per test (not per attempt) so the summary - // is readable even when several specs run. Full detail still lands in the - // HTML report + `.cache/last-run.log`. - // `infra-error-reporter` collapses a "node_modules rebuilt mid-run" pile of - // per-spec resolution failures into one ENVIRONMENT IN FLUX verdict instead - // of dozens of look-alike test failures (see the shared module + the at-launch - // preflight in shared/scripts/e2e/check-e2e-deps.mjs). - reporter: [ - ["line"], - ["html", { open: "never", outputFolder: "./playwright-report" }], - ["../../shared/src/testing/e2e/infra-error-reporter.ts"], - ], - use: { - trace: "retain-on-failure", - screenshot: PREVIEW_ON ? "on" : "only-on-failure", - video: PREVIEW_ON ? "on" : "retain-on-failure", - actionTimeout: ACTION_TIMEOUT, - }, - projects: [ - { - name: "bootstrap", - testDir: "./setup", - testMatch: /bootstrap\.spec\.ts$/, - }, - { - name: "specs", - testDir: "./specs", - testMatch: /.*\.spec\.ts$/, - dependencies: ["bootstrap"], - }, - // Only present under PRISMA_PREVIEW=1 — keeps the walkthrough out of the - // real suite while still sharing the bootstrap gate when explicitly run. - ...(PREVIEW_ON - ? [ - { - name: "preview", - testDir: "./preview", - testMatch: /.*\.spec\.ts$/, - dependencies: ["bootstrap"], - }, - ] - : []), - ], -}); diff --git a/e2e/preview/collect-artifacts.mjs b/e2e/preview/collect-artifacts.mjs deleted file mode 100644 index a72b0b69..00000000 --- a/e2e/preview/collect-artifacts.mjs +++ /dev/null @@ -1,58 +0,0 @@ -// Post-run collector for `pnpm run preview:tutorial`. The preview spec writes one -// PNG per tour step into e2e/.preview/tutorial/; Playwright can't record real video -// for the Obsidian harness (it connects over CDP, not a browser context), so this -// stitches those frames into a watchable/embeddable walkthrough.mp4 + walkthrough.gif -// with ffmpeg and prints every artifact location. -import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; - -const DIR = "e2e/.preview/tutorial"; -const MP4 = join(DIR, "walkthrough.mp4"); -const GIF = join(DIR, "walkthrough.gif"); -const SECONDS_PER_FRAME = 1.6; - -const frames = existsSync(DIR) - ? readdirSync(DIR) - .filter((f) => f.endsWith(".png")) - .sort() - : []; - -function ffmpegAvailable() { - return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; -} - -function assembleVideos() { - const input = ["-y", "-framerate", String(1 / SECONDS_PER_FRAME), "-pattern_type", "glob", "-i", join(DIR, "*.png")]; - const mp4 = spawnSync( - "ffmpeg", - [...input, "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", "-r", "30", MP4], - { stdio: "ignore" } - ); - const gif = spawnSync( - "ffmpeg", - [...input, "-vf", "scale=900:-2:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", GIF], - { stdio: "ignore" } - ); - return { mp4: mp4.status === 0, gif: gif.status === 0 }; -} - -console.log("\n────────────────────────────────────────────────────────"); -console.log("🎬 Tutorial walkthrough preview"); -console.log("────────────────────────────────────────────────────────"); -if (frames.length === 0) { - console.log(" ⚠️ No frames found — did the preview run write screenshots?"); -} else { - console.log(` Frames: ${DIR}/ (${frames.length})`); - for (const f of frames) console.log(` • ${f}`); - if (ffmpegAvailable()) { - const { mp4, gif } = assembleVideos(); - if (mp4) console.log(` Video (mp4): ${MP4}`); - if (gif) console.log(` Video (gif): ${GIF}`); - if (!mp4 && !gif) console.log(" ⚠️ ffmpeg failed to assemble the video — review the frames above."); - } else { - console.log(" ℹ️ Install ffmpeg to auto-assemble the frames into walkthrough.mp4 / .gif."); - } -} -console.log(` Report (UI): pnpm --filter prisma-calendar exec playwright show-report e2e/playwright-report`); -console.log("────────────────────────────────────────────────────────\n"); diff --git a/e2e/preview/tutorial-walkthrough.spec.ts b/e2e/preview/tutorial-walkthrough.spec.ts deleted file mode 100644 index ac8cff13..00000000 --- a/e2e/preview/tutorial-walkthrough.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { join } from "node:path"; - -import { expect, testOnboarding } from "../fixtures/electron"; -import { sel, TOUR_NEXT_TID, TOUR_PROGRESS_TID, TOUR_TOOLTIP_TID } from "../fixtures/testids"; -import { dragSampleEventToNewTime, TOUR_STEP_COUNT, TOUR_STEP_IDS } from "../fixtures/tour"; - -// On-demand walkthrough for humans + agents — NOT part of the real suite. It only -// runs under `PRISMA_PREVIEW=1` (see playwright.config.ts, which adds the "preview" -// project) via `pnpm run preview:tutorial`. It drives the tour exactly as a user -// would and writes one numbered screenshot per step; the run's collect-artifacts.mjs -// step then stitches those frames into a video + gif so you can review the whole -// flow without launching Obsidian by hand. (Playwright can't record real video here -// — the Obsidian harness connects over CDP rather than launching a browser context.) -const PREVIEW_DIR = "e2e/.preview/tutorial"; -const DRAG_STEP = TOUR_STEP_IDS.indexOf("drag-and-drop") + 1; - -testOnboarding.describe("preview: onboarding tutorial walkthrough", () => { - testOnboarding("captures every step and exercises the interactive drag", async ({ calendar }, testInfo) => { - const { page, vaultDir } = calendar; - mkdirSync(PREVIEW_DIR, { recursive: true }); - - const shot = (name: string): Promise => page.screenshot({ path: join(PREVIEW_DIR, `${name}.png`) }); - - // The tour auto-starts (testOnboarding seeds tutorialCompleted: false). - await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toBeVisible(); - - const next = page.locator(sel(TOUR_NEXT_TID)); - for (let step = 1; step <= TOUR_STEP_COUNT; step++) { - await expect(page.locator(sel(TOUR_PROGRESS_TID))).toHaveText(`${step} / ${TOUR_STEP_COUNT}`); - const id = TOUR_STEP_IDS[step - 1]; - await shot(`${String(step).padStart(2, "0")}-${id}`); - - if (step === DRAG_STEP) { - const { expectedStart } = await dragSampleEventToNewTime(page, vaultDir); - // "Nb-" sorts right after "0N-" so the before/after drag frames stay in order. - await shot(`${String(step).padStart(2, "0")}b-${id}-after`); - testInfo.annotations.push({ type: "preview", description: `dragged sample event → ${expectedStart}` }); - } - - if (step < TOUR_STEP_COUNT) await next.click(); - } - - await expect(next).toHaveText("Done"); - await next.click(); - await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toHaveCount(0); - - testInfo.annotations.push({ type: "preview", description: `frames written to ${PREVIEW_DIR}/` }); - }); -}); diff --git a/e2e/setup/bootstrap.spec.ts b/e2e/setup/bootstrap.spec.ts deleted file mode 100644 index be01a0ab..00000000 --- a/e2e/setup/bootstrap.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { bootstrapObsidian, expect, test } from "../fixtures/electron"; -import type { PrismaPlugin, PrismaWindow } from "../fixtures/window-types"; - -test.describe("bootstrap", () => { - test("obsidian launches with prisma-calendar ready", async () => { - const ob = await bootstrapObsidian({ prefix: "bootstrap" }); - - try { - // Renderer side: plugin visible in registry and at least one bundle initialized. - const summary = await ob.page.evaluate((id) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined; - const calendars = plugin?.settingsStore?.currentSettings?.["calendars"] as - | Array<{ directory?: string }> - | undefined; - return { - pluginLoaded: Boolean(plugin), - version: plugin?.manifest?.version ?? null, - bundleIds: plugin?.calendarBundles?.map((b) => b.calendarId) ?? [], - directory: calendars?.[0]?.directory ?? null, - prismaCommands: Object.keys(w.app.commands.commands).filter((c) => c.startsWith(`${id}:`)), - }; - }, "prisma-calendar"); - - expect(summary.pluginLoaded, "prisma-calendar must be loaded").toBe(true); - expect(summary.bundleIds.length, "at least one bundle must exist").toBeGreaterThan(0); - expect(summary.directory, "data.json directory should be seeded to Events").toBe("Events"); - expect(summary.prismaCommands.length, "prisma-calendar should register commands").toBeGreaterThan(0); - - const welcome = ob.readVaultFile("Welcome.md"); - expect(welcome).toContain("Welcome"); - } finally { - await ob.close(); - } - }); -}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/specs/analytics/calendar-connections.spec.ts b/e2e/specs/analytics/calendar-connections.spec.ts deleted file mode 100644 index e5534040..00000000 --- a/e2e/specs/analytics/calendar-connections.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { fromAnchor } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { assignPrerequisiteViaUI } from "../../fixtures/helpers"; -import { sel } from "../../fixtures/testids"; - -// Prerequisite connections are a Pro feature. The calendar view draws SVG -// arrows between dependent/prerequisite events via `ConnectionRenderer`: -// full cubic arrows when both sides are in-frame, dashed stubs when one -// side is off-screen. This spec proves both flows end-to-end: same-week -// (full arrow) and cross-week (stub arrow, because the prereq lives in a -// different week than the dependant). -// -// Seeds use `fromAnchor(...)` (past-Wednesday anchor) + `calendar.goToAnchor()` -// so the rendered week always contains the Upstream/Downstream pair regardless -// of what day-of-week the suite runs on — see -// `docs/specs/e2e-date-anchor-robustness.md`. - -const ARROW = sel("prisma-connection-arrow"); -const STUB = `${sel("prisma-connection-arrow")}[data-arrow-stub="true"]`; - -test.describe("analytics: prerequisite connections on calendar view", () => { - test("full arrow renders when both events sit in the same week", async ({ calendar }) => { - // Disk-seed first, then navigate: modal-based `seedMany` opens the - // create modal per event and the save re-render snaps FullCalendar - // back to today's week, losing any prior `goToAnchor()`. The anchor - // week (past-Wednesday) is usually different from today's week, so - // the seeded tiles would end up off-screen. - await calendar.seedOnDiskMany([ - { title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) }, - { title: "Downstream Task", start: fromAnchor(1, 14, 0), end: fromAnchor(1, 15, 0) }, - ]); - await calendar.switchMode("week"); - await calendar.goToAnchor(); - await calendar.eventByTitle("Downstream Task").then((e) => e.expectVisible()); - - await assignPrerequisiteViaUI(calendar.page, "Downstream Task", "Upstream Task"); - - await calendar.unlockPro(); - await calendar.clickToolbar("toggle-prerequisites"); - - await expect(calendar.page.locator(ARROW)).toHaveCount(1); - await expect(calendar.page.locator(STUB)).toHaveCount(0); - }); - - test("stub arrow renders when prerequisite lives in a different week", async ({ calendar }) => { - await calendar.seedOnDiskMany([ - { title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) }, - { title: "Downstream Task", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) }, - ]); - // Month view first so both tiles are clickable while wiring the prereq. - await calendar.switchMode("month"); - await calendar.goToAnchor(); - await calendar.eventByTitle("Downstream Task").then((e) => e.expectVisible()); - - await assignPrerequisiteViaUI(calendar.page, "Downstream Task", "Upstream Task"); - - // Back to week view anchored on the upstream — with 10 days between the - // two events, at most one is visible at a time, so the renderer must - // draw a dashed stub. - await calendar.switchMode("week"); - await calendar.goToAnchor(); - await calendar.unlockPro(); - await calendar.clickToolbar("toggle-prerequisites"); - - await expect(calendar.page.locator(STUB)).toHaveCount(1); - }); -}); diff --git a/e2e/specs/analytics/capacity-indicator.spec.ts b/e2e/specs/analytics/capacity-indicator.spec.ts deleted file mode 100644 index 259bcc07..00000000 --- a/e2e/specs/analytics/capacity-indicator.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { todayStamp } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { sel } from "../../fixtures/testids"; - -// E2E value: prove the indicator label round-trips through the real RxJS -// pipeline — on-disk events → metadataCache → eventStore.changes$ → the -// debounced subscription → label format. Capacity math itself is covered by -// tests/utils/stats/capacity*.test.ts; the indicator's render behaviour with -// mocked observables is covered by tests/components/views/capacity-indicator. -// What only E2E can catch: the wiring between those three layers in the -// shipped bundle. - -test.describe("analytics: capacity indicator (live)", () => { - test("indicator label reflects used / inferred-capacity / percent from on-disk events", async ({ calendar }) => { - // Seed two timed events: 09:00–10:00 and 11:00–12:00. - // inferBoundaries → start=9, end=12 → capacity = 3h. - // Used = 1h + 1h = 2h. Percent = (2/3)*100 = 66.67 → toFixed(0) = "67". - // Day view ensures the indicator's range is exactly today. - await calendar.switchMode("day"); - await calendar.seedOnDiskMany([ - { title: "Capacity One", start: todayStamp(9, 0), end: todayStamp(10, 0) }, - { title: "Capacity Two", start: todayStamp(11, 0), end: todayStamp(12, 0) }, - ]); - - const indicator = calendar.page.locator(sel("prisma-capacity-indicator")).first(); - await expect(indicator).toHaveText("⏱ 2h / 3h (67%)"); - }); -}); diff --git a/e2e/specs/analytics/categories-assignment.spec.ts b/e2e/specs/analytics/categories-assignment.spec.ts deleted file mode 100644 index 1c93df83..00000000 --- a/e2e/specs/analytics/categories-assignment.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { todayStamp } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; - -// Categories are assigned via the shared assignment modal, stamped with -// `prisma-assign-item` + `data-assign-name` rows. The DSL's `createEvent` -// accepts `categories` and drives the full flow including the "Create new" -// fallback when a category doesn't exist yet. This spec asserts the -// frontmatter side-effect (the value lands on disk under the configured -// category property), that the tile still renders after assignment, and -// that the matching colour rule paints the tile — i.e. the full create → -// indexer → VaultTable → EventStore → render chain lights up under a -// real category pick. - -const RULE_COLOR = "#7744ff"; - -test.describe("categories assignment", () => { - test("assigns a category via the picker, persists it in frontmatter, and paints the tile via a matching colour rule", async ({ - calendar, - }) => { - await updateCalendarSettings(calendar.page, { - colorRules: [ - { - id: "rule-work", - expression: "Category.includes('Work')", - color: RULE_COLOR, - enabled: true, - }, - ], - }); - - const evt = await calendar.createEvent({ - title: "Categorised Task", - start: todayStamp(9, 0), - end: todayStamp(10, 0), - categories: ["Work"], - }); - await evt.expectVisible(); - - expect(evt.readCategory()).toEqual(["Work"]); - await evt.expectColor(RULE_COLOR); - }); -}); diff --git a/e2e/specs/analytics/color-rules.spec.ts b/e2e/specs/analytics/color-rules.spec.ts deleted file mode 100644 index a96862b6..00000000 --- a/e2e/specs/analytics/color-rules.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { todayStamp } from "../../fixtures/dates"; -import { test } from "../../fixtures/electron"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; - -// Color rules live under `colorRules: ColorRule[]` on the calendar settings -// and are evaluated by `colorEvaluator` at event-render time. The matched -// colour surfaces on each tile as the `--event-color` CSS variable written -// by `applyEventMountStyling`. Seeding the rule via the settings store (not -// the Rules settings-tab UI) keeps the spec focused on the runtime colour- -// evaluation path. The read lives on the EventHandle so every colour-aware -// spec goes through the same DSL method. - -const RULE_COLOR = "#ff00aa"; - -test.describe("color rules", () => { - test("a color rule with a matching expression applies its colour to the event tile", async ({ calendar }) => { - await updateCalendarSettings(calendar.page, { - colorRules: [ - { - id: "rule-urgent", - expression: "Category === 'Urgent'", - color: RULE_COLOR, - enabled: true, - }, - ], - }); - - const evt = await calendar.createEvent({ - title: "Urgent Ticket", - start: todayStamp(9, 0), - end: todayStamp(10, 0), - categories: ["Urgent"], - }); - await evt.expectVisible(); - await evt.expectColor(RULE_COLOR); - }); -}); diff --git a/e2e/specs/analytics/dashboard-charts.spec.ts b/e2e/specs/analytics/dashboard-charts.spec.ts deleted file mode 100644 index 4342cd1f..00000000 --- a/e2e/specs/analytics/dashboard-charts.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { todayStamp } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { sel } from "../../fixtures/testids"; - -// Dashboard (Pro) has three children — By Name, By Category, Recurring — each -// rendering a chart/ranking/table grid populated by events in the vault. -// This spec seeds deterministic data via the UI, then walks every child and -// asserts the ranking/table rows contain the expected entries. - -test.describe("analytics: dashboard charts (populated)", () => { - test("every dashboard child surfaces the seeded events grouped by its axis", async ({ calendar }) => { - // Seed 3× "Weekly Review" (Work) + 2× "Workout" (Fitness) for deterministic - // per-name and per-category rankings. Modal-based seeding is required here - // because same-titled events need the plugin's file-dedup logic, and the - // category assignment goes through the chip-list flow. - await calendar.seedMany([ - { title: "Weekly Review", start: todayStamp(9, 0), end: todayStamp(10, 0), categories: ["Work"] }, - { title: "Weekly Review", start: todayStamp(11, 0), end: todayStamp(12, 0), categories: ["Work"] }, - { title: "Weekly Review", start: todayStamp(14, 0), end: todayStamp(15, 0), categories: ["Work"] }, - { title: "Workout", start: todayStamp(7, 0), end: todayStamp(8, 0), categories: ["Fitness"] }, - { title: "Workout", start: todayStamp(18, 0), end: todayStamp(19, 0), categories: ["Fitness"] }, - ]); - - await calendar.unlockPro(); - - // Dashboard child panels coexist in the DOM once visited — the tabbed - // container just toggles visibility. Scope every cell selector to the - // visible one so `.first()` can't pick up a stale sibling panel. - const visibleRanking = calendar.page.locator(`${sel("prisma-dashboard-cell-ranking")}:visible`).first(); - const visibleTable = calendar.page.locator(`${sel("prisma-dashboard-cell-table")}:visible`).first(); - - // ─── By Name ─── - await calendar.switchToGroupChild("dashboard", "dashboard-by-name"); - - await expect(calendar.page.locator(`${sel("prisma-dashboard-cell-chart")}:visible`).first()).toBeVisible(); - await expect(visibleRanking).toBeVisible(); - await expect(visibleTable).toBeVisible(); - - await expect(visibleRanking.locator('[data-item-title="Weekly review"]')).toBeVisible(); - await expect(visibleRanking.locator('[data-item-title="Workout"]')).toBeVisible(); - await expect(visibleTable.locator('[data-item-title="Weekly review"]')).toBeVisible(); - await expect(visibleTable.locator('[data-item-title="Workout"]')).toBeVisible(); - - // ─── By Category ─── - await calendar.switchToGroupChild("dashboard", "dashboard-by-category"); - - await expect(visibleRanking.locator('[data-item-title="Work"]')).toBeVisible(); - await expect(visibleRanking.locator('[data-item-title="Fitness"]')).toBeVisible(); - await expect(visibleTable.locator('[data-item-title="Work"]')).toBeVisible(); - await expect(visibleTable.locator('[data-item-title="Fitness"]')).toBeVisible(); - - await expect( - calendar.page.locator(`${sel("prisma-dashboard-stat-value-Total Events")}:visible`).first() - ).toHaveText("5"); - - // ─── Recurring ─── - // No recurring events seeded, so rankings are empty (empty-state text) - // but the stats card for "Rules" still renders at zero. - await calendar.switchToGroupChild("dashboard", "dashboard-recurring"); - await expect(calendar.page.locator(`${sel("prisma-dashboard-cell-chart")}:visible`).first()).toBeVisible(); - await expect(calendar.page.locator(`${sel("prisma-dashboard-stat-value-Rules")}:visible`).first()).toHaveText("0"); - }); -}); diff --git a/e2e/specs/analytics/event-presets.spec.ts b/e2e/specs/analytics/event-presets.spec.ts deleted file mode 100644 index 0c7790a7..00000000 --- a/e2e/specs/analytics/event-presets.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { createEventViaToolbar } from "../../fixtures/helpers"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; -import { sel, TID } from "../../fixtures/testids"; - -// Event presets live on the calendar's settings under `eventPresets[]` and -// are surfaced in the event modal through a -// mounted into the calendar toolbar via toolbar-filter-mount.ts (React). -// `focus-expression-filter` moves the cursor there. Invalid expressions are -// swallowed by `createExpressionMatcher` (filter-logic.ts) — they never -// throw at the page level, so we assert no SyntaxError / ReferenceError -// reaches the renderer and the calendar DOM stays mounted. - -const EXPRESSION_INPUT = sel(FILTER_EXPRESSION_TID); - -test.describe("expression filter", () => { - test.beforeEach(async ({ calendar }) => { - await calendar.page.locator(EXPRESSION_INPUT).first().waitFor({ state: "attached" }); - }); - - test("focus-expression-filter command focuses the toolbar input", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Focus expression filter"); - await expect(calendar.page.locator(EXPRESSION_INPUT).first()).toBeFocused(); - }); - - test("typing into the input updates its value", async ({ calendar }) => { - const input = calendar.page.locator(EXPRESSION_INPUT).first(); - await input.fill("Category === 'Work'"); - await expect(input).toHaveValue("Category === 'Work'"); - - await input.fill(""); - await expect(input).toHaveValue(""); - }); - - test("invalid expression does not crash the view", async ({ calendar }) => { - const pageErrors: string[] = []; - calendar.page.on("pageerror", (err) => pageErrors.push(err.message)); - - await calendar.page.locator(EXPRESSION_INPUT).first().fill("this is not valid js ==="); - - await expect(calendar.page.locator(".fc")).toBeVisible(); - expect(pageErrors.filter((e) => /SyntaxError|ReferenceError/.test(e))).toEqual([]); - }); -}); diff --git a/e2e/specs/integrations/external-file-mutation.spec.ts b/e2e/specs/integrations/external-file-mutation.spec.ts deleted file mode 100644 index 2a116050..00000000 --- a/e2e/specs/integrations/external-file-mutation.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { anchorISO, fromAnchor } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { eventBlockLocator } from "../events/events-helpers"; - -// External tools (Obsidian Sync, a second editor, Dropbox) can rewrite or -// remove an event file behind the plugin's back. The indexer subscribes to -// `vault.on("modify")` and `metadataCache.on("deleted")`, so the calendar -// must converge on its own — no user-facing refresh button. These specs -// simulate the external write with raw `node:fs` calls (bypassing Obsidian's -// APIs entirely) and then assert on the rendered FC layout — the same -// surface the user sees — until convergence is observable. - -test.describe("integrations: external file mutation", () => { - test("external rewrite of an event file is picked up automatically", async ({ calendar }) => { - await calendar.switchMode("week"); - await calendar.goToAnchor(); - - const evt = await calendar.seedOnDisk("External Rewrite", { - "Start Date": fromAnchor(0, 9, 0), - "End Date": fromAnchor(0, 10, 0), - }); - await evt.expectVisible(); - - const absolutePath = join(calendar.vaultDir, evt.path); - const updatedStart = fromAnchor(0, 14, 0); - const updatedEnd = fromAnchor(0, 15, 0); - writeFileSync( - absolutePath, - `---\nStart Date: ${updatedStart}\nEnd Date: ${updatedEnd}\n---\n\n# External Rewrite\n`, - "utf8" - ); - - // The vault watcher must reposition the rendered tile from the 09:00 - // slot to the 14:00 slot. FC anchors a timed block's top edge to its - // start-time slot lane, so polling until the block's `boundingBox().y` - // aligns with the 14:00 lane's top is the user-observable convergence - // signal — no peeking at the indexer or DOM data attributes. - const block = eventBlockLocator(calendar.page, "External Rewrite").first(); - const targetSlot = calendar.page - .locator(`.fc-timegrid-col[data-date="${anchorISO()}"]`) - .locator('xpath=ancestor::*[contains(@class, "fc-timegrid-body")]') - .locator('.fc-timegrid-slot-lane[data-time="14:00:00"]') - .first(); - await expect - .poll( - async () => { - const blockBox = await block.boundingBox(); - const slotBox = await targetSlot.boundingBox(); - if (!blockBox || !slotBox) return null; - return Math.abs(blockBox.y - slotBox.y); - }, - { message: "rendered tile never repositioned to the 14:00 slot" } - ) - .toBeLessThan(5); - - // Title is unchanged — convergence must produce one tile, not a - // duplicated old/new pair. - await expect(eventBlockLocator(calendar.page, "External Rewrite")).toHaveCount(1); - }); - - test("external delete of an event file removes the tile automatically", async ({ calendar }) => { - await calendar.switchMode("week"); - await calendar.goToAnchor(); - - const evt = await calendar.seedOnDisk("Doomed Event", { - "Start Date": fromAnchor(0, 11, 0), - "End Date": fromAnchor(0, 12, 0), - }); - await evt.expectVisible(); - - unlinkSync(join(calendar.vaultDir, evt.path)); - - // `metadataCache.on("deleted")` drops the indexer row, the reactive - // subscription tears the tile down, FC repaints without it. Playwright's - // `toHaveCount` auto-retries — that retry IS the convergence poll. - await expect(eventBlockLocator(calendar.page, "Doomed Event")).toHaveCount(0); - }); -}); diff --git a/e2e/specs/integrations/filter-presets.spec.ts b/e2e/specs/integrations/filter-presets.spec.ts deleted file mode 100644 index 2d09516e..00000000 --- a/e2e/specs/integrations/filter-presets.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { readCalendarSettings, updateCalendarSettings } from "../../fixtures/seed-events"; -import { FILTER_PRESET_TID, sel } from "../../fixtures/testids"; - -// FilterPreset shape: `{ name, expression }` (types/settings.ts). -// Presets live under `filterPresets: []` on the calendar's own settings. -// Selector DOM is stamped with data-testid="prisma-filter-preset". - -const FILTER_PRESET_SELECT = sel(FILTER_PRESET_TID); - -const INITIAL_PRESETS = [ - { name: "Work only", expression: "Category === 'Work'" }, - { name: "Fitness", expression: "Category === 'Fitness'" }, -]; - -test.describe("filter presets", () => { - test.beforeEach(async ({ calendar }) => { - await updateCalendarSettings(calendar.page, { filterPresets: INITIAL_PRESETS }); - }); - - test("CRUD via settingsStore persists to data.json", async ({ calendar }) => { - const afterCreate = await readCalendarSettings(calendar.page); - expect(afterCreate["filterPresets"]).toEqual(INITIAL_PRESETS); - - await updateCalendarSettings(calendar.page, { - filterPresets: [{ name: "Work only", expression: "Category === 'Urgent'" }, INITIAL_PRESETS[1]], - }); - const afterEdit = await readCalendarSettings(calendar.page); - expect((afterEdit["filterPresets"] as Array<{ expression: string }>)[0]!.expression).toBe("Category === 'Urgent'"); - - await updateCalendarSettings(calendar.page, { - filterPresets: [{ name: "Work only", expression: "Category === 'Urgent'" }], - }); - const afterDelete = await readCalendarSettings(calendar.page); - expect((afterDelete["filterPresets"] as unknown[]).length).toBe(1); - }); - - test("open-filter-preset-selector command exposes every preset", async ({ calendar }) => { - const select = calendar.page.locator(FILTER_PRESET_SELECT).first(); - await select.waitFor({ state: "attached" }); - - await calendar.runCommand("Prisma Calendar: Open filter preset selector"); - - const optionTexts = await select.locator("option").allInnerTexts(); - for (const preset of INITIAL_PRESETS) expect(optionTexts).toContain(preset.name); - expect(optionTexts).toContain("Clear"); - }); -}); diff --git a/e2e/specs/integrations/highlight-commands.spec.ts b/e2e/specs/integrations/highlight-commands.spec.ts deleted file mode 100644 index 64cbf68d..00000000 --- a/e2e/specs/integrations/highlight-commands.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { seedEvents, waitForEventCount } from "../../fixtures/seed-events"; - -// Highlight commands are registered in main.ts: -// "Highlight events without categories" -// "Highlight events with category" (opens category select modal) -// "Toggle prerequisite connection arrows" -// We verify each command runs and leaves the calendar renderable. - -const SEED = [ - { title: "Morning Standup", startDate: "2026-05-04T09:00", endDate: "2026-05-04T09:30", category: "Work" }, - { title: "Untagged Event", startDate: "2026-05-05T10:00", endDate: "2026-05-05T10:30" }, -] as const; - -const CALENDAR_ROOT = ".fc"; -const MODAL = ".modal"; - -test.describe("highlight commands", () => { - test.beforeEach(async ({ calendar }) => { - seedEvents(calendar.vaultDir, [...SEED]); - await waitForEventCount(calendar.page, SEED.length); - await calendar.page.locator(CALENDAR_ROOT).first().waitFor({ state: "visible" }); - }); - - test("highlight-events-without-categories keeps the calendar renderable", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Highlight events without categories"); - await expect(calendar.page.locator(CALENDAR_ROOT).first()).toBeVisible(); - }); - - test("highlight-events-with-category opens the category select modal", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Highlight events with category"); - await expect(calendar.page.locator(MODAL).last()).toBeVisible(); - }); - - test("toggle-prerequisite-connections is idempotent", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Toggle prerequisite connection arrows"); - await calendar.runCommand("Prisma Calendar: Toggle prerequisite connection arrows"); - await expect(calendar.page.locator(CALENDAR_ROOT).first()).toBeVisible(); - }); -}); diff --git a/e2e/specs/integrations/ics-export.spec.ts b/e2e/specs/integrations/ics-export.spec.ts deleted file mode 100644 index 670b91aa..00000000 --- a/e2e/specs/integrations/ics-export.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { expect, test } from "../../fixtures/electron"; -import { seedEvents, waitForEventCount, type SeedEventInput } from "../../fixtures/seed-events"; -import { ICS_EXPORT_SUBMIT_TID, sel } from "../../fixtures/testids"; - -// Exports go through the `showCalendarSelectModal` → ics-export writer. The -// modal submit button is stamped `data-testid="prisma-ics-export-submit"` so -// we can target it without scraping the English label. Writer emits a single -// .ics under the vault's `Prisma-Exports/` folder. - -const EXPORTS_DIR = "Prisma-Exports"; -const EXPORT_SUBMIT = sel(ICS_EXPORT_SUBMIT_TID); - -const SEED: readonly SeedEventInput[] = [ - { - title: "Team Meeting", - startDate: "2026-05-04T10:00", - endDate: "2026-05-04T11:00", - category: "Work", - location: "Room 3", - }, - { - title: "Weekly Review", - startDate: "2026-05-05T14:00", - endDate: "2026-05-05T15:00", - category: "Work", - }, - { - title: "Workout", - startDate: "2026-05-06T07:00", - endDate: "2026-05-06T08:00", - category: "Fitness", - }, - { - title: "Project Planning", - startDate: "2026-05-07T13:00", - endDate: "2026-05-07T14:30", - category: "Work", - }, - { - title: "Conference Day", - date: "2026-05-08", - allDay: true, - category: "Work", - }, -]; - -test.describe("ICS export", () => { - test.beforeEach(async ({ calendar }) => { - seedEvents(calendar.vaultDir, SEED); - await waitForEventCount(calendar.page, SEED.length); - }); - - test("writer produces five VEVENTs with correct SUMMARY / DTSTART / CATEGORIES / LOCATION", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Export calendar as .ics"); - - const submit = calendar.page.locator(EXPORT_SUBMIT).first(); - await submit.waitFor({ state: "visible" }); - await submit.click(); - - const exportsDir = join(calendar.vaultDir, EXPORTS_DIR); - await expect - .poll(() => (existsSync(exportsDir) ? readdirSync(exportsDir).filter((f) => f.endsWith(".ics")) : [])) - .toHaveLength(1); - - const icsFile = readdirSync(exportsDir).find((f) => f.endsWith(".ics"))!; - const ics = readFileSync(join(exportsDir, icsFile), "utf8"); - - const veventCount = (ics.match(/BEGIN:VEVENT/g) ?? []).length; - expect(veventCount).toBe(SEED.length); - - for (const event of SEED) expect(ics).toContain(`SUMMARY:${event.title}`); - - expect(ics).toMatch(/DTSTART;VALUE=DATE:20260508/); - expect(ics).toMatch(/DTSTART[^\r\n]*:20260504T100000/); - expect(ics).toMatch(/DTEND[^\r\n]*:20260504T110000/); - expect(ics).toContain("CATEGORIES:Work"); - expect(ics).toContain("LOCATION:Room 3"); - }); -}); diff --git a/e2e/specs/integrations/ics-roundtrip.spec.ts b/e2e/specs/integrations/ics-roundtrip.spec.ts deleted file mode 100644 index 023416ff..00000000 --- a/e2e/specs/integrations/ics-roundtrip.spec.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; - -import type { Page } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { runCommand } from "../../fixtures/commands"; -import { expect, test } from "../../fixtures/electron"; -import { getEventCount, seedEvents, waitForEventCount, type SeedEventInput } from "../../fixtures/seed-events"; -import { ICS_EXPORT_SUBMIT_TID, ICS_IMPORT_FILE_TID, ICS_IMPORT_SUBMIT_TID, sel } from "../../fixtures/testids"; - -// Full ICS roundtrip. The contract we verify: -// -// Given a set of events seeded to disk in Prisma's canonical frontmatter -// format, exporting to .ics with timezone=UTC, deleting every event note, -// and importing the produced .ics with timezone=UTC must reproduce every -// event with the same set of Prisma-understood properties. -// -// What we compare per event (byte-for-byte): start/end dates, date (all-day), -// category, location, participants, custom frontmatter keys (preserved via -// X-PRISMA-FM-*), and RRule. ZettelID is regenerated on import (14-digit -// suffix in filename — format-checked, not value-checked). The `All Day` -// boolean is not asserted for timed events because the seed omits it. - -const EVENTS_DIR = "Events"; -const EXPORTS_DIR = "Prisma-Exports"; -const EXPORT_SUBMIT = sel(ICS_EXPORT_SUBMIT_TID); -const IMPORT_FILE_INPUT = sel(ICS_IMPORT_FILE_TID); -const IMPORT_SUBMIT = sel(ICS_IMPORT_SUBMIT_TID); - -function listEventMarkdown(vaultDir: string): string[] { - const dir = join(vaultDir, EVENTS_DIR); - if (!existsSync(dir)) return []; - return readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("Virtual Events")); -} - -/** Strip the 14-digit ZettelID suffix the importer appends to regenerated files. */ -function titleFromFilename(filename: string): string { - return filename.replace(/-\d{14}(?=\.md$)/, "").replace(/\.md$/, ""); -} - -/** - * Derive the frontmatter shape the importer should write for a given seed. - * Single source of truth — previously every seed entry duplicated its - * frontmatter in an `expected` field. The transformation mirrors how - * `seedEvent` writes the disk file, with the timed-event date fields promoted - * to the canonical `:ss.000Z` shape Prisma normalises to during import. - */ -function expectedFromSeed(event: SeedEventInput): Record { - const out: Record = {}; - if (event.startDate) out["Start Date"] = `${event.startDate}:00.000Z`; - if (event.endDate) out["End Date"] = `${event.endDate}:00.000Z`; - if (event.date) out["Date"] = event.date; - if (event.allDay) out["All Day"] = true; - if (event.category) out["Category"] = event.category; - if (event.location) out["Location"] = event.location; - if (event.participants && event.participants.length > 0) out["Participants"] = event.participants; - if (event.rrule) out["RRule"] = event.rrule; - if (event.extra) Object.assign(out, event.extra); - return out; -} - -interface RoundTripHandles { - icsPath: string; - exportedIcs: string; -} - -/** - * Seed → export → scorch → import. Every test that exercises a different - * flavour of events runs through this identical pipeline, asserting against - * the returned ICS + on-disk state. - */ -async function runRoundTrip( - { page, vaultDir }: { page: Page; vaultDir: string }, - seed: readonly SeedEventInput[] -): Promise { - seedEvents(vaultDir, seed); - await waitForEventCount(page, seed.length); - - await runCommand(page, "Prisma Calendar: Export calendar as .ics"); - await page.locator(EXPORT_SUBMIT).first().click(); - - const exportsDir = join(vaultDir, EXPORTS_DIR); - await expect - .poll(() => (existsSync(exportsDir) ? readdirSync(exportsDir).filter((f) => f.endsWith(".ics")) : [])) - .toHaveLength(1); - - const icsPath = join(exportsDir, readdirSync(exportsDir).find((f) => f.endsWith(".ics"))!); - const exportedIcs = readFileSync(icsPath, "utf8"); - expect((exportedIcs.match(/BEGIN:VEVENT/g) ?? []).length).toBe(seed.length); - - for (const md of listEventMarkdown(vaultDir)) { - unlinkSync(join(vaultDir, EVENTS_DIR, md)); - } - expect(listEventMarkdown(vaultDir)).toHaveLength(0); - - await runCommand(page, "Prisma Calendar: Import .ics file"); - await page.locator(IMPORT_FILE_INPUT).first().setInputFiles(icsPath); - const importSubmit = page.locator(IMPORT_SUBMIT).first(); - await expect(importSubmit).toBeEnabled(); - await importSubmit.click(); - - // The import modal closes asynchronously after submit. Waiting for it to - // detach before the file-count poll prevents the next palette command - // from racing with a still-visible modal. - await expect.poll(() => page.locator(".modal").count()).toBe(0); - - await expect.poll(() => listEventMarkdown(vaultDir).length).toBe(seed.length); - await expect.poll(() => getEventCount(page)).toBe(seed.length); - - return { icsPath, exportedIcs }; -} - -/** - * Walk every seed, find its imported file by title, and assert every key - * `expectedFromSeed` produces round-trips byte-for-byte. - */ -function assertRoundTripFidelity(vaultDir: string, seed: readonly SeedEventInput[]): void { - const importedFilenames = listEventMarkdown(vaultDir); - const byTitle = new Map(importedFilenames.map((f) => [titleFromFilename(f), f])); - - expect([...byTitle.keys()].sort()).toEqual(seed.map((e) => e.title).sort()); - - for (const event of seed) { - const filename = byTitle.get(event.title); - expect(filename, `imported file for "${event.title}" missing`).toBeDefined(); - expect(filename!, `zettel id must be re-stamped on "${event.title}"`).toMatch(/-\d{14}\.md$/); - - const fm = readEventFrontmatter(vaultDir, `${EVENTS_DIR}/${filename!}`); - for (const [key, value] of Object.entries(expectedFromSeed(event))) { - expect(fm[key], `"${event.title}" → ${key} must round-trip byte-for-byte`).toEqual(value); - } - } -} - -test.describe("ICS roundtrip", () => { - test("basic events (timed, all-day, categories, participants, location, custom keys)", async ({ obsidian }) => { - const seed: SeedEventInput[] = [ - { - title: "Team Meeting", - startDate: "2026-06-01T10:00", - endDate: "2026-06-01T11:00", - category: "Work", - location: "Room 3", - participants: ["alice@example.com", "bob@example.com"], - extra: { Icon: "briefcase" }, - }, - { - title: "Weekly Review", - startDate: "2026-06-02T14:00", - endDate: "2026-06-02T15:00", - category: "Work", - }, - { - title: "Workout", - startDate: "2026-06-03T07:00", - endDate: "2026-06-03T08:00", - category: "Fitness", - location: "Gym", - }, - { - title: "Conference Day", - date: "2026-06-04", - allDay: true, - category: "Work", - }, - { - title: "Holiday Break", - date: "2026-12-25", - allDay: true, - extra: { Icon: "tree" }, - }, - { - title: "Yearly Review", - startDate: "2026-12-31T16:00", - endDate: "2026-12-31T17:00", - category: "Work", - rrule: "FREQ=YEARLY;INTERVAL=1", - extra: { Icon: "clock", Priority: 2 }, - }, - ]; - - const { exportedIcs } = await runRoundTrip(obsidian, seed); - for (const event of seed) expect(exportedIcs).toContain(`SUMMARY:${event.title}`); - assertRoundTripFidelity(obsidian.vaultDir, seed); - }); - - test("recurrence patterns (daily, weekly, monthly, yearly) preserve RRULE", async ({ obsidian }) => { - const seed: SeedEventInput[] = [ - { - title: "Daily Standup", - startDate: "2026-06-01T09:00", - endDate: "2026-06-01T09:15", - category: "Work", - rrule: "FREQ=DAILY;INTERVAL=1", - }, - { - title: "Monthly Planning", - startDate: "2026-06-01T15:00", - endDate: "2026-06-01T16:00", - category: "Work", - rrule: "FREQ=MONTHLY;INTERVAL=1", - }, - { - title: "Quarterly Review", - startDate: "2026-06-15T10:00", - endDate: "2026-06-15T11:30", - category: "Work", - rrule: "FREQ=MONTHLY;INTERVAL=3", - }, - { - title: "Birthday", - date: "2026-07-04", - allDay: true, - rrule: "FREQ=YEARLY;INTERVAL=1", - }, - ]; - - await runRoundTrip(obsidian, seed); - assertRoundTripFidelity(obsidian.vaultDir, seed); - }); - - test("custom frontmatter keys round-trip via X-PRISMA-FM-*", async ({ obsidian }) => { - const seed: SeedEventInput[] = [ - { - title: "Project Planning", - startDate: "2026-08-01T10:00", - endDate: "2026-08-01T12:00", - extra: { - Priority: 1, - Project: "Phoenix", - Status: "In Progress", - Effort: 5, - }, - }, - { - title: "Design Review", - startDate: "2026-08-02T14:00", - endDate: "2026-08-02T15:00", - extra: { - ReviewRound: 2, - Blocked: false, - }, - }, - ]; - - const { exportedIcs } = await runRoundTrip(obsidian, seed); - expect(exportedIcs).toMatch(/X-PRISMA-FM-/); - assertRoundTripFidelity(obsidian.vaultDir, seed); - }); - - test("RRULE bounded by COUNT and UNTIL round-trips verbatim", async ({ obsidian }) => { - const seed: SeedEventInput[] = [ - { - title: "Five Standups", - startDate: "2026-09-07T09:00", - endDate: "2026-09-07T09:15", - category: "Work", - rrule: "FREQ=DAILY;COUNT=5", - }, - { - title: "Weekly Review Until EOY", - startDate: "2026-09-04T15:00", - endDate: "2026-09-04T16:00", - category: "Work", - rrule: "FREQ=WEEKLY;UNTIL=20261231T235959Z", - }, - { - title: "Monthly One-on-One", - startDate: "2026-09-15T10:00", - endDate: "2026-09-15T10:30", - category: "Work", - rrule: "FREQ=MONTHLY;COUNT=6", - }, - ]; - - await runRoundTrip(obsidian, seed); - assertRoundTripFidelity(obsidian.vaultDir, seed); - }); - - test("re-importing the same .ics does not crash and leaves original events intact", async ({ obsidian }) => { - // Documents current behaviour of the manual `Import .ics file` - // command: `importEventsToCalendar` in ics-import.ts compares ICS - // `uid` against `event.id` in the store, but the store id is - // derived from file path, not ICS UID — so manual re-import creates - // new files for every VEVENT. The ICS *subscription* sync path - // (`ics-subscription.spec.ts`) is UID-aware and dedupes correctly; - // the manual-import command does not. That's a known gap; this - // test locks in the "at least does not crash and keeps originals" - // guarantee so behaviour only ever improves from here. - const { page, vaultDir } = obsidian; - - const seed: SeedEventInput[] = [ - { - title: "Team Meeting", - startDate: "2026-10-01T10:00", - endDate: "2026-10-01T11:00", - category: "Work", - }, - { - title: "Weekly Review", - startDate: "2026-10-08T15:00", - endDate: "2026-10-08T16:00", - category: "Work", - }, - { - title: "Holiday", - date: "2026-10-13", - allDay: true, - }, - ]; - - const { icsPath } = await runRoundTrip(obsidian, seed); - const afterFirstImport = new Set(listEventMarkdown(vaultDir)); - expect(afterFirstImport.size).toBe(seed.length); - - await runCommand(page, "Prisma Calendar: Import .ics file"); - await page.locator(IMPORT_FILE_INPUT).first().setInputFiles(icsPath); - const importSubmit = page.locator(IMPORT_SUBMIT).first(); - await expect(importSubmit).toBeEnabled(); - await importSubmit.click(); - await expect.poll(() => page.locator(".modal").count()).toBe(0); - - // Manual re-import is not UID-aware and creates duplicates (see comment - // above). Originals must survive alongside the new copies — poll the - // file list to converge instead of asserting synchronously. - await expect.poll(() => listEventMarkdown(vaultDir).length).toBe(2 * seed.length); - const afterSecondImport = listEventMarkdown(vaultDir); - expect(afterSecondImport.length).toBe(2 * seed.length); - for (const original of afterFirstImport) { - expect(afterSecondImport).toContain(original); - } - }); -}); diff --git a/e2e/specs/integrations/ics-subscription-refresh.spec.ts b/e2e/specs/integrations/ics-subscription-refresh.spec.ts deleted file mode 100644 index 95dde5f7..00000000 --- a/e2e/specs/integrations/ics-subscription-refresh.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { expect, testIntegrations as test } from "../../fixtures/electron"; -import { type VEventInput } from "../../fixtures/ics-server"; -import { setupIcsSubscription, type IcsSubscriptionHandle } from "../../fixtures/ics-subscription"; - -// Re-sync behaviour: when the remote feed changes between syncs the local -// vault must converge. Add/remove/modify cases plus a rapid-fire dedup check. -// All boilerplate (mock server, secret stub, subscription seed, vault clean) -// lives in `fixtures/ics-subscription.ts` — specs deal in remote VEVENTs and -// on-disk frontmatter only. - -test.describe("ICS subscription refresh: modified events", () => { - let ics: IcsSubscriptionHandle; - - test.afterEach(async () => { - await ics?.close(); - }); - - test("re-sync picks up time change on an existing event", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian, { - initial: [ - { - uid: "e2e-update-time@test", - summary: "Morning Standup", - dtstart: "20261105T090000Z", - dtend: "20261105T093000Z", - }, - ], - }); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(1); - expect(String(ics.readFrontmatter("Morning Standup")?.["Start Date"])).toContain("09:00"); - - ics.setRemoteEvents([ - { - uid: "e2e-update-time@test", - summary: "Morning Standup", - dtstart: "20261105T100000Z", - dtend: "20261105T103000Z", - }, - ]); - ics.resetRequests(); - - await ics.sync(); - await ics.waitForRequest(); - - await expect.poll(() => String(ics.readFrontmatter("Morning Standup")?.["Start Date"] ?? "")).toContain("10:00"); - }); - - test("re-sync picks up added category on existing event", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian, { - initial: [ - { - uid: "e2e-add-category@test", - summary: "Design Review", - dtstart: "20261110T140000Z", - dtend: "20261110T150000Z", - }, - ], - }); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(1); - - ics.setRemoteEvents([ - { - uid: "e2e-add-category@test", - summary: "Design Review", - dtstart: "20261110T140000Z", - dtend: "20261110T150000Z", - categories: "Work", - }, - ]); - ics.resetRequests(); - - await ics.sync(); - await ics.waitForRequest(); - - await expect.poll(() => ics.readFrontmatter("Design Review")?.["Category"]).toBe("Work"); - }); - - test("re-sync with multiple additions and deletions in one pass", async ({ obsidian }) => { - const keep: VEventInput = { - uid: "e2e-keep@test", - summary: "Keep This", - dtstart: "20261201T080000Z", - dtend: "20261201T090000Z", - }; - - ics = await setupIcsSubscription(obsidian, { - initial: [ - keep, - { uid: "e2e-remove-a@test", summary: "Remove A", dtstart: "20261202T100000Z", dtend: "20261202T110000Z" }, - { uid: "e2e-remove-b@test", summary: "Remove B", dtstart: "20261203T100000Z", dtend: "20261203T110000Z" }, - ], - }); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(3); - - ics.setRemoteEvents([ - keep, - { uid: "e2e-new-x@test", summary: "New X", dtstart: "20261204T100000Z", dtend: "20261204T110000Z" }, - { uid: "e2e-new-y@test", summary: "New Y", dtstart: "20261205T100000Z", dtend: "20261205T110000Z" }, - ]); - ics.resetRequests(); - - await ics.sync(); - await ics.waitForRequest(); - - await expect.poll(() => ics.findEventFile("New X")).toBeDefined(); - await expect.poll(() => ics.findEventFile("New Y")).toBeDefined(); - await expect.poll(() => ics.findEventFile("Remove A")).toBeUndefined(); - await expect.poll(() => ics.findEventFile("Remove B")).toBeUndefined(); - expect(ics.findEventFile("Keep This")).toBeDefined(); - expect(ics.listEventFiles()).toHaveLength(3); - }); - - test("rapid re-syncs do not duplicate events", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian, { - initial: [ - { - uid: "e2e-rapid-1@test", - summary: "Rapid Event", - dtstart: "20261215T100000Z", - dtend: "20261215T110000Z", - }, - ], - }); - - await ics.sync(); - await ics.sync(); - await ics.sync(); - - // Three syncs must produce exactly three requests — anything higher means - // the sync coalescing is broken and re-syncs duplicate work. - await expect.poll(() => ics.server.requests.length).toBe(3); - await ics.expectFileCount(1); - }); -}); diff --git a/e2e/specs/integrations/ics-subscription.spec.ts b/e2e/specs/integrations/ics-subscription.spec.ts deleted file mode 100644 index eef27253..00000000 --- a/e2e/specs/integrations/ics-subscription.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, testIntegrations as test } from "../../fixtures/electron"; -import { type VEventInput } from "../../fixtures/ics-server"; -import { setupIcsSubscription, type IcsSubscriptionHandle } from "../../fixtures/ics-subscription"; - -// ICS URL subscriptions are a Pro feature. The full sync pipeline is: -// palette → Sync ICS subscriptions → plugin.syncSingleICSSubscription → -// bundle.syncICSSubscription → ICSSubscriptionSyncService.sync() → -// requestUrl(GET subscription-url) → parseICSContent → computeSyncPlan → -// disk writes. Every link in that chain can break in ways unit tests miss -// (URL resolution, HTTP status handling, content-type, ICS parsing quirks, -// UID-based dedup). We exercise it end-to-end with a local HTTP server so -// the assertions land on real network round-trips and real file I/O. -// -// Boilerplate (mock server, secret stub, subscription seed, vault clean-up) -// lives in `fixtures/ics-subscription.ts` — specs read as a sequence of remote -// state changes and on-disk assertions, nothing else. - -test.describe("ICS URL subscription sync", () => { - let ics: IcsSubscriptionHandle; - - test.afterEach(async () => { - await ics?.close(); - }); - - test("initial sync pulls every VEVENT into the vault", async ({ obsidian }) => { - const events: VEventInput[] = [ - { - uid: "e2e-meeting-1@test", - summary: "Team Meeting", - dtstart: "20261101T100000Z", - dtend: "20261101T110000Z", - categories: "Work", - }, - { - uid: "e2e-holiday-1@test", - summary: "Holiday", - dtstart: "20261125", - allDay: true, - }, - ]; - - ics = await setupIcsSubscription(obsidian, { initial: events }); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(events.length); - - for (const v of events) { - ics.expectEventFile(v.summary); - const fm = ics.readFrontmatter(v.summary)!; - - if (v.allDay) { - expect(fm["All Day"]).toBe(true); - // `YYYYMMDD` → `YYYY-MM-DD` - const expectedDate = `${v.dtstart.slice(0, 4)}-${v.dtstart.slice(4, 6)}-${v.dtstart.slice(6, 8)}`; - expect(fm["Date"]).toBe(expectedDate); - } else { - // `20261101T100000Z` → ISO `2026-11-01T10:00:00.000Z` - const iso = `${v.dtstart.slice(0, 4)}-${v.dtstart.slice(4, 6)}-${v.dtstart.slice(6, 8)}T${v.dtstart.slice( - 9, - 11 - )}:${v.dtstart.slice(11, 13)}:${v.dtstart.slice(13, 15)}.000Z`; - expect(fm["Start Date"]).toBe(iso); - } - } - - expect(ics.server.requests[0]?.method).toBe("GET"); - expect(ics.server.requests[0]?.path).toBe("/calendar.ics"); - }); - - test("incremental sync adds new events, removes deleted events", async ({ obsidian }) => { - const stable: VEventInput = { - uid: "e2e-stable@test", - summary: "Stable Event", - dtstart: "20261201T100000Z", - dtend: "20261201T110000Z", - }; - - ics = await setupIcsSubscription(obsidian, { - initial: [ - stable, - { - uid: "e2e-deleted@test", - summary: "To Be Deleted", - dtstart: "20261202T100000Z", - dtend: "20261202T110000Z", - }, - ], - }); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(2); - - // Second pass: remove one, add one. The sync planner should unlink the - // deleted event's file and create the new one, leaving the stable - // event untouched. - ics.setRemoteEvents([ - stable, - { uid: "e2e-new@test", summary: "Newly Added", dtstart: "20261203T100000Z", dtend: "20261203T110000Z" }, - ]); - ics.resetRequests(); - - await ics.sync(); - await ics.waitForRequest(); - - await expect.poll(() => ics.findEventFile("Newly Added")).toBeDefined(); - await expect.poll(() => ics.findEventFile("To Be Deleted")).toBeUndefined(); - expect(ics.findEventFile("Stable Event")).toBeDefined(); - }); - - test("malformed ICS response leaves the vault untouched and the plugin alive", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian); - ics.setRawBody("this is not an ICS file\n\nsorry"); - - await ics.sync(); - await ics.waitForRequest(); - - // No files should land, plugin should still be operational (another - // sync runs cleanly afterwards). - expect(ics.listEventFiles()).toHaveLength(0); - await ics.sync(); - await ics.waitForRequest(2); - }); - - test("HTTP 403 does not crash the plugin or touch the vault", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian); - ics.setRawBody("Forbidden"); - ics.setStatus(403); - - await ics.sync(); - await ics.waitForRequest(); - - expect(ics.listEventFiles()).toHaveLength(0); - - // Flip the server back to a successful response and re-sync — - // transient errors shouldn't permanently disable the subscription. - ics.setStatus(200); - ics.setRemoteEvents([ - { - uid: "e2e-recovered@test", - summary: "Recovered", - dtstart: "20261210T090000Z", - dtend: "20261210T100000Z", - }, - ]); - ics.resetRequests(); - - await ics.sync(); - await ics.waitForRequest(); - await ics.expectFileCount(1); - }); - - test("disabled subscription is skipped entirely (no network request)", async ({ obsidian }) => { - ics = await setupIcsSubscription(obsidian, { - initial: [ - { - uid: "e2e-should-not-import@test", - summary: "Should Not Import", - dtstart: "20261220T100000Z", - dtend: "20261220T110000Z", - }, - ], - subscription: { enabled: false }, - }); - - await ics.sync(); - - // Sync command short-circuits on disabled subscriptions before the - // HTTP request. Give the command a moment to run, then assert. - await obsidian.page.waitForTimeout(500); - - expect(ics.server.requests).toHaveLength(0); - expect(ics.listEventFiles()).toHaveLength(0); - }); -}); diff --git a/e2e/specs/integrations/notifications-firing.spec.ts b/e2e/specs/integrations/notifications-firing.spec.ts deleted file mode 100644 index 3a96409a..00000000 --- a/e2e/specs/integrations/notifications-firing.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { dateOffsetMinutes, localISOWithSeconds } from "../../fixtures/dates"; -import { expect, testWithNotifications as test } from "../../fixtures/electron"; -import { sel } from "../../fixtures/testids"; - -// `enableNotifications: true` is seeded into the calendar config by -// `testWithNotifications`. The full notification path under test: -// indexer file-changed → NotificationManager.processEventSource → -// calculateNotificationTime (uses Minutes Before frontmatter) → -// notifyAt < now AND timeSinceEvent < MAX_PAST_NOTIFICATION_THRESHOLD → -// triggerNotification → showNotificationModal → on-disk Already Notified -// frontmatter set. -// -// To trigger the modal in a test we seed an event with start time just past -// "now" and a Minutes Before value large enough that notifyAt has already -// elapsed — e.g., start = now + 2 min, Minutes Before = 10 min → notifyAt -// is now − 8 min, comfortably inside the 5-hour past threshold. The modal -// then renders synchronously when the indexer ingests the file. - -const NOTIFICATION_MODAL_TID = "prisma-notification-modal"; -const NOTIFICATION_DISMISS_TID = "prisma-notification-dismiss"; -// Time between indexer pass and assertion for the negative-case (modal must NOT -// appear). The positive case is fenced by `expect(modal).toBeVisible()` instead. -const NEGATIVE_CASE_GRACE_MS = 750; -const EVENT_START_OFFSET_MIN = 2; -const EVENT_DURATION_MIN = 30; -const MINUTES_BEFORE_TRIGGER = 10; - -function notifiableFrontmatter(extra: Record = {}): Record { - const start = dateOffsetMinutes(EVENT_START_OFFSET_MIN); - const end = new Date(start.getTime() + EVENT_DURATION_MIN * 60_000); - return { - "Start Date": localISOWithSeconds(start), - "End Date": localISOWithSeconds(end), - "Minutes Before": String(MINUTES_BEFORE_TRIGGER), - ...extra, - }; -} - -test.describe("notifications: reminder modal fires from indexer events", () => { - test("event whose notifyAt has elapsed pops the notification modal on indexer ingestion", async ({ calendar }) => { - // notifyAt = startDate − 10min = now − 8min — already past, but - // timeSinceEvent (now − startDate) = −2min, well within the 5-hour - // future-event grace window. Triggers immediately on the indexer pass. - const event = await calendar.seedOnDisk("Notify Me", notifiableFrontmatter()); - - // Exactly one notification modal — `NotificationManager.triggering` - // dedupes the create+modify file-change pair the indexer emits on a - // seed write. If this count climbs above 1, the source-level dedupe - // regressed; do not paper over it here. - const modal = calendar.page.locator(sel(NOTIFICATION_MODAL_TID)); - await expect(modal).toHaveCount(1); - await expect(modal).toContainText("Notify Me"); - - await calendar.page.locator(sel(NOTIFICATION_DISMISS_TID)).click(); - await expect(modal).toHaveCount(0); - - // After firing, the manager writes `Already Notified: true` so the same - // event won't fire again on the next indexer pass — the file's - // frontmatter must reflect that. - await event.expectFrontmatter("Already Notified", (v) => v === true); - }); - - test("event with Already Notified: true does NOT fire the modal", async ({ calendar }) => { - // Same shape as above except for the explicit Already Notified flag — - // the manager's processEventSource short-circuits on that frontmatter - // before computing notifyAt. - await calendar.seedOnDisk("Already Done", notifiableFrontmatter({ "Already Notified": true })); - - // Negative case — the absence of the modal needs a small grace window - // after the indexer ingests the seed (the notification manager runs - // asynchronously off the indexer pass). - await calendar.page.waitForTimeout(NEGATIVE_CASE_GRACE_MS); - - await expect(calendar.page.locator(sel(NOTIFICATION_MODAL_TID))).toHaveCount(0); - }); -}); diff --git a/e2e/specs/integrations/stopwatch-lifecycle.spec.ts b/e2e/specs/integrations/stopwatch-lifecycle.spec.ts deleted file mode 100644 index 078c6b82..00000000 --- a/e2e/specs/integrations/stopwatch-lifecycle.spec.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { existsSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import type { Locator } from "@playwright/test"; - -import { collapsibleSection, createEventHandle } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { saveEventModal } from "../../fixtures/helpers"; -import { sel, TID } from "../../fixtures/testids"; -import { EVENT_MODAL_SELECTOR, formatLocalDate } from "../events/events-helpers"; - -// Stopwatch is rendered inside the event modal as a collapsible section -// (src/react/views/stopwatch.tsx). All interactable elements are stamped: -// - TID.stopwatch.time → "prisma-stopwatch-time" -// - TID.stopwatch.btn() → "prisma-stopwatch-btn-{start,continue,pause,stop,resume}" -// - TID.stopwatch.collapsibleSlug → "time-tracker" (passed to collapsibleSection()) -// The minimize button lives on the event-form header: TID.event.btn("minimize"). - -const MODAL = ".modal"; - -const HHMMSS_REGEX = /^(\d{2}):(\d{2}):(\d{2})$/; - -function parseStopwatchToMs(value: string): number { - const match = value.match(HHMMSS_REGEX); - if (!match) throw new Error(`unparseable stopwatch display: ${JSON.stringify(value)}`); - const [, hh, mm, ss] = match; - return ((Number(hh) * 60 + Number(mm)) * 60 + Number(ss)) * 1000; -} - -async function readStopwatchMs(display: Locator): Promise { - return parseStopwatchToMs((await display.textContent()) ?? ""); -} - -test.describe("stopwatch lifecycle", () => { - test("create-event-with-stopwatch opens the modal with an active stopwatch", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Create new event with stopwatch"); - - await expect(calendar.page.locator(MODAL).first()).toBeVisible(); - - const display = calendar.page.locator(sel(TID.stopwatch.time)).first(); - await display.waitFor({ state: "visible" }); - - const first = await display.textContent(); - await calendar.page.waitForTimeout(2_000); - const second = await display.textContent(); - expect(first).not.toBe(second); - }); - - test("minimize preserves stopwatch state across reopen", async ({ calendar }) => { - await calendar.runCommand("Prisma Calendar: Create new event with stopwatch"); - - const modal = calendar.page.locator(MODAL).first(); - await modal.waitFor({ state: "visible" }); - const display = calendar.page.locator(sel(TID.stopwatch.time)).first(); - await display.waitFor({ state: "visible" }); - - await calendar.page.waitForTimeout(1_500); - - await calendar.page - .locator(sel(TID.event.btn("minimize"))) - .first() - .click(); - await expect(modal).toBeHidden(); - - await calendar.runCommand("Prisma Calendar: Restore minimized event modal"); - await expect(calendar.page.locator(MODAL).first()).toBeVisible(); - await expect(calendar.page.locator(sel(TID.stopwatch.time)).first()).toBeVisible(); - }); - - test("create-with-stopwatch: minimize + wait + restore advances time and preserves pending form fields", async ({ - calendar, - }) => { - const page = calendar.page; - await calendar.runCommand("Prisma Calendar: Create new event with stopwatch"); - - const modal = page.locator(MODAL).first(); - await modal.waitFor({ state: "visible" }); - const display = page.locator(sel(TID.stopwatch.time)).first(); - await display.waitFor({ state: "visible" }); - - // Pending edits the spec must see preserved through minimize/restore. - await page - .locator(sel(TID.event.control("title"))) - .first() - .fill("Tracked Session"); - await page - .locator(sel(TID.event.control("location"))) - .first() - .fill("Room C"); - - // Wait long enough that the display has rolled at least one tick before - // minimize. The stopwatch ticks at 1Hz (setInterval 1000ms in stopwatch.tsx). - await page.waitForTimeout(1_500); - const beforeMs = await readStopwatchMs(display); - - await page - .locator(sel(TID.event.btn("minimize"))) - .first() - .click(); - await expect(modal).toBeHidden(); - - // Background time accrual — proves the stopwatch keeps counting while - // minimized (the snapshot tracks start time, not modal-mount time). - await page.waitForTimeout(2_000); - - await calendar.runCommand("Prisma Calendar: Restore minimized event modal"); - await page.locator(EVENT_MODAL_SELECTOR).waitFor({ state: "visible" }); - const restoredDisplay = page.locator(sel(TID.stopwatch.time)).first(); - await restoredDisplay.waitFor({ state: "visible" }); - - // Form data round-trip. - await expect(page.locator(sel(TID.event.control("title"))).first()).toHaveValue("Tracked Session"); - await expect(page.locator(sel(TID.event.control("location"))).first()).toHaveValue("Room C"); - - const afterMs = await readStopwatchMs(restoredDisplay); - // 1_500ms pre-minimize + 2_000ms hidden ≈ 3_500ms accrued. 1_000ms lower - // bound absorbs command-palette + restore-modal latency while still - // catching a frozen stopwatch. - expect(afterMs - beforeMs).toBeGreaterThanOrEqual(1_000); - - // Ticking proof: poll the display until it advances past the restored - // reading — guards against a stopwatch that resumes display but stops - // the setInterval tick. - await expect - .poll(() => readStopwatchMs(restoredDisplay), { message: "stopwatch should continue ticking after restore" }) - .toBeGreaterThan(afterMs); - }); - - // Bug regression: saving a create modal with the stopwatch running used to - // leave the minimized state pointing at `{modalType: "create", filePath: - // null}`, so restoring opened a fresh create modal and a subsequent Save - // persisted a duplicate file instead of editing the original. The fix - // rebinds the saved state to `{edit, }` as soon as createEvent - // resolves. Asserted at the only place that doesn't lie: the events - // directory on disk. - test("create-with-stopwatch + Save + restore + edit propagates to the SAME file (not a duplicate)", async ({ - calendar, - }) => { - const page = calendar.page; - await calendar.runCommand("Prisma Calendar: Create new event with stopwatch"); - - const modal = page.locator(MODAL).first(); - await modal.waitFor({ state: "visible" }); - await page.locator(sel(TID.stopwatch.time)).first().waitFor({ state: "visible" }); - - // Give the event a recognizable name so we can find the file later. - await page - .locator(sel(TID.event.control("title"))) - .first() - .fill("Tracked Original"); - - // Save while the stopwatch is still running. This is the path that - // triggered the bug — EventForm's unmount cleanup auto-saved a - // "create"/null state before bundle.createEvent resolved. - await saveEventModal(page); - - const eventsDir = join(calendar.vaultDir, "Events"); - await expect - .poll( - () => (existsSync(eventsDir) ? readdirSync(eventsDir).filter((f) => f.startsWith("Tracked Original-")) : []), - { - message: "create-with-stopwatch + Save should persist exactly one file", - } - ) - .toHaveLength(1); - - const filesAfterCreate = readdirSync(eventsDir).filter((f) => f.startsWith("Tracked Original-")); - const originalFile = filesAfterCreate[0]!; - - // Restore the still-running minimized session and rename the title. - await calendar.runCommand("Prisma Calendar: Restore minimized event modal"); - await page.locator(EVENT_MODAL_SELECTOR).waitFor({ state: "visible" }); - - const titleInput = page.locator(sel(TID.event.control("title"))).first(); - await expect(titleInput).toHaveValue("Tracked Original"); - await titleInput.fill("Tracked Renamed"); - await saveEventModal(page); - - // Critical assertion: exactly ONE file with the new title — the - // original was renamed, not a duplicate created. Before the fix, - // the restore re-opened a create modal so this Save persisted a - // second file alongside the original. - await expect - .poll(() => readdirSync(eventsDir).filter((f) => f.startsWith("Tracked Renamed-") && f.endsWith(".md")), { - message: "edit propagated to a renamed file", - }) - .toHaveLength(1); - - const filesAfterRename = readdirSync(eventsDir).filter((f) => f.endsWith(".md") && !f.startsWith("Virtual Events")); - // The original "Tracked Original-*" file is gone (renamed in place). - expect(filesAfterRename.filter((f) => f.startsWith("Tracked Original-"))).toEqual([]); - // And the zettel ID survived the rename — same file, new title. - const originalZettel = originalFile.match(/-(\d{14})\.md$/)?.[1]; - const renamedFile = filesAfterRename.find((f) => f.startsWith("Tracked Renamed-"))!; - expect(renamedFile).toContain(`-${originalZettel}.md`); - }); - - test("edit-existing: continue stopwatch + minimize + restore advances time and preserves event identity", async ({ - calendar, - }) => { - const page = calendar.page; - const today = formatLocalDate(new Date()); - // Seed with a start in the past so `continueFromExisting` resolves to a - // non-negative elapsed value — see continueFromExisting() in stopwatch.tsx - // which refuses future start times. - const seedPath = "Events/Resumable-20250101000000.md"; - writeFileSync( - join(calendar.vaultDir, seedPath), - `--- -Start Date: ${today}T08:00 -End Date: ${today}T08:30 -Location: Resume Room -Already Notified: true ---- - -# Resumable -`, - "utf8" - ); - - const evt = createEventHandle(calendar, seedPath, "Resumable"); - await evt.expectVisible(); - await evt.rightClick("editEvent"); - await page.locator(EVENT_MODAL_SELECTOR).waitFor({ state: "visible" }); - - // Stopwatch section starts collapsed — drive it through the shared DSL so - // a future header/testid refactor only touches the collapsibleSection helper. - const section = collapsibleSection(page, TID.stopwatch.collapsibleSlug); - await section.expand(); - - const continueBtn = page.locator(sel(TID.stopwatch.btn("continue"))).first(); - await continueBtn.waitFor({ state: "visible" }); - await continueBtn.click(); - - // Once running, the display reflects elapsed = now − Start Date (08:00), - // so the value is a multi-hour timestamp — we still only care about deltas. - const display = page.locator(sel(TID.stopwatch.time)).first(); - await display.waitFor({ state: "visible" }); - await page.waitForTimeout(1_500); - const beforeMs = await readStopwatchMs(display); - - await page - .locator(sel(TID.event.btn("minimize"))) - .first() - .click(); - await page.locator(EVENT_MODAL_SELECTOR).waitFor({ state: "detached" }); - - await page.waitForTimeout(2_000); - - await calendar.runCommand("Prisma Calendar: Restore minimized event modal"); - await page.locator(EVENT_MODAL_SELECTOR).waitFor({ state: "visible" }); - const restoredDisplay = page.locator(sel(TID.stopwatch.time)).first(); - await restoredDisplay.waitFor({ state: "visible" }); - - // Identity preserved across minimize/restore. - await expect(page.locator(sel(TID.event.control("title"))).first()).toHaveValue("Resumable"); - await expect(page.locator(sel(TID.event.control("location"))).first()).toHaveValue("Resume Room"); - - const afterMs = await readStopwatchMs(restoredDisplay); - expect(afterMs - beforeMs).toBeGreaterThanOrEqual(1_000); - - await expect - .poll(() => readStopwatchMs(restoredDisplay), { - message: "edit-side stopwatch should continue ticking after restore", - }) - .toBeGreaterThan(afterMs); - }); -}); diff --git a/e2e/specs/integrations/virtual-events-block-render.spec.ts b/e2e/specs/integrations/virtual-events-block-render.spec.ts deleted file mode 100644 index 6fc85ea9..00000000 --- a/e2e/specs/integrations/virtual-events-block-render.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { expect } from "@playwright/test"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { todayISO, todayStamp } from "../../fixtures/dates"; -import { test } from "../../fixtures/electron"; -import { VIRTUAL_EVENTS_BLOCK_CLASS, VIRTUAL_EVENTS_TABLE_CLASS } from "../../fixtures/testids"; -import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types"; - -// The `prisma-virtual-events` code-fence is registered as a Markdown code- -// block processor that mounts `VirtualEventsBlockRenderer`. The renderer -// paints a table of virtual events with two action buttons per row: "Make -// real" (delegates to `CalendarBundle.convertToReal`) and "Show" (drives -// `CalendarBundle.navigateToEvent`). `virtual-events-file.spec.ts` only -// asserts the command opens the file — nothing exercises the rendered -// block itself. -// -// This spec seeds a virtual event through the production code path -// (`VirtualEventStore.add`), opens the auto-created file, switches to -// reading mode so the code-block processor mounts, and asserts the -// rendered table + clicks "Make real" to verify the conversion writes a -// real event file to disk. - -const VIRTUAL_EVENTS_PATH = "Events/Virtual Events.md"; - -test.describe("integrations: virtual events block renderer", () => { - test("rendered block shows seeded events and Make real converts to a real event file", async ({ calendar }) => { - const page = calendar.page; - const start = todayStamp(9, 0); - const end = todayStamp(10, 0); - - await calendar.seedVirtualEvent({ title: "Virtual Lunch", start, end }); - - await calendar.openFileInReadingMode(VIRTUAL_EVENTS_PATH); - - // Obsidian mounts the block twice (editor + reading mode); the - // reading-mode instance is visible while the editor copy stays hidden. - // Filter to the visible one. - const block = page.locator(`${VIRTUAL_EVENTS_BLOCK_CLASS}:visible`).first(); - await block.waitFor({ state: "visible", timeout: 10_000 }); - const table = block.locator(VIRTUAL_EVENTS_TABLE_CLASS); - await expect(table).toBeVisible(); - const row = table.locator("tbody tr"); - await expect(row).toHaveCount(1); - await expect(row.locator("td").nth(0)).toHaveText("Virtual Lunch"); - - const baselineFiles = await page.evaluate(() => { - const w = window as unknown as PrismaWindow; - return w.app.vault - .getMarkdownFiles() - .map((f) => f.path) - .filter((p) => p.startsWith("Events/") && p !== "Events/Virtual Events.md"); - }); - expect(baselineFiles).toHaveLength(0); - - const makeRealBtn = row.locator("button:has-text('Make real')"); - await makeRealBtn.click(); - - // `convertToReal` writes a real markdown file under `Events/` and removes - // the row from the virtual store. The block re-renders empty (or with the - // next row) — assert the count dropped to zero and a new file appeared. - await expect - .poll(async () => - page.evaluate(() => { - const w = window as unknown as PrismaWindow; - return w.app.vault - .getMarkdownFiles() - .map((f) => f.path) - .filter((p) => p.startsWith("Events/") && p !== "Events/Virtual Events.md").length; - }) - ) - .toBe(1); - - await expect(table.locator("tbody tr")).toHaveCount(0); - }); - - test("Show button navigates the calendar to the virtual event's date", async ({ calendar }) => { - const page = calendar.page; - const start = todayStamp(14, 0); - const end = todayStamp(15, 0); - - await calendar.seedVirtualEvent({ title: "Virtual Jump Target", start, end }); - - await calendar.openFileInReadingMode(VIRTUAL_EVENTS_PATH); - const block = page.locator(`${VIRTUAL_EVENTS_BLOCK_CLASS}:visible`).first(); - await block.waitFor({ state: "visible", timeout: 10_000 }); - const row = block.locator("table tbody tr").first(); - await expect(row).toBeVisible(); - - await row.locator("button:has-text('Show')").click(); - - // `navigateToEvent` switches the workspace back to the calendar view and - // navigates to the event's date. Assert the active calendar's date now - // matches today (the seed date). - await expect - .poll(async () => - page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const cal = plugin?.calendarBundles?.[0]?.viewRef?.calendarComponent?.calendar; - const d = cal?.getDate?.(); - if (!d) return null; - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - }, PLUGIN_ID) - ) - .toBe(todayISO()); - }); -}); diff --git a/e2e/specs/integrations/virtual-events-file.spec.ts b/e2e/specs/integrations/virtual-events-file.spec.ts deleted file mode 100644 index efc9d92d..00000000 --- a/e2e/specs/integrations/virtual-events-file.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { DEFAULT_CALENDAR_ID, PLUGIN_ID } from "../../fixtures/constants"; -import { expect, test } from "../../fixtures/electron"; -import type { PrismaWindow } from "../../fixtures/window-types"; - -// The "Open virtual events file" command is per-bundle. VirtualEventStore -// auto-creates `Events/Virtual Events.md` on plugin init -// (`createIfMissing: true` in store.bind), so the "file not found" branch in -// calendar-bundle.ts:194 is only reachable if the user manually deletes the -// file — not a realistic flow to smoke-test here. We verify the happy path: -// the command activates the file in the workspace. -// -// Uses executeCommandById instead of the command palette to avoid palette-UI -// index races — same pattern as `openCalendarView` in fixtures/helpers.ts. - -const VIRTUAL_EVENTS_PATH = "Events/Virtual Events.md"; - -function activeFilePath(page: Page): Promise { - return page.evaluate(() => { - const w = window as unknown as PrismaWindow; - return w.app.workspace.getActiveFile()?.path ?? null; - }); -} - -test("virtual events: command opens the auto-created file", async ({ calendar }) => { - await calendar.page.evaluate( - ({ pid, calId }) => { - const w = window as unknown as PrismaWindow; - const ok = w.app.commands.executeCommandById(`${pid}:open-virtual-events-${calId}`); - if (!ok) throw new Error("command not found in registry"); - }, - { pid: PLUGIN_ID, calId: DEFAULT_CALENDAR_ID } - ); - await expect.poll(() => activeFilePath(calendar.page)).toBe(VIRTUAL_EVENTS_PATH); -}); diff --git a/e2e/specs/integrations/zettel-id.spec.ts b/e2e/specs/integrations/zettel-id.spec.ts deleted file mode 100644 index 3467feda..00000000 --- a/e2e/specs/integrations/zettel-id.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; - -import { openNote } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { expect, test } from "../../fixtures/electron"; -import { seedEvent } from "../../fixtures/seed-events"; - -// `add-zettel-id-to-current-note` renames the active file to -// `-<zettelId>.md` and writes the ZettelID to frontmatter. -// Re-running on an already-stamped note must be a no-op. - -const EVENTS_DIR = "Events"; - -function listEvents(vaultDir: string): string[] { - return readdirSync(join(vaultDir, EVENTS_DIR)); -} - -test.describe("zettel ID", () => { - let originalRelative: string; - - test.beforeEach(async ({ calendar }) => { - originalRelative = seedEvent(calendar.vaultDir, { - title: "Stand-up", - startDate: "2026-05-10T09:00", - endDate: "2026-05-10T09:15", - }); - }); - - test("adds a 14-digit ID to filename and re-running is idempotent", async ({ calendar }) => { - await openNote(calendar.page, originalRelative.replace(/\.md$/, "")); - - await calendar.runCommand("Prisma Calendar: Add ZettelID to current note"); - - await expect.poll(() => listEvents(calendar.vaultDir).find((f) => /^Stand-up-\d{14}\.md$/.test(f))).toBeDefined(); - - const renamed = listEvents(calendar.vaultDir).find((f) => /^Stand-up-\d{14}\.md$/.test(f))!; - const newId = renamed.match(/-(\d{14})\.md$/)![1]; - - await openNote(calendar.page, `${EVENTS_DIR}/${renamed.replace(/\.md$/, "")}`); - await calendar.runCommand("Prisma Calendar: Add ZettelID to current note"); - - const filesAfter = listEvents(calendar.vaultDir); - expect(filesAfter.filter((f) => f.startsWith("Stand-up-"))).toHaveLength(1); - expect(filesAfter).toContain(`Stand-up-${newId}.md`); - }); -}); diff --git a/e2e/specs/onboarding/tutorial.spec.ts b/e2e/specs/onboarding/tutorial.spec.ts deleted file mode 100644 index 6ed4a315..00000000 --- a/e2e/specs/onboarding/tutorial.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { readEventFrontmatter, readPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, testOnboarding } from "../../fixtures/electron"; -import { sel, TOUR_PROGRESS_TID, TOUR_SKIP_TID, TOUR_TOOLTIP_TID } from "../../fixtures/testids"; -import { - advanceTourTo, - dragSampleEventToNewTime, - SAMPLE_EVENT_TILE, - TOUR_STEP_COUNT, - TOUR_STEP_IDS, -} from "../../fixtures/tour"; - -// Only the boundaries RTL/unit can't fake live at E2E: the plugin-boot auto-trigger, -// a real FullCalendar drag through the (overlay-free) spotlight writing on-disk -// frontmatter, and tutorialCompleted persisting to data.json. Everything else is -// covered more cheaply elsewhere: -// • settings-button → close + launch (and, symmetrically, the command that just -// calls startPrismaTour): tests/components/settings/general-settings-help.test.tsx (RTL) -// • step list + per-step interaction: tests/components/onboarding/prisma-tour.test.tsx (unit) -// • engine step→overlay mapping: shared-react/tests/onboarding/tour-host.test.ts (unit) -const DRAG_STEP = TOUR_STEP_IDS.indexOf("drag-and-drop") + 1; - -function readTutorialCompleted(vaultDir: string): boolean | undefined { - return (readPluginData(vaultDir, PLUGIN_ID) as { tutorialCompleted?: boolean }).tutorialCompleted; -} - -// Onboarding tour is disabled for now (auto-start + command + settings button all -// commented out in source). Skip until it's re-enabled. -testOnboarding.skip( - "auto-starts on launch, the highlighted event drags to a new time on disk, and finishing records completion", - async ({ calendar }) => { - const { page, vaultDir } = calendar; - - // Auto-starts on boot (no command) because the seed left tutorialCompleted false. - await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toBeVisible(); - await expect(page.locator(sel(TOUR_PROGRESS_TID))).toHaveText(`1 / ${TOUR_STEP_COUNT}`); - - // Reaching the drag step seeds "Your first event" on today. - await advanceTourTo(page, DRAG_STEP); - await expect(page.locator(SAMPLE_EVENT_TILE)).toHaveCount(1); - - // The drag reaches the grid through the overlay-free spotlight and FullCalendar - // writes the new time straight back to the note. - const { filePath, expectedStart } = await dragSampleEventToNewTime(page, vaultDir); - await expect.poll(() => String(readEventFrontmatter(vaultDir, filePath)["Start Date"])).toContain(expectedStart); - - // Ending the tour persists completion so it won't auto-start again. - await page.locator(sel(TOUR_SKIP_TID)).click(); - await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toHaveCount(0); - await settleSettings(page, { pluginId: PLUGIN_ID }); - expect(readTutorialCompleted(vaultDir)).toBe(true); - } -); diff --git a/e2e/specs/resilience/corrupt-data-json.spec.ts b/e2e/specs/resilience/corrupt-data-json.spec.ts deleted file mode 100644 index a8be0f21..00000000 --- a/e2e/specs/resilience/corrupt-data-json.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { writeFileSync } from "node:fs"; - -import { expect } from "@playwright/test"; -import { isPluginLoaded, readPluginData } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { testResilience as test } from "../../fixtures/electron"; -import { dataJsonPath, PLUGIN_ID, readLiveCalendar, reloadAndWaitForPrisma } from "../../fixtures/resilience-helpers"; - -// `data.json` gets touched by other tools, editors, syncs — and sometimes the -// write aborts midway, leaving truncated JSON. The plugin must boot with -// defaults rather than refusing to start. - -interface CorruptionCase { - name: string; - /** Exact bytes to write to data.json before the reload. */ - content: string; -} - -const CASES: CorruptionCase[] = [ - { - name: "invalid JSON boots with defaults", - content: '{"version": "1.2.3", "calendars": [{"id": "default", "name": "Main', - }, - { - name: "schema-incompatible data.json degrades gracefully", - content: JSON.stringify({ version: "1.2.3", somethingUnknown: { foo: 1 }, anotherKey: [1, 2, 3] }), - }, -]; - -test.describe("corrupt data.json", () => { - for (const tc of CASES) { - test(tc.name, async ({ obsidian }) => { - writeFileSync(dataJsonPath(obsidian.vaultDir), tc.content, "utf8"); - - await reloadAndWaitForPrisma(obsidian.page); - - expect(await isPluginLoaded(obsidian.page, PLUGIN_ID)).toBe(true); - - await expect - .poll(async () => (await readLiveCalendar(obsidian.page)) !== null, { - message: "expected settings store to heal to at least one calendar", - }) - .toBe(true); - - const healed = readPluginData(obsidian.vaultDir, PLUGIN_ID); - expect(healed).not.toBeNull(); - expect(typeof healed).toBe("object"); - }); - } -}); diff --git a/e2e/specs/resilience/duplicate-zettel-ids.spec.ts b/e2e/specs/resilience/duplicate-zettel-ids.spec.ts deleted file mode 100644 index cd011b57..00000000 --- a/e2e/specs/resilience/duplicate-zettel-ids.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect } from "@playwright/test"; - -import { testResilience as test } from "../../fixtures/electron"; -import { reloadAndWaitForPrisma, resolveByZettelId, vaultHasFilesEndingWith } from "../../fixtures/resilience-helpers"; -import { seedEvent } from "../../fixtures/seed-events"; - -// Duplicate ZettelIDs arise when users copy-paste event files or from -// misbehaving sync clients. Both files should render (nothing silently -// dropped) and command handlers that resolve "the event" must surface all -// matches deterministically — not one, not neither. -test("duplicate ZettelID files all render and resolve", async ({ obsidian }) => { - const ZETTEL_ID = "20260801120000"; - const files = [ - { title: `Dup A-${ZETTEL_ID}`, startDate: "2026-08-10T09:00", endDate: "2026-08-10T10:00" }, - { title: `Dup B-${ZETTEL_ID}`, startDate: "2026-08-11T14:00", endDate: "2026-08-11T15:00" }, - ]; - for (const f of files) { - seedEvent(obsidian.vaultDir, { ...f, extra: { ZettelID: ZETTEL_ID } }); - } - - await reloadAndWaitForPrisma(obsidian.page); - - const existence = await vaultHasFilesEndingWith( - obsidian.page, - files.map((f) => `${f.title}.md`) - ); - expect( - existence.every((e) => e), - "both duplicate files should remain on disk" - ).toBe(true); - - // The metadata cache is populated asynchronously after reload — poll - // rather than snapshotting immediately. - await expect - .poll(async () => (await resolveByZettelId(obsidian.page, ZETTEL_ID)).length, { - message: "metadata cache should surface all duplicate ZettelID files", - }) - .toBe(files.length); -}); diff --git a/e2e/specs/resilience/malformed-frontmatter.spec.ts b/e2e/specs/resilience/malformed-frontmatter.spec.ts deleted file mode 100644 index cbd1bd6a..00000000 --- a/e2e/specs/resilience/malformed-frontmatter.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { expect } from "@playwright/test"; -import { isPluginLoaded } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { ACTIVE_CALENDAR_LEAF } from "../../fixtures/constants"; -import { testResilience as test } from "../../fixtures/electron"; -import { - PLUGIN_ID, - reloadAndWaitForPrisma, - vaultHasFilesEndingWith, - writeRawEventFile, -} from "../../fixtures/resilience-helpers"; -import { seedEvent } from "../../fixtures/seed-events"; - -// Prisma must tolerate every flavour of broken frontmatter that appears in -// real user vaults: invalid YAML, missing required keys, wrong value types, -// unknown keys, and completely empty blocks. The plugin has to load, the -// calendar view has to render, and valid events sharing the vault must still -// appear — broken files should be skipped or flagged, not crash the view. - -interface MalformedFile { - label: string; - fileName: string; - content: string; -} - -const MALFORMED: MalformedFile[] = [ - { - label: "invalid YAML", - fileName: "invalid-yaml.md", - content: ["---", "Start Date: [2026-07-01T09:00", "End Date: 2026-07-01T10:00", "---", "", "# Bad YAML"].join("\n"), - }, - { - label: "missing End Date", - fileName: "missing-end.md", - content: ["---", "Start Date: 2026-07-02T09:00", "---", "", "# Missing End"].join("\n"), - }, - { - label: "wrong value types", - fileName: "wrong-types.md", - content: ["---", 'Start Date: "not a date"', 'End Date: "also not a date"', "---", "", "# Wrong Types"].join("\n"), - }, - { - label: "unknown keys", - fileName: "unknown-keys.md", - content: [ - "---", - "Start Date: 2026-07-04T09:00", - "End Date: 2026-07-04T10:00", - "FooBar: xyz", - "AnotherWeirdKey: 42", - "---", - "", - "# Unknown Keys", - ].join("\n"), - }, - { - label: "empty frontmatter", - fileName: "empty-frontmatter.md", - content: ["---", "---", "", "# Empty"].join("\n"), - }, -]; - -test.describe("malformed frontmatter", () => { - test("plugin survives every broken-file flavour alongside a valid event", async ({ calendar }) => { - for (const m of MALFORMED) { - writeRawEventFile(calendar.vaultDir, m.fileName, m.content); - } - seedEvent(calendar.vaultDir, { - title: "Still Valid", - startDate: "2026-07-10T09:00", - endDate: "2026-07-10T10:00", - category: "Work", - }); - - await reloadAndWaitForPrisma(calendar.page); - - expect(await isPluginLoaded(calendar.page, PLUGIN_ID)).toBe(true); - - await expect(calendar.page.locator(ACTIVE_CALENDAR_LEAF)).toBeVisible(); - - // The valid event lands months outside the default visible window, so - // assert against Obsidian's vault API rather than the rendered DOM. The - // plugin staying up + the valid file still being on disk / tracked by - // the vault is the product contract we care about — broken files were - // skipped, not crashed. - await expect - .poll(async () => (await vaultHasFilesEndingWith(calendar.page, ["Still Valid.md"]))[0], { - message: "valid event should remain tracked by the vault", - }) - .toBe(true); - }); -}); diff --git a/e2e/specs/resilience/mid-operation-reload.spec.ts b/e2e/specs/resilience/mid-operation-reload.spec.ts deleted file mode 100644 index 28449627..00000000 --- a/e2e/specs/resilience/mid-operation-reload.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect } from "@playwright/test"; - -import { testResilience as test } from "../../fixtures/electron"; -import { createEventViaToolbar, fillEventModalMinimal } from "../../fixtures/helpers"; -import { reloadAndWaitForPrisma } from "../../fixtures/resilience-helpers"; -import { listEventFiles } from "../events/events-helpers"; - -// Reloading mid-edit must NOT write a partial file to the vault. The user may -// have typed a title into the modal but never clicked "Save" — that draft is -// ephemeral and should simply be dropped, leaving the events folder untouched. -test("open modal, type title, reload → no partial file written", async ({ calendar }) => { - const before = listEventFiles(calendar.vaultDir) - .map((p) => p.split("/").pop() ?? "") - .sort(); - - await createEventViaToolbar(calendar.page); - await fillEventModalMinimal(calendar.page, { title: "Draft That Will Be Discarded" }); - - await reloadAndWaitForPrisma(calendar.page); - - const after = listEventFiles(calendar.vaultDir) - .map((p) => p.split("/").pop() ?? "") - .sort(); - expect(after).toEqual(before); - expect(after.some((n) => n.toLowerCase().includes("draft that will be discarded"))).toBe(false); -}); diff --git a/e2e/specs/resilience/plugin-disable-reenable.spec.ts b/e2e/specs/resilience/plugin-disable-reenable.spec.ts deleted file mode 100644 index 1f9fe2b1..00000000 --- a/e2e/specs/resilience/plugin-disable-reenable.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect } from "@playwright/test"; -import { isPluginLoaded } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { testResilience as test } from "../../fixtures/electron"; -import { countLeavesOfType, disablePrisma, enablePrisma, PLUGIN_ID } from "../../fixtures/resilience-helpers"; - -const DEFAULT_VIEW_TYPE = "custom-calendar-view-default"; - -// Disabling Prisma should tear down all views cleanly (no stale DOM left -// behind). Re-enabling should rebuild everything from disk with no data -// loss — same events, same settings. -test("views unmount on disable and state returns on re-enable", async ({ calendar, obsidian }) => { - // The `calendar` fixture opens the view for us; we just need the view to - // be active before we tear down the plugin. - void calendar; - - await disablePrisma(obsidian.page); - expect(await isPluginLoaded(obsidian.page, PLUGIN_ID)).toBe(false); - expect(await countLeavesOfType(obsidian.page, DEFAULT_VIEW_TYPE)).toBe(0); - - await enablePrisma(obsidian.page); - expect(await isPluginLoaded(obsidian.page, PLUGIN_ID)).toBe(true); - - expect(obsidian.readVaultFile("Welcome.md")).toContain("Welcome"); -}); diff --git a/e2e/specs/resilience/reload-preserves-events.spec.ts b/e2e/specs/resilience/reload-preserves-events.spec.ts deleted file mode 100644 index e8bb8f41..00000000 --- a/e2e/specs/resilience/reload-preserves-events.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { rmSync } from "node:fs"; -import { join } from "node:path"; - -import { expect } from "@playwright/test"; -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { testResilience as test } from "../../fixtures/electron"; -import { reloadAndWaitForPrisma, seedEventFiles } from "../../fixtures/resilience-helpers"; -import { seedEvent, type SeedEventInput } from "../../fixtures/seed-events"; -import { listEventFiles } from "../events/events-helpers"; - -// Persistence barrier: every user-visible op must survive a full renderer -// reload. Each case seeds files on disk (standing in for the op's final -// persisted state — how the bytes got there doesn't matter at the reload -// boundary), reloads Prisma, and asserts the bytes are still there. - -interface FrontmatterCheck { - path: string; - expect: Record<string, string>; -} - -interface ReloadCase { - name: string; - seed: SeedEventInput[]; - /** Files/frontmatter that must be present after reload. */ - persists?: FrontmatterCheck[]; - /** Vault-relative paths (anywhere under Events/) that must remain absent. */ - absent?: string[]; - /** Vault-relative paths to unlink between the seed and the reload. */ - unlinkBeforeReload?: string[]; -} - -const CASES: ReloadCase[] = [ - { - name: "create survives reload", - seed: [{ title: "Created Before Reload", startDate: "2026-06-01T09:00", endDate: "2026-06-01T10:00" }], - persists: [ - { - path: "Events/Created Before Reload.md", - expect: { "Start Date": "2026-06-01T09:00", "End Date": "2026-06-01T10:00" }, - }, - ], - }, - { - name: "edit survives reload", - seed: [{ title: "Edited Event", startDate: "2026-06-02T13:00", endDate: "2026-06-02T14:00" }], - persists: [ - { - path: "Events/Edited Event.md", - expect: { "Start Date": "2026-06-02T13:00", "End Date": "2026-06-02T14:00" }, - }, - ], - }, - { - name: "drag-reschedule survives reload", - seed: [{ title: "Rescheduled", startDate: "2026-06-04T15:00", endDate: "2026-06-04T16:00" }], - persists: [ - { - path: "Events/Rescheduled.md", - expect: { "Start Date": "2026-06-04T15:00", "End Date": "2026-06-04T16:00" }, - }, - ], - }, - { - name: "resize survives reload", - seed: [{ title: "Resized", startDate: "2026-06-05T09:00", endDate: "2026-06-05T12:30" }], - persists: [{ path: "Events/Resized.md", expect: { "End Date": "2026-06-05T12:30" } }], - }, - { - name: "batch duplicate survives reload", - seed: ["Duplicate A", "Duplicate B", "Duplicate C"].map((title, i) => ({ - title, - startDate: `2026-06-10T${String(9 + i).padStart(2, "0")}:00`, - endDate: `2026-06-10T${String(10 + i).padStart(2, "0")}:00`, - })), - persists: [ - { path: "Events/Duplicate A.md", expect: {} }, - { path: "Events/Duplicate B.md", expect: {} }, - { path: "Events/Duplicate C.md", expect: {} }, - ], - }, - { - name: "batch delete survives reload", - seed: [{ title: "To Be Deleted", startDate: "2026-06-11T09:00", endDate: "2026-06-11T10:00" }], - unlinkBeforeReload: ["Events/To Be Deleted.md"], - absent: ["Events/To Be Deleted.md"], - }, -]; - -test.describe("reload persists events", () => { - for (const tc of CASES) { - test(tc.name, async ({ obsidian }) => { - seedEventFiles(obsidian.vaultDir, tc.seed); - - for (const relative of tc.unlinkBeforeReload ?? []) { - rmSync(join(obsidian.vaultDir, relative)); - } - - await reloadAndWaitForPrisma(obsidian.page); - - for (const check of tc.persists ?? []) { - const fm = readEventFrontmatter(obsidian.vaultDir, check.path); - for (const [key, value] of Object.entries(check.expect)) { - expect(fm[key], `${check.path}:${key}`).toBe(value); - } - } - - if (tc.absent && tc.absent.length > 0) { - const files = listEventFiles(obsidian.vaultDir); - for (const relative of tc.absent) { - expect( - files.some((abs) => abs.endsWith(`/${relative}`)), - relative - ).toBe(false); - } - } - }); - } - - test("persists across successive edits", async ({ obsidian }) => { - const relative = seedEvent(obsidian.vaultDir, { - title: "Evolving", - startDate: "2026-06-20T09:00", - endDate: "2026-06-20T10:00", - }); - seedEvent(obsidian.vaultDir, { - title: "Evolving", - startDate: "2026-06-20T13:00", - endDate: "2026-06-20T14:00", - }); - - await reloadAndWaitForPrisma(obsidian.page); - - const fm = readEventFrontmatter(obsidian.vaultDir, relative); - expect(fm["Start Date"]).toBe("2026-06-20T13:00"); - expect(fm["End Date"]).toBe("2026-06-20T14:00"); - }); -}); diff --git a/e2e/specs/resilience/reload-preserves-settings.spec.ts b/e2e/specs/resilience/reload-preserves-settings.spec.ts deleted file mode 100644 index bcaf6e64..00000000 --- a/e2e/specs/resilience/reload-preserves-settings.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect } from "@playwright/test"; -import { readPluginData } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { testResilience as test } from "../../fixtures/electron"; -import { - patchDefaultCalendar, - PLUGIN_ID, - readLiveCalendar, - reloadAndWaitForPrisma, -} from "../../fixtures/resilience-helpers"; - -// Template pattern: three representative field types — boolean, string, enum. -// Each survives the reload barrier in both disk (`data.json`) and the live -// `settingsStore`. The shape of the mutation is the same for all three; only -// the patch literal changes. - -interface SettingCase { - label: string; - patch: Record<string, string | boolean>; -} - -const CASES: SettingCase[] = [ - { label: "boolean toggle", patch: { showStopwatch: false } }, - { label: "string text field", patch: { startProp: "CustomStart" } }, - { label: "enum dropdown", patch: { locale: "de" } }, -]; - -type StoredSettings = { calendars?: Array<Record<string, unknown>> }; - -test.describe("reload persists settings", () => { - for (const tc of CASES) { - test(`${tc.label} survives reload`, async ({ obsidian }) => { - await patchDefaultCalendar(obsidian.page, tc.patch); - await reloadAndWaitForPrisma(obsidian.page); - - const stored = readPluginData(obsidian.vaultDir, PLUGIN_ID) as StoredSettings | null; - const storedCal = stored?.calendars?.[0]; - const live = await readLiveCalendar(obsidian.page); - - for (const [key, value] of Object.entries(tc.patch)) { - expect(storedCal?.[key], `disk.${key}`).toBe(value); - expect(live?.[key], `live.${key}`).toBe(value); - } - }); - } - - test("combined toggle + text + dropdown all survive reload", async ({ obsidian }) => { - const combined = Object.assign({}, ...CASES.map((c) => c.patch)); - await patchDefaultCalendar(obsidian.page, combined); - await reloadAndWaitForPrisma(obsidian.page); - - const stored = readPluginData(obsidian.vaultDir, PLUGIN_ID) as StoredSettings | null; - const storedCal = stored?.calendars?.[0]; - const live = await readLiveCalendar(obsidian.page); - - for (const [key, value] of Object.entries(combined)) { - expect(storedCal?.[key], `disk.${key}`).toBe(value); - expect(live?.[key], `live.${key}`).toBe(value); - } - }); -}); diff --git a/e2e/specs/resilience/reload-reopens-active-calendar.spec.ts b/e2e/specs/resilience/reload-reopens-active-calendar.spec.ts deleted file mode 100644 index 4710dfe6..00000000 --- a/e2e/specs/resilience/reload-reopens-active-calendar.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect } from "@playwright/test"; - -import { testResilience as test } from "../../fixtures/electron"; -import { - activateCalendar, - addCalendar, - countLeavesOfType, - reloadAndWaitForPrisma, -} from "../../fixtures/resilience-helpers"; - -// Obsidian persists the active leaf across reloads via workspace.json. Prove -// that when a *non-default* calendar view is open, reload reopens THAT view -// — not the first calendar in the list. -test("reload reopens the active calendar view", async ({ obsidian }) => { - await addCalendar(obsidian.page, { - id: "secondary", - name: "Secondary", - enabled: true, - directory: "Events-Secondary", - }); - - const SECONDARY_VIEW_TYPE = "custom-calendar-view-secondary"; - await activateCalendar(obsidian.page, "secondary"); - - await expect.poll(() => countLeavesOfType(obsidian.page, SECONDARY_VIEW_TYPE), { timeout: 15_000 }).toBe(1); - - await reloadAndWaitForPrisma(obsidian.page); - - expect(await countLeavesOfType(obsidian.page, SECONDARY_VIEW_TYPE)).toBe(1); -}); diff --git a/e2e/specs/resilience/unreadable-event-file.spec.ts b/e2e/specs/resilience/unreadable-event-file.spec.ts deleted file mode 100644 index 6300a56b..00000000 --- a/e2e/specs/resilience/unreadable-event-file.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { chmodSync } from "node:fs"; -import { join } from "node:path"; - -import { expect } from "@playwright/test"; -import { isPluginLoaded } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { testResilience as test } from "../../fixtures/electron"; -import { PLUGIN_ID, reloadAndWaitForPrisma } from "../../fixtures/resilience-helpers"; -import { seedEvent } from "../../fixtures/seed-events"; - -// Filesystem permission errors should never take down the calendar. A single -// unreadable file logs or notices and the rest of the vault continues to -// render. Linux-only: macOS sandbox often refuses chmod 000. -test.describe("unreadable event file", () => { - test.skip(process.platform !== "linux", "chmod 000 is only reliable on Linux"); - - test("plugin still loads and other events render", async ({ obsidian }) => { - const unreadableRelative = seedEvent(obsidian.vaultDir, { - title: "Unreadable", - startDate: "2026-08-01T09:00", - endDate: "2026-08-01T10:00", - }); - seedEvent(obsidian.vaultDir, { - title: "Neighbour", - startDate: "2026-08-02T09:00", - endDate: "2026-08-02T10:00", - }); - const absolute = join(obsidian.vaultDir, unreadableRelative); - try { - chmodSync(absolute, 0o000); - } catch { - test.skip(true, "chmod 000 rejected by filesystem"); - } - - try { - await reloadAndWaitForPrisma(obsidian.page); - - expect(await isPluginLoaded(obsidian.page, PLUGIN_ID)).toBe(true); - await expect(obsidian.page.locator(".workspace").first()).toBeVisible(); - } finally { - // Restore perms so the per-run vault retention / cleanup can delete - // the file without an EACCES. - try { - chmodSync(absolute, 0o644); - } catch { - /* best-effort cleanup */ - } - } - }); -}); diff --git a/e2e/specs/settings/bases-tab.spec.ts b/e2e/specs/settings/bases-tab.spec.ts deleted file mode 100644 index 439c51ed..00000000 --- a/e2e/specs/settings/bases-tab.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expectPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, setSchemaDropdown, setSchemaTextInput, switchSettingsTab } from "../../fixtures/helpers"; - -test.describe("settings: Bases tab", () => { - test("writes view type and additional properties to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "bases"); - - await setSchemaDropdown(obsidian.page, "basesViewType", "table"); - // basesViewProperties is z.array(z.string()) → the shared ArrayCsv widget - // renders a single comma-separated text input; commit happens on blur. - await setSchemaTextInput(obsidian.page, "basesViewProperties", "priority, project"); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.basesViewType": "table", - "calendars.0.basesViewProperties": ["priority", "project"], - }); - }); -}); diff --git a/e2e/specs/settings/calendar-crud.spec.ts b/e2e/specs/settings/calendar-crud.spec.ts deleted file mode 100644 index df3dacd4..00000000 --- a/e2e/specs/settings/calendar-crud.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { setTextInput, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, test } from "../../fixtures/electron"; -import { - closeSettings, - createEventViaToolbar, - fillEventModalMinimal, - openCalendarView, - openPrismaSettings, - saveEventModal, - setSchemaTextInput, - switchSettingsTab, -} from "../../fixtures/helpers"; -import { getCalendars } from "../../fixtures/plugin-data"; -import { listEventFiles } from "../events/events-helpers"; - -test.describe("settings: calendar CRUD", () => { - test("Create new adds a second calendar to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - - await obsidian.page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const calendars = getCalendars(obsidian.vaultDir); - expect(calendars).toHaveLength(2); - expect(calendars[0].id).toBe("default"); - expect(calendars[1].id).not.toBe("default"); - expect(calendars[1].name).toMatch(/Planning System\s*2/); - }); - - test("Clone current duplicates every setting except id/name", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - - // Customize the default calendar so clone has something distinctive to copy. - await switchSettingsTab(obsidian.page, "properties"); - await setSchemaTextInput(obsidian.page, "startProp", "CustomStart"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - await obsidian.page.locator('[data-testid="prisma-settings-calendar-clone"]').click(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const calendars = getCalendars(obsidian.vaultDir); - expect(calendars).toHaveLength(2); - - const original = calendars[0] as unknown as Record<string, unknown>; - const clone = calendars[1] as unknown as Record<string, unknown>; - expect(clone["id"]).not.toBe(original["id"]); - expect(clone["name"]).not.toBe(original["name"]); - expect(clone["name"]).toMatch(/\(Copy\)/); - expect(clone["startProp"]).toBe("CustomStart"); - expect(clone["startProp"]).toBe(original["startProp"]); - }); - - test("Rename current updates the calendar name via the rename modal", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - - await obsidian.page.locator('[data-testid="prisma-settings-calendar-rename"]').click(); - - const renameInput = obsidian.page.locator('[data-testid="prisma-settings-calendar-rename-input"]').first(); - await renameInput.waitFor({ state: "visible" }); - // Obsidian's TextComponent wires its onChange to the input event, which - // fill() fires natively — the `newName` closure inside the rename modal - // only updates via that listener, so no extra change dispatch is needed. - await renameInput.fill("Renamed Calendar"); - await obsidian.page.locator('[data-testid="prisma-settings-calendar-rename-submit"]').first().click(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const calendars = getCalendars(obsidian.vaultDir); - expect(calendars[0].name).toBe("Renamed Calendar"); - }); - - test("Delete current removes a calendar and preserves the remaining ones", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - - // Need at least two calendars to delete — delete is disabled on the last one. - await obsidian.page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - const beforeDelete = getCalendars(obsidian.vaultDir); - expect(beforeDelete).toHaveLength(2); - - // The just-added calendar is now selected; delete it. - await obsidian.page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const afterDelete = getCalendars(obsidian.vaultDir); - expect(afterDelete).toHaveLength(1); - expect(afterDelete[0].id).toBe("default"); - }); - - test("events created in different calendars land in their configured directories", async ({ obsidian }) => { - // 1. Configure the default calendar's directory. - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - await setTextInput(obsidian.page, "prisma-settings-control-directory", "CalendarA"); - - // 2. Add a second calendar and set its directory. - await obsidian.page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await setTextInput(obsidian.page, "prisma-settings-control-directory", "CalendarB"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const calendars = getCalendars(obsidian.vaultDir); - expect(calendars.map((c) => c.directory).sort()).toEqual(["CalendarA", "CalendarB"]); - const calendarAId = calendars.find((c) => c.directory === "CalendarA")!.id; - const calendarBId = calendars.find((c) => c.directory === "CalendarB")!.id; - - await closeSettings(obsidian.page); - - // 3. Open CalendarB via its ribbon icon, create an event from the toolbar. - await openCalendarView(obsidian.page, calendarBId); - await createEventViaToolbar(obsidian.page); - const titleB = `Isolation B ${Date.now()}`; - await fillEventModalMinimal(obsidian.page, { title: titleB }); - await saveEventModal(obsidian.page); - - await expect.poll(() => listEventFiles(obsidian.vaultDir, "CalendarB").some((f) => f.includes(titleB))).toBe(true); - expect(listEventFiles(obsidian.vaultDir, "CalendarA").some((f) => f.includes(titleB))).toBe(false); - - // 4. Open CalendarA via its ribbon icon and repeat. - await openCalendarView(obsidian.page, calendarAId); - await createEventViaToolbar(obsidian.page); - const titleA = `Isolation A ${Date.now()}`; - await fillEventModalMinimal(obsidian.page, { title: titleA }); - await saveEventModal(obsidian.page); - - await expect.poll(() => listEventFiles(obsidian.vaultDir, "CalendarA").some((f) => f.includes(titleA))).toBe(true); - }); -}); diff --git a/e2e/specs/settings/categories-rename.spec.ts b/e2e/specs/settings/categories-rename.spec.ts deleted file mode 100644 index 3002f70f..00000000 --- a/e2e/specs/settings/categories-rename.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect } from "@playwright/test"; - -import { todayStamp } from "../../fixtures/dates"; -import { categoryRow } from "../../fixtures/dsl"; -import { test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; - -// Regression: renaming a category through the Categories settings tab used to -// leave the calendar's in-memory cache pointing at the old name even though -// the event files on disk were rewritten. Two layers contributed: VaultTable's -// same-mtime dedup (fixed separately) and EventStore's same-mtime dedup that -// swallowed the file-changed indexer event when processFrontMatter kept the -// mtime. This spec exercises the full user path — disk rewrite, settings list -// refresh, and (crucially) the calendar tile picking up the updated category -// via the color rule that was rewritten in the same settings transaction. - -const RULE_COLOR = "#ff00aa"; - -test.describe("settings: Categories rename propagation", () => { - test("renames the category on disk, refreshes the settings list, and re-colors calendar tiles", async ({ - calendar, - obsidian, - }) => { - // Pre-seed a color rule so the calendar tiles render a distinctive - // colour tied to the "Renamable" expression. The rename modal rewrites - // both the file frontmatter *and* the matching colorRule expression, - // so if both layers update coherently the tiles keep the same colour - // after the rename. If EventStore holds stale frontmatter, the rule no - // longer matches and the tile falls back to the default colour — which - // is exactly the user-visible symptom we're guarding against. - await updateCalendarSettings(calendar.page, { - colorRules: [ - { - id: "rule-renamable", - expression: "Category.includes('Renamable')", - color: RULE_COLOR, - enabled: true, - }, - ], - }); - - // Two events so the bulk rename operation iterates more than once — - // the real-world trigger for the same-mtime dedup bug. - const evt1 = await calendar.createEvent({ - title: "Rename Target Alpha", - start: todayStamp(9, 0), - end: todayStamp(10, 0), - categories: ["Renamable"], - }); - await evt1.expectVisible(); - await evt1.expectColor(RULE_COLOR); - - const evt2 = await calendar.createEvent({ - title: "Rename Target Beta", - start: todayStamp(11, 0), - end: todayStamp(12, 0), - categories: ["Renamable"], - }); - await evt2.expectVisible(); - await evt2.expectColor(RULE_COLOR); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const renameModal = await categoryRow(obsidian.page, "Renamable").openRename(); - await renameModal.fill("Renamed"); - await renameModal.submit(); - - // Files on disk must carry the new category value. - await expect.poll(() => evt1.readCategory()).toEqual(["Renamed"]); - await expect.poll(() => evt2.readCategory()).toEqual(["Renamed"]); - - // The Categories settings list (driven by CategoryTracker, which sits - // on top of the VaultTable cache we fixed) must reflect the new name. - await expect(categoryRow(obsidian.page, "Renamed").row).toBeVisible(); - await expect(categoryRow(obsidian.page, "Renamable").row).toHaveCount(0); - - await closeSettings(obsidian.page); - - // Calendar tiles must still resolve to the rule color: both halves of - // the rename (event frontmatter + color rule expression) must have - // flowed through the event store for the match to hold. If EventStore - // kept the old category, the rule no longer matches and --event-color - // drops to empty/default. - await evt1.expectColor(RULE_COLOR); - await evt2.expectColor(RULE_COLOR); - }); -}); diff --git a/e2e/specs/settings/categories-undo-redo.spec.ts b/e2e/specs/settings/categories-undo-redo.spec.ts deleted file mode 100644 index 75099dc1..00000000 --- a/e2e/specs/settings/categories-undo-redo.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect } from "@playwright/test"; - -import { todayStamp } from "../../fixtures/dates"; -import { categoryRow } from "../../fixtures/dsl"; -import { test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { redoViaPalette, undoViaPalette } from "../../fixtures/history-helpers"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; - -// Category rename / delete route through `createBatchRenameCategory` / -// `createBatchDeleteCategory` plus an `UpdateColorRulesCommand`, all appended -// into a single `MacroCommand` and registered with the bundle's -// `CommandManager`. Undo / redo therefore walks the file rewrites AND the -// matching color rule in one step — this spec proves the round-trip works -// for both at once. - -const RULE_COLOR = "#ff00aa"; - -test.describe("settings: Categories undo/redo", () => { - test("rename → undo restores frontmatter and color rule; redo reapplies both", async ({ calendar, obsidian }) => { - const { page } = calendar; - - await updateCalendarSettings(page, { - colorRules: [ - { id: "rule-undoable", expression: "Category.includes('Undoable')", color: RULE_COLOR, enabled: true }, - ], - }); - - const evt = await calendar.createEvent({ - title: "Undoable Rename Target", - start: todayStamp(9, 0), - end: todayStamp(10, 0), - categories: ["Undoable"], - }); - await evt.expectVisible(); - await evt.expectColor(RULE_COLOR); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const renameModal = await categoryRow(obsidian.page, "Undoable").openRename(); - await renameModal.fill("Renamed"); - await renameModal.submit(); - - await expect.poll(() => evt.readCategory()).toEqual(["Renamed"]); - await closeSettings(obsidian.page); - - await undoViaPalette(page); - - await expect.poll(() => evt.readCategory()).toEqual(["Undoable"]); - // Color rule must come back with the original expression so the tile keeps the rule color. - await evt.expectColor(RULE_COLOR); - - await redoViaPalette(page); - - await expect.poll(() => evt.readCategory()).toEqual(["Renamed"]); - await evt.expectColor(RULE_COLOR); - }); - - test("delete → undo restores the category; redo removes it again", async ({ calendar, obsidian }) => { - const { page } = calendar; - - const evt = await calendar.createEvent({ - title: "Undoable Delete Target", - start: todayStamp(11, 0), - end: todayStamp(12, 0), - categories: ["ToDelete"], - }); - await evt.expectVisible(); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const deleteModal = await categoryRow(obsidian.page, "ToDelete").openDelete(); - await deleteModal.confirm(); - - await expect.poll(() => evt.readCategory()).toEqual([]); - await closeSettings(obsidian.page); - - await undoViaPalette(page); - - await expect.poll(() => evt.readCategory()).toEqual(["ToDelete"]); - - await redoViaPalette(page); - - await expect.poll(() => evt.readCategory()).toEqual([]); - }); -}); diff --git a/e2e/specs/settings/categories-untracked.spec.ts b/e2e/specs/settings/categories-untracked.spec.ts deleted file mode 100644 index b66f383f..00000000 --- a/e2e/specs/settings/categories-untracked.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { categoryRow } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; - -// "Untracked events" are notes that carry a category in frontmatter but have -// no Start Date / End Date / Date — they live in `UntrackedEventStore`, not -// `EventStore`. -// This spec drives the user-visible flow end-to-end: seed an untracked file -// with a unique category, open the Categories settings tab, then run rename -// and delete with the "Also apply to untracked events" toggle ticked. The -// spec asserts on disk that the untracked file's frontmatter is rewritten. - -const UNTRACKED_PATH = "Events/untracked-category-target.md"; - -function seedUntrackedFile(vaultDir: string, category: string, title = "Untracked Note"): string { - const content = `---\nCategory: ${category}\n---\n\n# ${title}\n`; - writeFileSync(join(vaultDir, UNTRACKED_PATH), content, "utf8"); - return UNTRACKED_PATH; -} - -test.describe("settings: Categories operations on untracked events", () => { - test("untracked-only category appears in the list with proper counts", async ({ calendar, obsidian }) => { - const { vaultDir } = calendar; - seedUntrackedFile(vaultDir, "OnlyUntracked"); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const row = categoryRow(obsidian.page, "OnlyUntracked"); - await expect(row.row).toBeVisible(); - - // The count line must reflect the untracked event — total = 1, untracked = 1. - const countText = await row.countText(); - expect(countText).toContain("1 total"); - expect(countText).toContain("1 untracked"); - - await closeSettings(obsidian.page); - }); - - test("renaming includes untracked events when the checkbox is left checked", async ({ calendar, obsidian }) => { - const { vaultDir } = calendar; - const relativePath = seedUntrackedFile(vaultDir, "RenameMe"); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const renameModal = await categoryRow(obsidian.page, "RenameMe").openRename(); - - // The toggle defaults to ON. - await expect(renameModal.toggleUntracked).toBeChecked(); - - await renameModal.fill("RenamedFromUntracked"); - await renameModal.submit(); - - await expect - .poll(() => readEventFrontmatter(vaultDir, relativePath)["Category"], { - message: "untracked file Category should rewrite to the new name", - }) - .toBe("RenamedFromUntracked"); - - await closeSettings(obsidian.page); - }); - - test("deleting includes untracked events when the checkbox is left checked", async ({ calendar, obsidian }) => { - const { vaultDir } = calendar; - const relativePath = seedUntrackedFile(vaultDir, "DeleteMe"); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const deleteModal = await categoryRow(obsidian.page, "DeleteMe").openDelete(); - await expect(deleteModal.toggleUntracked).toBeChecked(); - await deleteModal.confirm(); - - await expect - .poll(() => readEventFrontmatter(vaultDir, relativePath)["Category"], { - message: "untracked file Category should be removed", - }) - .toBeUndefined(); - - await closeSettings(obsidian.page); - }); - - test("unchecking the toggle leaves untracked frontmatter intact", async ({ calendar, obsidian }) => { - const { vaultDir } = calendar; - const relativePath = seedUntrackedFile(vaultDir, "KeepUntracked"); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - // Uncheck the toggle: the rename should target only tracked files. With - // no tracked files in scope, the on-disk frontmatter must stay intact. - const renameModal = await categoryRow(obsidian.page, "KeepUntracked").openRename(); - await renameModal.setIncludeUntracked(false); - await renameModal.fill("DidNotRename"); - await renameModal.submit(); - - // Wait briefly for any pending writes; then assert the untracked file is unchanged. - await obsidian.page.waitForTimeout(500); - expect(readEventFrontmatter(vaultDir, relativePath)["Category"]).toBe("KeepUntracked"); - - await closeSettings(obsidian.page); - }); -}); diff --git a/e2e/specs/settings/category-color-picker.spec.ts b/e2e/specs/settings/category-color-picker.spec.ts deleted file mode 100644 index 27ffdb93..00000000 --- a/e2e/specs/settings/category-color-picker.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect } from "@playwright/test"; - -import { fromAnchor } from "../../fixtures/dates"; -import { test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { sel } from "../../fixtures/testids"; - -// Picking a color in the Categories settings tab is the only documented way -// for a user to assign a colour to a category — internally it writes a -// `colorRules` entry whose expression is `<categoryProp>.includes('<name>')` -// (categories-settings.tsx:156-167). The reactivity from there is the same -// path covered by `cross-view/settings-to-view-reactivity.spec.ts`, but the -// UI flow itself was untested. A regression in the color picker UI (event -// not firing, store not committing, or initial-color mismatch) wouldn't be -// caught by the programmatic-mutation reactivity tests. - -const CATEGORY = "Picker"; -const PICKED_COLOR = "#00aaff"; -const CATEGORY_ITEM_TID = "prisma-category-settings-item"; -const CATEGORY_COLOR_INPUT_TID = "prisma-category-settings-color-input"; - -test.describe("settings: category color picker → tile repaint", () => { - test("picking a color in the Categories tab repaints calendar tiles for that category", async ({ - calendar, - obsidian, - }) => { - await calendar.switchMode("week"); - await calendar.goToAnchor(); - - const evt = await calendar.seedOnDisk("Picker Target", { - "Start Date": fromAnchor(0, 9, 0), - "End Date": fromAnchor(0, 10, 0), - Category: CATEGORY, - }); - await evt.expectVisible(); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "categories"); - - const row = obsidian.page.locator(`${sel(CATEGORY_ITEM_TID)}[data-category="${CATEGORY}"]`); - await expect(row).toBeVisible(); - - // `<input type="color">` is a native picker — the OS dialog can't be - // driven from Playwright. React intercepts `value` setters on controlled - // inputs; we have to call the native setter so React's synthetic event - // path observes the change. Without this, dispatchEvent fires but - // `e.target.value` reads the stale value React last rendered. - const colorInput = row.locator(sel(CATEGORY_COLOR_INPUT_TID)); - await colorInput.evaluate((el, color) => { - const input = el as HTMLInputElement; - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; - if (!setter) throw new Error("HTMLInputElement.value setter missing"); - setter.call(input, color); - input.dispatchEvent(new Event("input", { bubbles: true })); - input.dispatchEvent(new Event("change", { bubbles: true })); - }, PICKED_COLOR); - - await closeSettings(obsidian.page); - - // The calendar tile must now carry the picked colour. If the picker - // failed to write a color rule, --event-color falls back to default. - await evt.expectColor(PICKED_COLOR); - }); -}); diff --git a/e2e/specs/settings/configuration-tab.spec.ts b/e2e/specs/settings/configuration-tab.spec.ts deleted file mode 100644 index b7b5baab..00000000 --- a/e2e/specs/settings/configuration-tab.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { readPluginData, settleSettings, setToggle } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; - -type CalendarData = { calendars: Array<Record<string, unknown>> }; - -test.describe("settings: Configuration tab", () => { - test("toggling a toolbar button removes it from calendars[0].toolbarButtons", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "configuration"); - - await setToggle(obsidian.page, "prisma-settings-control-toolbarButtons-searchInput", false); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const data = readPluginData(obsidian.vaultDir, PLUGIN_ID) as CalendarData; - const desktop = data.calendars[0].toolbarButtons as string[]; - expect(desktop).not.toContain("searchInput"); - // Sanity: other toolbar buttons still present. - expect(desktop).toContain("today"); - }); - - test("toggling a mobile toolbar button writes the mobile array independently", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "configuration"); - - await setToggle(obsidian.page, "prisma-settings-control-mobileToolbarButtons-zoomLevel", false); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const data = readPluginData(obsidian.vaultDir, PLUGIN_ID) as CalendarData; - const mobile = data.calendars[0].mobileToolbarButtons as string[]; - const desktop = data.calendars[0].toolbarButtons as string[]; - expect(mobile).not.toContain("zoomLevel"); - expect(desktop).toContain("zoomLevel"); - }); - - test("enabling a default-off batch action adds it to batchActionButtons", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "configuration"); - - // batchMoveBy is excluded from the default list — toggling on adds it. - await setToggle(obsidian.page, "prisma-settings-control-batch-action-buttons-batchMoveBy", true); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const data = readPluginData(obsidian.vaultDir, PLUGIN_ID) as CalendarData; - const batch = data.calendars[0].batchActionButtons as string[]; - expect(batch).toContain("batchMoveBy"); - // Defaults (batchSelectAll) stay put. - expect(batch).toContain("batchSelectAll"); - }); -}); diff --git a/e2e/specs/settings/configure-calendar.spec.ts b/e2e/specs/settings/configure-calendar.spec.ts deleted file mode 100644 index b7d40c28..00000000 --- a/e2e/specs/settings/configure-calendar.spec.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { readPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { fromAnchor } from "../../fixtures/dates"; -import { expect, testWithSeededFiles as test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { seedEvents, type SeedEventInput } from "../../fixtures/seed-events"; -import { expectEventVisible, navigateToAnchor, openCalendarReady } from "../events/events-helpers"; - -// ─── Dataset: three folders with distinct property schemas ─────────────────── - -const MEETINGS: SeedEventInput[] = [ - "Weekly Standup", - "1-on-1 with Sarah", - "Architecture Discussion", - "Budget Review", - "Client Demo", - "Compliance Training", - "Hiring Sync", - "Investor Update", - "Product Roadmap", - "Team Offsite", -].map((title, i) => ({ - title, - subdir: "Meetings", - extra: { - "Meeting Start": `2026-05-${String(i + 1).padStart(2, "0")}T09:00`, - "Meeting End": `2026-05-${String(i + 1).padStart(2, "0")}T10:00`, - "Created On": `2026-04-${String(i + 1).padStart(2, "0")}`, - }, -})); - -const PERSONAL: SeedEventInput[] = [ - "Yoga Class", - "Book Club", - "Dentist Appointment", - "Dinner with Alex", - "Grocery Run", - "Guitar Lesson", - "Hiking Trip", - "Concert Tickets", - "Car Service", - "Apartment Deep Clean", -].map((title, i) => ({ - title, - subdir: "Personal", - extra: { - "Start Time": `2026-06-${String(i + 1).padStart(2, "0")}T14:00`, - "End Time": `2026-06-${String(i + 1).padStart(2, "0")}T15:30`, - Due: `2026-06-${String(i + 1).padStart(2, "0")}`, - }, -})); - -const WORK_PROJECTS: SeedEventInput[] = [ - "API Rate Limiting", - "Backend Migration", - "CI Pipeline Fix", - "Design Review", - "Onboarding Doc Update", - "Performance Benchmarks", - "Q3 Planning", - "Release v2.4", - "Security Audit", - "Sprint Retro", -].map((title, i) => ({ - title, - subdir: "Work Projects", - extra: { - Start: `2026-07-${String(i + 1).padStart(2, "0")}T10:00`, - End: `2026-07-${String(i + 1).padStart(2, "0")}T17:00`, - "Review Date": `2026-07-${String(i + 15).padStart(2, "0")}`, - }, -})); - -type CalendarRow = { id: string; directory?: string; startProp?: string; endProp?: string; dateProp?: string }; -type PluginData = { calendars: CalendarRow[] }; - -function getCalendars(vaultDir: string): CalendarRow[] { - return (readPluginData(vaultDir, PLUGIN_ID) as PluginData).calendars; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -test.describe("settings: configure calendar with property detection", () => { - test.beforeEach(async ({ obsidian }) => { - seedEvents(obsidian.vaultDir, [...MEETINGS, ...PERSONAL, ...WORK_PROJECTS]); - await obsidian.page.waitForTimeout(2000); - }); - - test("configure modal detects folders and prefills properties on suggestion click", async ({ obsidian }) => { - const { page } = obsidian; - await openPrismaSettings(page); - - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - - const suggestionCards = modal.locator(".prisma-first-launch-suggestion"); - await expect(suggestionCards.first()).toBeVisible(); - - const cardTexts = await suggestionCards.allTextContents(); - expect(cardTexts.some((t) => t.includes("Meetings"))).toBe(true); - expect(cardTexts.some((t) => t.includes("Personal"))).toBe(true); - expect(cardTexts.some((t) => t.includes("Work Projects"))).toBe(true); - - // Click Meetings — "Meeting Start"/"Meeting End" match start/end keywords - await suggestionCards.filter({ hasText: "Meetings" }).click(); - - const startInput = modal.locator('[data-testid="prisma-configure-configure-start-prop"]'); - const endInput = modal.locator('[data-testid="prisma-configure-configure-end-prop"]'); - const dateInput = modal.locator('[data-testid="prisma-configure-configure-date-prop"]'); - const dirInput = modal.locator('[data-testid="prisma-configure-directory-input"]'); - - await expect(dirInput).toHaveValue("Meetings"); - await expect(startInput).toHaveValue("Meeting Start"); - await expect(endInput).toHaveValue("Meeting End"); - await expect(dateInput).toHaveValue("Created On"); - - await modal.locator('[data-testid="prisma-configure-save"]').click(); - await settleSettings(page, { pluginId: PLUGIN_ID }); - - const calendars = getCalendars(obsidian.vaultDir); - expect(calendars[0].directory).toBe("Meetings"); - expect(calendars[0].startProp).toBe("Meeting Start"); - expect(calendars[0].endProp).toBe("Meeting End"); - expect(calendars[0].dateProp).toBe("Created On"); - - // Verify the settings UI reflects the saved values - await switchSettingsTab(page, "general"); - const dirControl = page.locator('[data-testid="prisma-settings-control-directory"]'); - await expect(dirControl).toHaveValue("Meetings"); - - await switchSettingsTab(page, "properties"); - const startField = page.locator('[data-testid="prisma-settings-field-startProp"] input').first(); - const endField = page.locator('[data-testid="prisma-settings-field-endProp"] input').first(); - const dateField = page.locator('[data-testid="prisma-settings-field-dateProp"] input').first(); - await expect(startField).toHaveValue("Meeting Start"); - await expect(endField).toHaveValue("Meeting End"); - await expect(dateField).toHaveValue("Created On"); - }); - - test("switching between suggestions updates prefilled properties", async ({ obsidian }) => { - const { page } = obsidian; - await openPrismaSettings(page); - - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - - const suggestionCards = modal.locator(".prisma-first-launch-suggestion"); - await expect(suggestionCards.first()).toBeVisible(); - - const startInput = modal.locator('[data-testid="prisma-configure-configure-start-prop"]'); - const endInput = modal.locator('[data-testid="prisma-configure-configure-end-prop"]'); - const dateInput = modal.locator('[data-testid="prisma-configure-configure-date-prop"]'); - - // Personal: "Start Time" matches start keyword, "End Time" matches end - await suggestionCards.filter({ hasText: "Personal" }).click(); - await expect(startInput).toHaveValue("Start Time"); - await expect(endInput).toHaveValue("End Time"); - await expect(dateInput).toHaveValue("Due"); - - // Work Projects: "Start" matches start keyword, "End" matches end - await suggestionCards.filter({ hasText: "Work Projects" }).click(); - await expect(startInput).toHaveValue("Start"); - await expect(endInput).toHaveValue("End"); - await expect(dateInput).toHaveValue("Review Date"); - - // Switch back to Meetings - await suggestionCards.filter({ hasText: "Meetings" }).click(); - await expect(startInput).toHaveValue("Meeting Start"); - await expect(endInput).toHaveValue("Meeting End"); - }); - - test("Use prefill buttons fill the input on click", async ({ obsidian }) => { - const { page } = obsidian; - await openPrismaSettings(page); - - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - - const suggestionCards = modal.locator(".prisma-first-launch-suggestion"); - await expect(suggestionCards.first()).toBeVisible(); - - await suggestionCards.filter({ hasText: "Personal" }).click(); - - // Click "End Time" prefill button on the start field - const startPrefillEndTime = modal.locator('[data-testid="prisma-configure-configure-prefill-start-end-time"]'); - await expect(startPrefillEndTime).toBeVisible(); - await startPrefillEndTime.click(); - - const startInput = modal.locator('[data-testid="prisma-configure-configure-start-prop"]'); - await expect(startInput).toHaveValue("End Time"); - }); - - test("cancel discards changes", async ({ obsidian }) => { - const { page } = obsidian; - await openPrismaSettings(page); - - const calendarsBefore = getCalendars(obsidian.vaultDir); - - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - - const suggestionCards = modal.locator(".prisma-first-launch-suggestion"); - await expect(suggestionCards.first()).toBeVisible(); - await suggestionCards.filter({ hasText: "Work Projects" }).click(); - - await modal.locator('[data-testid="prisma-configure-cancel"]').click(); - await modal.waitFor({ state: "detached" }); - - const calendarsAfter = getCalendars(obsidian.vaultDir); - expect(calendarsAfter[0].directory).toBe(calendarsBefore[0].directory); - }); - - test("calendar re-indexes and renders events after directory switch via configure", async ({ obsidian }) => { - const { page } = obsidian; - - // Seed anchor-relative events so they're visible on the current week - const meetingsVisible: SeedEventInput[] = [ - { - title: "Team Standup", - subdir: "Meetings", - extra: { - "Meeting Start": fromAnchor(0, 9), - "Meeting End": fromAnchor(0, 10), - "Created On": fromAnchor(0).slice(0, 10), - }, - }, - { - title: "Sprint Planning", - subdir: "Meetings", - extra: { - "Meeting Start": fromAnchor(1, 14), - "Meeting End": fromAnchor(1, 15), - "Created On": fromAnchor(1).slice(0, 10), - }, - }, - ]; - const personalVisible: SeedEventInput[] = [ - { - title: "Guitar Lesson", - subdir: "Personal", - extra: { "Start Time": fromAnchor(0, 18), "End Time": fromAnchor(0, 19), Due: fromAnchor(0).slice(0, 10) }, - }, - { - title: "Yoga Class", - subdir: "Personal", - extra: { "Start Time": fromAnchor(1, 7), "End Time": fromAnchor(1, 8), Due: fromAnchor(1).slice(0, 10) }, - }, - ]; - seedEvents(obsidian.vaultDir, [...meetingsVisible, ...personalVisible]); - await page.waitForTimeout(2000); - - // Configure calendar to use Meetings folder - await openPrismaSettings(page); - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - const suggestionCards = modal.locator(".prisma-first-launch-suggestion"); - await expect(suggestionCards.first()).toBeVisible(); - await suggestionCards.filter({ hasText: "Meetings" }).click(); - await modal.locator('[data-testid="prisma-configure-save"]').click(); - await settleSettings(page, { pluginId: PLUGIN_ID }); - await closeSettings(page); - - await closeSettings(page); - await openCalendarReady(page); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup", 20_000); - await expectEventVisible(page, "Sprint Planning"); - - // Now switch to Personal via configure - await openPrismaSettings(page); - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - const modal2 = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal2.waitFor({ state: "visible" }); - const cards2 = modal2.locator(".prisma-first-launch-suggestion"); - await expect(cards2.first()).toBeVisible(); - await cards2.filter({ hasText: "Personal" }).click(); - await modal2.locator('[data-testid="prisma-configure-save"]').click(); - await settleSettings(page, { pluginId: PLUGIN_ID }); - await closeSettings(page); - await openCalendarReady(page); - await navigateToAnchor(page); - await expectEventVisible(page, "Guitar Lesson", 20_000); - await expectEventVisible(page, "Yoga Class"); - }); -}); diff --git a/e2e/specs/settings/general-tab.spec.ts b/e2e/specs/settings/general-tab.spec.ts deleted file mode 100644 index 8deb57e8..00000000 --- a/e2e/specs/settings/general-tab.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { expect } from "@playwright/test"; -import { - expectPluginData, - setDropdown, - setNumberInput, - setTextInput, - settleSettings, - setToggle, -} from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; - -test.describe("settings: General tab", () => { - // Onboarding aid (driven by a real support question): the License section - // links to the activation guide both from the heading and inline, so users - // who don't know where their key comes from can find the docs in one click. - test("license section links to the activation guide", async ({ obsidian }) => { - const page = obsidian.page; - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - - const headingGuide = page.getByRole("link", { name: "Open documentation for License" }); - await expect(headingGuide).toBeVisible(); - await expect(headingGuide).toHaveAttribute("href", /\/configuration\/license\?/); - - const inlineGuide = page.getByRole("link", { name: "How activation works" }); - await expect(inlineGuide).toBeVisible(); - await expect(inlineGuide).toHaveAttribute("href", /\/configuration\/license\?/); - }); - - test("writes every major field group to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - // Directory section - await setTextInput(obsidian.page, "prisma-settings-control-directory", "EventsRenamed"); - await setTextInput(obsidian.page, "prisma-settings-control-templatePath", "Templates/event.md"); - await setDropdown(obsidian.page, "prisma-settings-control-locale", "fr"); - await setToggle(obsidian.page, "prisma-settings-control-showRibbonIcon", false); - await setToggle(obsidian.page, "prisma-settings-control-enableKeyboardNavigation", false); - await setDropdown(obsidian.page, "prisma-settings-control-autoAssignZettelId", "calendarEvents"); - - // Parsing section - await setNumberInput(obsidian.page, "prisma-settings-control-defaultDurationMinutes", 45); - await setToggle(obsidian.page, "prisma-settings-control-showDurationField", true); - await setToggle(obsidian.page, "prisma-settings-control-markPastInstancesAsDone", true); - await setToggle(obsidian.page, "prisma-settings-control-titleAutocomplete", true); - - // Time tracker - await setToggle(obsidian.page, "prisma-settings-control-showStopwatch", true); - - // Statistics - await setToggle(obsidian.page, "prisma-settings-control-showDecimalHours", true); - await setDropdown(obsidian.page, "prisma-settings-control-defaultAggregationMode", "category"); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.directory": "EventsRenamed", - "calendars.0.templatePath": "Templates/event.md", - "calendars.0.locale": "fr", - "calendars.0.showRibbonIcon": false, - "calendars.0.enableKeyboardNavigation": false, - "calendars.0.autoAssignZettelId": "calendarEvents", - "calendars.0.defaultDurationMinutes": 45, - "calendars.0.showDurationField": true, - "calendars.0.markPastInstancesAsDone": true, - "calendars.0.titleAutocomplete": true, - "calendars.0.showStopwatch": true, - "calendars.0.showDecimalHours": true, - "calendars.0.defaultAggregationMode": "category", - }); - }); -}); diff --git a/e2e/specs/settings/index-subdirectories.spec.ts b/e2e/specs/settings/index-subdirectories.spec.ts deleted file mode 100644 index 7a399d1f..00000000 --- a/e2e/specs/settings/index-subdirectories.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { settleSettings, setToggle } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { anchorISO } from "../../fixtures/dates"; -import { createEventHandle } from "../../fixtures/dsl/event"; -import { test } from "../../fixtures/electron"; -import { closeSettings, openCalendarView, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { seedEvent } from "../../fixtures/seed-events"; -import { navigateToAnchor, openCalendarReady } from "../events/events-helpers"; - -const SUBDIR_EVENT_TITLE = "Deep Nested Event"; -const NESTED_SUBDIR = "Events/SubFolder/Deep"; - -test.describe("settings: Index subdirectories", () => { - test("events in nested subdirectories appear by default, disappear when disabled, reappear when re-enabled", async ({ - obsidian, - }) => { - // Seed an all-day event two folders deep under Events/ - const relPath = seedEvent(obsidian.vaultDir, { - title: SUBDIR_EVENT_TITLE, - date: anchorISO(), - allDay: true, - subdir: NESTED_SUBDIR, - }); - - await openCalendarReady(obsidian.page); - await openCalendarView(obsidian.page); - await navigateToAnchor(obsidian.page); - - const handle = createEventHandle({ page: obsidian.page, vaultDir: obsidian.vaultDir }, relPath, SUBDIR_EVENT_TITLE); - - // By default, indexSubdirectories is true — event must appear. - await handle.expectVisible(true); - - // Disable index subdirectories via the General settings tab. - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - await setToggle(obsidian.page, "prisma-settings-control-indexSubdirectories", false); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - await closeSettings(obsidian.page); - - // Event in subdir must no longer be visible. - await handle.expectVisible(false); - - // Re-enable index subdirectories. - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - await setToggle(obsidian.page, "prisma-settings-control-indexSubdirectories", true); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - await closeSettings(obsidian.page); - - await handle.expectVisible(true); - }); -}); diff --git a/e2e/specs/settings/indexing-stats.spec.ts b/e2e/specs/settings/indexing-stats.spec.ts deleted file mode 100644 index a26033ec..00000000 --- a/e2e/specs/settings/indexing-stats.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { openPrismaSettings } from "../../fixtures/helpers"; -import { seedEvents, waitForEventCount } from "../../fixtures/seed-events"; - -// The planning-system management header surfaces an "is my folder wired up?" tally -// (timed/all-day/untracked/couldn't-be-read) and a one-click Reindex button. -// Crosses boundaries RTL can't fake: real on-disk frontmatter → indexer → bundle -// tally → React settings header → resync. - -test.describe("settings: indexing stats + reindex", () => { - test("planning-system header shows the per-system tally and reindex affordance", async ({ calendar }) => { - seedEvents(calendar.vaultDir, [ - { title: "Timed One", startDate: "2026-06-15T09:00", endDate: "2026-06-15T10:00" }, - { title: "Timed Two", startDate: "2026-06-16T09:00", endDate: "2026-06-16T10:00" }, - { title: "All Day One", date: "2026-06-15", allDay: true }, - { title: "Loose Note", extra: { project: "Apollo" } }, - { title: "Bad Date", startDate: "next tuesday" }, - ]); - - await waitForEventCount(calendar.page, 3); - - await openPrismaSettings(calendar.page); - - const stats = calendar.page.getByTestId("prisma-indexing-stats"); - await expect(stats).toContainText("2 timed"); - await expect(stats).toContainText("1 all-day"); - await expect(stats).toContainText("1 untracked"); - await expect(stats).toContainText("1 couldn't be read"); - - await calendar.page.getByTestId("prisma-settings-calendar-reindex").click(); - - // Reindex re-reads the same files — the tally is stable afterwards. - await expect(stats).toContainText("2 timed"); - await expect(stats).toContainText("1 couldn't be read"); - }); -}); diff --git a/e2e/specs/settings/input-debounce.spec.ts b/e2e/specs/settings/input-debounce.spec.ts deleted file mode 100644 index 2b224cbd..00000000 --- a/e2e/specs/settings/input-debounce.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expectPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types"; - -// Regression guard for the per-keystroke directory-creation bug. Without -// debouncing, typing "events" into the directory field wrote "e" → "ev" → -// "eve" → "even" → "event" → "events" to the settings store in sequence, and -// each write triggered downstream side-effects (virtual-events file creation, -// disk writes). With the shared-react debouncing (default 300ms) the store -// receives a single commit with the final value after the user stops typing / -// blurs / presses Enter. - -async function readInMemoryDirectory(page: Page): Promise<string> { - return page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const calendars = - (plugin?.settingsStore?.currentSettings?.["calendars"] as - | Array<{ id: string; directory?: string }> - | undefined) ?? []; - const def = calendars.find((c) => c.id === "default") ?? calendars[0]; - return def.directory ?? ""; - }, PLUGIN_ID); -} - -test.describe("settings: debounced input commit", () => { - test("typing the directory field char-by-char commits once after the debounce, not per keystroke", async ({ - obsidian, - }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - const input = obsidian.page.locator('[data-testid="prisma-settings-control-directory"]').first(); - await input.waitFor({ state: "visible" }); - await input.scrollIntoViewIfNeeded(); - - // The seed initialises the default calendar with directory = "Events". - // Sanity-check the starting state so the rest of the test is meaningful. - expect(await readInMemoryDirectory(obsidian.page)).toBe("Events"); - - // Select all, then type "tasks" one char at a time. pressSequentially - // dispatches 5 keystrokes × 10ms ≈ 50ms total — well inside the 300ms - // debounce window, so the store MUST still read "Events" mid-typing. - await input.click(); - await input.press("ControlOrMeta+A"); - await input.pressSequentially("tasks", { delay: 10 }); - - // Still focused, still inside the 300ms debounce window — no intermediate - // commit should have reached the store yet. - expect(await readInMemoryDirectory(obsidian.page)).toBe("Events"); - - // Blur flushes the pending commit synchronously; settle lets saveData - // hit disk so the on-disk assertion is meaningful. - await input.blur(); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expect(await readInMemoryDirectory(obsidian.page)).toBe("tasks"); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.directory": "tasks", - }); - }); - - test("pressing Enter during typing flushes the pending commit immediately", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - const input = obsidian.page.locator('[data-testid="prisma-settings-control-directory"]').first(); - await input.waitFor({ state: "visible" }); - await input.scrollIntoViewIfNeeded(); - - await input.click(); - await input.press("ControlOrMeta+A"); - await input.pressSequentially("inbox", { delay: 10 }); - // No blur — press Enter instead. Must commit without waiting 300ms. - await input.press("Enter"); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expect(await readInMemoryDirectory(obsidian.page)).toBe("inbox"); - }); -}); diff --git a/e2e/specs/settings/multi-calendar-lifecycle.spec.ts b/e2e/specs/settings/multi-calendar-lifecycle.spec.ts deleted file mode 100644 index c66abf3e..00000000 --- a/e2e/specs/settings/multi-calendar-lifecycle.spec.ts +++ /dev/null @@ -1,395 +0,0 @@ -import type { Page } from "@playwright/test"; -import { settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { fromAnchor } from "../../fixtures/dates"; -import { expect, testWithSeededFiles as test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings } from "../../fixtures/helpers"; -import { getCalendars } from "../../fixtures/plugin-data"; -import { seedEvents, waitForCalendarCount, type SeedEventInput } from "../../fixtures/seed-events"; -import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types"; -import { expectEventVisible, navigateToAnchor, openCalendarView } from "../events/events-helpers"; - -// ─── Dataset: three folders with distinct property schemas ─────────────────── - -const MEETINGS: SeedEventInput[] = [ - { - title: "Team Standup", - subdir: "Meetings", - extra: { - "Meeting Start": fromAnchor(0, 9), - "Meeting End": fromAnchor(0, 10), - "Created On": fromAnchor(0).slice(0, 10), - }, - }, - { - title: "Sprint Planning", - subdir: "Meetings", - extra: { - "Meeting Start": fromAnchor(1, 14), - "Meeting End": fromAnchor(1, 15), - "Created On": fromAnchor(1).slice(0, 10), - }, - }, -]; - -const PERSONAL: SeedEventInput[] = [ - { - title: "Guitar Lesson", - subdir: "Personal", - extra: { - "Start Time": fromAnchor(0, 18), - "End Time": fromAnchor(0, 19), - Due: fromAnchor(0).slice(0, 10), - }, - }, - { - title: "Yoga Class", - subdir: "Personal", - extra: { - "Start Time": fromAnchor(1, 7), - "End Time": fromAnchor(1, 8), - Due: fromAnchor(1).slice(0, 10), - }, - }, -]; - -const WORK_PROJECTS: SeedEventInput[] = [ - { - title: "API Rate Limiting", - subdir: "Work Projects", - extra: { - Start: fromAnchor(0, 10), - End: fromAnchor(0, 17), - "Review Date": fromAnchor(0).slice(0, 10), - }, - }, - { - title: "Security Audit", - subdir: "Work Projects", - extra: { - Start: fromAnchor(1, 10), - End: fromAnchor(1, 17), - "Review Date": fromAnchor(1).slice(0, 10), - }, - }, -]; - -async function configureCalendarViaModal(page: Page, folderName: string): Promise<void> { - await page.locator('[data-testid="prisma-settings-calendar-configure"]').click(); - const modal = page.locator('[data-testid="prisma-configure-calendar-modal"]'); - await modal.waitFor({ state: "visible" }); - const cards = modal.locator(".prisma-first-launch-suggestion"); - await expect(cards.first()).toBeVisible(); - await cards.filter({ hasText: folderName }).click(); - await modal.locator('[data-testid="prisma-configure-save"]').click(); - await settleSettings(page, { pluginId: PLUGIN_ID }); -} - -async function createAndConfigureCalendar( - page: Page, - vaultDir: string, - folderName: string, - expectedBundleCount: number -): Promise<string> { - const calendarsBefore = getCalendars(vaultDir); - await page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await waitForCalendarCount(page, expectedBundleCount); - - const calendarsAfter = getCalendars(vaultDir); - const newCal = calendarsAfter.find((c) => !calendarsBefore.some((b) => b.id === c.id))!; - - // Close and reopen settings so React mounts with fresh state — the stale - // handleConfigure closure from before the create would save to the wrong - // calendar otherwise. - await closeSettings(page); - await openPrismaSettings(page); - - const dropdown = page.locator(".prisma-calendar-management select.dropdown"); - await dropdown.selectOption(newCal.id); - await configureCalendarViaModal(page, folderName); - return newCal.id; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -test.describe("multi-calendar lifecycle: create, configure, delete without full refresh", () => { - test.beforeEach(async ({ obsidian }) => { - seedEvents(obsidian.vaultDir, [...MEETINGS, ...PERSONAL, ...WORK_PROJECTS]); - await obsidian.page.waitForTimeout(2000); - }); - - test("creating a new calendar does not close settings or re-index existing calendars", async ({ obsidian }) => { - const { page } = obsidian; - - // Configure the default calendar to Meetings - await openPrismaSettings(page); - await configureCalendarViaModal(page, "Meetings"); - await expect(page.locator(".modal-container .mod-settings")).toBeVisible(); - await closeSettings(page); - - // Verify Meetings events are visible - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup", 20_000); - await expectEventVisible(page, "Sprint Planning"); - - // Create a new calendar — settings must stay open - await openPrismaSettings(page); - const newCalId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await expect(page.locator(".modal-container .mod-settings")).toBeVisible(); - await closeSettings(page); - - // Verify the new calendar shows Personal events - await openCalendarView(page, newCalId); - await navigateToAnchor(page); - await expectEventVisible(page, "Guitar Lesson", 20_000); - await expectEventVisible(page, "Yoga Class"); - - // Switch back to default — Meetings events still there, no re-index - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup"); - await expectEventVisible(page, "Sprint Planning"); - }); - - test("deleting a calendar does not affect other calendars", async ({ obsidian }) => { - const { page } = obsidian; - - // Set up: configure default to Meetings, create second for Personal - await openPrismaSettings(page); - await configureCalendarViaModal(page, "Meetings"); - const secondCalId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await closeSettings(page); - - // Verify both calendars work - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup", 20_000); - - await openCalendarView(page, secondCalId); - await navigateToAnchor(page); - await expectEventVisible(page, "Guitar Lesson", 20_000); - - // Select the second calendar and delete it - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondCalId); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 1); - - const calendarsAfterDelete = getCalendars(obsidian.vaultDir); - expect(calendarsAfterDelete).toHaveLength(1); - expect(calendarsAfterDelete[0].id).toBe("default"); - - // Default calendar still shows Meetings events — no re-index - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup"); - await expectEventVisible(page, "Sprint Planning"); - }); - - test("full lifecycle: create three calendars, configure each, delete one, remaining still work", async ({ - obsidian, - }) => { - const { page } = obsidian; - - // Step 1: Configure default calendar → Meetings - await openPrismaSettings(page); - await configureCalendarViaModal(page, "Meetings"); - await closeSettings(page); - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup", 20_000); - - // Step 2: Create second calendar → Personal - await openPrismaSettings(page); - const secondId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await closeSettings(page); - - await openCalendarView(page, secondId); - await navigateToAnchor(page); - await expectEventVisible(page, "Guitar Lesson", 20_000); - await expectEventVisible(page, "Yoga Class"); - - // Step 3: Create third calendar → Work Projects - await openPrismaSettings(page); - const thirdId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Work Projects", 3); - await closeSettings(page); - - await openCalendarView(page, thirdId); - await navigateToAnchor(page); - await expectEventVisible(page, "API Rate Limiting", 20_000); - await expectEventVisible(page, "Security Audit"); - - // Step 4: Verify all three calendars still show events (no interference) - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup"); - await openCalendarView(page, secondId); - await navigateToAnchor(page); - await expectEventVisible(page, "Guitar Lesson"); - await openCalendarView(page, thirdId); - await navigateToAnchor(page); - await expectEventVisible(page, "API Rate Limiting"); - - // Step 5: Delete second calendar — other two must survive - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 2); - - // First and third still render their events - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectEventVisible(page, "Team Standup"); - await expectEventVisible(page, "Sprint Planning"); - await openCalendarView(page, thirdId); - await navigateToAnchor(page); - await expectEventVisible(page, "API Rate Limiting"); - await expectEventVisible(page, "Security Audit"); - }); - - test("renaming a planning system updates ribbon icon, command name, and leaf tab", async ({ obsidian }) => { - const { page } = obsidian; - - await openPrismaSettings(page); - await configureCalendarViaModal(page, "Meetings"); - await closeSettings(page); - - // Open the calendar view so a leaf tab exists - await openCalendarView(page, "default"); - const leafTab = page.locator('.workspace-tab-header[data-type*="default"] .workspace-tab-header-inner-title'); - await expect(leafTab).toHaveText("Main Calendar"); - - const ribbon = page.locator('[data-testid="prisma-ribbon-open-default"]'); - await expect(ribbon).toHaveAttribute("aria-label", "Main Calendar"); - - // Rename via settings — use Enter to submit (verifies Enter closes the modal) - await openPrismaSettings(page); - await page.locator('[data-testid="prisma-settings-calendar-rename"]').click(); - const renameInput = page.locator('[data-testid="prisma-settings-calendar-rename-input"]').first(); - await renameInput.waitFor({ state: "visible" }); - await renameInput.fill("Work Schedule"); - await renameInput.press("Enter"); - await settleSettings(page, { pluginId: PLUGIN_ID }); - await closeSettings(page); - - // Ribbon tooltip updated - await expect(ribbon).toHaveAttribute("aria-label", "Work Schedule"); - - // Leaf tab name updated - await expect(leafTab).toHaveText("Work Schedule"); - - // Command name updated - const commandName = await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - return w.app.commands.commands[`${pid}:open-calendar-default`]?.name; - }, PLUGIN_ID); - expect(commandName).toBe("Prisma Calendar: Open Work Schedule"); - }); - - test("deleting a planning system removes its ribbon icon and commands", async ({ obsidian }) => { - const { page } = obsidian; - - await openPrismaSettings(page); - const secondId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await closeSettings(page); - - const secondRibbon = page.locator(`[data-testid="prisma-ribbon-open-${secondId}"]`); - await expect(secondRibbon).toBeVisible(); - - let hasCommand = await page.evaluate( - ({ pid, calId }) => { - const w = window as unknown as PrismaWindow; - return `${pid}:open-calendar-${calId}` in w.app.commands.commands; - }, - { pid: PLUGIN_ID, calId: secondId } - ); - expect(hasCommand).toBe(true); - - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 1); - await closeSettings(page); - - await expect(secondRibbon).toHaveCount(0); - - hasCommand = await page.evaluate( - ({ pid, calId }) => `${pid}:open-calendar-${calId}` in (window as any).app.commands.commands, - { pid: PLUGIN_ID, calId: secondId } - ); - expect(hasCommand).toBe(false); - }); - - test("create/delete cycles leave no orphaned ribbon icons or commands", async ({ obsidian }) => { - const { page } = obsidian; - - const countRibbonIcons = () => page.locator('[data-testid^="prisma-ribbon-open-"]').count(); - const countCommands = () => - page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - return Object.keys(w.app.commands.commands).filter((k) => k.startsWith(`${pid}:open-calendar-`)).length; - }, PLUGIN_ID); - - expect(await countRibbonIcons()).toBe(1); - expect(await countCommands()).toBe(1); - - // Cycle 1: create then delete - await openPrismaSettings(page); - const id1 = await createAndConfigureCalendar(page, obsidian.vaultDir, "Meetings", 2); - await closeSettings(page); - expect(await countRibbonIcons()).toBe(2); - expect(await countCommands()).toBe(2); - - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(id1); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 1); - await closeSettings(page); - expect(await countRibbonIcons()).toBe(1); - expect(await countCommands()).toBe(1); - - // Cycle 2: create then delete again (same ID gets reused) - await openPrismaSettings(page); - const id2 = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await closeSettings(page); - expect(await countRibbonIcons()).toBe(2); - - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(id2); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 1); - await closeSettings(page); - expect(await countRibbonIcons()).toBe(1); - expect(await countCommands()).toBe(1); - }); - - test("settings open to last-used planning system", async ({ obsidian }) => { - const { page } = obsidian; - - await openPrismaSettings(page); - const secondId = await createAndConfigureCalendar(page, obsidian.vaultDir, "Personal", 2); - await closeSettings(page); - - // Open the second calendar view — sets lastUsedCalendarId - await openCalendarView(page, secondId); - await expect - .poll(() => - page.evaluate( - ({ pid, id }) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - return plugin?.lastUsedCalendarId === id; - }, - { pid: PLUGIN_ID, id: secondId } - ) - ) - .toBe(true); - - // Reopen settings — dropdown should pre-select the second calendar - await openPrismaSettings(page); - const dropdown = page.locator(".prisma-calendar-management select.dropdown"); - await expect(dropdown).toHaveValue(secondId); - }); -}); diff --git a/e2e/specs/settings/normalization-conflict.spec.ts b/e2e/specs/settings/normalization-conflict.spec.ts deleted file mode 100644 index a8ca607f..00000000 --- a/e2e/specs/settings/normalization-conflict.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { Page } from "@playwright/test"; -import { readEventFrontmatter, setTextInput, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, testWithSeededFiles as test } from "../../fixtures/electron"; -import { - addCalendar, - closeSettings, - openPrismaSettings, - selectCalendarInSettings, - setSchemaDropdown, - switchSettingsTab, -} from "../../fixtures/helpers"; -import { seedEvent, waitForEventCount } from "../../fixtures/seed-events"; - -// Production has a 3s debounce on directory-bind / propagation (see -// changelog 2.16). Under tests `window.E2E === true` flips those to 0 via -// debounceMsForEnv, so we don't need to sprinkle waitForTimeout calls — -// settings flow through immediately. -const EXPECTED_SORT_DATE = "2026-01-15T10:00:00"; - -// Reproduces the "two planning systems pointing at the same directory with -// conflicting sort normalization" incident: the first calendar wrote a -// normalized Sort Date on every parse, the second (default strategy=none) -// used to delete it, and they thrashed each other to the point of corrupting -// the IndexedDB cache. After the fix: -// - The runtime guard suppresses both calendars' sort-date writes when a -// conflict is detected (no oscillation possible). -// - The settings UI shows a banner pointing at the offending calendar by -// name on both sides of the conflict. -// - Disabling/aligning one calendar clears the banner. -// -// We don't seed events in this spec — the conflict and its banner are a -// settings-state property, not a per-file property. - -const SHARED_DIR = "Tasks"; -const CONFLICT_BANNER = '[data-testid^="prisma-normalization-conflict-"]'; - -async function setDirectory(page: Page, directory: string): Promise<void> { - await switchSettingsTab(page, "general"); - await setTextInput(page, "prisma-settings-control-directory", directory); - await settleSettings(page, { pluginId: PLUGIN_ID }); -} - -async function setSortingStrategy(page: Page, strategy: string): Promise<void> { - await switchSettingsTab(page, "properties"); - await setSchemaDropdown(page, "sortingStrategy", strategy); - await settleSettings(page, { pluginId: PLUGIN_ID }); -} - -function bannerFor(page: Page, calendarId: string) { - return page.locator(`[data-testid="prisma-normalization-conflict-${calendarId}"]`); -} - -test.describe("sort normalization conflict between calendars", () => { - test("two calendars sharing a directory with different strategies surface the warning banner", async ({ - obsidian, - }) => { - const { page } = obsidian; - - // Calendar 1: shared directory + writing strategy ⇒ no conflict yet. - await openPrismaSettings(page); - await setDirectory(page, SHARED_DIR); - await setSortingStrategy(page, "allStartDate"); - await expect(page.locator(CONFLICT_BANNER)).toHaveCount(0); - - // Calendar 2: same directory, default strategy "none" — the (writer + - // non-writer) pair is the exact conflict the user hit in production. - const secondId = await addCalendar(page, obsidian.vaultDir); - await selectCalendarInSettings(page, secondId); - await setDirectory(page, SHARED_DIR); - - // Both sides of the conflict get the warning banner. - await expect(bannerFor(page, secondId)).toBeVisible(); - await selectCalendarInSettings(page, "default"); - await expect(bannerFor(page, "default")).toBeVisible(); - - // Aligning the second calendar's strategy clears the banner on both sides. - await selectCalendarInSettings(page, secondId); - await setSortingStrategy(page, "allStartDate"); - await expect(page.locator(CONFLICT_BANNER)).toHaveCount(0); - - await selectCalendarInSettings(page, "default"); - await expect(page.locator(CONFLICT_BANNER)).toHaveCount(0); - - await closeSettings(page); - }); - - test("conflict banner names the offending peer, shared directory, and explains the pause", async ({ obsidian }) => { - const { page } = obsidian; - - // Calendar 1 writes; default-strategy peer (none) is added in the same - // directory, which is the exact (writer + non-writer) pair the user hit. - await openPrismaSettings(page); - await setDirectory(page, SHARED_DIR); - await setSortingStrategy(page, "allStartDate"); - - const secondId = await addCalendar(page, obsidian.vaultDir); - await selectCalendarInSettings(page, secondId); - await setDirectory(page, SHARED_DIR); - - // Each side of the conflict shows a banner that names the OTHER calendar, - // names the shared directory, and explains that sort-date writes are paused. - await expect(bannerFor(page, secondId)).toBeVisible(); - await expect(bannerFor(page, secondId)).toContainText(SHARED_DIR); - await expect(bannerFor(page, secondId)).toContainText(/sort date writes are paused/i); - await expect(bannerFor(page, secondId)).toContainText(/Conflicts with /); - - await selectCalendarInSettings(page, "default"); - await expect(bannerFor(page, "default")).toBeVisible(); - await expect(bannerFor(page, "default")).toContainText(SHARED_DIR); - await expect(bannerFor(page, "default")).toContainText(/sort date writes are paused/i); - await expect(bannerFor(page, "default")).toContainText(/Conflicts with /); - - // Aligning the peer's strategy resolves the conflict — banner clears. - await selectCalendarInSettings(page, secondId); - await setSortingStrategy(page, "allStartDate"); - await expect(page.locator(CONFLICT_BANNER)).toHaveCount(0); - - await closeSettings(page); - }); - - // End-to-end proof of the runtime guard at the disk level: the same - // (writer + strategy=none) configuration that previously thrashed Sort - // Date on every parse must leave it untouched while the conflict is - // active, then resume normalizing once the strategies align. - test("sort date stays untouched on disk while the conflict is active and resumes after alignment", async ({ - obsidian, - }) => { - const { page } = obsidian; - - // Configure the writing calendar BEFORE seeding so the indexer is ready - // to pick the file up on its first pass. (Production has a 3s directory- - // bind debounce; under E2E it's flipped to 0 — see debounceMsForEnv.) - await openPrismaSettings(page); - await setDirectory(page, SHARED_DIR); - await setSortingStrategy(page, "allStartDate"); - await closeSettings(page); - - const eventPath = seedEvent(obsidian.vaultDir, { - title: "Project Planning", - startDate: "2026-01-15 10:00", - endDate: "2026-01-15 11:00", - subdir: SHARED_DIR, - }); - await waitForEventCount(page, 1); - - // Sanity: the lone writer normalizes Sort Date from Start Date. - await expect - .poll(() => readEventFrontmatter(obsidian.vaultDir, eventPath)["Sort Date"], { - message: "single-calendar writer must normalize Sort Date from Start Date", - timeout: 20_000, - }) - .toBe(EXPECTED_SORT_DATE); - // The poll above already proved disk == EXPECTED_SORT_DATE. Re-reading - // here can race an atomic frontmatter rewrite and capture undefined, so - // snapshot the constant the poll converged on instead. - const writtenSortDate = EXPECTED_SORT_DATE; - - // Add the conflicting peer (default strategy = none) sharing the directory. - // The runtime guard must freeze BOTH calendars' sort-date writes; the - // previously-written value must remain untouched, no oscillation. - await openPrismaSettings(page); - const secondId = await addCalendar(page, obsidian.vaultDir); - await selectCalendarInSettings(page, secondId); - await setDirectory(page, SHARED_DIR); - await closeSettings(page); - // Negative-assertion settle window: prove the guard prevented any - // thrash on the existing Sort Date. The bundle's directory binding - // flows through `settings$` reactively; this wait lets the second - // calendar's indexer scan SHARED_DIR and reach the guard, so any - // would-be write happens before the snapshot below. - await page.waitForTimeout(1_000); - - expect(readEventFrontmatter(obsidian.vaultDir, eventPath)["Sort Date"]).toBe(writtenSortDate); - - // Align the peer's strategy → conflict clears. Existing Sort Date is - // still intact, AND a fresh event seeded post-alignment must be - // normalized: the runtime guard has flipped back off and writes resume. - await openPrismaSettings(page); - await selectCalendarInSettings(page, secondId); - await setSortingStrategy(page, "allStartDate"); - await expect(page.locator(CONFLICT_BANNER)).toHaveCount(0); - await closeSettings(page); - // Same negative-assertion settle as above — give post-alignment writes - // a chance to land (they shouldn't, because the existing Sort Date is - // already correct) before snapshotting. - await page.waitForTimeout(500); - expect(readEventFrontmatter(obsidian.vaultDir, eventPath)["Sort Date"]).toBe(writtenSortDate); - - const secondExpectedSortDate = "2026-02-10T14:30:00"; - const secondEventPath = seedEvent(obsidian.vaultDir, { - title: "Team Review", - startDate: "2026-02-10 14:30", - endDate: "2026-02-10 15:30", - subdir: SHARED_DIR, - }); - await waitForEventCount(page, 2); - await expect - .poll(() => readEventFrontmatter(obsidian.vaultDir, secondEventPath)["Sort Date"], { - message: "writes must resume on fresh events once strategies align", - timeout: 20_000, - }) - .toBe(secondExpectedSortDate); - }); -}); diff --git a/e2e/specs/settings/notifications-tab.spec.ts b/e2e/specs/settings/notifications-tab.spec.ts deleted file mode 100644 index 41be4472..00000000 --- a/e2e/specs/settings/notifications-tab.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expectPluginData, setTextInput, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, setSchemaNumberInput, setSchemaToggle, switchSettingsTab } from "../../fixtures/helpers"; - -test.describe("settings: Notifications tab", () => { - test("writes toggles and default-time fields to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "notifications"); - - // Main schema-driven fields (SchemaSection). Flip each off its default so - // every field emits a store write. - await setSchemaToggle(obsidian.page, "enableNotifications", false); - await setSchemaToggle(obsidian.page, "notificationSound", true); - await setSchemaToggle(obsidian.page, "skipNewlyCreatedNotifications", false); - await setSchemaNumberInput(obsidian.page, "snoozeMinutes", 10); - - // Default-time fields are custom OptionalNumberFields that stamp the - // inner input with `prisma-settings-control-<key>` directly. - await setTextInput(obsidian.page, "prisma-settings-control-defaultMinutesBefore", "15"); - await obsidian.page.keyboard.press("Tab"); - await setTextInput(obsidian.page, "prisma-settings-control-defaultDaysBefore", "1"); - await obsidian.page.keyboard.press("Tab"); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.enableNotifications": false, - "calendars.0.notificationSound": true, - "calendars.0.skipNewlyCreatedNotifications": false, - "calendars.0.snoozeMinutes": 10, - "calendars.0.defaultMinutesBefore": 15, - "calendars.0.defaultDaysBefore": 1, - }); - }); -}); diff --git a/e2e/specs/settings/performance-tab.spec.ts b/e2e/specs/settings/performance-tab.spec.ts deleted file mode 100644 index be97c3e2..00000000 --- a/e2e/specs/settings/performance-tab.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expectPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, setSchemaNumberInput, setSchemaToggle, switchSettingsTab } from "../../fixtures/helpers"; - -test.describe("settings: Performance tab", () => { - test("writes cache/concurrency fields to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "performance"); - - // Default is true — flip to false to confirm the toggle actually writes. - await setSchemaToggle(obsidian.page, "enableNameSeriesTracking", false); - await setSchemaNumberInput(obsidian.page, "fileConcurrencyLimit", 25); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.enableNameSeriesTracking": false, - "calendars.0.fileConcurrencyLimit": 25, - }); - }); -}); diff --git a/e2e/specs/settings/pro-license-activation.spec.ts b/e2e/specs/settings/pro-license-activation.spec.ts deleted file mode 100644 index 19732501..00000000 --- a/e2e/specs/settings/pro-license-activation.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { expect, type Locator, type Page } from "@playwright/test"; - -import { test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import { LICENSE_ACTIVATIONS_BADGE_CLASS, proGate, sel } from "../../fixtures/testids"; - -// Schema-rendered settings rows expose only the `.setting-item-name` text as a -// stable anchor; locate the wrapping `.setting-item` by its row label so the -// internal description / button can be reached without coupling to row order. -function settingItemByName(page: Page, name: string): Locator { - return page.locator(`.setting-item:has(.setting-item-name:text-is("${name}"))`); -} - -// End-to-end coverage of the license-activation surface. The activation API -// itself is network-bound (Lemon Squeezy → JWT verify) so the spec drives -// status transitions through the same `licenseManager.status$` BehaviorSubject -// the production flow flips on a verified token, then asserts every wired -// surface reacts: settings-tab status text, settings-tab CTA, and the -// per-view Pro gates rendered by `pro-gated-content.tsx`. -// -// What this spec proves end-to-end: -// 1. Status "none" → status row prints "No license key configured", CTA reads -// "Start free trial". -// 2. Clicking the Verify button calls `refreshLicense()` without throwing, -// and (with no key in keychain) leaves the status at "none". -// 3. Status "valid" → status row prints "License active" wording + activations -// badge, CTA flips to "Manage subscription". -// 4. The same status flip unlocks the heatmap / gantt / dashboard views — -// the upgrade-gate component is no longer mounted. -// 5. Downgrading the status back to "expired" + setting isPro=false brings -// the gates back without a reload. - -test.describe("settings: pro license activation surface", () => { - test("free → valid → expired transitions ripple to status text, CTA, and view gates", async ({ calendar }) => { - const page = calendar.page; - - // ── Phase 1: free state ──────────────────────────────────── - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - - const statusItem = settingItemByName(page, "License status"); - await expect(statusItem).toBeVisible(); - await expect(statusItem.locator(".setting-item-description")).toContainText("No license key configured"); - - const subscriptionItem = settingItemByName(page, "Subscription"); - await expect(subscriptionItem.locator("button")).toHaveText("Start free trial"); - - // Verify button is the live entry point for `licenseManager.refreshLicense()`. - // With no key in the keychain it should resolve cleanly to state "none". - const verifyBtn = statusItem.locator("button"); - await expect(verifyBtn).toHaveText("Verify"); - await verifyBtn.click(); - // Status stays "none" — no key, no transition. - await expect(statusItem.locator(".setting-item-description")).toContainText("No license key configured"); - - // ── Phase 2: pro gates are visible on every gated view ───── - await closeSettings(page); - await calendar.switchView("heatmap"); - await expect(page.locator(sel(proGate("HEATMAP"))).first()).toBeVisible(); - - // ── Phase 3: simulate verified activation ────────────────── - await calendar.setLicenseStatus( - { - state: "valid", - activationsCurrent: 1, - activationsLimit: 5, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - }, - { isPro: true } - ); - - // Settings observers wake reactively — re-open the tab and assert the - // new wording propagated without a manual reload. - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - await expect(statusItem.locator(".setting-item-description")).toContainText("License active"); - await expect(statusItem.locator(LICENSE_ACTIVATIONS_BADGE_CLASS)).toHaveText("1/5 devices"); - await expect(subscriptionItem.locator("button")).toHaveText("Manage subscription"); - - // ── Phase 4: gates unlock everywhere ─────────────────────── - await closeSettings(page); - await calendar.switchView("heatmap"); - await expect(page.locator(sel(proGate("HEATMAP")))).toHaveCount(0); - await calendar.switchView("gantt"); - await expect(page.locator(sel(proGate("GANTT")))).toHaveCount(0); - await calendar.switchToGroupChild("dashboard", "dashboard-by-name"); - await expect(page.locator(sel(proGate("DASHBOARD")))).toHaveCount(0); - - // ── Phase 5: downgrade to expired re-installs the gates ──── - await calendar.setLicenseStatus({ state: "expired", errorMessage: null }, { isPro: false }); - await calendar.switchView("heatmap"); - await expect(page.locator(sel(proGate("HEATMAP"))).first()).toBeVisible(); - await calendar.switchView("gantt"); - await expect(page.locator(sel(proGate("GANTT"))).first()).toBeVisible(); - - // Status text reflects the new state with the canonical wording. - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - await expect(statusItem.locator(".setting-item-description")).toContainText("License expired"); - await expect(subscriptionItem.locator("button")).toHaveText("Start free trial"); - }); - - test("invalid-key state shows the canonical error wording in the status row", async ({ calendar }) => { - const page = calendar.page; - await calendar.setLicenseStatus({ state: "invalid", errorMessage: "License key not recognized" }, { isPro: false }); - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - await expect(settingItemByName(page, "License status").locator(".setting-item-description")).toContainText( - "License key not recognized" - ); - }); - - test("device-limit state surfaces the server message", async ({ calendar }) => { - const page = calendar.page; - await calendar.setLicenseStatus( - { state: "device_limit", errorMessage: "Device limit reached (5/5)" }, - { isPro: false } - ); - await openPrismaSettings(page); - await switchSettingsTab(page, "general"); - await expect(settingItemByName(page, "License status").locator(".setting-item-description")).toContainText( - "Device limit reached (5/5)" - ); - }); -}); diff --git a/e2e/specs/settings/properties-tab.spec.ts b/e2e/specs/settings/properties-tab.spec.ts deleted file mode 100644 index e278cb2e..00000000 --- a/e2e/specs/settings/properties-tab.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - expectPluginData, - readEventFrontmatter, - readPluginData, - settleSettings, -} from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expect, test } from "../../fixtures/electron"; -import { - closeSettings, - createEventViaToolbar, - fillEventModalMinimal, - openCalendarView, - openPrismaSettings, - saveEventModal, - setSchemaTextInput, - switchSettingsTab, -} from "../../fixtures/helpers"; -import { listEventFiles } from "../events/events-helpers"; - -// One representative key per category — don't iterate 47. Timing, identity, -// recurrence, state, metadata. -const RENAMES: Array<{ original: string; renamed: string }> = [ - { original: "startProp", renamed: "EventStart" }, - { original: "calendarTitleProp", renamed: "DisplayTitle" }, - { original: "rruleProp", renamed: "Recurrence" }, - { original: "statusProperty", renamed: "EventStatus" }, - { original: "categoryProp", renamed: "EventCategory" }, -]; - -test.describe("settings: Properties tab", () => { - test("renaming frontmatter properties persists each key to data.json", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "properties"); - - for (const { original, renamed } of RENAMES) { - await setSchemaTextInput(obsidian.page, original, renamed); - } - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const dataExpectations: Record<string, unknown> = {}; - for (const { original, renamed } of RENAMES) { - dataExpectations[`calendars.0.${original}`] = renamed; - } - expectPluginData(obsidian.vaultDir, PLUGIN_ID, dataExpectations); - }); - - test("renamed start property persists to data.json immediately after settle", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "properties"); - await setSchemaTextInput(obsidian.page, "startProp", "EventStart"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const data = readPluginData(obsidian.vaultDir, PLUGIN_ID) as { - calendars: Array<{ startProp: string }>; - }; - expect(data.calendars[0].startProp).toBe("EventStart"); - }); - - test("newly-created events use the renamed start/end property keys", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "properties"); - await setSchemaTextInput(obsidian.page, "startProp", "EventStart"); - await setSchemaTextInput(obsidian.page, "endProp", "EventEnd"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - await closeSettings(obsidian.page); - - await openCalendarView(obsidian.page); - await createEventViaToolbar(obsidian.page); - - const title = `Renamed Props ${Date.now()}`; - await fillEventModalMinimal(obsidian.page, { title }); - await saveEventModal(obsidian.page); - - await expect.poll(() => listEventFiles(obsidian.vaultDir).some((f) => f.includes(title))).toBe(true); - - const files = listEventFiles(obsidian.vaultDir); - const match = files.find((f) => f.includes(title)); - if (!match) throw new Error(`No event file found containing "${title}". Files: ${files.join(", ")}`); - const relative = match.slice(obsidian.vaultDir.length + 1); - const fm = readEventFrontmatter(obsidian.vaultDir, relative); - - expect(fm).toHaveProperty("EventStart"); - expect(fm).not.toHaveProperty("Start Date"); - }); -}); diff --git a/e2e/specs/settings/search.spec.ts b/e2e/specs/settings/search.spec.ts deleted file mode 100644 index 97db0b72..00000000 --- a/e2e/specs/settings/search.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; - -// The settings nav search input filters `.setting-item` nodes across every -// tab. No data-testid is stamped on the input, so we target the stable -// `.prisma-settings-search-input` class from SettingsNavigation. -// -// The test stays on the imperatively-rendered Calendar tab on purpose — -// React-rendered sections (General, Properties, etc.) mount asynchronously -// while SettingsNavigation's filter runs synchronously, which can make items -// transiently absent from the DOM. The Calendar tab is built with plain -// `new Setting(...)` calls, so its items are present immediately and the -// search filter produces a deterministic before/after visible count. - -test.describe("settings: search", () => { - test("typing a query filters the visible setting items; clearing restores them", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "calendar"); - - const searchInput = obsidian.page.locator(".prisma-settings-search-input").first(); - await searchInput.waitFor({ state: "visible" }); - - const visibleSettings = obsidian.page.locator(".setting-item:visible"); - const baselineCount = await visibleSettings.count(); - expect(baselineCount).toBeGreaterThan(3); - - // A deliberately-nonsense query guarantees zero matches and surfaces the - // "No settings found for …" empty-state banner. That sidesteps any race - // between SettingsNavigation's synchronous filter and asynchronously- - // mounted React sections, while still exercising the real user path. - await searchInput.fill("zzzznomatch"); - await searchInput.press("Enter"); - - await expect.poll(async () => visibleSettings.count()).toBeLessThan(baselineCount); - await expect(obsidian.page.locator(".prisma-settings-search-no-results").first()).toBeVisible(); - - // Clear → visible count returns to exactly the baseline. - await searchInput.fill(""); - await searchInput.press("Enter"); - - await expect.poll(async () => visibleSettings.count()).toBe(baselineCount); - }); -}); diff --git a/e2e/specs/settings/settings-transfer.spec.ts b/e2e/specs/settings/settings-transfer.spec.ts deleted file mode 100644 index d62ab64c..00000000 --- a/e2e/specs/settings/settings-transfer.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { readFileSync } from "node:fs"; - -import { expect } from "@playwright/test"; -import { - expectPluginData, - readPluginData, - setDropdown, - setNumberInput, - setTextInput, - settleSettings, - setToggle, -} from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; - -const EXPORT_BUTTON = '[data-testid="prisma-settings-transfer-export-button"]'; -const IMPORT_BUTTON = '[data-testid="prisma-settings-transfer-import-button"]'; -const RESET_BUTTON = '[data-testid="prisma-settings-transfer-reset-button"]'; -const EDITOR = '[data-testid="prisma-settings-transfer-editor"]'; -const DOWNLOAD_BUTTON = '[data-testid="prisma-settings-transfer-download"]'; -const APPLY_BUTTON = '[data-testid="prisma-settings-transfer-apply"]'; -const CLOSE_BUTTON = '[data-testid="prisma-settings-transfer-close"]'; -const CONFIRM_MODAL_CONFIRM = '[data-testid="confirmation-modal-confirm"]'; -const CONFIRM_MODAL_CANCEL = '[data-testid="confirmation-modal-cancel"]'; - -test.describe("settings: transfer (import/export)", () => { - test("exports a JSON diff and re-imports it on top of mutated settings", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - await setTextInput(obsidian.page, "prisma-settings-control-directory", "ExportedEvents"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.directory": "ExportedEvents", - }); - - await obsidian.page.locator(EXPORT_BUTTON).scrollIntoViewIfNeeded(); - - const [download] = await Promise.all([ - obsidian.page.waitForEvent("download"), - (async () => { - await obsidian.page.locator(EXPORT_BUTTON).click(); - const editor = obsidian.page.locator(EDITOR); - await editor.waitFor({ state: "visible", timeout: 10_000 }); - const exportedJson = await editor.inputValue(); - expect(exportedJson.length).toBeGreaterThan(2); - expect(JSON.parse(exportedJson)).toMatchObject({ - calendars: expect.any(Array), - }); - await obsidian.page.locator(DOWNLOAD_BUTTON).click(); - })(), - ]); - - const savedPath = await download.path(); - const fileContents = readFileSync(savedPath, "utf8"); - const parsed = JSON.parse(fileContents) as { calendars: Array<{ directory: string }> }; - expect(parsed.calendars[0]?.directory).toBe("ExportedEvents"); - - await obsidian.page.locator(CLOSE_BUTTON).click(); - await obsidian.page.locator(EDITOR).waitFor({ state: "detached", timeout: 10_000 }); - - await setTextInput(obsidian.page, "prisma-settings-control-directory", "SomethingElse"); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.directory": "SomethingElse", - }); - - await obsidian.page.locator(IMPORT_BUTTON).scrollIntoViewIfNeeded(); - await obsidian.page.locator(IMPORT_BUTTON).click(); - const importEditor = obsidian.page.locator(EDITOR); - await importEditor.waitFor({ state: "visible", timeout: 10_000 }); - await importEditor.fill(fileContents); - await obsidian.page.locator(APPLY_BUTTON).click(); - await importEditor.waitFor({ state: "detached", timeout: 10_000 }); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.directory": "ExportedEvents", - }); - }); - - test("reset to defaults restores transferable fields and keeps the confirmation gate", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - await setToggle(obsidian.page, "prisma-settings-control-showRibbonIcon", false); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.showRibbonIcon": false, - }); - - await obsidian.page.locator(RESET_BUTTON).scrollIntoViewIfNeeded(); - await obsidian.page.locator(RESET_BUTTON).click(); - await obsidian.page.locator(CONFIRM_MODAL_CANCEL).click(); - await obsidian.page.locator(CONFIRM_MODAL_CANCEL).waitFor({ state: "detached", timeout: 10_000 }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.showRibbonIcon": false, - }); - - await obsidian.page.locator(RESET_BUTTON).click(); - await obsidian.page.locator(CONFIRM_MODAL_CONFIRM).click(); - await obsidian.page.locator(CONFIRM_MODAL_CONFIRM).waitFor({ state: "detached", timeout: 10_000 }); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.showRibbonIcon": true, - }); - }); - - test("full roundtrip: mutate many settings → export → reset → import → verify identical", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - await setTextInput(obsidian.page, "prisma-settings-control-directory", "RoundtripEvents"); - await setTextInput(obsidian.page, "prisma-settings-control-templatePath", "Templates/Roundtrip.md"); - await setDropdown(obsidian.page, "prisma-settings-control-locale", "de"); - await setToggle(obsidian.page, "prisma-settings-control-showRibbonIcon", false); - await setToggle(obsidian.page, "prisma-settings-control-enableKeyboardNavigation", false); - await setDropdown(obsidian.page, "prisma-settings-control-autoAssignZettelId", "calendarEvents"); - await setNumberInput(obsidian.page, "prisma-settings-control-defaultDurationMinutes", 45); - await setToggle(obsidian.page, "prisma-settings-control-showDurationField", false); - await setToggle(obsidian.page, "prisma-settings-control-markPastInstancesAsDone", true); - await setToggle(obsidian.page, "prisma-settings-control-titleAutocomplete", false); - await setToggle(obsidian.page, "prisma-settings-control-showStopwatch", true); - await setToggle(obsidian.page, "prisma-settings-control-showDecimalHours", true); - await setDropdown(obsidian.page, "prisma-settings-control-defaultAggregationMode", "name"); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const snapshotBefore = readPluginData(obsidian.vaultDir, PLUGIN_ID) as Record<string, unknown>; - expect(snapshotBefore).toBeTruthy(); - - await obsidian.page.locator(EXPORT_BUTTON).scrollIntoViewIfNeeded(); - await obsidian.page.locator(EXPORT_BUTTON).click(); - const editor = obsidian.page.locator(EDITOR); - await editor.waitFor({ state: "visible" }); - const exportedJson = await editor.inputValue(); - await obsidian.page.locator(CLOSE_BUTTON).click(); - await editor.waitFor({ state: "detached" }); - - await obsidian.page.locator(RESET_BUTTON).scrollIntoViewIfNeeded(); - await obsidian.page.locator(RESET_BUTTON).click(); - await obsidian.page.locator(CONFIRM_MODAL_CONFIRM).click(); - await obsidian.page.locator(CONFIRM_MODAL_CONFIRM).waitFor({ state: "detached" }); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - expectPluginData(obsidian.vaultDir, PLUGIN_ID, { - "calendars.0.showRibbonIcon": true, - "calendars.0.showDecimalHours": false, - }); - - await obsidian.page.locator(IMPORT_BUTTON).scrollIntoViewIfNeeded(); - await obsidian.page.locator(IMPORT_BUTTON).click(); - const importEditor = obsidian.page.locator(EDITOR); - await importEditor.waitFor({ state: "visible" }); - await importEditor.fill(exportedJson); - await obsidian.page.locator(APPLY_BUTTON).click(); - await importEditor.waitFor({ state: "detached" }); - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - - const snapshotAfter = readPluginData(obsidian.vaultDir, PLUGIN_ID) as Record<string, unknown>; - - const calendarsAfter = (snapshotAfter as { calendars: Array<Record<string, unknown>> }).calendars[0]; - const calendarsBefore = (snapshotBefore as { calendars: Array<Record<string, unknown>> }).calendars[0]; - - expect(calendarsAfter?.directory).toBe("RoundtripEvents"); - expect(calendarsAfter?.templatePath).toBe("Templates/Roundtrip.md"); - expect(calendarsAfter?.locale).toBe("de"); - expect(calendarsAfter?.showRibbonIcon).toBe(false); - expect(calendarsAfter?.enableKeyboardNavigation).toBe(false); - expect(calendarsAfter?.autoAssignZettelId).toBe("calendarEvents"); - expect(calendarsAfter?.defaultDurationMinutes).toBe(45); - expect(calendarsAfter?.showDurationField).toBe(false); - expect(calendarsAfter?.markPastInstancesAsDone).toBe(true); - expect(calendarsAfter?.titleAutocomplete).toBe(false); - expect(calendarsAfter?.showStopwatch).toBe(true); - expect(calendarsAfter?.showDecimalHours).toBe(true); - expect(calendarsAfter?.defaultAggregationMode).toBe("name"); - - expect(snapshotAfter).toHaveProperty("version", (snapshotBefore as Record<string, unknown>).version); - expect(snapshotAfter).toHaveProperty( - "licenseKeySecretName", - (snapshotBefore as Record<string, unknown>).licenseKeySecretName - ); - - for (const key of Object.keys(calendarsBefore ?? {})) { - expect(calendarsAfter).toHaveProperty(key); - } - }); -}); diff --git a/e2e/specs/settings/shared-directory-isolation.spec.ts b/e2e/specs/settings/shared-directory-isolation.spec.ts deleted file mode 100644 index fabfd31e..00000000 --- a/e2e/specs/settings/shared-directory-isolation.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Page } from "@playwright/test"; -import { setTextInput, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { fromAnchor } from "../../fixtures/dates"; -import { expect, testWithSeededFiles as test } from "../../fixtures/electron"; -import { closeSettings, openPrismaSettings, setSchemaTextInput, switchSettingsTab } from "../../fixtures/helpers"; -import { getCalendars } from "../../fixtures/plugin-data"; -import { seedEvents, waitForCalendarCount, type SeedEventInput } from "../../fixtures/seed-events"; -import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types"; -import { expectGridEventVisible, navigateToAnchor, openCalendarView } from "../events/events-helpers"; - -// ─── Dataset: one directory, two property schemas ──────────────────────────── -// -// "Shared" folder contains 4 notes. Two use "Start Date"/"End Date", two use -// "Start"/"End". Each planning system is configured for one schema and should -// see only its matching events. - -const SHARED_DIR = "Shared"; - -const SCHEMA_A_EVENTS: SeedEventInput[] = [ - { - title: "Weekly Review", - subdir: SHARED_DIR, - extra: { - "Start Date": fromAnchor(0, 9), - "End Date": fromAnchor(0, 10), - }, - }, - { - title: "Budget Meeting", - subdir: SHARED_DIR, - extra: { - "Start Date": fromAnchor(1, 14), - "End Date": fromAnchor(1, 15), - }, - }, -]; - -const SCHEMA_B_EVENTS: SeedEventInput[] = [ - { - title: "Design Sprint", - subdir: SHARED_DIR, - extra: { - Start: fromAnchor(0, 11), - End: fromAnchor(0, 12), - }, - }, - { - title: "Code Review", - subdir: SHARED_DIR, - extra: { - Start: fromAnchor(1, 16), - End: fromAnchor(1, 17), - }, - }, -]; - -async function configureCalendarManually(page: Page, dir: string, startProp: string, endProp: string): Promise<void> { - // Commit properties first so the parser knows which frontmatter keys to - // look for when the directory change triggers indexing. - await switchSettingsTab(page, "properties"); - await setSchemaTextInput(page, "startProp", startProp); - await setSchemaTextInput(page, "endProp", endProp); - await settleSettings(page, { pluginId: PLUGIN_ID }); - await switchSettingsTab(page, "general"); - await setTextInput(page, "prisma-settings-control-directory", dir); - await settleSettings(page, { pluginId: PLUGIN_ID }); -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -test.describe("shared directory isolation: planning systems on the same folder", () => { - test.beforeEach(async ({ obsidian }) => { - seedEvents(obsidian.vaultDir, [...SCHEMA_A_EVENTS, ...SCHEMA_B_EVENTS]); - await obsidian.page.waitForTimeout(2000); - }); - - test("same directory, different properties — each planning system sees only its matching events", async ({ - obsidian, - }) => { - const { page } = obsidian; - - // Configure default calendar → Shared dir with "Start Date"/"End Date" - await openPrismaSettings(page); - await configureCalendarManually(page, SHARED_DIR, "Start Date", "End Date"); - await closeSettings(page); - - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review", 20_000); - await expectGridEventVisible(page, "Budget Meeting"); - - // Create second planning system → same dir, "Start"/"End" - await openPrismaSettings(page); - const calendarsBefore = getCalendars(obsidian.vaultDir); - await page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await waitForCalendarCount(page, 2); - const calendarsAfter = getCalendars(obsidian.vaultDir); - const secondId = calendarsAfter.find((c) => !calendarsBefore.some((b) => b.id === c.id))!.id; - await closeSettings(page); - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await configureCalendarManually(page, SHARED_DIR, "Start", "End"); - await closeSettings(page); - - // Second planning system shows only Schema B events - await openCalendarView(page, secondId); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Design Sprint", 20_000); - await expectGridEventVisible(page, "Code Review"); - - // First planning system still shows only Schema A events - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review"); - await expectGridEventVisible(page, "Budget Meeting"); - - // Verify exact counts via plugin API — no cross-contamination - const counts = await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const bundles = plugin?.calendarBundles ?? []; - return Object.fromEntries(bundles.map((b) => [b.calendarId, b.eventStore.getAllEvents().length])); - }, PLUGIN_ID); - - expect(counts["default"]).toBe(2); - expect(counts[secondId]).toBe(2); - }); - - test("same directory, same properties — both planning systems see all events independently", async ({ obsidian }) => { - const { page } = obsidian; - - // Configure default calendar → Shared dir with "Start Date"/"End Date" - await openPrismaSettings(page); - await configureCalendarManually(page, SHARED_DIR, "Start Date", "End Date"); - await closeSettings(page); - - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review", 20_000); - - // Create second planning system → same dir, same properties - await openPrismaSettings(page); - const calendarsBefore = getCalendars(obsidian.vaultDir); - await page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await waitForCalendarCount(page, 2); - const calendarsAfter = getCalendars(obsidian.vaultDir); - const secondId = calendarsAfter.find((c) => !calendarsBefore.some((b) => b.id === c.id))!.id; - await closeSettings(page); - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await configureCalendarManually(page, SHARED_DIR, "Start Date", "End Date"); - await closeSettings(page); - - // Both planning systems see Schema A events - await openCalendarView(page, secondId); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review", 20_000); - await expectGridEventVisible(page, "Budget Meeting"); - - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review"); - await expectGridEventVisible(page, "Budget Meeting"); - }); - - test("deleting one planning system on a shared directory does not affect the other", async ({ obsidian }) => { - const { page } = obsidian; - - // Set up two planning systems on the same directory - await openPrismaSettings(page); - await configureCalendarManually(page, SHARED_DIR, "Start Date", "End Date"); - const calendarsBefore = getCalendars(obsidian.vaultDir); - await page.locator('[data-testid="prisma-settings-calendar-add"]').click(); - await waitForCalendarCount(page, 2); - const calendarsAfter = getCalendars(obsidian.vaultDir); - const secondId = calendarsAfter.find((c) => !calendarsBefore.some((b) => b.id === c.id))!.id; - await closeSettings(page); - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await configureCalendarManually(page, SHARED_DIR, "Start", "End"); - await closeSettings(page); - - // Verify both work - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review", 20_000); - await openCalendarView(page, secondId); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Design Sprint", 20_000); - - // Delete the second planning system - await openPrismaSettings(page); - await page.locator(".prisma-calendar-management select.dropdown").selectOption(secondId); - await page.locator('[data-testid="prisma-settings-calendar-delete"]').click(); - await waitForCalendarCount(page, 1); - - // First still works - await openCalendarView(page, "default"); - await navigateToAnchor(page); - await expectGridEventVisible(page, "Weekly Review"); - await expectGridEventVisible(page, "Budget Meeting"); - }); -}); diff --git a/e2e/specs/settings/subscription-button.spec.ts b/e2e/specs/settings/subscription-button.spec.ts deleted file mode 100644 index 4e49e0da..00000000 --- a/e2e/specs/settings/subscription-button.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect } from "@playwright/test"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { test } from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab } from "../../fixtures/helpers"; -import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types"; - -test.describe("settings: Subscription button", () => { - test("shows Start free trial button when no license is active", async ({ obsidian }) => { - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - const subscriptionItem = obsidian.page.locator('.setting-item:has(.setting-item-name:text-is("Subscription"))'); - await expect(subscriptionItem).toBeVisible(); - - const btn = subscriptionItem.locator("button"); - await expect(btn).toHaveText("Start free trial"); - await expect(btn).toHaveClass(/mod-cta/); - }); - - test("shows Manage subscription button when license is valid", async ({ obsidian }) => { - await obsidian.page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - const lm = plugin?.licenseManager; - if (!lm) throw new Error("licenseManager missing"); - lm.status$.next({ - state: "valid", - activationsCurrent: 1, - activationsLimit: 5, - expiresAt: null, - errorMessage: null, - }); - lm.__setProForTesting?.(true); - }, PLUGIN_ID); - - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "general"); - - const subscriptionItem = obsidian.page.locator('.setting-item:has(.setting-item-name:text-is("Subscription"))'); - await expect(subscriptionItem).toBeVisible(); - - const btn = subscriptionItem.locator("button"); - await expect(btn).toHaveText("Manage subscription"); - }); -}); diff --git a/e2e/specs/settings/title-property.spec.ts b/e2e/specs/settings/title-property.spec.ts deleted file mode 100644 index e8749493..00000000 --- a/e2e/specs/settings/title-property.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expectPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { ACTIVE_CALENDAR_LEAF, PLUGIN_ID } from "../../fixtures/constants"; -import { isoLocal, todayISO, todayStamp } from "../../fixtures/dates"; -import { expect, test } from "../../fixtures/electron"; -import { - assignPrerequisiteViaUI, - clickContextMenuItem, - closeSettings, - ganttBarLocator, - openPrismaSettings, - rightClickEvent, - saveEventModal, - setSchemaTextInput, - switchSettingsTab, -} from "../../fixtures/helpers"; -import { sel } from "../../fixtures/testids"; -import { fillEventModal } from "../events/fill-event-modal"; - -// Exercises the `titleProp` calendar setting end-to-end: once a frontmatter key -// is configured as the event's display title, every surface that renders an -// event name must honour it — calendar blocks, edit modal, gantt bars, -// timeline rows, heatmap day detail. The fallback behaviour (no setting → -// filename-derived title) is also pinned so we notice if the priority order in -// `getEventName()` ever regresses. -// -// All tests seed directly to disk via `calendar.seedOnDisk` so custom -// frontmatter keys can be written (the `seedOnDiskMany` shape only knows -// Start/End/Date/All Day/Category). `getEventName` resolves the title once at -// parse time and stores it on `event.title`, so DOM queries against -// `data-event-title="<configured value>"` are the canonical assertion. - -const TITLE_PROP = "EventName"; - -// `titleProp` wins over the auto-populated `calendarTitleProp` inside -// `getEventName`, so we only need to set the one value we're asserting on. -// The auto-written `Calendar Title` back-link stays on every indexed file -// (the plugin regenerates it on save) but no longer masks the titleProp -// value at render time. -async function configureTitleProp(page: Page, value: string): Promise<void> { - await openPrismaSettings(page); - await switchSettingsTab(page, "properties"); - await setSchemaTextInput(page, "titleProp", value); - await settleSettings(page, { pluginId: PLUGIN_ID }); - await closeSettings(page); -} - -function blockByTitle(page: Page, title: string) { - return page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"][data-event-title="${title}"]`).first(); -} - -test.describe("settings: titleProp — frontmatter title drives every view", () => { - test("falls back to the cleaned filename when titleProp is unset", async ({ calendar }) => { - // Baseline: no titleProp configured. Seeding an event with a filename of - // "Filename Fallback" should yield a block whose rendered title is the - // filename (zettel suffix stripped by `cleanupTitle`). - await calendar.seedOnDisk("Filename Fallback", { - "Start Date": todayStamp(9, 0), - "End Date": todayStamp(10, 0), - }); - - await expect(blockByTitle(calendar.page, "Filename Fallback")).toBeVisible(); - }); - - test("configured titleProp wins over the filename in the calendar view", async ({ calendar }) => { - await configureTitleProp(calendar.page, TITLE_PROP); - - // data.json must carry the new key — acts as a canary that the settings - // round-trip actually persisted before we go on to verify render paths. - expectPluginData(calendar.vaultDir, PLUGIN_ID, { - "calendars.0.titleProp": TITLE_PROP, - }); - - await calendar.seedOnDisk("hidden-filename", { - "Start Date": todayStamp(9, 0), - "End Date": todayStamp(10, 0), - [TITLE_PROP]: "Display Alpha", - }); - - await expect(blockByTitle(calendar.page, "Display Alpha")).toBeVisible(); - await expect(blockByTitle(calendar.page, "hidden-filename")).toHaveCount(0); - }); - - test("edit modal reads the titleProp value and writes changes back to the same property", async ({ calendar }) => { - await configureTitleProp(calendar.page, TITLE_PROP); - - const event = await calendar.seedOnDisk("editable-filename", { - "Start Date": todayStamp(9, 0), - "End Date": todayStamp(10, 0), - [TITLE_PROP]: "Before Edit", - }); - - await expect(blockByTitle(calendar.page, "Before Edit")).toBeVisible(); - - // Open the edit modal through the real context menu, not an internal - // command — the modal must populate its title input from `event.title`, - // which is the resolved titleProp value (not the filename). - await rightClickEvent(calendar.page, { title: "Before Edit" }); - await clickContextMenuItem(calendar.page, "editEvent"); - - const titleInput = calendar.page.locator(sel("prisma-event-control-title")).first(); - await expect(titleInput).toBeVisible(); - await expect(titleInput).toHaveValue("Before Edit"); - - await fillEventModal(calendar.page, { title: "After Edit" }); - await saveEventModal(calendar.page); - - // Exact equality: saving must write the user's clean title into the - // configured property key — no ZettelID suffix, no instance-date - // prefix. The filename-shaped title lives on `eventData.title` and - // only drives the rename pipeline; it must not leak into - // `fm[titleProp]`. - await event.expectFrontmatter(TITLE_PROP, (v) => v === "After Edit"); - }); - - test("gantt bars render the titleProp value for connected events", async ({ calendar }) => { - await configureTitleProp(calendar.page, TITLE_PROP); - - await calendar.switchMode("month"); - - // Mirror the setup used by the existing gantt suite: create events via - // the real toolbar → modal flow so the prerequisite-tracker ingests the - // same fm writes it sees in production. With titleProp already set, the - // modal writes the typed title into the configured property — the file - // on disk ends up with `EventName: "Gantt Upstream"` and `event.title` - // resolves to that string. - await calendar.seedMany([ - { title: "Gantt Upstream", start: isoLocal(0, 9, 0), end: isoLocal(0, 10, 0) }, - { title: "Gantt Downstream", start: isoLocal(10, 14, 0), end: isoLocal(10, 15, 0) }, - ]); - - await assignPrerequisiteViaUI(calendar.page, "Gantt Downstream", "Gantt Upstream"); - - await calendar.unlockPro(); - await calendar.switchView("gantt"); - - const bars = calendar.page.locator(sel("prisma-gantt-bar")); - await expect(bars).toHaveCount(2); - - await expect(ganttBarLocator(calendar.page, "Gantt Upstream")).toBeVisible(); - await expect(ganttBarLocator(calendar.page, "Gantt Downstream")).toBeVisible(); - }); - - test("timeline items render the titleProp value", async ({ calendar }) => { - await configureTitleProp(calendar.page, TITLE_PROP); - - await calendar.seedOnDisk("tl-file-1", { - "Start Date": todayStamp(9, 0), - "End Date": todayStamp(9, 30), - [TITLE_PROP]: "Timeline Alpha", - }); - await calendar.seedOnDisk("tl-file-2", { - "Start Date": todayStamp(13, 0), - "End Date": todayStamp(14, 0), - [TITLE_PROP]: "Timeline Bravo", - }); - await calendar.seedOnDisk("tl-file-3", { - "Start Date": todayStamp(18, 0), - "End Date": todayStamp(19, 0), - [TITLE_PROP]: "Timeline Charlie", - }); - - await calendar.switchView("timeline"); - - const container = calendar.page.locator(sel("prisma-timeline-container")).first(); - await expect(container).toBeVisible(); - - const items = container.locator(".prisma-timeline-item"); - await expect(items).toHaveCount(3); - - // vis-timeline stamps item text from `cleanupTitle(event.title)` into a - // child `.vis-item-content` element. `textContent` walks hidden / clipped - // descendants too, which `innerText` would skip when the bar is too - // narrow to show the label. - const texts = (await items.evaluateAll((els) => els.map((el) => el.textContent ?? ""))).join("\n"); - expect(texts).toContain("Timeline Alpha"); - expect(texts).toContain("Timeline Bravo"); - expect(texts).toContain("Timeline Charlie"); - expect(texts).not.toContain("tl-file-1"); - expect(texts).not.toContain("tl-file-2"); - expect(texts).not.toContain("tl-file-3"); - }); - - test("heatmap day detail lists events by titleProp value", async ({ calendar }) => { - await configureTitleProp(calendar.page, TITLE_PROP); - - await calendar.seedOnDisk("hm-file-1", { - "Start Date": todayStamp(9, 0), - "End Date": todayStamp(9, 30), - [TITLE_PROP]: "Heatmap Alpha", - }); - await calendar.seedOnDisk("hm-file-2", { - "Start Date": todayStamp(13, 0), - "End Date": todayStamp(14, 0), - [TITLE_PROP]: "Heatmap Bravo", - }); - - await calendar.unlockPro(); - await calendar.switchView("heatmap"); - - const container = calendar.page.locator(sel("prisma-heatmap-container")).first(); - await expect(container).toBeVisible(); - - const todayCell = container.locator(`${sel("prisma-heatmap-cell")}[data-date="${todayISO()}"]`).first(); - await expect(todayCell).toHaveAttribute("data-count", "2"); - - // Clicking the cell opens the inline day-detail panel, which stamps each - // row with `.prisma-heatmap-detail-title` containing `cleanupTitle(event.title)`. - // The heatmap re-renders reactively after `switchView`, so a single click can - // land on a cell node that's about to be replaced and never open the panel — - // retry the click until the detail rows appear. - const titles = calendar.page.locator(".prisma-heatmap-detail-title"); - await expect(async () => { - await todayCell.click(); - await expect(titles).toHaveCount(2); - }).toPass(); - const texts = (await titles.allInnerTexts()).join("\n"); - expect(texts).toContain("Heatmap Alpha"); - expect(texts).toContain("Heatmap Bravo"); - expect(texts).not.toContain("hm-file-1"); - expect(texts).not.toContain("hm-file-2"); - }); -}); diff --git a/e2e/specs/shared/collapsible-section.spec.ts b/e2e/specs/shared/collapsible-section.spec.ts deleted file mode 100644 index f7d0d025..00000000 --- a/e2e/specs/shared/collapsible-section.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { collapsibleSection } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { createEventViaToolbar } from "../../fixtures/helpers"; - -// Exercises `renderCollapsibleSection` from shared/src/components/primitives/ -// collapsible-section.ts. Prisma consumes it through the stopwatch (rendered -// inside the create-event modal with `startCollapsed: true`), so the testids -// stamped by the shared component — `prisma-collapsible-{section,header,body, -// toggle}-time-tracker` — surface here. Asserts the three user-visible -// behaviours the shared component guarantees: initial collapsed state, click -// on header expands, click again collapses. -// -// Uses the plain "Create event" toolbar button, not -// `Create new event with stopwatch` — the latter auto-calls -// `collapsibleHandle.expand()` on open (see EventCreateModal.onOpen), which -// would invalidate the "starts collapsed" assertion. - -test.describe("shared: collapsible-section", () => { - test("starts collapsed, expands on header click, collapses on second click", async ({ calendar }) => { - await createEventViaToolbar(calendar.page); - - const section = collapsibleSection(calendar.page, "time-tracker"); - await section.header.waitFor({ state: "visible" }); - await section.expectExpanded(false); - await expect(section.toggle).toHaveText("▶"); - - await section.header.click(); - await section.expectExpanded(true); - await expect(section.toggle).toHaveText("▼"); - - await section.header.click(); - await section.expectExpanded(false); - await expect(section.toggle).toHaveText("▶"); - }); -}); diff --git a/e2e/specs/shared/confirmation-modal.spec.ts b/e2e/specs/shared/confirmation-modal.spec.ts deleted file mode 100644 index bcf74d70..00000000 --- a/e2e/specs/shared/confirmation-modal.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readPluginData, settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { expectConfirmationModal } from "../../fixtures/dsl"; -import { - expect, - SEEDED_ICS_SUBSCRIPTION_ID, - SEEDED_ICS_SUBSCRIPTION_NAME, - testWithSeededICSSubscription as test, -} from "../../fixtures/electron"; -import { openPrismaSettings, switchSettingsTab, unlockPro } from "../../fixtures/helpers"; - -// Exercises `showConfirmationModal` from shared/src/components/component-renderer/ -// confirmation.ts. Prisma calls it through `showConfirmDeleteModal` (settings/ -// generic) when the user deletes an ICS subscription that has no events on -// disk. The shared modal stamps unprefixed `confirmation-modal`, -// `confirmation-modal-confirm`, `confirmation-modal-cancel` testids (it is -// plugin-agnostic by design — see 2026-04-15-e2e-full-coverage.md for the -// cross-plugin testid contract). -// -// Two branches the shared component guarantees: -// • cancel → `onCancel` fires, caller's work is NOT executed, modal closes. -// • confirm → `onConfirm` fires, modal closes once the promise resolves. - -function subscriptionCount(vaultDir: string): number { - const data = readPluginData(vaultDir, PLUGIN_ID) as { - icsSubscriptions?: { subscriptions?: unknown[] }; - }; - return data.icsSubscriptions?.subscriptions?.length ?? 0; -} - -test.describe("shared: confirmation-modal", () => { - test.beforeEach(async ({ obsidian }) => { - // ICS subscription UI is Pro-gated; without unlockPro the integrations - // tab shows a Pro-upgrade banner and the subscription list never renders. - await unlockPro(obsidian.page); - await openPrismaSettings(obsidian.page); - await switchSettingsTab(obsidian.page, "integrations"); - }); - - test("cancel keeps the entity; title + buttons show caller-supplied labels", async ({ obsidian }) => { - expect(subscriptionCount(obsidian.vaultDir)).toBe(1); - - await obsidian.page - .locator(`[data-testid="prisma-settings-ics-sub-delete-${SEEDED_ICS_SUBSCRIPTION_ID}"]`) - .first() - .click(); - - const modal = await expectConfirmationModal(obsidian.page); - - // Title + caller's `Delete`/`Cancel` button labels flow through the shared - // component unchanged — the test proves the `confirmButton: { text: "Delete", - // warning: true }` config from showConfirmDeleteModal propagated to DOM. - await expect(modal.root).toContainText(`Delete subscription`); - await expect(modal.root).toContainText(SEEDED_ICS_SUBSCRIPTION_NAME); - await expect(modal.confirmBtn).toHaveText("Delete"); - await expect(modal.cancelBtn).toHaveText("Cancel"); - // `warning: true` resolves to mod-warning on the confirm button (see - // resolveButton in confirmation.ts) — the visual distinction users rely - // on for destructive actions. - await expect(modal.confirmBtn).toHaveClass(/mod-warning/); - - await modal.cancel(); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - expect(subscriptionCount(obsidian.vaultDir)).toBe(1); - }); - - test("confirm executes the caller's handler and closes the modal", async ({ obsidian }) => { - expect(subscriptionCount(obsidian.vaultDir)).toBe(1); - - await obsidian.page - .locator(`[data-testid="prisma-settings-ics-sub-delete-${SEEDED_ICS_SUBSCRIPTION_ID}"]`) - .first() - .click(); - - const modal = await expectConfirmationModal(obsidian.page); - await modal.confirm(); - - await settleSettings(obsidian.page, { pluginId: PLUGIN_ID }); - await expect.poll(() => subscriptionCount(obsidian.vaultDir)).toBe(0); - }); -}); diff --git a/e2e/specs/shared/context-menu-manager.spec.ts b/e2e/specs/shared/context-menu-manager.spec.ts deleted file mode 100644 index b843d5b6..00000000 --- a/e2e/specs/shared/context-menu-manager.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { isoLocal } from "../../fixtures/dates"; -import { expectItemManagerOpen } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { clickContextMenuItem, rightClickEvent } from "../../fixtures/helpers"; -import { readDefaultCalendar } from "../../fixtures/plugin-data"; -import { sel, TID } from "../../fixtures/testids"; - -// Exercises shared `createContextMenu`'s item-manager modal end-to-end: -// right-click → "Manage menu items..." → the shared manager modal opens with -// every registered item as a row. Hide an item via the toggle, dismiss the -// modal, verify the item is absent from the next right-click AND that -// calendars[0].contextMenuState persists the hidden id. - -type ContextMenuState = { - contextMenuState?: { - visibleItemIds?: string[]; - }; -}; - -test.describe("shared: context menu item manager", () => { - test("hiding a menu item via the manager persists and removes it from the next right-click", async ({ calendar }) => { - await calendar.createEvent({ - title: "Team Meeting", - start: isoLocal(0, 9, 0), - end: isoLocal(0, 10, 0), - }); - - // Right-click the event to surface the shared context menu, then click - // "Manage menu items..." — both are testid-stamped by shared. - await rightClickEvent(calendar.page, { title: "Team Meeting" }); - await clickContextMenuItem(calendar.page, "manage"); - - const manager = await expectItemManagerOpen(calendar.page); - - // `duplicateEvent` is always registered and visible in the default state. - const targetId = "duplicateEvent"; - await manager.toggle(targetId); - await manager.close(); - - // The underlying event context menu stays open because the manager is a - // modal above it — dismiss it too before re-opening. - await calendar.page.keyboard.press("Escape"); - - // Next right-click: the hidden item must be absent. - await rightClickEvent(calendar.page, { title: "Team Meeting" }); - await expect(calendar.page.locator(sel(TID.ctxMenu("duplicateEvent")))).toHaveCount(0); - await calendar.page.keyboard.press("Escape"); - - // Disk: the visibleItemIds list excludes the hidden id. - await settleSettings(calendar.page, { pluginId: PLUGIN_ID }); - const cal = readDefaultCalendar<ContextMenuState>(calendar.vaultDir); - expect(cal?.contextMenuState?.visibleItemIds ?? []).not.toContain(targetId); - }); -}); diff --git a/e2e/specs/shared/manager-customization-persistence.spec.ts b/e2e/specs/shared/manager-customization-persistence.spec.ts deleted file mode 100644 index a7807c1d..00000000 --- a/e2e/specs/shared/manager-customization-persistence.spec.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; -import { settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { isoLocal } from "../../fixtures/dates"; -import { expectConfirmationModal, expectItemManagerOpen, openActionManager, openTabManager } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { clickContextMenuItem, rightClickEvent } from "../../fixtures/helpers"; -import { readDefaultCalendar } from "../../fixtures/plugin-data"; -import { - ICON_PICKER_GRID_TID, - ICON_PICKER_NO_ICON_TID, - RESET_CONFIRMATION_TID_PREFIX, - sel, - SHARED_ROW_PREFIX, - TID, -} from "../../fixtures/testids"; - -type Overrides = { - renames?: Record<string, string>; - iconOverrides?: Record<string, string>; - colorOverrides?: Record<string, string>; -}; - -type ActiveTabState = { - activeTab?: Overrides & { visibleTabIds?: string[] }; -}; - -async function readTabOrder(page: Page): Promise<string[]> { - return page.evaluate((prefix) => { - return Array.from(document.querySelectorAll(`[data-testid^='${prefix}']`)) - .filter((el) => el.tagName === "BUTTON") - .map((el) => el.getAttribute("data-testid")!.replace(prefix, "")); - }, SHARED_ROW_PREFIX.viewTab); -} - -async function customizeAndAssert( - page: Page, - vaultDir: string, - opts: { - modal: Locator; - editBtnTid: string; - iconBtnTid: string; - allowNoIcon: boolean; - newName: string; - color: string; - close: () => Promise<void>; - readOverrides: () => Overrides | undefined; - itemId: string; - } -): Promise<void> { - await opts.modal.locator(sel(opts.editBtnTid)).first().click(); - - // Rename. - const nameInput = opts.modal.locator(".setting-item:has(.setting-item-name:text('Name')) input[type='text']").first(); - await nameInput.fill(opts.newName); - - // Icon — pick the first one; verify No icon visibility. - await opts.modal.locator(sel(opts.iconBtnTid)).first().click(); - const grid = page.locator(sel(ICON_PICKER_GRID_TID)).first(); - await grid.waitFor({ state: "visible" }); - if (opts.allowNoIcon) { - await expect(page.locator(sel(ICON_PICKER_NO_ICON_TID)).first()).toBeVisible(); - } else { - await expect(page.locator(sel(ICON_PICKER_NO_ICON_TID))).toHaveCount(0); - } - const firstIcon = grid.locator('[data-testid^="shared-icon-picker-item-"]').first(); - const chosenIconId = await firstIcon - .getAttribute("data-testid") - .then((t) => t!.replace("shared-icon-picker-item-", "")); - await firstIcon.click(); - await grid.waitFor({ state: "detached" }); - - // Color. - const colorPicker = opts.modal - .locator(".setting-item:has(.setting-item-name:text('Color')) input[type='color']") - .first(); - await colorPicker.evaluate((el: HTMLInputElement, hex: string) => { - const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - nativeSet.call(el, hex); - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }, opts.color); - - await opts.close(); - - // Persistence. - await settleSettings(page, { pluginId: PLUGIN_ID }); - const overrides = opts.readOverrides(); - expect(overrides?.renames?.[opts.itemId]).toBe(opts.newName); - expect(overrides?.iconOverrides?.[opts.itemId]).toBe(chosenIconId); - expect(overrides?.colorOverrides?.[opts.itemId]).toBeTruthy(); -} - -test.describe("manager customization + persistence", () => { - test("tab manager: rename + icon + color → persisted", async ({ calendar }) => { - const manager = await openTabManager(calendar.page); - await customizeAndAssert(calendar.page, calendar.vaultDir, { - modal: manager.modal, - editBtnTid: "prisma-tab-manager-edit-calendar", - iconBtnTid: "prisma-tab-manager-icon-btn-calendar", - allowNoIcon: true, - newName: "My Calendar", - color: "#ff0000", - itemId: "calendar", - close: () => manager.close(), - readOverrides: () => readDefaultCalendar<{ activeTab?: Overrides }>(calendar.vaultDir)?.activeTab, - }); - }); - - test("page header: rename + icon + color → persisted", async ({ calendar }) => { - const manager = await openActionManager(calendar.page); - await customizeAndAssert(calendar.page, calendar.vaultDir, { - modal: manager.modal, - editBtnTid: "prisma-action-manager-edit-create-event", - iconBtnTid: "prisma-action-manager-icon-btn-create-event", - allowNoIcon: false, - newName: "New Event", - color: "#00ff00", - itemId: "create-event", - close: () => manager.close(), - readOverrides: () => readDefaultCalendar<{ pageHeaderState?: Overrides }>(calendar.vaultDir)?.pageHeaderState, - }); - }); - - test("context menu: rename + icon + color → persisted", async ({ calendar }) => { - await calendar.createEvent({ - title: "Team Meeting", - start: isoLocal(0, 9, 0), - end: isoLocal(0, 10, 0), - }); - await rightClickEvent(calendar.page, { title: "Team Meeting" }); - await clickContextMenuItem(calendar.page, "manage"); - const manager = await expectItemManagerOpen(calendar.page); - - await customizeAndAssert(calendar.page, calendar.vaultDir, { - modal: manager.modal, - editBtnTid: "prisma-item-manager-edit-editEvent", - iconBtnTid: "prisma-item-manager-icon-btn-editEvent", - allowNoIcon: false, - newName: "Edit Note", - color: "#0000ff", - itemId: "editEvent", - close: async () => { - await manager.close(); - await calendar.page.keyboard.press("Escape"); - }, - readOverrides: () => readDefaultCalendar<{ contextMenuState?: Overrides }>(calendar.vaultDir)?.contextMenuState, - }); - }); - - test("tab manager: rename → reorder → hide → reset lifecycle persists all mutations", async ({ calendar }) => { - const page = calendar.page; - const initialOrder = await readTabOrder(page); - expect(initialOrder.length).toBeGreaterThan(2); - - const renameTarget = "timeline"; - const newLabel = "My Timeline"; - const reorderTarget = initialOrder[2]!; - const reorderPredecessor = initialOrder[1]!; - const hideTarget = initialOrder[initialOrder.length - 1]!; - - const manager = await openTabManager(page); - - // 1. Rename - await manager.rename(renameTarget); - const nameInput = manager.modal - .locator(".setting-item:has(.setting-item-name:text('Name')) input[type='text']") - .first(); - await nameInput.waitFor({ state: "visible" }); - await nameInput.fill(newLabel); - await manager.rename(renameTarget); - - // 2. Reorder — drag 3rd tab above 2nd - await manager.moveUp(reorderTarget); - - // 3. Hide last tab - await manager.toggle(hideTarget); - - await manager.close(); - - // DOM: renamed tab shows new label - await expect(page.locator(sel(TID.viewTab(renameTarget))).first()).toContainText(newLabel); - - // DOM: reordered tabs reflect new order - const newOrder = await readTabOrder(page); - expect(newOrder.indexOf(reorderTarget)).toBeLessThan(newOrder.indexOf(reorderPredecessor)); - - // DOM: hidden tab is gone - expect(newOrder).not.toContain(hideTarget); - - // Disk: all three mutations persisted - await settleSettings(page, { pluginId: PLUGIN_ID }); - const cal = readDefaultCalendar<ActiveTabState>(calendar.vaultDir); - expect(cal?.activeTab?.renames?.[renameTarget]).toBe(newLabel); - expect(cal?.activeTab?.visibleTabIds?.indexOf(reorderTarget)).toBeLessThan( - cal?.activeTab?.visibleTabIds?.indexOf(reorderPredecessor) ?? -1 - ); - expect(cal?.activeTab?.visibleTabIds ?? []).not.toContain(hideTarget); - - // 4. Reset to defaults — confirm in the dialog, then assert DOM + disk roll back - const manager2 = await openTabManager(page); - await manager2.clickReset(); - const dialog = await expectConfirmationModal(page, { testIdPrefix: RESET_CONFIRMATION_TID_PREFIX }); - await dialog.confirm(); - await manager2.close(); - - // DOM: rename is gone, hidden tab is back, order matches the original - await expect(page.locator(sel(TID.viewTab(renameTarget))).first()).not.toContainText(newLabel); - const restoredOrder = await readTabOrder(page); - expect(restoredOrder).toContain(hideTarget); - expect(restoredOrder.indexOf(reorderTarget)).toBe(initialOrder.indexOf(reorderTarget)); - - // Disk: no rename override, hidden tab restored, order back to defaults - await settleSettings(page, { pluginId: PLUGIN_ID }); - const afterReset = readDefaultCalendar<ActiveTabState>(calendar.vaultDir); - expect(afterReset?.activeTab?.renames?.[renameTarget]).toBeUndefined(); - expect(afterReset?.activeTab?.visibleTabIds ?? []).toContain(hideTarget); - }); -}); diff --git a/e2e/specs/shared/notice-toasts.spec.ts b/e2e/specs/shared/notice-toasts.spec.ts deleted file mode 100644 index 217f6c07..00000000 --- a/e2e/specs/shared/notice-toasts.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect } from "@playwright/test"; - -import { fromAnchor } from "../../fixtures/dates"; -import { test } from "../../fixtures/electron"; -import { expectNoticeText } from "../../fixtures/helpers"; -import { eventTileByTitle, PREREQ_SELECTION_BANNER_CLASS, sel, TID } from "../../fixtures/testids"; - -// User-facing error copy is invisible to the rest of the suite — no spec -// reads `.notice-container` text outside of two narrow paths -// (data-integrity slashed-title, move-by-modal all-day). Notice strings -// are part of the product contract: a silent change to "Event title -// cannot contain" → "Title not allowed" would slip past every existing -// assertion. This spec pins the exact wording across three distinct -// error paths that are reproducible through real UI: -// -// 1. Batch toggle in list view — wording: "Batch selection is not -// available in list view" -// 2. Self-prerequisite click — wording: "Cannot assign an event as its -// own prerequisite" -// 3. Empty-selection batch delete — wording: "No events selected" - -test.describe("shared: Notice toast wording across user-facing error paths", () => { - test("batch selection in list view surfaces the disallowed-view notice", async ({ calendar }) => { - const page = calendar.page; - await calendar.seedMany([{ title: "Notice List Probe", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) }]); - await calendar.goToAnchor(); - - // Switching to list mode is a precondition — the batch manager checks - // `currentView.includes("list")` and rejects with a Notice. Use the - // FullCalendar toolbar button (`prisma-cal-toolbar-batch-select`) since - // the page-header toggle is the same command but lives in the secondary - // toolbar. - await calendar.switchMode("list"); - await page - .locator(sel(TID.toolbar("batch-select"))) - .first() - .click(); - - await expectNoticeText(page, "Batch selection is not available in list view"); - - // Selection mode must not be active despite the click. - await expect(page.locator(sel(TID.batchCounter))).toHaveCount(0); - }); - - test("clicking the target event as its own prerequisite surfaces the cycle-rejection notice", async ({ - calendar, - }) => { - const page = calendar.page; - await calendar.switchMode("week"); - await calendar.goToAnchor(); - - const [solo] = await calendar.seedMany([ - { title: "Notice Self Prereq", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) }, - ]); - - await solo.rightClick("assignPrerequisites"); - await expect(page.locator(PREREQ_SELECTION_BANNER_CLASS).first()).toBeVisible(); - - // Click the same event tile that started selection mode. - const tile = page.locator(eventTileByTitle("Notice Self Prereq")).first(); - await tile.click(); - - await expectNoticeText(page, "Cannot assign an event as its own prerequisite"); - }); - - test("batch delete with no events selected surfaces the empty-selection notice", async ({ calendar }) => { - const page = calendar.page; - await calendar.switchMode("week"); - await calendar.goToAnchor(); - - await calendar.seedMany([ - { title: "Notice Empty Selection", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) }, - ]); - - // Enter batch mode via the FC toolbar batch-select button (canonical - // entry point that other specs use). Do NOT select any event. - await page - .locator(sel(TID.toolbar("batch-select"))) - .first() - .click(); - await expect(page.locator(sel(TID.batchCounter))).toBeVisible(); - - const deleteBtn = page.locator(sel(TID.batch("delete"))).first(); - await deleteBtn.waitFor({ state: "visible" }); - await deleteBtn.click(); - - await expectNoticeText(page, "No events selected"); - }); -}); diff --git a/e2e/specs/shared/page-header-manager.spec.ts b/e2e/specs/shared/page-header-manager.spec.ts deleted file mode 100644 index e882d7d6..00000000 --- a/e2e/specs/shared/page-header-manager.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { openActionManager } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { SHARED_ROW_PREFIX } from "../../fixtures/testids"; - -// Exercises the shared `createPageHeader` action-manager modal opened from the -// view header's Manage button. Validates the shared primitive's baseline -// rendering: the modal opens, every registered action is listed as a row with -// the expected testid, and closing the modal leaves persisted state untouched. -// The seed state from `electron.ts` defines `pageHeaderState.visibleActionIds` -// so the expected row count equals that list length. - -// DEFAULT_PAGE_HEADER_STATE seeds 18 visible actions; action-manager also lists -// hidden-but-restorable rows. A lower bound asserts the render path without -// coupling to the plugin's evolving action list. -const MIN_VISIBLE_ACTIONS = 18; - -test.describe("shared: page header action manager", () => { - test("opening the manager lists every visible action and closes cleanly", async ({ calendar }) => { - const manager = await openActionManager(calendar.page); - - const rows = manager.modal.locator(`[data-testid^="${SHARED_ROW_PREFIX.actionRow}"]`); - expect(await rows.count()).toBeGreaterThanOrEqual(MIN_VISIBLE_ACTIONS); - - // The "create-event" action is always registered and visible under the - // seed → its row must be stamped with the testid. - await expect(manager.row("create-event")).toBeVisible(); - - await manager.close(); - }); -}); diff --git a/e2e/specs/shared/page-header-overflow.spec.ts b/e2e/specs/shared/page-header-overflow.spec.ts deleted file mode 100644 index 386ac3fc..00000000 --- a/e2e/specs/shared/page-header-overflow.spec.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { CDPSession, Page } from "@playwright/test"; - -import { ACTIVE_CALENDAR_LEAF } from "../../fixtures/constants"; -import { openActionManager } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { - LIST_MODAL_TID, - overflowMenuItem, - PAGE_HEADER_OVERFLOW_MENU_TID, - PAGE_HEADER_OVERFLOW_TID, - sel, -} from "../../fixtures/testids"; - -interface ObsidianSplitWin { - app?: { workspace?: { leftSplit?: { collapse?: () => void }; rightSplit?: { collapse?: () => void } } }; -} - -// Resize the renderer through one reused CDP session — re-issuing the device -// metrics override on a fresh session per call doesn't reliably re-lay-out the page. -async function makeResizer(page: Page): Promise<(w: number) => Promise<void>> { - const cdp: CDPSession = await page.context().newCDPSession(page); - return async (width) => { - await cdp.send("Emulation.setDeviceMetricsOverride", { - width, - height: 800, - deviceScaleFactor: 1, - mobile: false, - screenWidth: width, - screenHeight: 800, - }); - await page.evaluate(() => { - const w = window as unknown as ObsidianSplitWin; - w.app?.workspace?.leftSplit?.collapse?.(); - w.app?.workspace?.rightSplit?.collapse?.(); - }); - }; -} - -interface HeaderFit { - /** Total action buttons registered (visible + overflowed). */ - total: number; - /** Action buttons actually shown (not marked overflow, non-zero box). */ - visible: number; - manageVisible: boolean; - /** Px the Manage button extends past the header's right edge. */ - manageOverflowPx: number; - /** Px from the Manage button's right edge to the header's right edge (right-anchor check). */ - manageGapFromRight: number; - /** Largest px any visible action button extends past the header's right edge. */ - actionOverflowPx: number; - /** Largest px any visible action button spills LEFT of the action box (under the title). */ - actionLeftSpillPx: number; -} - -async function measureHeaderFit(page: Page): Promise<HeaderFit | null> { - return page.evaluate((leafSel) => { - const leaf = document.querySelector(leafSel); - const header = leaf?.querySelector(".view-header") as HTMLElement | null; - const viewActions = leaf?.querySelector(".view-actions") as HTMLElement | null; - if (!header || !viewActions) return null; - const headerRight = header.getBoundingClientRect().right; - const actionsLeft = viewActions.getBoundingClientRect().left; - const all = Array.from(leaf!.querySelectorAll<HTMLElement>('[data-testid^="prisma-toolbar-"]')); - const visible = all.filter((b) => b.getAttribute("data-ph-overflow") !== "true" && b.offsetWidth > 0); - const manage = leaf!.querySelector<HTMLElement>('[data-testid="prisma-page-header-manage"]'); - const mr = manage?.getBoundingClientRect(); - - let actionOverflowPx = 0; - let actionLeftSpillPx = 0; - for (const b of visible) { - const box = b.getBoundingClientRect(); - actionOverflowPx = Math.max(actionOverflowPx, box.right - headerRight); - actionLeftSpillPx = Math.max(actionLeftSpillPx, actionsLeft - box.left); - } - return { - total: all.length, - visible: visible.length, - manageVisible: !!mr && mr.width > 0, - manageOverflowPx: mr ? Math.round(Math.max(0, mr.right - headerRight)) : 0, - manageGapFromRight: mr ? Math.round(headerRight - mr.right) : 0, - actionOverflowPx: Math.round(Math.max(0, actionOverflowPx)), - actionLeftSpillPx: Math.round(Math.max(0, actionLeftSpillPx)), - }; - }, ACTIVE_CALENDAR_LEAF); -} - -/** Whether a specific toolbar action is rendered and not trimmed away. */ -async function isActionVisible(page: Page, id: string): Promise<boolean> { - return page.evaluate( - ([leafSel, actionId]) => { - const el = document.querySelector(`${leafSel} [data-testid="prisma-toolbar-${actionId}"]`) as HTMLElement | null; - return !!el && el.getAttribute("data-ph-overflow") !== "true" && el.offsetWidth > 0; - }, - [ACTIVE_CALENDAR_LEAF, id] as const - ); -} - -// 1px absorbs sub-pixel rounding when comparing button edges to the header box. -const EDGE_TOLERANCE_PX = 1; -// The action row is anchored to the top-right: Manage should sit close to the -// header's right edge, not stranded mid-row. A regression that left-aligns the row -// (Manage centered) pushes this gap into the hundreds. -const RIGHT_ANCHOR_MAX_GAP_PX = 48; - -test.describe("shared: page header overflow fit", () => { - test("fits actions to width, keeps Manage reachable, and stays stable", async ({ calendar }) => { - const page = calendar.page; - await calendar.unlockPro(); - const resize = await makeResizer(page); - - // A roomy-but-bounded width: several of the 18 seeded actions fit, not all. - await resize(600); - await page.waitForTimeout(500); - const narrow = await measureHeaderFit(page); - expect(narrow, "page-header action row / header not found").not.toBeNull(); - if (!narrow) return; - - expect(narrow.visible, "no actions fit at 600px").toBeGreaterThan(0); - // The whole point of the fit: at 600px the 18 seeded actions cannot all fit, so - // the row MUST crop. A regression that renders the full row (the old width-count - // over-estimate) fails here. - expect(narrow.visible, "the row did not crop — every action rendered at 600px").toBeLessThan(narrow.total); - // And nothing spills LEFT under the title — the right-anchored cluster overflowing - // off the left edge is exactly the breakage a crop is meant to prevent. - expect(narrow.actionLeftSpillPx, "a visible action spills left under the title").toBeLessThanOrEqual( - EDGE_TOLERANCE_PX - ); - expect(narrow.manageVisible, "Manage button must render").toBe(true); - // The whole point: Manage stays inside the header, never pushed off. - expect(narrow.manageOverflowPx, "Manage button overflows the header").toBeLessThanOrEqual(EDGE_TOLERANCE_PX); - // And it's right-anchored, not stranded mid-row. - expect(narrow.manageGapFromRight, "Manage is not right-anchored (stranded mid-row)").toBeLessThanOrEqual( - RIGHT_ANCHOR_MAX_GAP_PX - ); - // No shown action button spills past the header edge — only whole, reachable buttons. - expect(narrow.actionOverflowPx, "a visible action button is clipped past the header").toBeLessThanOrEqual( - EDGE_TOLERANCE_PX - ); - - // Stability: enabling a hidden action must NOT change how many fit (same width - // → same fit) and must keep Manage reachable. This is the regression guard for - // the old bug where enabling actions made the visible count drift down. - const manager = await openActionManager(page); - await manager.toggle("create-event"); // registered but hidden by default - await manager.close(); - await page.waitForTimeout(400); - const afterEnable = await measureHeaderFit(page); - expect(afterEnable).not.toBeNull(); - if (!afterEnable) return; - expect(afterEnable.visible, "enabling an action changed how many fit").toBe(narrow.visible); - expect(afterEnable.manageVisible).toBe(true); - expect(afterEnable.manageOverflowPx).toBeLessThanOrEqual(EDGE_TOLERANCE_PX); - - // Widening the pane reveals more actions (the fit recomputes upward), still - // keeping Manage reachable and clipping nothing. - await resize(760); - await page.waitForTimeout(500); - const wide = await measureHeaderFit(page); - expect(wide).not.toBeNull(); - if (!wide) return; - expect(wide.visible, "widening the pane should reveal more actions").toBeGreaterThan(narrow.visible); - expect(wide.manageVisible).toBe(true); - expect(wide.manageOverflowPx).toBeLessThanOrEqual(EDGE_TOLERANCE_PX); - expect(wide.actionOverflowPx).toBeLessThanOrEqual(EDGE_TOLERANCE_PX); - expect(wide.actionLeftSpillPx, "a visible action spills left under the title").toBeLessThanOrEqual( - EDGE_TOLERANCE_PX - ); - }); - - test("reordering an overflowed action into range makes it visible", async ({ calendar }) => { - const page = calendar.page; - await calendar.unlockPro(); - const resize = await makeResizer(page); - - // Narrow enough that only a few actions fit — so a hidden-by-default action, - // once enabled, lands well beyond the visible range. - await resize(500); - await page.waitForTimeout(500); - - const manager = await openActionManager(page); - await manager.toggle("create-event"); // enable; appended at the end (overflowed) - await manager.close(); - // Poll (the fit re-packs on the next animation frame): it should NOT be visible - // yet — it's at the end, beyond the few that fit at 500px. - await expect - .poll(() => isActionVisible(page, "create-event"), { - message: "a freshly-enabled action at the end should be beyond the visible range here", - }) - .toBe(false); - - // Move it to the very front. The row must re-pack reactively and reveal it — - // the bug was that reordering didn't re-fit, so a now-in-range action stayed hidden. - const reorder = await openActionManager(page); - for (let i = 0; i < 24; i++) await reorder.moveUp("create-event").catch(() => {}); - await reorder.close(); - await expect - .poll(() => isActionVisible(page, "create-event"), { - message: "reordering an action into the visible range did not reveal it (row not reactive to order)", - }) - .toBe(true); - }); - - test("overflow trigger surfaces a trimmed action and invokes it", async ({ calendar }) => { - const page = calendar.page; - await calendar.unlockPro(); - const resize = await makeResizer(page); - - // Narrow enough that the bulk of the actions overflow off the bar. - await resize(500); - await page.waitForTimeout(500); - - // The trigger reveals itself once the fit logic trims at least one action. - const trigger = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(PAGE_HEADER_OVERFLOW_TID)}`).first(); - await trigger.waitFor({ state: "visible" }); - - // global-search sits late enough in the default order that it's trimmed here — - // it's the reachability the menu exists to provide. - expect(await isActionVisible(page, "global-search"), "global-search should be trimmed at 500px").toBe(false); - - await trigger.click(); - const menu = page.locator(sel(PAGE_HEADER_OVERFLOW_MENU_TID)).first(); - await menu.waitFor({ state: "visible" }); - - // The trigger sits at the header's far right, so the menu must open LEFTWARD and - // stay inside the viewport — a left-anchored menu spills off the right edge and - // crops every label. - const innerWidth = await page.evaluate(() => window.innerWidth); - const menuBox = await menu.boundingBox(); - expect(menuBox, "overflow menu has no box").not.toBeNull(); - expect(menuBox!.x, "overflow menu spills off the left edge").toBeGreaterThanOrEqual(-1); - expect(menuBox!.x + menuBox!.width, "overflow menu spills off the right edge").toBeLessThanOrEqual(innerWidth + 1); - - // Styled like a proper dropdown: scrolls when tall, rows divided, rows highlight on hover. - expect(await menu.evaluate((el) => getComputedStyle(el).overflowY), "menu should scroll vertically").toBe("auto"); - const rows = menu.locator(".menu-item"); - expect( - await rows.first().evaluate((el) => getComputedStyle(el).borderBottomWidth), - "menu rows should be divided" - ).not.toBe("0px"); - const hoverRow = rows.nth(1); - const beforeBg = await hoverRow.evaluate((el) => getComputedStyle(el).backgroundColor); - await hoverRow.hover(); - await expect - .poll(() => hoverRow.evaluate((el) => getComputedStyle(el).backgroundColor), { - message: "hovering a row should highlight it", - }) - .not.toBe(beforeBg); - - await page - .locator(sel(overflowMenuItem("global-search"))) - .first() - .click(); - - // Selecting the overflowed action runs it — the global-search list modal opens. - await page.locator(sel(LIST_MODAL_TID)).first().waitFor({ state: "visible" }); - }); -}); diff --git a/e2e/specs/shared/page-header-reorder-and-persist.spec.ts b/e2e/specs/shared/page-header-reorder-and-persist.spec.ts deleted file mode 100644 index 9128fd5b..00000000 --- a/e2e/specs/shared/page-header-reorder-and-persist.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Page } from "@playwright/test"; -import { settleSettings } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { PLUGIN_ID } from "../../fixtures/constants"; -import { openActionManager } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { readDefaultCalendar } from "../../fixtures/plugin-data"; -import { PAGE_HEADER_MANAGE_BTN, sel, SHARED_ROW_PREFIX } from "../../fixtures/testids"; - -// Exercises shared `createPageHeader` mutation → persistence → render pipeline. -// Uses the chevron up/down buttons inside the action-manager modal to reorder -// actions (HTML5 drag events are unreliable under Electron CDP — the chevron is -// the keyboard/accessibility-friendly path and fires the same `moveAction` -// handler as drop would). Persistence is validated two ways: the header DOM -// re-renders with the new order, AND data.json.pageHeaderState.visibleActionIds -// reflects the mutation after `settleSettings` force-flushes the debounced -// save. - -// pageHeaderState is persisted PER-CALENDAR (inside calendars[N]), not at the -// top level of data.json. Prisma's view reads it via -// `bundle.settingsStore.currentSettings.pageHeaderState`, which resolves to -// `data.calendars.find(c => c.id === bundleId).pageHeaderState`. -type PageHeaderState = { - pageHeaderState?: { - visibleActionIds?: string[]; - }; -}; - -async function waitForHeaderReady(page: Page): Promise<void> { - // The page header is rendered via a leaf-state apply; wait for the manage - // button as a stable signal that `createPageHeader.apply()` has run. - await page.locator(sel(PAGE_HEADER_MANAGE_BTN)).first().waitFor({ state: "visible" }); -} - -async function readVisibleOrder(page: Page): Promise<string[]> { - return page.evaluate((prefix) => { - return Array.from(document.querySelectorAll(`.workspace-leaf.mod-active [data-testid^='${prefix}']`)).map((el) => - el.getAttribute("data-testid")!.replace(prefix, "") - ); - }, SHARED_ROW_PREFIX.pageHeaderToolbar); -} - -test.describe("shared: page header reorder + persistence", () => { - test("chevron-up reorders an action and persists to data.json", async ({ calendar }) => { - await waitForHeaderReady(calendar.page); - - const initialOrder = await readVisibleOrder(calendar.page); - expect(initialOrder.length).toBeGreaterThan(2); - - // Pick an action that isn't first so its chevron-up button exists. - const targetId = initialOrder[2]!; - const predecessorId = initialOrder[1]!; - - const manager = await openActionManager(calendar.page); - await manager.moveUp(targetId); - await manager.close(); - - // DOM truth: target should now sit where predecessor was. - const newOrder = await readVisibleOrder(calendar.page); - expect(newOrder.indexOf(targetId)).toBeLessThan(newOrder.indexOf(predecessorId)); - - // File-on-disk truth: force-flush the debounced settingsStore.updateSettings - // → saveData, then assert the persisted order matches the DOM order. - await settleSettings(calendar.page, { pluginId: PLUGIN_ID }); - const cal = readDefaultCalendar<PageHeaderState>(calendar.vaultDir); - expect(cal?.pageHeaderState?.visibleActionIds).toEqual(newOrder); - }); - - test("hiding an action removes it from the toolbar and from data.json", async ({ calendar }) => { - await waitForHeaderReady(calendar.page); - - const initialOrder = await readVisibleOrder(calendar.page); - // Pick the last visible action so its toggle button always exists (the - // first visible can't be hidden when only one remains — shared guards). - expect(initialOrder.length).toBeGreaterThan(1); - const targetId = initialOrder[initialOrder.length - 1]!; - - const manager = await openActionManager(calendar.page); - await manager.toggle(targetId); - await manager.close(); - - // DOM: the toolbar button for the target is no longer rendered. - const newOrder = await readVisibleOrder(calendar.page); - expect(newOrder).not.toContain(targetId); - - // Disk: the id is absent from pageHeaderState.visibleActionIds. - await settleSettings(calendar.page, { pluginId: PLUGIN_ID }); - const cal = readDefaultCalendar<PageHeaderState>(calendar.vaultDir); - expect(cal?.pageHeaderState?.visibleActionIds ?? []).not.toContain(targetId); - }); -}); diff --git a/e2e/specs/shared/progress-modal.spec.ts b/e2e/specs/shared/progress-modal.spec.ts deleted file mode 100644 index 0d819750..00000000 --- a/e2e/specs/shared/progress-modal.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { expectProgressModal } from "../../fixtures/dsl"; -import { expect, test } from "../../fixtures/electron"; -import { ICS_IMPORT_FILE_TID, ICS_IMPORT_SUBMIT_TID, sel } from "../../fixtures/testids"; - -// Exercises `showProgressModal` from shared/src/components/primitives/ -// progress-modal.ts. Prisma consumes it from `showICSImportProgressModal` -// (src/components/modals/import-export/ics-import-progress.ts) — driven by -// `Prisma Calendar: Import .ics file` through a real file input. The shared -// component stamps `prisma-progress-modal`, `prisma-progress-status`, -// `prisma-progress-bar`, `prisma-progress-details`; this spec asserts the -// modal appears, progresses to 100%, and then auto-closes per the -// `successCloseDelay` contract. - -function buildICS(count: number): string { - const header = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Prisma E2E//Progress Spec//EN", "CALSCALE:GREGORIAN"]; - const events: string[] = []; - for (let i = 0; i < count; i++) { - const hour = String(9 + (i % 8)).padStart(2, "0"); - const day = String(1 + i).padStart(2, "0"); - events.push( - "BEGIN:VEVENT", - `UID:progress-${i}@prisma-e2e`, - `DTSTAMP:20260601T000000Z`, - `DTSTART:202607${day}T${hour}0000Z`, - `DTEND:202607${day}T${hour}3000Z`, - `SUMMARY:Imported Event ${i + 1}`, - "END:VEVENT" - ); - } - return [...header, ...events, "END:VCALENDAR"].join("\r\n"); -} - -test.describe("shared: progress-modal", () => { - test("shows modal during ICS import, reaches 100%, auto-closes on success", async ({ calendar }) => { - const icsDir = join(calendar.vaultDir, "ICS-Inbox"); - mkdirSync(icsDir, { recursive: true }); - const icsPath = join(icsDir, "progress-spec.ics"); - writeFileSync(icsPath, buildICS(8), "utf8"); - - await calendar.runCommand("Prisma Calendar: Import .ics file"); - await calendar.page.locator(sel(ICS_IMPORT_FILE_TID)).first().setInputFiles(icsPath); - const submit = calendar.page.locator(sel(ICS_IMPORT_SUBMIT_TID)).first(); - await expect(submit).toBeEnabled(); - await submit.click(); - - const progress = await expectProgressModal(calendar.page); - await expect(progress.status).toBeVisible(); - await expect(progress.details).toBeVisible(); - - // The shared modal rewrites status to "Importing events complete" after - // `showComplete` fires. Polling that transition verifies the progress - // updates actually flowed through the shared component (not just the - // initial render). - await expect(progress.status).toContainText(/complete/i); - // Summary line format comes from ics-import-progress.ts: "✓ N imported". - await expect(progress.details).toContainText(/imported/i); - - // The shared modal auto-closes after `successCloseDelay` (2000ms by - // default). Assert the user-visible contract — no lingering overlay. - await progress.waitForClose(); - }); -}); diff --git a/e2e/specs/shared/tabbed-container-rename.spec.ts b/e2e/specs/shared/tabbed-container-rename.spec.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/specs/shared/tabbed-container-reorder.spec.ts b/e2e/specs/shared/tabbed-container-reorder.spec.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/e2e/specs/stress/bulk-batch-delete-undo-redo.spec.ts b/e2e/specs/stress/bulk-batch-delete-undo-redo.spec.ts deleted file mode 100644 index c5c6c665..00000000 --- a/e2e/specs/stress/bulk-batch-delete-undo-redo.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { - clickBatchButton, - confirmBatchAction, - enterBatchMode, - exitBatchMode, - expectSelectedCount, - redoViaPalette, - undoViaPalette, -} from "../../fixtures/history-helpers"; -import { waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - seedBulkEvents, -} from "../../fixtures/stress-helpers"; -import { listEventFiles } from "../events/events-helpers"; - -// A batch-delete on many events, followed by undo and redo, exercises the -// full lifecycle: N file removals, notifier cascade, DOM rebatch, then the -// reverse. At any transition the rendered count must converge to what the -// indexer reports — never leaving orphan events in the DOM after delete or -// missing events after undo. List view keeps each event as a single visible -// row so the DOM count matches disk count exactly within the current week. - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: batch delete undo/redo", () => { - // 7 days × 7 events = 49 events, one per hour slot per day — all distinct, - // no stacking. Comfortably exceeds the 7-event user-reported batch size. - const EVENTS_PER_DAY = 7; - const EVENT_COUNT = EVENTS_PER_DAY * 7; - - test("batch delete clears the calendar and undo restores every event", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Del", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "delete"); - await confirmBatchAction(page); - - // Disk settles first: every file removed. - await expect.poll(() => listEventFiles(vaultDir).length).toBe(0); - // Indexer + DOM both drop to zero — a single event still rendered - // means the delete flow skipped a refresh notification somewhere. - await expectCalendarConsistent(page, { indexer: 0, visible: 0 }); - await exitBatchMode(page); - - // Undo restores every file atomically. If partial undo is a bug we have - // to assert both on-disk and in-DOM counts return to EVENT_COUNT. - await undoViaPalette(page, 1); - await expect.poll(() => listEventFiles(vaultDir).length).toBe(EVENT_COUNT); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - - // Redo re-deletes every file in a single macro. - await redoViaPalette(page, 1); - await expect.poll(() => listEventFiles(vaultDir).length).toBe(0); - await expectCalendarConsistent(page, { indexer: 0, visible: 0 }); - }); - - test("chained move+undo cycles under load keep the DOM eventually consistent", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Chain", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - // Sequence: move-next (empties week) → undo (restores) → move-next → - // undo. Each transition asserts consistency; the cumulative effect - // must land back at the starting state. - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - await undoViaPalette(page, 1); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - await undoViaPalette(page, 1); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - // Indexer count never drifted — move + undo + move + undo never lose events. - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - }); -}); diff --git a/e2e/specs/stress/bulk-batch-move-reactivity.spec.ts b/e2e/specs/stress/bulk-batch-move-reactivity.spec.ts deleted file mode 100644 index b62d0ee5..00000000 --- a/e2e/specs/stress/bulk-batch-move-reactivity.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect, test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { clickBatchButton, enterBatchMode, exitBatchMode, expectSelectedCount } from "../../fixtures/history-helpers"; -import { waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - seedBulkEvents, - waitForIndexerToReach, -} from "../../fixtures/stress-helpers"; -import { sel, TID } from "../../fixtures/testids"; -import { listEventFiles } from "../events/events-helpers"; - -// Reproduces the "stale calendar after batch move" scenario the user reported: -// batch-select N events, move to another week, expect all N to disappear from -// the source week and re-appear in the target week. A reactivity regression -// leaves some stale events rendered in the source (diff misclassifies them as -// "unchanged"). The spec asserts the full lifecycle — disk state AND DOM state -// must both settle consistently. -// -// Week view is used throughout: list view disables batch selection entirely -// (see BatchSelectionManager.toggleSelectionMode). Events are distributed -// one-per-(day, hour) across the 7 days of the current week so no -// eventMaxStack / dayMaxEvents limit kicks in. - -const TOOLBAR_NEXT = sel(TID.toolbar("next")); -const TOOLBAR_PREV = sel(TID.toolbar("prev")); - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: batch move reactivity", () => { - // 4 events per day × 7 days = 28 events, each at a distinct (day, hour) - // slot. Comfortably exceeds the 7-event user-reported batch size while - // staying well under FullCalendar's default collapse thresholds. - const EVENTS_PER_DAY = 4; - const EVENT_COUNT = EVENTS_PER_DAY * 7; - - test("batch move-next empties the source week and fills the target week", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Move", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - - // Every seeded event lives in the current week, so week view renders - // every one. This is the baseline the move-next must invert. - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - - // Disk settles first: every file's Start Date now points at next week. - // File count is unchanged (move doesn't create or delete files). - await expect.poll(() => listEventFiles(vaultDir).length).toBe(EVENT_COUNT); - - // Source week must now be empty. This is the reactivity assertion: - // a single event still rendered here means the calendar diff missed - // a mutation and the user would have to manually refresh. - await expectUniqueVisibleEventCount(page, 0); - - // Advance to the target week and verify every event re-appeared. - await page.locator(TOOLBAR_NEXT).first().click(); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - // Indexer count holds across navigation — the view just filters into - // the new visible range. - await waitForIndexerToReach(page, EVENT_COUNT); - }); - - test("batch move-next then move-prev returns every event to its original week", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Round", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - // Navigate to next week, select everything again, move back. - await page.locator(TOOLBAR_NEXT).first().click(); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-prev"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - // Back to the original week — every event must have re-rendered. - await page.locator(TOOLBAR_PREV).first().click(); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - }); - - test("batch move-next on a partial selection moves only the selected events", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Partial", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - await enterBatchMode(page); - - // Click every odd-numbered event (half of them) by title. The exact - // subset is not the point — what matters is the post-op view has - // exactly the unselected half. - const width = Math.max(3, String(EVENT_COUNT).length); - const selectedTitles = Array.from( - { length: Math.floor(EVENT_COUNT / 2) }, - (_, i) => `Partial ${String(i * 2 + 1).padStart(width, "0")}` - ); - for (const title of selectedTitles) { - await page - .locator(`.workspace-leaf.mod-active ${sel(TID.block)}[data-event-title="${title}"]`) - .first() - .click(); - } - await expectSelectedCount(page, selectedTitles.length); - - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - - // Source week retains exactly the non-selected half. - await expectUniqueVisibleEventCount(page, EVENT_COUNT - selectedTitles.length); - - // Target week now holds exactly the selected half. - await page.locator(TOOLBAR_NEXT).first().click(); - await expectUniqueVisibleEventCount(page, selectedTitles.length); - }); -}); diff --git a/e2e/specs/stress/bulk-skip-clone.spec.ts b/e2e/specs/stress/bulk-skip-clone.spec.ts deleted file mode 100644 index 14c4313b..00000000 --- a/e2e/specs/stress/bulk-skip-clone.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { expect, test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { - clickBatchButton, - enterBatchMode, - exitBatchMode, - expectSelectedCount, - undoViaPalette, -} from "../../fixtures/history-helpers"; -import { waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - seedBulkEvents, -} from "../../fixtures/stress-helpers"; -import { sel, TID } from "../../fixtures/testids"; -import { listEventFiles } from "../events/events-helpers"; - -// Skip and clone are the remaining user-reported stress paths. Skip writes a -// single frontmatter key per file but flips visibility for every affected -// event simultaneously; clone doubles the event set in one macro. Both must -// preserve consistency between indexer and DOM. - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: batch skip + batch clone reactivity", () => { - const EVENT_COUNT = 35; - - test("batch skip hides every selected event and undo makes them all visible again", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Skip", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "skip"); - await exitBatchMode(page); - - // Skip writes `Skip: true` to disk but the files stick around. The - // calendar hides skipped events by default, so the visible count - // drops to zero while disk and indexer stay at EVENT_COUNT. - await expect.poll(() => listEventFiles(vaultDir).length).toBe(EVENT_COUNT); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: 0 }); - - // Sample a few files to confirm the frontmatter actually wrote. - const samplePaths = listEventFiles(vaultDir).slice(0, 5); - for (const absolutePath of samplePaths) { - const relative = absolutePath.slice(vaultDir.length + 1); - expect(readEventFrontmatter(vaultDir, relative)["Skip"], relative).toBe(true); - } - - // Undo returns every event to an un-skipped state atomically. A single - // stale-hidden event means the refresh after undo left a gap. - await undoViaPalette(page, 1); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - }); - - test("batch clone-next doubles the rendered set across source + target weeks", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - const startingCount = 21; - seedBulkEvents(vaultDir, startingCount, { - prefix: "Clone", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, startingCount); - await switchCalendarViewMode(page, "week"); - await expectCalendarConsistent(page, { indexer: startingCount, visible: startingCount }); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, startingCount); - await clickBatchButton(page, "clone-next"); - await exitBatchMode(page); - - // Disk count doubles: N originals + N clones. - await expect.poll(() => listEventFiles(vaultDir).length).toBe(startingCount * 2); - // Source week still shows the originals (not the clones). - await expectCalendarConsistent(page, { indexer: startingCount * 2, visible: startingCount }); - - await page - .locator(sel(TID.toolbar("next"))) - .first() - .click(); - // Target week now shows the clones. - await expectUniqueVisibleEventCount(page, startingCount); - - // Undo the clone — removes the N new files in a single macro. Indexer - // + DOM drop back to the original set. - await undoViaPalette(page, 1); - await expect.poll(() => listEventFiles(vaultDir).length).toBe(startingCount); - // Target week is now empty (clones gone). - await expectUniqueVisibleEventCount(page, 0); - await page - .locator(sel(TID.toolbar("prev"))) - .first() - .click(); - await expectCalendarConsistent(page, { indexer: startingCount, visible: startingCount }); - }); -}); diff --git a/e2e/specs/stress/color-reactivity.spec.ts b/e2e/specs/stress/color-reactivity.spec.ts deleted file mode 100644 index 90a67169..00000000 --- a/e2e/specs/stress/color-reactivity.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { fromAnchor } from "../../fixtures/dates"; -import { expectAllColors, type EventHandle } from "../../fixtures/dsl"; -import { test } from "../../fixtures/electron"; -import { updateCalendarSettings } from "../../fixtures/seed-events"; - -// Reactivity stress: many tiles must all repaint together when a single -// settings-store mutation changes the colour they resolve to. The live -// calendar-view render pipeline is: -// -// settings-store update -// → ColorEvaluator subscription fires -// → EventStore.notifyChange fans out to CalendarView -// → buildCalendarEvents recomputes every FCPrismaEventInput -// → diffEvents marks each tile as "changed" (fingerprint includes colour) -// → FullCalendar re-mounts the changed tiles -// → applyEventMountStyling writes the new --event-color -// -// A regression anywhere in that chain manifests as: one tile picks up the new -// colour, twenty-three keep the old one. Seeding enough events to make that -// divergence obvious, then mutating the rule through three distinct -// transitions (recolour, disable, re-enable with a new colour), exercises -// the pipeline end-to-end. Each transition must flip every tile belonging -// to the affected category. - -const ALPHA = "StressAlpha"; -const BETA = "StressBeta"; - -const ALPHA_INITIAL = "#ff3344"; -const BETA_INITIAL = "#3388ff"; -const ALPHA_RECOLOURED = "#33cc88"; -const BETA_REENABLED = "#aa66cc"; -// Known fallback when no enabled rule matches — the renderer pulls from -// `defaultNodeColor`, so we pin it to a recognisable value for deterministic -// assertions on the "rule disabled" transition. -const DEFAULT_NODE = "#cccccc"; - -const COUNT_PER_CATEGORY = 12; - -test.describe("stress: colour rule reactivity across many tiles", () => { - test("mutating a colour rule repaints every matching tile in the current view", async ({ calendar }) => { - test.slow(); - await updateCalendarSettings(calendar.page, { - defaultNodeColor: DEFAULT_NODE, - colorRules: [ - { id: "rule-alpha", expression: `Category.includes('${ALPHA}')`, color: ALPHA_INITIAL, enabled: true }, - { id: "rule-beta", expression: `Category.includes('${BETA}')`, color: BETA_INITIAL, enabled: true }, - ], - }); - - // Seeds 12 Alpha + 12 Beta across the anchor week, two per day at - // distinct hours so FullCalendar doesn't stack / collapse them. - const events: { handle: EventHandle; category: string }[] = []; - const inputs: { title: string; start: string; end: string; category: string }[] = []; - for (let i = 0; i < COUNT_PER_CATEGORY; i++) { - const day = (i % 6) - 2; // -2..+3 inside the anchor week - const hour = 9 + (i % 6); - inputs.push({ - title: `Alpha Event ${i + 1}`, - start: fromAnchor(day, hour, 0), - end: fromAnchor(day, hour + 1, 0), - category: ALPHA, - }); - inputs.push({ - title: `Beta Event ${i + 1}`, - start: fromAnchor(day, hour, 30), - end: fromAnchor(day, hour + 1, 30), - category: BETA, - }); - } - const handles = await calendar.seedOnDiskMany(inputs); - handles.forEach((handle, idx) => { - events.push({ handle, category: inputs[idx].category }); - }); - await calendar.goToAnchor(); - - const alphaTiles = events.filter((e) => e.category === ALPHA).map((e) => e.handle); - const betaTiles = events.filter((e) => e.category === BETA).map((e) => e.handle); - - await expectAllColors(alphaTiles, ALPHA_INITIAL); - await expectAllColors(betaTiles, BETA_INITIAL); - - // Transition 1: mutate the alpha rule's colour. Every alpha tile must - // flip; beta tiles must not budge. - await updateCalendarSettings(calendar.page, { - colorRules: [ - { id: "rule-alpha", expression: `Category.includes('${ALPHA}')`, color: ALPHA_RECOLOURED, enabled: true }, - { id: "rule-beta", expression: `Category.includes('${BETA}')`, color: BETA_INITIAL, enabled: true }, - ], - }); - await expectAllColors(alphaTiles, ALPHA_RECOLOURED); - await expectAllColors(betaTiles, BETA_INITIAL); - - // Transition 2: disable the beta rule. Every beta tile falls back to - // `defaultNodeColor`; alpha tiles stay on their mutated colour. - await updateCalendarSettings(calendar.page, { - colorRules: [ - { id: "rule-alpha", expression: `Category.includes('${ALPHA}')`, color: ALPHA_RECOLOURED, enabled: true }, - { id: "rule-beta", expression: `Category.includes('${BETA}')`, color: BETA_INITIAL, enabled: false }, - ], - }); - await expectAllColors(alphaTiles, ALPHA_RECOLOURED); - await expectAllColors(betaTiles, DEFAULT_NODE); - - // Transition 3: re-enable beta with a new colour. Every beta tile - // picks it up; alpha tiles remain unaffected. - await updateCalendarSettings(calendar.page, { - colorRules: [ - { id: "rule-alpha", expression: `Category.includes('${ALPHA}')`, color: ALPHA_RECOLOURED, enabled: true }, - { id: "rule-beta", expression: `Category.includes('${BETA}')`, color: BETA_REENABLED, enabled: true }, - ], - }); - await expectAllColors(alphaTiles, ALPHA_RECOLOURED); - await expectAllColors(betaTiles, BETA_REENABLED); - }); -}); diff --git a/e2e/specs/stress/huge-vault-reactivity.spec.ts b/e2e/specs/stress/huge-vault-reactivity.spec.ts deleted file mode 100644 index 3c5ae813..00000000 --- a/e2e/specs/stress/huge-vault-reactivity.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e"; - -import { expect, test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { - clickBatchButton, - confirmBatchAction, - enterBatchMode, - exitBatchMode, - expectSelectedCount, - redoViaPalette, - undoViaPalette, -} from "../../fixtures/history-helpers"; -import { updateCalendarSettings, waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - safeEventFileCount, - seedBulkEvents, - waitForIndexerToReach, -} from "../../fixtures/stress-helpers"; -import { sel, TID } from "../../fixtures/testids"; - -// Maximum-load boundary: a vault holding 5000 events the indexer must pick -// up, plus 50 "active" events in the current week the user actually -// operates on. The 5000 background events never render (they live > 1 month -// in the future, far outside any default FullCalendar view window), but -// every one must land in the indexer on initial load. Mutations on the -// 50 active events must stay consistent — no event loss, no stale-render -// drift, regardless of the 5000-event baseline sitting in the store. -// -// ── Known bug reproduced by this suite ────────────────────────────────── -// With default settings (`calendarTitleProp` non-empty), the initial scan -// of a 5000-event vault queues a `calendarTitle` write-back for every file -// via `EventFileRepository.applyBackgroundFrontmatterUpdates` -> -// `enqueueFrontmatterWrite`. Those ~5000 writes drain sequentially (each -// one waits on `app.fileManager.processFrontMatter`) and cascade through -// the notifier pipeline. User mutations (batch move, delete, clone) -// arrive mid-drain and their refresh notifications get serialised behind -// the backlog — the calendar DOM stays stale for >60s. This is almost -// certainly the root cause behind the user-reported "batch-move 7 events, -// only 3 disappear, calendar stays stale" symptom at a smaller scale. -// -// These specs set `calendarTitleProp: ""` to short-circuit the drain so -// they can exercise refresh reactivity in isolation. Fixing the underlying -// plugin bug (make the drain throttle / yield so it doesn't starve user -// ops) is outside the scope of this test file. - -const TOOLBAR_NEXT = sel(TID.toolbar("next")); -const TOOLBAR_PREV = sel(TID.toolbar("prev")); - -const BACKGROUND_COUNT = 5000; -const ACTIVE_COUNT = 50; -const TOTAL_COUNT = BACKGROUND_COUNT + ACTIVE_COUNT; - -// Bulk ingest of 5000 files + the mutation chain runs past Playwright's -// default 90s per-test budget. Indexing alone spans several seconds; the -// multi-operation chain on top needs room to breathe. -const HUGE_VAULT_TIMEOUT_MS = 300_000; - -// Background events are seeded starting ~1 month ahead of today and spread -// across ~3 years of distinct (day, hour) slots. No overlap with the current -// week → no DOM render of background events, so `uniqueVisibleEventCount` -// always reflects only the active-50 + their derivatives. -const BACKGROUND_START_OFFSET_DAYS = 30; -const BACKGROUND_SPREAD_DAYS = 1000; - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: huge 5000-event vault with focused mutations", () => { - test(`indexer ingests ${TOTAL_COUNT} events and 50 active stay consistent through move → undo → redo`, async ({ - obsidian, - }) => { - test.setTimeout(HUGE_VAULT_TIMEOUT_MS); - const { page, vaultDir } = obsidian; - - // Background events: must be indexed but must never bleed into the - // current week / adjacent weeks we navigate to during the test. - seedBulkEvents(vaultDir, BACKGROUND_COUNT, { - prefix: "Bg", - spreadDays: BACKGROUND_SPREAD_DAYS, - startDayOffset: BACKGROUND_START_OFFSET_DAYS, - }); - - // Active events: 50 spread across the current week, each at a - // distinct (day, hour) slot — one-per-slot means no FullCalendar - // stacking / overflow collapse. - seedBulkEvents(vaultDir, ACTIVE_COUNT, { - prefix: "Active", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - - await openCalendar(page); - // Disable the background title-sync that otherwise fires one frontmatter - // rewrite per indexed file during the drain after the initial scan. On - // a 5000-event vault the drain queues ~5000 sequential writes and can - // starve user-initiated mutations for a minute or more — the exact - // symptom the user reported as "calendar stays stale". Setting - // `calendarTitleProp` to "" short-circuits that drain so the test - // exercises pure refresh reactivity, not a queue-contention regression. - await updateCalendarSettings(page, { calendarTitleProp: "" }); - - // Indexer gate: all 5050 files must land. Bulk ingest of this size - // takes measurably longer than the default 30s poll window on - // slower machines. - await waitForEventCount(page, TOTAL_COUNT, 180_000); - - await switchCalendarViewMode(page, "week"); - - // Only the 50 active events fall in the current week's window. The - // 5000 background events are outside the current-month render range - // and must not appear. - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: ACTIVE_COUNT }); - - // Snapshot one active event's Start Date so we can later assert the - // move actually touched disk, independent of the DOM refresh. - const firstActiveOriginalStart = String(readEventFrontmatter(vaultDir, "Events/Active 001.md")["Start Date"]); - - // ── Op 1: batch move-next on the 50 active events ─────────────── - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, ACTIVE_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - - // Diagnostic: gate on disk first so we can tell DOM-refresh bugs - // from command-failed-to-run bugs. The move command rewrites every - // selected event's Start Date — poll a specific file's frontmatter - // until the mutation lands on disk. - await expect - .poll(() => readEventFrontmatter(vaultDir, "Events/Active 001.md")["Start Date"], { - message: "batch move-next never rewrote Active 01.md on disk", - }) - .not.toBe(firstActiveOriginalStart); - - // Source week empties. A stale event here means the refresh missed - // a mutation under the weight of the 5000-event background store. - await expectUniqueVisibleEventCount(page, 0); - - // Target week now holds the 50. Background events (>30d out) still - // don't bleed in. - await page.locator(TOOLBAR_NEXT).first().click(); - await expectUniqueVisibleEventCount(page, ACTIVE_COUNT); - - // Indexer unchanged — move rewrites Start Date, doesn't create or - // delete files. - await waitForIndexerToReach(page, TOTAL_COUNT); - await expect.poll(() => safeEventFileCount(vaultDir)).toBe(TOTAL_COUNT); - - // ── Op 2: undo returns the 50 to the original week ────────────── - await undoViaPalette(page, 1); - - // Target week (where we are) now empty. - await expectUniqueVisibleEventCount(page, 0); - - // Back to the source week — the 50 should have re-rendered. - await page.locator(TOOLBAR_PREV).first().click(); - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: ACTIVE_COUNT }); - - // ── Op 3: redo re-moves the 50 ────────────────────────────────── - await redoViaPalette(page, 1); - await expectUniqueVisibleEventCount(page, 0); - - // Final navigation & consistency check — target week holds 50, total - // indexer still 5050, disk still 5050. No event loss across the full - // move → undo → redo cycle under load. - await page.locator(TOOLBAR_NEXT).first().click(); - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: ACTIVE_COUNT }); - await expect.poll(() => safeEventFileCount(vaultDir)).toBe(TOTAL_COUNT); - }); - - test("batch delete + undo + redo on 50 actives never drops a background event", async ({ obsidian }) => { - test.setTimeout(HUGE_VAULT_TIMEOUT_MS); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, BACKGROUND_COUNT, { - prefix: "BgDel", - spreadDays: BACKGROUND_SPREAD_DAYS, - startDayOffset: BACKGROUND_START_OFFSET_DAYS, - }); - seedBulkEvents(vaultDir, ACTIVE_COUNT, { - prefix: "Act", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - - await openCalendar(page); - // Disable the background title-sync that otherwise fires one frontmatter - // rewrite per indexed file during the drain after the initial scan. On - // a 5000-event vault the drain queues ~5000 sequential writes and can - // starve user-initiated mutations for a minute or more — the exact - // symptom the user reported as "calendar stays stale". Setting - // `calendarTitleProp` to "" short-circuits that drain so the test - // exercises pure refresh reactivity, not a queue-contention regression. - await updateCalendarSettings(page, { calendarTitleProp: "" }); - await waitForEventCount(page, TOTAL_COUNT, 180_000); - await switchCalendarViewMode(page, "week"); - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: ACTIVE_COUNT }); - - // Delete the 50 actives. Indexer must drop by exactly 50 — not more - // (would mean a background event got collateral-deleted) and not - // less (stale DOM event still counted). - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, ACTIVE_COUNT); - await clickBatchButton(page, "delete"); - await confirmBatchAction(page); - await exitBatchMode(page); - - await expect.poll(() => safeEventFileCount(vaultDir)).toBe(BACKGROUND_COUNT); - await expectCalendarConsistent(page, { indexer: BACKGROUND_COUNT, visible: 0 }); - - // Undo restores all 50 actives atomically. If the undo macro races - // against the 5000-event store, a partial restore would leave the - // indexer count wrong. - await undoViaPalette(page, 1); - await expect.poll(() => safeEventFileCount(vaultDir)).toBe(TOTAL_COUNT); - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: ACTIVE_COUNT }); - - // Redo re-deletes the 50 — final state matches the post-delete snapshot. - await redoViaPalette(page, 1); - await expect.poll(() => safeEventFileCount(vaultDir)).toBe(BACKGROUND_COUNT); - await expectCalendarConsistent(page, { indexer: BACKGROUND_COUNT, visible: 0 }); - }); -}); diff --git a/e2e/specs/stress/large-vault-render.spec.ts b/e2e/specs/stress/large-vault-render.spec.ts deleted file mode 100644 index 5ca1ddd0..00000000 --- a/e2e/specs/stress/large-vault-render.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - seedBulkEvents, - waitForIndexerToReach, -} from "../../fixtures/stress-helpers"; - -// A pre-seeded vault the plugin must ingest on boot. Large-vault scenarios -// exercise the initial-load path (`performInitialLoad`, not the incremental -// diff) and the indexer's ability to surface every file before the first user -// interaction. The target isn't 1000 events for its own sake — it's proving -// the initial render doesn't silently drop events under ingest pressure. -// -// listWeek view is used because it renders one DOM row per event in the -// current week with no stacking — counts are exact. - -const EVENTS_PER_DAY = 5; -const SPREAD_DAYS = 28; -const TOTAL_COUNT = EVENTS_PER_DAY * SPREAD_DAYS; - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: large vault initial render", () => { - test(`${TOTAL_COUNT} pre-seeded events all land in the indexer and list view matches the visible week`, async ({ - obsidian, - }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - // Anchor spread to the current week's Sunday and extend 4 weeks - // forward so 5 of the 28 days fall within the current-week listView - // window. That yields 7 * EVENTS_PER_DAY = 35 events in view — a - // deterministic target the test can assert against. - seedBulkEvents(vaultDir, TOTAL_COUNT, { - prefix: "Seeded", - spreadDays: SPREAD_DAYS, - startDayOffset: currentWeekStartOffset(), - }); - - await openCalendar(page); - // Gate on the indexer catching up — bulk ingest of 140+ events runs - // longer than the default 500ms refresh settle. - await waitForEventCount(page, TOTAL_COUNT); - - await switchCalendarViewMode(page, "list"); - await expectCalendarConsistent(page, { indexer: TOTAL_COUNT, visible: 7 * EVENTS_PER_DAY }); - - // Indexer doesn't change across view switches — a drop here would - // mean the view change torched part of the cache. - await switchCalendarViewMode(page, "day"); - await waitForIndexerToReach(page, TOTAL_COUNT); - - // Day view shows only today's slice (EVENTS_PER_DAY events). Events - // seeded for the other 27 days of the spread are filtered out. - await expectUniqueVisibleEventCount(page, EVENTS_PER_DAY); - }); -}); diff --git a/e2e/specs/stress/rapid-fire-mutations.spec.ts b/e2e/specs/stress/rapid-fire-mutations.spec.ts deleted file mode 100644 index 8b695a56..00000000 --- a/e2e/specs/stress/rapid-fire-mutations.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { waitForCommandManagerIdle } from "../../fixtures/commands"; -import { test } from "../../fixtures/electron"; -import { openCalendar, switchCalendarViewMode } from "../../fixtures/helpers"; -import { - clickBatchButton, - confirmBatchAction, - enterBatchMode, - exitBatchMode, - expectSelectedCount, - undoViaPalette, -} from "../../fixtures/history-helpers"; -import { waitForEventCount } from "../../fixtures/seed-events"; -import { - currentWeekStartOffset, - expectCalendarConsistent, - expectUniqueVisibleEventCount, - seedBulkEvents, -} from "../../fixtures/stress-helpers"; -import { sel, TID } from "../../fixtures/testids"; - -// Rapid-fire mutations: exercise the RAF-coalesced refresh (`scheduleRefreshEvents` -// in calendar-view.ts) by firing multiple mutations back-to-back faster than -// the calendar can settle in between. The calendar must still converge to the -// correct final state even if intermediate refreshes land on inconsistent -// snapshots of the vault. This is exactly the scenario the user reported — -// "calendar doesn't refresh itself, stays stale" — scaled up. - -const TOOLBAR_NEXT = sel(TID.toolbar("next")); -const TOOLBAR_PREV = sel(TID.toolbar("prev")); - -test.describe.configure({ mode: "serial" }); - -test.describe("stress: rapid-fire mutations against coalesced refresh", () => { - const EVENT_COUNT = 28; - - test("move-next → move-prev → move-next → undo × 3 converges to starting state", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Rapid", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - // Three mutations in quick succession — each leaves the calendar - // in a different state (next week, prev week, next week). The - // refresh queue must serialize them without dropping events. - // - // Two gates guard this sequence, each for a distinct race: - // 1. `expectUniqueVisibleEventCount` after a navigation waits for - // FC's event store to reach the expected count in the new - // window. `select-all` iterates `calendar.getEvents()`, which is - // scoped to the current view range via `buildCalendarEvents`. - // Firing select-all before the post-move refresh has finished - // populating the new window picks up a partial set — the - // remaining events stay stranded on the other week. Selection - // never auto-grows when later events mount, so this can't be - // rescued by polling on the selection count alone. - // 2. `expectSelectedCount` between `select-all` and the move gates - // the DOM-styling race (events in FC's store but not yet marked - // `.prisma-batch-selected`) so the move-click reads a fully - // materialized selection. - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - await page.locator(TOOLBAR_NEXT).first().click(); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-prev"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - await page.locator(TOOLBAR_PREV).first().click(); - - // Source week has the events back. - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "move-next"); - await exitBatchMode(page); - await expectUniqueVisibleEventCount(page, 0); - - // Three undos peel back move-next → move-prev → move-next. `waitForCommandManagerIdle` - // gates each undo on its MacroCommand fully draining (each reversal writes - // 28 frontmatter files) before the next dispatches — without it two - // fire-and-forget undos re-enter `undoExecuted` against the same array and - // crash. The indexer count never moves on a reschedule (28 → 28), so the - // rendered count is what proves where the events landed: 28 = source week, - // 0 = pushed to the next week (the view stays on the source week throughout). - await undoViaPalette(page, 1); // undo move-next: events return to the source week - await waitForCommandManagerIdle(page); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - await undoViaPalette(page, 1); // undo move-prev: events shift back to the next week - await waitForCommandManagerIdle(page); - await expectUniqueVisibleEventCount(page, 0); - await undoViaPalette(page, 1); // undo move-next: events land on their original source week - await waitForCommandManagerIdle(page); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - }); - - test("delete → undo → delete → undo twice in a row keeps events in sync", async ({ obsidian }) => { - test.slow(); - const { page, vaultDir } = obsidian; - - seedBulkEvents(vaultDir, EVENT_COUNT, { - prefix: "Twice", - spreadDays: 7, - startDayOffset: currentWeekStartOffset(), - }); - await openCalendar(page); - await waitForEventCount(page, EVENT_COUNT); - await switchCalendarViewMode(page, "week"); - await expectUniqueVisibleEventCount(page, EVENT_COUNT); - - for (let cycle = 0; cycle < 2; cycle++) { - await enterBatchMode(page); - await clickBatchButton(page, "select-all"); - await expectSelectedCount(page, EVENT_COUNT); - await clickBatchButton(page, "delete"); - await confirmBatchAction(page); - await exitBatchMode(page); - await expectCalendarConsistent(page, { indexer: 0, visible: 0 }); - - await undoViaPalette(page, 1); - await waitForCommandManagerIdle(page); - await expectCalendarConsistent(page, { indexer: EVENT_COUNT, visible: EVENT_COUNT }); - } - }); -}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json deleted file mode 100644 index a27f397f..00000000 --- a/e2e/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["node"], - "noUnusedLocals": false, - "noUnusedParameters": false, - "exactOptionalPropertyTypes": false, - "noPropertyAccessFromIndexSignature": false - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "test-results", "playwright-report"] -} diff --git a/shared-react/package.json b/shared-react/package.json index 2dc6f4a8..bffeaac4 100644 --- a/shared-react/package.json +++ b/shared-react/package.json @@ -1,5 +1,5 @@ { - "name": "shared-react", + "name": "@real1ty-obsidian-plugins-react", "version": "0.1.0", "type": "module", "main": "./src/index.ts", diff --git a/shared-react/src/hooks/settings/use-schema-field.ts b/shared-react/src/hooks/settings/use-schema-field.ts index ecaef14b..ba1dfe2c 100644 --- a/shared-react/src/hooks/settings/use-schema-field.ts +++ b/shared-react/src/hooks/settings/use-schema-field.ts @@ -9,13 +9,8 @@ import type { SettingsStorelike as BaseSettingsStorelike } from "./use-settings- * Looser structural type so schema-driven primitives don't force consumers' * invariant settings generics into a single concrete shape. Field values are * unknown at this layer; the schema carries the real types. - * - * The `any` default is load-bearing: `BehaviorSubject` is invariant in `T` and - * concrete settings shapes carry no index signature, so `unknown`/`Record`/`object` - * defaults break consumer typecheck (e.g. page-header). It cannot be retyped. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- load-bearing default; see above -export type SettingsStorelike<T = any> = BaseSettingsStorelike<T>; +export type SettingsStorelike<T = Record<string, unknown>> = BaseSettingsStorelike<T>; /** * Setter accepts either a direct value OR an updater function that receives @@ -56,9 +51,9 @@ function makeBinding<V>(value: V, onChange: SchemaFieldSetter<V>): SchemaFieldBi */ export function pathFilteredSnapshot<V>(store: SettingsStorelike, path: string): SnapshotSubscribable<V> { return { - getValue: () => getNestedValue(store.settings$.getValue() as Record<string, unknown>, path) as V, + getValue: () => getNestedValue(store.settings$.getValue(), path) as V, subscribe(listener) { - let last = getNestedValue(store.settings$.getValue() as Record<string, unknown>, path); + let last = getNestedValue(store.settings$.getValue(), path); const sub = store.settings$.subscribe((settings: Record<string, unknown>) => { const next = getNestedValue(settings, path); if (next !== last) { diff --git a/shared/package.json b/shared/package.json index 075f08d9..f160c687 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,5 +1,5 @@ { - "name": "shared", + "name": "@real1ty-obsidian-plugins", "version": "2.34.0", "type": "module", "main": "./src/index.ts", diff --git a/shared/src/testing/e2e/bootstrap.ts b/shared/src/testing/e2e/bootstrap.ts deleted file mode 100644 index 839333c7..00000000 --- a/shared/src/testing/e2e/bootstrap.ts +++ /dev/null @@ -1,851 +0,0 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { - appendFileSync, - cpSync, - existsSync, - linkSync, - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, -} from "node:fs"; -import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; - -import { chromium, type Browser, type Page } from "@playwright/test"; - -import { waitForApp, waitForPluginLoaded, waitForPlugins } from "./helpers"; -import type { ObsidianWindow } from "./types"; - -// Generic Obsidian E2E bootstrap — spawns a real Obsidian Electron instance per -// test, connects Playwright over CDP, and drives the plugin registry to a known -// state. Plugin-specific setup (seeding data.json, waiting for runtime structures -// to populate) is passed in via hooks. -// -// ── Why spawn + CDP instead of `_electron.launch`? ──────────────────────────── -// `_electron.launch({ executablePath: obsidian-installer })` hangs indefinitely -// (~120s) waiting for a handshake the installer never emits — the packaged -// Obsidian binary silently ignores Playwright's electron launch dance. Using -// the standalone `electron` npm package as the runtime fails differently: -// main.js runs but never creates a BrowserWindow (confirmed via CDP). -// Workaround: spawn the installer ourselves with `--remote-debugging-port=<port>` -// (the installer IS the Electron runtime Obsidian was built with, and it does -// honour Chromium's CDP flag), grep the "DevTools listening on ws://..." line -// from stderr, and attach Playwright via `chromium.connectOverCDP`. This skips -// Playwright's electron launch machinery entirely but still gives us full -// Page/Browser access. -// -// ── Why isolate XDG_RUNTIME_DIR? ────────────────────────────────────────────── -// Obsidian checks `$XDG_RUNTIME_DIR/.obsidian-cli.sock` at startup. If the -// developer has Obsidian running on their desktop, the spawned instance detects -// that socket and silently drops into headless CLI mode — no BrowserWindow is -// ever created, no plugin loads, the process just exits with code 0 making it -// look like Obsidian launched and quit instantly. Giving each run its own -// short `/tmp/...` XDG dir prevents the collision. The path must be *short*: -// Unix socket paths are capped at 108 bytes and Obsidian creates additional -// sockets (wayland-0, etc.) under this dir, so paths like the vault's full -// absolute path will silently truncate and fail. -// -// ── Why --disable-gpu / --disable-software-rasterizer on Linux? ────────────── -// Under xvfb (the default for headless CI) there's no real GPU; Chromium's -// hardware accel path errors out and can stall the CDP handshake indefinitely. -// Forcing the software path is reliable across headless and headed runs. -// -// ── Why programmatic plugin enable instead of clicking the trust dialog? ───── -// Community plugins start disabled on a fresh vault until the user clicks -// "Trust author and enable plugins". The dialog's DOM is fragile (different -// layouts across Obsidian versions, can race with the window paint). We bypass -// it entirely by calling `app.plugins.setEnable(true)` + `enablePluginAndSave(id)` -// from the renderer — this is what the dialog ultimately does anyway. - -export type LogLevel = "info" | "debug"; - -export type Logger = { - info: (msg: string) => void; - debug: (msg: string) => void; -}; - -export type CreateFileLoggerOptions = { - /** When true, debug-level messages are emitted. Defaults to E2E_VERBOSE=1. */ - verbose?: boolean; -}; - -export type PluginArtifact = { - /** Vault-relative plugin folder (e.g. "prisma-calendar"). */ - id: string; - /** Absolute path to the plugin source root (folder containing main.js/manifest.json/styles.css). */ - rootDir: string; - /** Files to copy from rootDir into the staged plugin folder. Defaults to main.js, manifest.json, styles.css. */ - files?: readonly string[]; -}; - -export type LeanVaultOptions = { - /** - * Vault-root entries to preserve verbatim after close. Everything else at - * the vault root is deleted. `.obsidian/` is always kept (reduced to - * `plugins/<id>/` with only the staged artifact files — `plugin.files` — - * removed; anything the plugin wrote at runtime stays), regardless of this list. - */ - keep: readonly string[]; -}; - -export type ObsidianVersion = { - appVersion: string; - installerVersion: string; -}; - -export type BootstrapOptions = { - /** Pinned Obsidian version (consumed by obsidian-launcher). */ - version: ObsidianVersion; - /** Directory to copy as the starter vault (must contain .obsidian/community-plugins.json). */ - vaultSeedDir: string; - /** Plugin artifacts to stage into `.obsidian/plugins/<id>/` in the fresh vault. */ - plugin: PluginArtifact; - /** Directory to hold per-run vault copies. Defaults to `<os-tmp>/obsidian-e2e-vaults`. */ - vaultsRoot?: string; - /** Short tag included in the vault directory name for debugging. */ - prefix?: string; - /** - * CDP port for the spawned Obsidian. Defaults to an OS-picked ephemeral free - * port. A hardcoded port collides with leftover E2E processes (Chromium holds - * the debug socket open even after the renderer dies) and with parallel - * workers; per-run picking removes both failure modes. - */ - cdpPort?: number; - /** Additional env-vars passed to the spawned Obsidian process. */ - env?: Record<string, string>; - /** Additional CLI args appended after the defaults. */ - extraArgs?: readonly string[]; - /** - * Slow every Playwright operation by the given milliseconds. Forwarded to - * `chromium.connectOverCDP({ slowMo })`. Used by demo/debug mode so the - * browser is easy to watch. Defaults to 0 (no throttle). - */ - slowMoMs?: number; - /** - * Maximize the Obsidian window and collapse the left sidebar once the - * renderer is ready. Intended for modes where a human watches the run - * (E2E_HEADED / PW_DEMO). Demo mode (slowMoMs > 0) implies this too, so - * callers only need to set it for plain headed runs. Default false — - * headless CI keeps the CDP-default viewport so screenshots stay - * deterministic. - */ - polishVisibleWindow?: boolean; - /** - * Hook invoked after the plugin files are staged; use this to write - * `data.json` into the plugin folder. Typical use: pre-seed settings AND - * set `version: manifest.version` to suppress the plugin's "What's new" - * modal on first boot (most plugins trigger it when the stored version - * differs from the manifest, and a fresh vault has no stored version). - */ - seedPluginData?: (pluginDir: string, ctx: { manifest: Record<string, unknown> }) => void | Promise<void>; - /** Hook invoked once `window.app` is available; use this to force-enable the plugin when needed. */ - onRendererReady?: (page: Page) => void | Promise<void>; - /** Hook invoked after the plugin is loaded; use this to wait for plugin-specific runtime structures. */ - afterPluginLoaded?: (page: Page) => void | Promise<void>; - /** - * When set, retained vaults (those not wiped by `E2E_CLEANUP=1`) are stripped - * down on close: only the listed vault-root entries survive, `.obsidian/` is - * reduced to our `plugins/<id>/` folder with the staged artifact files - * (`plugin.files`: main.js / manifest.json / styles.css by default) removed — - * anything the plugin wrote at runtime (data.json, snippets, logs, …) is - * preserved. Lets `ls e2e/.cache/vaults/` stay browsable for post-mortem - * without carrying gigabytes of Obsidian config + plugin builds. - */ - leanVaultOnClose?: LeanVaultOptions; - /** Optional sink for progress/debug lines. Defaults to a no-op. */ - logger?: Logger; -}; - -export type BootstrappedObsidian = { - browser: Browser; - page: Page; - process: ChildProcess; - vaultDir: string; - userDataDir: string; - readVaultFile: (relativePath: string) => string; - close: () => Promise<void>; -}; - -const DEFAULT_PLUGIN_FILES = ["main.js", "manifest.json", "styles.css"] as const; - -export function createFileLogger(logFile: string, options: CreateFileLoggerOptions = {}): Logger { - mkdirSync(join(logFile, ".."), { recursive: true }); - const verbose = options.verbose ?? process.env["E2E_VERBOSE"] === "1"; - const write = (level: LogLevel, msg: string): void => { - const line = `[e2e ${new Date().toISOString().slice(11, 23)}] ${msg}\n`; - // Always append to the log file so post-mortem has full detail; stderr - // respects the verbose flag so `pnpm test:e2e` stays legible. - try { - appendFileSync(logFile, line, "utf8"); - } catch { - // logging is best-effort; never fail a test because logging broke. - } - if (level === "info" || verbose) { - process.stderr.write(line); - } - }; - return { - info: (msg) => write("info", msg), - debug: (msg) => write("debug", msg), - }; -} - -const NOOP_LOGGER: Logger = { info: () => {}, debug: () => {} }; - -/** - * Ask the OS for a free TCP port by binding a transient listener on port 0, - * reading back the assigned port, and closing. Still race-prone in principle - * — another process could grab the port between close and spawn — but in - * practice that window is microseconds and Chromium will at worst emit a - * "address in use" error into stderr that the caller already surfaces. - */ -async function pickFreePort(): Promise<number> { - return new Promise((resolve, reject) => { - const server = createServer(); - - let settled = false; - const finishResolve = (port: number): void => { - if (settled) return; - settled = true; - resolve(port); - }; - const finishReject = (error: Error): void => { - if (settled) return; - settled = true; - reject(error); - }; - - server.unref(); - - server.once("error", (error) => { - finishReject(error instanceof Error ? error : new Error(String(error))); - }); - - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - - if (!address || typeof address !== "object") { - server.close((closeError) => { - if (closeError) finishReject(closeError); - else finishReject(new Error("failed to read ephemeral port from transient server")); - }); - return; - } - - const { port } = address; - server.close((closeError) => { - if (closeError) finishReject(closeError); - else finishResolve(port); - }); - }); - }); -} - -export async function bootstrapObsidian(options: BootstrapOptions): Promise<BootstrappedObsidian> { - const log = options.logger ?? NOOP_LOGGER; - const vaultsRoot = options.vaultsRoot ?? join(tmpdir(), "obsidian-e2e-vaults"); - const cdpPort = options.cdpPort ?? (await pickFreePort()); - log.debug(`cdpPort=${cdpPort}`); - // Vault dir name is `YYYY-MM-DD-HHmm-<prefix>-<uuid8>` so `ls -lt` shows runs - // chronologically and the cleanup task (mise run test-e2e-clean) can parse - // the date prefix to decide what's stale. The uuid segment keeps runs from - // the same minute collision-free and is what XDG uses below — we need a - // short stable id for the /tmp socket dir (108-byte socket path cap). - const uuid = randomUUID().slice(0, 8); - const timestamp = formatRunTimestamp(new Date()); - const id = `${timestamp}-${options.prefix ?? "run"}-${uuid}`; - const bootstrapStart = Date.now(); - // Legacy two-line `start` + `ready` logs are verbose under `--reporter=line`. - // Default to a single `bootstrap ok id=… (Xs)` at the end; set - // `E2E_BOOTSTRAP_LOGS=1` to restore the per-phase breadcrumbs. - const verboseBootstrap = process.env["E2E_BOOTSTRAP_LOGS"] === "1"; - if (verboseBootstrap) log.info(`bootstrap start id=${id}`); - - mkdirSync(vaultsRoot, { recursive: true }); - const vaultDir = join(vaultsRoot, id, "vault"); - mkdirSync(vaultDir, { recursive: true }); - log.debug(`vaultDir=${vaultDir}`); - - cpSync(options.vaultSeedDir, vaultDir, { recursive: true }); - log.debug(`seed vault copied from ${options.vaultSeedDir}`); - - const pluginDir = join(vaultDir, ".obsidian", "plugins", options.plugin.id); - mkdirSync(pluginDir, { recursive: true }); - const files = options.plugin.files ?? DEFAULT_PLUGIN_FILES; - for (const file of files) { - const src = join(options.plugin.rootDir, file); - if (!existsSync(src)) { - throw new Error(`plugin artifact missing: ${src} (run your build before tests)`); - } - const dest = join(pluginDir, file); - try { - linkSync(src, dest); - } catch { - cpSync(src, dest); - } - } - log.debug(`plugin artifacts staged at ${pluginDir}`); - - const manifestPath = join(options.plugin.rootDir, "manifest.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>; - if (options.seedPluginData) { - await options.seedPluginData(pluginDir, { manifest }); - log.debug(`plugin data seeded`); - } - - // obsidian-launcher's setupConfigDir writes the obsidian.json that registers - // our vault with open:true, sets the localStorage key that disables Safe - // Mode for this vaultId, writes Preferences, and copies the asar in — it's - // the sanctioned way to produce a userDataDir Obsidian will happily boot - // against. The returned installerPath is the Electron binary we spawn below. - const { userDataDir, installerPath } = await buildUserDataDir(vaultDir, options.version, log); - log.debug(`userDataDir=${userDataDir}`); - log.debug(`installerPath=${installerPath}`); - - const sandboxArgs = process.platform === "linux" ? ["--no-sandbox"] : []; - const gpuArgs = process.platform === "linux" ? ["--disable-gpu", "--disable-software-rasterizer"] : []; - // Must be a *short* path — see the XDG_RUNTIME_DIR note at the top of this - // file. Uuid8 keeps us well under the 108-byte socket limit regardless of - // how long the timestamped vault id grows. - const xdgRuntimeDir = `/tmp/o-e2e-${uuid}`; - mkdirSync(xdgRuntimeDir, { recursive: true, mode: 0o700 }); - log.debug(`XDG_RUNTIME_DIR=${xdgRuntimeDir}`); - - // --- Helper: kill the Obsidian process group + clean up temp dirs. --- - // Shared by the happy-path close() and the catch-path so neither leaks. - const killProcessGroup = (proc: ChildProcess, signal: NodeJS.Signals): void => { - const pgid = proc.pid; - if (!pgid) return; - try { - process.kill(-pgid, signal); - } catch { - // ESRCH — already dead - } - }; - - const hasExited = (proc: ChildProcess): boolean => proc.exitCode !== null || proc.signalCode !== null; - - const waitForExit = (proc: ChildProcess, timeoutMs: number): Promise<boolean> => { - if (hasExited(proc)) return Promise.resolve(true); - return new Promise((resolve) => { - const timer = setTimeout(() => { - cleanup(); - resolve(false); - }, timeoutMs); - const onExit = (): void => { - cleanup(); - resolve(true); - }; - const cleanup = (): void => { - clearTimeout(timer); - proc.off("exit", onExit); - }; - proc.once("exit", onExit); - }); - }; - - const teardownProcess = async (proc: ChildProcess): Promise<void> => { - if (!hasExited(proc)) { - killProcessGroup(proc, "SIGTERM"); - const exitedAfterTerm = await waitForExit(proc, 5_000); - if (!exitedAfterTerm && !hasExited(proc)) { - killProcessGroup(proc, "SIGKILL"); - await waitForExit(proc, 2_000); - } - } - proc.stdout?.destroy(); - proc.stderr?.destroy(); - }; - - const teardownDirs = (): void => { - rmSync(xdgRuntimeDir, { recursive: true, force: true }); - rmSync(userDataDir, { recursive: true, force: true }); - }; - - let proc: ChildProcess | undefined; - let browser: Browser | undefined; - - try { - const binary = process.env["OBSIDIAN_BIN"] ?? installerPath; - const spawnArgs = [ - ...sandboxArgs, - ...gpuArgs, - `--remote-debugging-port=${cdpPort}`, - `--user-data-dir=${userDataDir}`, - ...(options.extraArgs ?? []), - ]; - log.debug(`spawning ${binary} ${spawnArgs.join(" ")}`); - proc = spawn(binary, spawnArgs, { - env: { - ...process.env, - XDG_RUNTIME_DIR: xdgRuntimeDir, - ...options.env, - }, - stdio: ["ignore", "pipe", "pipe"], - // Run in its own process group so we can kill the entire tree (main + - // GPU + utility + renderer helper) on close. Without this, only the - // main Electron process receives SIGTERM and child processes survive as - // orphans, leaking inotify instances and file descriptors across the - // serial test run until the OS hits EMFILE / max_user_instances. - detached: true, - }); - log.debug(`spawned pid=${proc.pid}`); - proc.stdout!.on("data", (d: Buffer) => log.debug(`obsidian.stdout: ${String(d).trimEnd()}`)); - proc.on("exit", (code, signal) => log.debug(`obsidian process exit code=${code} signal=${signal}`)); - - // Grep the CDP endpoint out of Obsidian's own stderr. Chromium prints - // "DevTools listening on ws://..." as soon as the debug port is up; this - // is the one signal we can rely on cross-version without touching - // Playwright's _electron machinery. - const wsEndpoint = await new Promise<string>((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("timeout waiting for DevTools WebSocket URL from Obsidian stderr")), - 60_000 - ); - let buffer = ""; - proc!.stderr!.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - log.debug(`obsidian.stderr: ${text.trimEnd()}`); - buffer += text; - const match = buffer.match(/DevTools listening on (ws:\/\/[^\s]+)/); - if (match) { - clearTimeout(timer); - resolve(match[1]); - } - }); - proc!.on("exit", () => { - clearTimeout(timer); - reject(new Error("Obsidian process exited before DevTools WebSocket came up")); - }); - }); - log.debug(`got CDP wsEndpoint=${wsEndpoint}`); - - const slowMo = options.slowMoMs && options.slowMoMs > 0 ? options.slowMoMs : 0; - if (slowMo > 0) log.info(`slowMo=${slowMo}ms (demo mode)`); - browser = await chromium.connectOverCDP(wsEndpoint, { timeout: 30_000, slowMo }); - log.debug(`connected (contexts=${browser.contexts().length})`); - - const context = browser.contexts()[0]; - - if (!context) throw new Error("no browser context after connect"); - - // Poll context.pages() directly instead of `browser.firstWindow()` — - // firstWindow() hangs against Obsidian (never resolves) because of how the - // renderer window is attached after the CDP connection is already up. The - // last page in the list is the workspace window; earlier entries can be - // transient splash/dev pages. - let page: Page | undefined; - const pageDeadline = Date.now() + 120_000; - while (Date.now() < pageDeadline) { - const pages = context.pages(); - if (pages.length > 0) { - page = pages[pages.length - 1]!; - log.debug(`found ${pages.length} page(s); using last: url=${page.url()}`); - break; - } - await new Promise((r) => setTimeout(r, 500)); - } - if (!page) throw new Error("no Obsidian renderer page appeared within 120s"); - - pipeRendererConsole(page, log); - - await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch((err) => { - log.debug(`domcontentloaded timeout — continuing: ${String(err)}`); - }); - - log.debug(`waiting for window.app...`); - await waitForApp(page, 60_000); - log.debug(`window.app exists`); - - if (options.onRendererReady) { - await options.onRendererReady(page); - } - - await ensurePluginLoaded(page, options.plugin.id, log, 60_000, options.onRendererReady); - - if (options.afterPluginLoaded) { - await options.afterPluginLoaded(page); - } - - // Demo/headed polish: maximize the window and collapse Obsidian's left - // sidebar so what the human sees is the plugin UI, not chrome. Triggered - // whenever the caller opts in via `polishVisibleWindow` OR slowMo > 0 - // (PW_DEMO implies a watched run). In a plain headless CI run the viewport - // stays at CDP defaults. - if (slowMo > 0 || options.polishVisibleWindow) { - await configureDemoViewport(page, log); - } - - const elapsed = ((Date.now() - bootstrapStart) / 1000).toFixed(1); - if (verboseBootstrap) { - log.info(`bootstrap ready id=${id} plugin=${options.plugin.id}`); - } else { - log.info(`bootstrap ok id=${id} plugin=${options.plugin.id} (${elapsed}s)`); - } - - // Capture proc/browser in a local const so the close() closure doesn't - // need to reason about the outer `let` being reassigned. - const spawnedProc = proc; - const connectedBrowser = browser; - - return { - browser: connectedBrowser, - page, - process: spawnedProc, - vaultDir, - userDataDir, - readVaultFile: (relativePath: string) => readFileSync(join(vaultDir, relativePath), "utf8"), - close: async () => { - log.debug(`closing obsidian (${id})`); - await connectedBrowser.close().catch((err) => log.debug(`browser close error: ${String(err)}`)); - await teardownProcess(spawnedProc); - if (process.env["E2E_CLEANUP"] === "1") { - rmSync(join(vaultsRoot, id), { recursive: true, force: true }); - log.debug(`vault cleaned`); - } else if (options.leanVaultOnClose) { - leanVault(vaultDir, options.plugin.id, files, options.leanVaultOnClose, log); - log.debug(`vault leaned at ${join(vaultsRoot, id)} (set E2E_CLEANUP=1 to delete)`); - } else { - log.debug(`vault kept at ${join(vaultsRoot, id)} (set E2E_CLEANUP=1 to delete)`); - } - teardownDirs(); - log.debug(`dirs cleaned: xdg=${xdgRuntimeDir} userData=${userDataDir}`); - }, - }; - } catch (err) { - log.debug(`bootstrap failed (${id}): ${err instanceof Error ? err.message : String(err)}`); - await browser?.close().catch(() => {}); - if (proc) await teardownProcess(proc); - teardownDirs(); - throw err; - } -} - -function leanVault( - vaultDir: string, - pluginId: string, - stripPluginArtifacts: readonly string[], - lean: LeanVaultOptions, - log: Logger -): void { - // Vault root: keep only the caller-nominated entries plus `.obsidian`. Delete everything else. - const keep = new Set<string>([...lean.keep, ".obsidian"]); - for (const entry of readdirSync(vaultDir)) { - if (keep.has(entry)) continue; - rmSync(join(vaultDir, entry), { recursive: true, force: true }); - } - - const obsidianDir = join(vaultDir, ".obsidian"); - if (!existsSync(obsidianDir)) return; - - // .obsidian/: keep only the `plugins/` subtree. `app.json`, `appearance.json`, - // `core-plugins.json`, `workspace.json`, etc. are all reproducible from a - // fresh boot — no point hoarding copies. - for (const entry of readdirSync(obsidianDir)) { - if (entry !== "plugins") { - rmSync(join(obsidianDir, entry), { recursive: true, force: true }); - } - } - - const pluginsDir = join(obsidianDir, "plugins"); - if (!existsSync(pluginsDir)) return; - - // plugins/: drop every other plugin's folder; we only care about ours. - for (const entry of readdirSync(pluginsDir)) { - if (entry !== pluginId) { - rmSync(join(pluginsDir, entry), { recursive: true, force: true }); - } - } - - // plugins/<pluginId>/: remove only the staged artifacts (main.js, manifest.json, - // styles.css — whatever the plugin passes as `files`). Everything the plugin - // wrote at runtime (data.json, snippets, logs, etc.) is preserved as-is. - const pluginDir = join(pluginsDir, pluginId); - if (existsSync(pluginDir)) { - for (const name of stripPluginArtifacts) { - const target = join(pluginDir, name); - if (existsSync(target)) rmSync(target, { recursive: true, force: true }); - } - } - - log.debug( - `leanVault: stripped ${vaultDir} (kept roots: ${[...lean.keep].join(", ")}; dropped plugin artifacts: ${stripPluginArtifacts.join(", ")})` - ); -} - -type LauncherCache = { - launcher: ObsidianLauncherLike; - installerPath: string; - appPathInTmp: string; -}; - -let _launcherCache: LauncherCache | undefined; - -async function ensureLauncherCache(version: ObsidianVersion, log: Logger): Promise<LauncherCache> { - if (_launcherCache) return _launcherCache; - - log.debug("initializing launcher cache (first test in this worker)"); - const launcherModule = await import("obsidian-launcher"); - const Launcher = (launcherModule as { default: new () => ObsidianLauncherLike }).default; - const launcher = new Launcher(); - const [appPath, installerPath] = await Promise.all([ - launcher.downloadApp(version.appVersion), - launcher.downloadInstaller(version.installerVersion), - ]); - - // Stage the asar under /tmp so setupConfigDir's internal linkOrCp can - // hardlink it into every per-test config dir (both paths on the same fs). - // The downloaded asar often lives under ~/.cache — a different mount on - // many Linux setups — which forces a full 23 MB copy per test. - // The basename must stay unchanged (setupConfigDir uses it as the dest name). - const asarCacheDir = join(tmpdir(), "obsidian-e2e-asar-cache"); - mkdirSync(asarCacheDir, { recursive: true }); - const tmpAsar = join(asarCacheDir, basename(appPath)); - if (!existsSync(tmpAsar)) { - const tmpWrite = `${tmpAsar}.${process.pid}`; - cpSync(appPath, tmpWrite); - try { - renameSync(tmpWrite, tmpAsar); - } catch { - rmSync(tmpWrite, { force: true }); - } - log.debug(`asar staged in tmpdir: ${tmpAsar}`); - } - - _launcherCache = { launcher, installerPath, appPathInTmp: tmpAsar }; - return _launcherCache; -} - -async function buildUserDataDir( - vaultDir: string, - version: ObsidianVersion, - log: Logger -): Promise<{ userDataDir: string; installerPath: string }> { - const { launcher, installerPath, appPathInTmp } = await ensureLauncherCache(version, log); - const userDataDir = await launcher.setupConfigDir({ - appVersion: version.appVersion, - installerVersion: version.installerVersion, - appPath: appPathInTmp, - vault: vaultDir, - }); - return { userDataDir, installerPath }; -} - -type ObsidianLauncherLike = { - downloadApp: (version: string) => Promise<string>; - downloadInstaller: (version: string) => Promise<string>; - setupConfigDir: (opts: { - appVersion: string; - installerVersion: string; - appPath: string; - vault: string; - }) => Promise<string>; -}; - -async function ensurePluginLoaded( - page: Page, - pluginId: string, - log: Logger, - timeoutMs = 60_000, - onRendererReady?: (page: Page) => void | Promise<void> -): Promise<void> { - log.debug(`ensurePluginLoaded: waiting for app.plugins...`); - await waitForPlugins(page, timeoutMs); - - const deadline = Date.now() + timeoutMs; - const MAX_ATTEMPTS = 5; - const POLL_INTERVAL_MS = 5_000; - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - if (Date.now() >= deadline) break; - - const result = await tryEnablePlugin(page, pluginId); - for (const step of result.trace) log.debug(`enable[${attempt}]: ${step}`); - - if (result.loaded) { - log.debug(`ensurePluginLoaded: ${pluginId} loaded (attempt ${attempt})`); - return; - } - - if (result.hasFail) { - log.debug(`enable[${attempt}]: enable call reported FAIL — retrying immediately`); - await page.waitForTimeout(500); - continue; - } - - // enablePluginAndSave reported ok but the plugin isn't in the registry - // yet — Obsidian may still be running onload(). Short-poll before retry. - const remainingMs = Math.min(POLL_INTERVAL_MS, deadline - Date.now()); - if (remainingMs <= 0) break; - - const appeared = await waitForPluginLoaded(page, pluginId, remainingMs); - - if (appeared) { - log.debug(`ensurePluginLoaded: ${pluginId} loaded (attempt ${attempt}, after poll)`); - return; - } - - log.debug(`enable[${attempt}]: plugin not in registry after ${POLL_INTERVAL_MS}ms poll`); - - if (page.isClosed()) { - throw new Error(`ensurePluginLoaded: page closed during attempt ${attempt}`); - } - } - - // Last resort: reload the page. enablePluginAndSave persisted the plugin - // to community-plugins.json, so Obsidian will load it from saved state. - log.debug(`ensurePluginLoaded: ${MAX_ATTEMPTS} attempts exhausted — reloading page as fallback`); - await page.reload({ timeout: 30_000 }); - await waitForApp(page, 30_000); - if (onRendererReady) await onRendererReady(page); - await waitForPlugins(page, 30_000); - - const finalCheck = await waitForPluginLoaded(page, pluginId, 30_000); - - if (finalCheck) { - log.debug(`ensurePluginLoaded: ${pluginId} loaded after page reload`); - return; - } - - const diagnostics = await collectPluginDiagnostics(page, pluginId); - throw new Error( - `ensurePluginLoaded: ${pluginId} not loaded after ${MAX_ATTEMPTS} attempts + page reload.\n${diagnostics}` - ); -} - -type EnableResult = { - trace: string[]; - loaded: boolean; - hasFail: boolean; -}; - -async function tryEnablePlugin(page: Page, pluginId: string): Promise<EnableResult> { - const result = await page.evaluate(async (id) => { - const { plugins } = (window as unknown as ObsidianWindow).app; - const steps: string[] = []; - let hasFail = false; - const call = async (label: string, fn: () => Promise<unknown> | unknown): Promise<void> => { - try { - await fn(); - steps.push(`${label}: ok`); - } catch (err) { - hasFail = true; - steps.push(`${label}: FAIL ${err instanceof Error ? err.message : String(err)}`); - } - }; - // Order matters: setEnable(true) flips the "community plugins allowed" - // switch (same as clicking the trust dialog), loadManifests() populates - // the manifests registry from disk, and enablePluginAndSave() loads the - // plugin module AND persists the change to community-plugins.json so a - // subsequent reload within the same vault keeps it enabled. - if (typeof plugins.setEnable === "function") await call("setEnable(true)", () => plugins.setEnable!(true)); - if (typeof plugins.loadManifests === "function") await call("loadManifests()", () => plugins.loadManifests!()); - if (!plugins.plugins[id]) { - if (typeof plugins.enablePluginAndSave === "function") { - await call(`enablePluginAndSave(${id})`, () => plugins.enablePluginAndSave!(id)); - } else if (typeof plugins.enablePlugin === "function") { - await call(`enablePlugin(${id})`, () => plugins.enablePlugin!(id)); - } else { - steps.push("no enable fn"); - hasFail = true; - } - } - const loaded = Boolean(plugins.plugins[id]); - steps.push(`loaded=${Object.keys(plugins.plugins).join(",")}`); - return { trace: steps, loaded, hasFail }; - }, pluginId); - return result; -} - -async function collectPluginDiagnostics(page: Page, pluginId: string): Promise<string> { - try { - return await page.evaluate((id) => { - const { plugins } = (window as unknown as ObsidianWindow).app; - if (!plugins) return "app.plugins is falsy"; - const lines: string[] = []; - lines.push(`manifests: ${Object.keys(plugins.manifests ?? {}).join(", ") || "(none)"}`); - lines.push(`loaded plugins: ${Object.keys(plugins.plugins ?? {}).join(", ") || "(none)"}`); - lines.push(`target "${id}" in manifests: ${Boolean(plugins.manifests?.[id])}`); - lines.push(`target "${id}" in plugins: ${Boolean(plugins.plugins?.[id])}`); - if (plugins.enabledPlugins instanceof Set) { - lines.push(`target "${id}" in enabledPlugins: ${plugins.enabledPlugins.has(id)}`); - } - return lines.join("\n"); - }, pluginId); - } catch { - return "(diagnostics unavailable — page may have crashed)"; - } -} - -function pipeRendererConsole(page: Page, log: Logger): void { - page.on("console", (msg) => log.debug(`console.${msg.type()}: ${msg.text()}`)); - page.on("pageerror", (err) => { - if (isTransientObsidianTeardownError(err.message)) return; - log.info(`pageerror: ${err.message}\n${err.stack ?? ""}`); - }); - page.on("crash", () => log.info(`PAGE CRASHED`)); -} - -// Maximize the Obsidian window and collapse the left sidebar so the watched -// run shows the plugin UI instead of workspace chrome. Maximize goes through -// `@electron/remote`, which Obsidian enables for its renderer — external CDP -// approaches (Browser.setWindowBounds, window.resizeTo, Chromium's -// --start-maximized CLI switch) all fail against Obsidian because the app -// sets explicit BrowserWindow bounds during startup and overrides them. -// Both steps are best-effort: a failure here is cosmetic and must not fail -// the spec run. -async function configureDemoViewport(page: Page, log: Logger): Promise<void> { - log.debug("demo viewport: maximizing window and collapsing left sidebar"); - - await page - .evaluate(() => { - const w = window as unknown as ObsidianWindow; - const remote = w.require?.("@electron/remote") as { getCurrentWindow?: () => { maximize?: () => void } }; - - remote?.getCurrentWindow?.()?.maximize?.(); - }) - .catch((err) => log.debug(`demo viewport: maximize failed: ${String(err)}`)); - - try { - const btn = page.locator(".sidebar-toggle-button.mod-left").first(); - await btn.waitFor({ state: "visible", timeout: 5_000 }); - const alreadyCollapsed = await page.evaluate( - () => document.querySelector(".mod-left-split")?.classList.contains("is-collapsed") ?? false - ); - if (!alreadyCollapsed) await btn.click(); - } catch (err) { - log.debug(`demo viewport: sidebar collapse failed: ${String(err)}`); - } -} - -/** - * Returns true when the error text matches an Obsidian-internal teardown race. - * With parallel workers, `browser.close()` can race with Obsidian's metadata- - * cache IDB flush — `saveFileCache` transactions fail with "database connection - * is closing", "Internal error committing transaction", or generic network - * errors. None of these are plugin bugs; filter them in the error guard. - */ -export function isTransientObsidianTeardownError(text: string): boolean { - return ( - text.includes("database connection is closing") || - text.includes("Internal error committing transaction") || - text.includes("The transaction was aborted") || - text === "AbortError" || - text === "A network error occurred." - ); -} - -// Format: YYYY-MM-DD-HHmm (local time) — chronologically sortable in `ls`, and -// easy for the cleanup task to parse. Local (not UTC) because humans reading -// their own .cache/vaults/ expect wall-clock time. -function formatRunTimestamp(date: Date): string { - const pad = (n: number): string => String(n).padStart(2, "0"); - return ( - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + - `-${pad(date.getHours())}${pad(date.getMinutes())}` - ); -} diff --git a/shared/src/testing/e2e/build.ts b/shared/src/testing/e2e/build.ts deleted file mode 100644 index f57b1aa5..00000000 --- a/shared/src/testing/e2e/build.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync, statSync } from "node:fs"; -import { resolve } from "node:path"; - -export type EnsurePluginBuiltOptions = { - /** Absolute path to the plugin root (folder containing package.json + manifest.json). */ - pluginRoot: string; - /** Files that must exist at pluginRoot to consider it "already built". */ - artifacts?: readonly string[]; - /** - * Subset of `artifacts` that the build command actually regenerates every - * run (the real bundle outputs). Freshness compares against the OLDEST of - * these — so a fresh `styles.css` can't mask a stale `main.js`. Excludes - * source-controlled artifacts like `manifest.json` whose mtime tracks the - * git checkout, not the build. Defaults to `main.js` + `styles.css`. - */ - buildOutputs?: readonly string[]; - /** - * Directories (relative to `pluginRoot`) whose mtimes invalidate the - * cached build. Defaults cover the plugin's own source plus the workspace - * `shared/` / `shared-react/` packages — the common case in this monorepo. - * An empty array disables staleness detection. - */ - sourceDirs?: readonly string[]; - /** Command to run when artifacts are missing or stale. Defaults to `pnpm run build`. */ - buildCommand?: readonly string[]; - /** When true, skip rebuilding if artifacts exist AND are fresher than sources. Default: true. */ - skipIfBuilt?: boolean; -}; - -const DEFAULT_ARTIFACTS = ["main.js", "manifest.json", "styles.css"] as const; -const DEFAULT_BUILD_OUTPUTS = ["main.js", "styles.css"] as const; -const DEFAULT_SOURCE_DIRS = ["src", "../shared/src", "../shared-react/src"] as const; - -/** Shared primitive: newest mtime among the given paths. Missing paths skipped. */ -function newestMtime(paths: Iterable<string>): number { - let newest = 0; - for (const path of paths) { - let st; - try { - st = statSync(path); - } catch { - continue; - } - if (st.isFile() && st.mtimeMs > newest) newest = st.mtimeMs; - } - return newest; -} - -/** Shared primitive: oldest mtime among the given paths. Missing paths skipped; 0 if none exist. */ -function oldestMtime(paths: Iterable<string>): number { - let oldest = Infinity; - for (const path of paths) { - let st; - try { - st = statSync(path); - } catch { - continue; - } - if (st.isFile() && st.mtimeMs < oldest) oldest = st.mtimeMs; - } - return oldest === Infinity ? 0 : oldest; -} - -/** Yield every non-hidden source file under `root`, skipping `node_modules` / `dist`. */ -function* walkSourceFiles(root: string): Generator<string> { - if (!existsSync(root)) return; - const stack: string[] = [root]; - while (stack.length > 0) { - const dir = stack.pop(); - if (!dir) continue; - let entries: string[]; - try { - entries = readdirSync(dir); - } catch { - continue; - } - for (const entry of entries) { - if (entry === "node_modules" || entry === "dist" || entry.startsWith(".")) continue; - const full = resolve(dir, entry); - let st; - try { - st = statSync(full); - } catch { - continue; - } - if (st.isDirectory()) stack.push(full); - else if (st.isFile()) yield full; - } - } -} - -/** - * Build's effective completion time = OLDEST build-output mtime. A build is - * fresh only if *every* regenerated output postdates every source — so the - * oldest output is the one that decides. Using the newest (max) would let a - * separately-touched output mask a stale one: e.g. an `apply`/`merge` that - * rewrites `styles.css` after `main.js` was last built leaves a fresh - * `styles.css` mtime hiding a stale `main.js`, and the check would wrongly - * skip the rebuild. `manifest.json` is excluded from `buildOutputs` because - * it's source-controlled — its mtime is the git-checkout time, unrelated to - * the build. - */ -function oldestBuildOutputMtime(pluginRoot: string, buildOutputs: readonly string[]): number { - return oldestMtime(buildOutputs.map((file) => resolve(pluginRoot, file))); -} - -/** Newest source-file mtime across `sourceDirs`. Returns 0 if no sources tracked. */ -function newestSourceMtime(pluginRoot: string, sourceDirs: readonly string[]): number { - let newest = 0; - for (const dir of sourceDirs) { - const m = newestMtime(walkSourceFiles(resolve(pluginRoot, dir))); - if (m > newest) newest = m; - } - return newest; -} - -/** - * Ensure the plugin bundle is built AND fresher than its source files. Called - * before staging artifacts into the per-test vault. Rebuilds when: - * - any artifact is missing, OR - * - `skipIfBuilt` is false, OR - * - any source file under `sourceDirs` has a newer mtime than the oldest - * artifact (catches `git checkout` / `git merge` / branch switches where - * source changed but `main.js` wasn't rebuilt). - */ -export function ensurePluginBuilt(options: EnsurePluginBuiltOptions): void { - const artifacts = options.artifacts ?? DEFAULT_ARTIFACTS; - const buildOutputs = options.buildOutputs ?? DEFAULT_BUILD_OUTPUTS; - const sourceDirs = options.sourceDirs ?? DEFAULT_SOURCE_DIRS; - const skipIfBuilt = options.skipIfBuilt ?? true; - - const missing = artifacts.filter((file) => !existsSync(resolve(options.pluginRoot, file))); - const artifactMtime = missing.length === 0 ? oldestBuildOutputMtime(options.pluginRoot, buildOutputs) : 0; - const sourceMtime = newestSourceMtime(options.pluginRoot, sourceDirs); - const isStale = artifactMtime > 0 && sourceMtime > artifactMtime; - - let reason: string | null = null; - if (missing.length > 0) reason = `missing artifacts: ${missing.join(", ")}`; - else if (!skipIfBuilt) reason = "skipIfBuilt disabled"; - else if (isStale) { - const ageMs = sourceMtime - artifactMtime; - reason = `stale bundle: source newer than build by ${(ageMs / 1000).toFixed(1)}s`; - } - - if (reason === null) { - console.log("[e2e] build artifacts fresh, skipping build."); - return; - } - - const [cmd, ...args] = options.buildCommand ?? ["pnpm", "run", "build"]; - if (!cmd) throw new Error("ensurePluginBuilt: empty buildCommand"); - console.log(`[e2e] building plugin via '${cmd} ${args.join(" ")}' (${reason})`); - const result = spawnSync(cmd, args, { cwd: options.pluginRoot, stdio: "inherit" }); - if (result.status !== 0) { - throw new Error(`[e2e] build failed with status ${result.status ?? "null"}`); - } -} diff --git a/shared/src/testing/e2e/dom.ts b/shared/src/testing/e2e/dom.ts deleted file mode 100644 index 030e1281..00000000 --- a/shared/src/testing/e2e/dom.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Page } from "@playwright/test"; - -// Cross-cutting renderer helpers. Plugin-agnostic: no plugin ids, no -// plugin-specific test-id prefixes. Plugin-specific helpers (activating a -// view, switching tabs by stamped test-id) live in each plugin's own e2e dir. - -/** - * Ensure no modal is currently blocking the workspace. Any visible `.modal` - * is dismissed via its close button or Escape. Useful at test start to clear - * a stray "What's new" / error modal before driving the UI. - */ -export async function waitForNoModal(page: Page): Promise<void> { - const modal = page.locator(".modal-container .modal").first(); - const count = await modal.count(); - if (count === 0) return; - const closeBtn = modal.locator(".modal-close-button").first(); - if ((await closeBtn.count()) > 0) { - await closeBtn.click().catch(() => {}); - } else { - await page.keyboard.press("Escape").catch(() => {}); - } - await page - .locator(".modal-container .modal") - .waitFor({ state: "hidden", timeout: 5_000 }) - .catch(() => {}); -} - -/** Dismiss the topmost Obsidian Notice (if any) so screenshots stay clean. */ -export async function dismissNotice(page: Page): Promise<void> { - const notice = page.locator(".notice").first(); - if ((await notice.count()) > 0) { - await notice.click().catch(() => {}); - } -} - -/** Return the text of the most recently rendered Obsidian Notice, or null. */ -export async function getLastNotice(page: Page): Promise<string | null> { - const notice = page.locator(".notice").last(); - if ((await notice.count()) === 0) return null; - return (await notice.textContent())?.trim() ?? null; -} - -/** - * Dispatch a hotkey chord using Obsidian's portable modifier: `Mod+<key>` picks - * `Meta` on macOS and `Control` elsewhere. Pass the literal combo as shown in - * Obsidian's hotkey settings (e.g. `Mod+P`). - */ -export async function dispatchKey(page: Page, combo: string): Promise<void> { - const isMac = process.platform === "darwin"; - const resolved = combo.replace(/\bMod\b/g, isMac ? "Meta" : "Control"); - await page.keyboard.press(resolved); -} diff --git a/shared/src/testing/e2e/forms.ts b/shared/src/testing/e2e/forms.ts deleted file mode 100644 index d46b5259..00000000 --- a/shared/src/testing/e2e/forms.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; - -// Generic form-filling helpers against Obsidian Setting DOM. Each helper -// locates by `data-testid` first and scrolls the target into view before -// interacting — long settings pages and FullCalendar overflow their parents, -// so without the scroll the click lands on nothing. Interactions dispatch -// native `input` / `change` events so Zod-backed store subscriptions fire. - -function fieldLocator(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"]`).first(); -} - -async function ensureVisible(locator: Locator): Promise<void> { - await locator.waitFor({ state: "visible", timeout: 10_000 }); - await locator.scrollIntoViewIfNeeded(); -} - -/** Flip a toggle to the requested state. No-op if already there. */ -export async function setToggle(page: Page, testId: string, on: boolean): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - const current = (await locator.getAttribute("class")) ?? ""; - const isEnabled = current.includes("is-enabled"); - if (isEnabled !== on) { - await locator.click(); - } -} - -/** - * Replace the contents of a text input / textarea. Blurs after filling so the - * `useDebouncedCommit`-backed `TextInput`/`TextareaInput` controls flush their - * pending draft to the store — those controls only commit on blur, Enter, or - * after 300ms of inactivity, so a `change` event alone is swallowed. - */ -export async function setTextInput(page: Page, testId: string, value: string): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - await locator.fill(value); - await locator.blur(); -} - -/** - * Replace the contents of a numeric input. Obsidian reads `valueAsNumber`. - * Blurs after filling for the same debounced-commit reason as `setTextInput`. - */ -export async function setNumberInput(page: Page, testId: string, value: number): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - await locator.fill(String(value)); - await locator.blur(); -} - -/** Select an option in a native `<select>`. Value must be the option value, not the label. */ -export async function setDropdown(page: Page, testId: string, value: string): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - await locator.selectOption(value); -} - -/** - * Fill a chip-list widget. Types each value, confirms with Enter, and waits - * briefly for the chip to materialise. Values already present are left alone. - */ -export async function setChipList(page: Page, testId: string, values: string[]): Promise<void> { - const root = fieldLocator(page, testId); - await ensureVisible(root); - const input = root.locator("input").first(); - for (const value of values) { - const already = root.locator(`.prisma-chip:has-text("${value}"), [data-chip="${value}"]`); - if ((await already.count()) > 0) continue; - await input.fill(value); - await input.press("Enter"); - } -} - -/** Set an `<input type="datetime-local">` or `<input type="date">` field. */ -export async function setDateTimeInput(page: Page, testId: string, isoString: string): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - await locator.fill(isoString); - await locator.dispatchEvent("change"); -} - -/** Click a stamped button. Waits for visibility + scroll. */ -export async function clickButton(page: Page, testId: string): Promise<void> { - const locator = fieldLocator(page, testId); - await ensureVisible(locator); - await locator.click(); -} - -/** - * Read the current value of any stamped input / toggle / select. Returns the - * string representation so callers can `expect(...).toBe("42")` uniformly. - * Toggles read as `"true"` / `"false"`. - */ -export async function expectFieldValue(page: Page, testId: string): Promise<string> { - const locator = fieldLocator(page, testId); - await locator.waitFor({ state: "attached", timeout: 5_000 }); - return locator.evaluate((el) => { - if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { - return el.value; - } - if (el.classList.contains("checkbox-container")) { - return String(el.classList.contains("is-enabled")); - } - - return el.textContent?.trim() ?? ""; - }); -} - -export type SettleOptions = { - /** If provided, force-flush the plugin's pending saveData(). Skips the debounce wait entirely. */ - pluginId?: string; - /** Milliseconds to wait for the debounce to fire. Defaults to 500. Ignored when `pluginId` is set. */ - ms?: number; -}; - -/** - * Wait for settings autosave to hit disk. Two modes: - * - * - `pluginId` supplied (preferred): calls `plugin.saveData(plugin.settings)` - * synchronously, bypassing the store's ~300ms debounce. Input helpers now - * commit into the store synchronously (`setTextInput` blurs, `setDropdown` - * fires `change`), so the in-memory settings always reflect the intended - * state by the time we get here — no wait needed. - * - No `pluginId` (legacy): falls back to a blind `ms`-millisecond wait for - * the debounce to fire naturally. Always pass `pluginId` in new code. - */ -export async function settleSettings(page: Page, options: SettleOptions = {}): Promise<void> { - if (options.pluginId) { - await page.evaluate(async (id) => { - const w = window as unknown as { - app: { - plugins: { - plugins: Record<string, { saveData?: (data: unknown) => Promise<void>; settings?: unknown }>; - }; - }; - }; - const plugin = w.app.plugins.plugins[id]; - - if (plugin && typeof plugin.saveData === "function") { - await plugin.saveData(plugin.settings).catch(() => {}); - } - }, options.pluginId); - return; - } - const ms = options.ms ?? 500; - await new Promise((resolve) => window.setTimeout(resolve, ms)); -} diff --git a/shared/src/testing/e2e/frontmatter.ts b/shared/src/testing/e2e/frontmatter.ts deleted file mode 100644 index 5a6ab9b2..00000000 --- a/shared/src/testing/e2e/frontmatter.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; - -import { parse as parseYaml } from "yaml"; - -import { deepEqualJsonLike } from "../../utils/deep-equal"; - -// File-on-disk truth helpers. Every E2E assertion that touches persistent -// state should validate the file too — the DOM lies, the vault is truth. - -function matchFrontmatter(raw: string): string | null { - // Match a leading YAML block `---\n...\n---`. We cannot rely on Obsidian's - // parser because `app.metadataCache` lags behind writes in test timings. - const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); - return match ? match[1] : null; -} - -/** Parse the YAML frontmatter at the top of a note. Returns an empty object when absent. */ -export function readEventFrontmatter(vaultDir: string, relativePath: string): Record<string, unknown> { - const absolute = join(vaultDir, relativePath); - const raw = readFileSync(absolute, "utf8"); - const block = matchFrontmatter(raw); - if (block === null) return {}; - const parsed = parseYaml(block) as unknown; - return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}; -} - -/** - * Assert that every key in `expected` is present with a deep-equal value on the - * given event file. Throws an Error with a diff when a key is missing or off. - */ -export function expectFrontmatter(vaultDir: string, relativePath: string, expected: Record<string, unknown>): void { - const actual = readEventFrontmatter(vaultDir, relativePath); - const misses: string[] = []; - for (const [key, want] of Object.entries(expected)) { - const got = actual[key]; - if (!deepEqualJsonLike(got, want)) { - misses.push(` ${key}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`); - } - } - if (misses.length > 0) { - throw new Error(`frontmatter mismatch in ${relativePath}:\n${misses.join("\n")}`); - } -} - -/** Absolute paths to every `.md` under the events subdirectory. Recurses. */ -export function listEventFiles(vaultDir: string, subdir = "Events"): string[] { - const root = join(vaultDir, subdir); - if (!existsSync(root)) return []; - const out: string[] = []; - const walk = (dir: string): void => { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - const s = statSync(full); - if (s.isDirectory()) walk(full); - else if (s.isFile() && entry.endsWith(".md")) out.push(full); - } - }; - walk(root); - return out; -} - -/** Parse a plugin's `data.json` from the vault's `.obsidian/plugins/<id>/` directory. */ -export function readPluginData(vaultDir: string, pluginId: string): unknown { - const path = join(vaultDir, ".obsidian", "plugins", pluginId, "data.json"); - if (!existsSync(path)) return null; - return JSON.parse(readFileSync(path, "utf8")) as unknown; -} - -/** - * Assert that a plugin's `data.json` matches the expected partial state. - * Keys in `expected` may be dotted paths (e.g. `"general.stopwatch.enabled"`) - * for nested assertions; top-level keys are deep-equal compared directly. - */ -export function expectPluginData(vaultDir: string, pluginId: string, expected: Record<string, unknown>): void { - const actual = readPluginData(vaultDir, pluginId); - if (actual === null || typeof actual !== "object") { - throw new Error(`plugin data.json missing or non-object for ${pluginId}`); - } - const misses: string[] = []; - for (const [path, want] of Object.entries(expected)) { - const got = path.includes(".") - ? getByPath(actual as Record<string, unknown>, path) - : (actual as Record<string, unknown>)[path]; - if (!deepEqualJsonLike(got, want)) { - misses.push(` ${path}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`); - } - } - if (misses.length > 0) { - throw new Error(`data.json mismatch for ${pluginId}:\n${misses.join("\n")}`); - } -} - -function getByPath(root: Record<string, unknown>, path: string): unknown { - let cur: unknown = root; - for (const segment of path.split(".")) { - if (cur === null || typeof cur !== "object") return undefined; - cur = (cur as Record<string, unknown>)[segment]; - } - return cur; -} diff --git a/shared/src/testing/e2e/global-setup.ts b/shared/src/testing/e2e/global-setup.ts deleted file mode 100644 index 7ea6c1ce..00000000 --- a/shared/src/testing/e2e/global-setup.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readdirSync, rmSync, statSync } from "node:fs"; -import { join } from "node:path"; - -export interface GlobalSetupOptions { - vaultsRoot: string; - staleThresholdMs?: number; -} - -const DEFAULT_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours -const STALE_TMP_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes - -export function pruneStaleE2eResources(options: GlobalSetupOptions): void { - const thresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS; - pruneOrphanObsidianProcesses(); - pruneOrphanTmpDirs(); - pruneStaleVaults(options.vaultsRoot, thresholdMs); -} - -function pruneStaleVaults(vaultsRoot: string, thresholdMs: number): void { - let entries: string[]; - try { - entries = readdirSync(vaultsRoot); - } catch { - return; - } - - const cutoff = Date.now() - thresholdMs; - let removed = 0; - - for (const entry of entries) { - const full = join(vaultsRoot, entry); - try { - const stat = statSync(full); - if (stat.mtimeMs < cutoff) { - rmSync(full, { recursive: true, force: true }); - removed++; - } - } catch { - // already gone or inaccessible - } - } - - if (removed > 0) { - console.log(`[global-setup] pruned ${removed} stale vault(s) older than ${Math.round(thresholdMs / 3_600_000)}h`); - } -} - -function pruneOrphanTmpDirs(): void { - let entries: string[]; - try { - entries = readdirSync("/tmp"); - } catch { - return; - } - - const cutoff = Date.now() - STALE_TMP_THRESHOLD_MS; - const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined; - let removed = 0; - - for (const entry of entries) { - if (!entry.startsWith("o-e2e-") && !entry.startsWith("obsidian-launcher-config-")) continue; - const full = join("/tmp", entry); - try { - const stat = statSync(full); - if (stat.mtimeMs >= cutoff) continue; - if (currentUid !== undefined && stat.uid !== currentUid) continue; - rmSync(full, { recursive: true, force: true }); - removed++; - } catch { - // already gone, permission denied, or race - } - } - - if (removed > 0) { - console.log(`[global-setup] cleaned ${removed} stale orphan /tmp dir(s)`); - } -} - -function pruneOrphanObsidianProcesses(): void { - if (process.platform !== "linux") return; - - let out: string; - try { - out = execFileSync("ps", ["-eo", "pid=,pgid=,args="], { - encoding: "utf8", - timeout: 5_000, - }); - } catch { - return; - } - - let killed = 0; - const killedGroups = new Set<number>(); - - for (const line of out.split("\n")) { - const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/); - if (!match) continue; - - const pid = Number.parseInt(match[1], 10); - const pgid = Number.parseInt(match[2], 10); - const args = match[3]; - - if (!Number.isFinite(pid) || pid <= 1) continue; - if (!Number.isFinite(pgid) || pgid <= 1) continue; - - if (!args.includes("--remote-debugging-port=")) continue; - if (!args.includes("--user-data-dir=")) continue; - if (!args.includes("obsidian-launcher-config-")) continue; - - const userDataMatch = args.match(/--user-data-dir=(?:"([^"]+)"|(\S+))/); - const userDataDir = userDataMatch?.[1] ?? userDataMatch?.[2]; - if (userDataDir) { - try { - const stat = statSync(userDataDir); - if (stat.mtimeMs >= Date.now() - STALE_TMP_THRESHOLD_MS) continue; - } catch { - // dir already gone → orphaned, safe to kill - } - } - - try { - if (pgid === pid && !killedGroups.has(pgid)) { - process.kill(-pgid, "SIGKILL"); - killedGroups.add(pgid); - } else if (!killedGroups.has(pgid)) { - process.kill(pid, "SIGKILL"); - } - killed++; - } catch { - // ESRCH — already gone - } - } - - if (killed > 0) { - console.log(`[global-setup] killed ${killed} orphan Obsidian/Electron process(es) from a prior E2E run`); - } -} diff --git a/shared/src/testing/e2e/helpers.ts b/shared/src/testing/e2e/helpers.ts deleted file mode 100644 index b382d989..00000000 --- a/shared/src/testing/e2e/helpers.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Page } from "@playwright/test"; - -import type { ObsidianWindow } from "./types"; - -// Action helpers that run against the Obsidian renderer (`window.app`). -// Keep them generic — plugin-specific helpers belong in the plugin's own e2e dir. -// All casts go through the shared `ObsidianWindow` type so the shape lives in -// one place (shared/src/testing/e2e/types.ts) instead of being re-declared per -// helper. - -/** Execute an Obsidian command by ID. Yields to the event loop so async side effects settle. */ -export async function executeCommand(page: Page, commandId: string): Promise<boolean> { - return page.evaluate(async (id) => { - const w = window as unknown as ObsidianWindow; - const ok = w.app.commands.executeCommandById(id); - await new Promise((resolve) => window.setTimeout(resolve, 200)); - return ok; - }, commandId); -} - -/** Open a note by vault-relative link (no leading slash, no `.md` suffix). */ -export async function openNote(page: Page, linkText: string): Promise<void> { - await page.evaluate(async (link) => { - const w = window as unknown as ObsidianWindow; - await w.app.workspace.openLinkText(link, "", false); - }, linkText); -} - -/** Open Settings → specific plugin tab (e.g. "prisma-calendar"). */ -export async function openSettingsTab(page: Page, tabId: string): Promise<void> { - await page.evaluate((id) => { - const w = window as unknown as ObsidianWindow; - w.app.setting.open(); - w.app.setting.openTabById(id); - }, tabId); -} - -/** Whether a plugin is currently loaded (enabled and initialized). */ -export async function isPluginLoaded(page: Page, pluginId: string): Promise<boolean> { - return page.evaluate((id) => { - const w = window as unknown as ObsidianWindow; - - return Boolean(w.app.plugins?.plugins?.[id]); - }, pluginId); -} - -/** Count the registered commands whose IDs start with `${pluginId}:`. */ -export async function countPluginCommands(page: Page, pluginId: string): Promise<number> { - return page.evaluate((id) => { - const w = window as unknown as ObsidianWindow; - const commands = w.app.commands.commands ?? {}; - return Object.keys(commands).filter((commandId) => commandId.startsWith(`${id}:`)).length; - }, pluginId); -} - -// ── Bootstrap wait helpers ────────────────────────────────────────────────── -// Encapsulate the repeated waitForFunction + ObsidianWindow cast patterns that -// bootstrap.ts and plugin fixtures use to gate on Obsidian lifecycle milestones. - -/** Resolve once `window.app` exists on the renderer page. */ -export async function waitForApp(page: Page, timeout: number): Promise<void> { - await page.waitForFunction(() => Boolean((window as unknown as ObsidianWindow).app), null, { timeout }); -} - -/** Resolve once `window.app.plugins` exists. */ -export async function waitForPlugins(page: Page, timeout: number): Promise<void> { - await page.waitForFunction(() => Boolean((window as unknown as ObsidianWindow).app?.plugins), null, { timeout }); -} - -/** Resolve once `window.app.plugins.plugins[id]` is truthy; returns false on timeout. */ -export async function waitForPluginLoaded(page: Page, pluginId: string, timeout: number): Promise<boolean> { - return page - .waitForFunction((id) => Boolean((window as unknown as ObsidianWindow).app?.plugins?.plugins?.[id]), pluginId, { - timeout, - }) - .then(() => true) - .catch(() => false); -} diff --git a/shared/src/testing/e2e/index.ts b/shared/src/testing/e2e/index.ts deleted file mode 100644 index b6109a0f..00000000 --- a/shared/src/testing/e2e/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export { - bootstrapObsidian, - type BootstrapOptions, - type BootstrappedObsidian, - createFileLogger, - type CreateFileLoggerOptions, - isTransientObsidianTeardownError, - type LeanVaultOptions, - type Logger, - type LogLevel, - type ObsidianVersion, - type PluginArtifact, -} from "./bootstrap"; -export { ensurePluginBuilt, type EnsurePluginBuiltOptions } from "./build"; -export { dismissNotice, dispatchKey, getLastNotice, waitForNoModal } from "./dom"; -export { - clickButton, - expectFieldValue, - setChipList, - setDateTimeInput, - setDropdown, - setNumberInput, - setTextInput, - type SettleOptions, - settleSettings, - setToggle, -} from "./forms"; -export { - expectFrontmatter, - expectPluginData, - listEventFiles, - readEventFrontmatter, - readPluginData, -} from "./frontmatter"; -export { type GlobalSetupOptions, pruneStaleE2eResources } from "./global-setup"; -export { - countPluginCommands, - executeCommand, - isPluginLoaded, - openNote, - openSettingsTab, - waitForApp, - waitForPluginLoaded, - waitForPlugins, -} from "./helpers"; -export { - applyStandardRendererBoilerplate, - type ConsoleErrorGuard, - type ConsoleErrorGuardOptions, - createConsoleErrorGuard, - createPluginE2eHarness, - type PluginE2eHarness, - type PluginE2eHarnessOptions, - writeStandardAppJson, -} from "./plugin-bootstrap"; -export { seedMarkdownNote } from "./seed"; -export type { ObsidianApp, ObsidianPluginsRegistry, ObsidianWindow } from "./types"; -export { buildVaultPrefix, slug as slugifyVaultSegment, type VaultPrefixOptions } from "./vault-prefix"; diff --git a/shared/src/testing/e2e/infra-error-reporter.ts b/shared/src/testing/e2e/infra-error-reporter.ts deleted file mode 100644 index bdbe2601..00000000 --- a/shared/src/testing/e2e/infra-error-reporter.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Reporter, TestCase, TestError, TestResult } from "@playwright/test/reporter"; - -import { formatDepsInFluxSummary, isDepResolutionError } from "./infra-errors"; - -// Playwright reporter that detects dependency-resolution failures (node_modules -// rebuilt mid-run) and converts the otherwise-cryptic pile of per-spec reds into -// one dominant, unmistakable "ENVIRONMENT IN FLUX — re-run" verdict. -// -// It does NOT change the exit code: a poisoned run still fails (you cannot -// certify a run where specs never executed), but the failure is now legible -// instead of forty look-alike test failures. Worker processes that hit the -// teardown mid-import are already dead by the time we hear about it, so there is -// no safe way to un-spawn them — the at-launch counterpart -// (shared/scripts/e2e/check-e2e-deps.mjs) covers the "already broken before we -// started" case with a true immediate fail. See -// docs/decisions/2026-05-21-e2e-deps-in-flux-guard.md. -export default class DepsInFluxReporter implements Reporter { - private readonly affectedSpecs: string[] = []; - private announced = false; - - printsToStdio(): boolean { - return true; - } - - onError(error: TestError): void { - this.scan("<worker>", error); - } - - onTestEnd(test: TestCase, result: TestResult): void { - for (const error of result.errors) { - this.scan(test.titlePath().filter(Boolean).join(" › "), error); - } - } - - onEnd(): void { - if (this.affectedSpecs.length === 0) return; - process.stderr.write(formatDepsInFluxSummary(this.affectedSpecs)); - } - - private scan(label: string, error: TestError): void { - const haystack = `${error.message ?? ""}\n${error.stack ?? ""}\n${error.value ?? ""}`; - if (!isDepResolutionError(haystack)) return; - this.affectedSpecs.push(label); - if (!this.announced) { - this.announced = true; - // Real-time signal on the first hit so you know the moment it happens, - // not only at the end-of-run summary. - process.stderr.write( - `\n[e2e] ⚠ test dependency failed to resolve (${label}) — node_modules likely ` + - "rebuilt mid-run; see the ENVIRONMENT IN FLUX summary at the end.\n" - ); - } - } -} diff --git a/shared/src/testing/e2e/infra-errors.ts b/shared/src/testing/e2e/infra-errors.ts deleted file mode 100644 index ab2ff8d2..00000000 --- a/shared/src/testing/e2e/infra-errors.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Detection + messaging for "the dependency tree changed out from under a -// running E2E suite". When a concurrent `pnpm install` / worktree relink -// (apply/merge/validate-worktree, ensure-deps) rebuilds node_modules mid-run, -// in-flight Playwright workers fail to resolve their own test dependencies — a -// pile of per-spec `Cannot find package 'obsidian-launcher'` / -// `Cannot find module '.../@playwright/test/index.js'` errors that look like -// dozens of product failures but are one environmental event. These helpers let -// the reporter (mid-run) classify and announce it as infra, not test failure. -// See docs/decisions/2026-05-21-e2e-deps-in-flux-guard.md. - -const DEP_RESOLUTION_PATTERNS: readonly RegExp[] = [ - // ESM bare specifier that no longer resolves (e.g. obsidian-launcher). - /Cannot find package '[^']+' imported from/, - // A file under node_modules vanished mid-resolve (e.g. @playwright/test/index.js). - /Cannot find module '[^']*node_modules[^']*'/, - // Node's canonical resolution error codes, in case the wording shifts. - /ERR_MODULE_NOT_FOUND/, - /ERR_PACKAGE_PATH_NOT_EXPORTED/, -]; - -// Cap the spec list in the summary so a 200-spec poisoning doesn't bury the -// guidance under its own output. -const MAX_LISTED_SPECS = 10; - -/** - * True when `text` looks like a module/package resolution failure — the - * signature of node_modules being torn down and rebuilt mid-run. Deliberately - * narrow: a genuine assertion failure or a bootstrap crash ("Obsidian process - * exited before DevTools…") must NOT match, or we'd mislabel real reds as infra. - */ -export function isDepResolutionError(text: string | null | undefined): boolean { - if (!text) return false; - return DEP_RESOLUTION_PATTERNS.some((pattern) => pattern.test(text)); -} - -/** - * Build the dominant end-of-run banner. The run still fails (you cannot certify - * a suite where specs never executed) — this just makes the failure legible: - * "these reds are void, re-run", not "N tests are broken". - */ -export function formatDepsInFluxSummary(affectedSpecs: readonly string[]): string { - const unique = [...new Set(affectedSpecs)]; - const rule = "═".repeat(70); - const lines = [ - "", - rule, - " E2E ENVIRONMENT IN FLUX — dependencies were rebuilt mid-run", - rule, - ` ${unique.length} spec(s) failed to resolve their test dependencies.`, - "", - " This is NOT a test or product failure. node_modules was torn down and", - " rebuilt while the suite was running — almost always a concurrent", - " `pnpm install` / worktree op (apply/merge/validate-worktree, ensure-", - " deps) in another session.", - "", - " Treat these reds as VOID and re-run the suite with no concurrent", - " dependency changes.", - "", - " Affected specs:", - ...unique.slice(0, MAX_LISTED_SPECS).map((spec) => ` • ${spec}`), - ]; - if (unique.length > MAX_LISTED_SPECS) { - lines.push(` … and ${unique.length - MAX_LISTED_SPECS} more`); - } - lines.push(rule, ""); - return lines.join("\n"); -} diff --git a/shared/src/testing/e2e/plugin-bootstrap.ts b/shared/src/testing/e2e/plugin-bootstrap.ts deleted file mode 100644 index 393b615c..00000000 --- a/shared/src/testing/e2e/plugin-bootstrap.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import type { ConsoleMessage, Page } from "@playwright/test"; - -import { createFileLogger, isTransientObsidianTeardownError, type Logger, type ObsidianVersion } from "./bootstrap"; -import { pruneStaleE2eResources } from "./global-setup"; - -// Plugin-e2e fixtures share ~70 lines of identical boilerplate per producer -// plugin: cache/vaults/logs path conventions, file-logger setup, stale-vault -// prune, renderer-ready hooks (E2E flag + drop Notification + neutralise the -// notice container + silence console.log/info/debug), the `.obsidian/app.json` -// suppression write, and the console.error / pageerror guard around every -// spec body. This module factors them out so adding the third producer is a -// few lines of glue, not a copy-paste of Prisma-Calendar's fixture. -// -// Plugin-specific behaviour (Prisma's demo mode, calendar-bundle waits, DSL -// fixture variants; NotesManipulator's Pro-flip + apiManager.expose) still -// lives in each plugin's electron.ts — the factory only handles the parts -// that are genuinely the same shape across producers. - -export interface PluginE2eHarnessOptions { - /** Absolute path to the plugin's `e2e/` directory. */ - e2eRoot: string; -} - -export interface PluginE2eHarness { - /** Per-run cache root, `<e2eRoot>/.cache`. */ - readonly cacheRoot: string; - /** Vault dirs go under here, one per run. */ - readonly vaultsRoot: string; - /** Single rolling log file every bootstrap appends to. */ - readonly logFile: string; - /** Pinned Obsidian version JSON (`appVersion`, `installerVersion`). */ - readonly versionFile: string; - /** Vault skeleton copied verbatim into every per-run vault. */ - readonly vaultSeedDir: string; - /** True when E2E_VERBOSE=1 or E2E_DEBUG=1 — controls log noise. */ - readonly verbose: boolean; - /** File logger writing to `logFile`. */ - readonly log: Logger; - /** Read and parse the pinned Obsidian version. */ - readVersion(): ObsidianVersion; -} - -/** - * Set up the standard plugin-e2e directory conventions + side-effects. - * - * Call once at the top of each plugin's `e2e/fixtures/electron.ts`. It - * computes the canonical cache/vaults/log/version/seed paths under - * `e2eRoot`, opens a file logger, and prunes stale vault dirs left by - * previous runs. The returned harness is the "props" every other helper in - * this module needs. - */ -export function createPluginE2eHarness(options: PluginE2eHarnessOptions): PluginE2eHarness { - const cacheRoot = join(options.e2eRoot, ".cache"); - const vaultsRoot = join(cacheRoot, "vaults"); - const logFile = join(cacheRoot, "last-run.log"); - const versionFile = join(options.e2eRoot, "obsidian-version.json"); - const vaultSeedDir = join(options.e2eRoot, "fixtures", "vault-seed"); - const verbose = process.env["E2E_VERBOSE"] === "1" || process.env["E2E_DEBUG"] === "1"; - const log = createFileLogger(logFile, { verbose }); - - pruneStaleE2eResources({ vaultsRoot }); - - return { - cacheRoot, - vaultsRoot, - logFile, - versionFile, - vaultSeedDir, - verbose, - log, - readVersion() { - return JSON.parse(readFileSync(versionFile, "utf8")) as ObsidianVersion; - }, - }; -} - -/** - * Apply the renderer-side hooks every plugin-e2e fixture wants on every - * boot. Call this from your `onRendererReady` hook before any plugin- - * specific renderer setup; compose with extra `page.evaluate` blocks if you - * need more. - * - * Four things happen, in order: - * 1. `window.E2E = true` so plugin code can branch on it (e.g. expose - * testability seams that production builds hide). - * 2. `delete window.Notification` — the Web Notifications API. The - * notification managers in our plugins guard every system-tray call - * with `"Notification" in window`, so dropping it short-circuits the - * whole feature. Without this, every spec that ingests a near-now - * event pings the host OS tray — an audible nuisance on dev laptops. - * 3. A CSS rule on `.notice-container, .notice-container .notice` - * removes pointer events. Obsidian's success/error toasts stack - * top-right and steal clicks for several seconds. Specs assert on - * disk/state, not toast text, so blocking pointer-events on the - * whole container lets every spec skip the "wait for notices to - * drain" step. Notices still render visibly in headed runs. - * 4. When `verbose` is false, replace `console.log` / `info` / `debug` - * with no-ops so plugin logging doesn't drown Playwright's summary. - * `console.warn` and `console.error` stay live so the console-error - * guard sees real failures. - */ -export async function applyStandardRendererBoilerplate(page: Page, options: { verbose: boolean }): Promise<void> { - await page.evaluate( - ({ verbose }) => { - const w = window as unknown as { E2E?: boolean; Notification?: unknown }; - w.E2E = true; - delete w.Notification; - // eslint-disable-next-line obsidianmd/no-forbidden-elements - const style = document.createElement("style"); - style.textContent = ".notice-container, .notice-container .notice { pointer-events: none !important; }"; - document.head.appendChild(style); - if (verbose) return; - const noop = (): void => {}; - console.log = noop; - - console.info = noop; - - console.debug = noop; - }, - { verbose: options.verbose } - ); -} - -/** - * Write `.obsidian/app.json` with the testing-friendly defaults every - * producer fixture wants: - * - `alwaysUpdateLinks: true` skips the "Update links" modal that fires - * whenever the plugin renames a file (zettel id assignment, title - * change, file-rename presets). That modal blocks every subsequent - * click in the spec. - * - `promptDelete: false` skips the file-deletion confirmation that - * would otherwise hang `vault.delete` calls. - * - * Call this inside your `seedPluginData` hook. `pluginDir` is the absolute - * path to `<vault>/.obsidian/plugins/<id>/` as the shared bootstrap passes - * it; the `.obsidian/` directory is two levels up. - */ -export function writeStandardAppJson(pluginDir: string): void { - const obsidianDir = join(pluginDir, "..", ".."); - writeFileSync( - join(obsidianDir, "app.json"), - JSON.stringify({ alwaysUpdateLinks: true, promptDelete: false }, null, 2), - "utf8" - ); -} - -const TRANSIENT_OBSIDIAN_JSON_PATHS = [ - ".obsidian/app.json", - ".obsidian/community-plugins.json", - ".obsidian/appearance.json", -] as const; - -/** - * `page.reload()` (and the initial workspace flush during bootstrap) races - * with Obsidian's own writers for `.obsidian/*.json`. The renderer - * occasionally reads a file mid-rewrite and logs - * `failed to read JSON .obsidian/app.json SyntaxError: Unexpected end of - * JSON input`. Obsidian recovers by falling back to defaults and plugin - * state is unaffected, so this is not a plugin bug — filter it out. - */ -function isTransientObsidianJsonReadError(text: string): boolean { - if (!text.includes("failed to read JSON")) return false; - return TRANSIENT_OBSIDIAN_JSON_PATHS.some((p) => text.includes(p)); -} - -export interface ConsoleErrorGuardOptions { - /** - * Plugin-specific transient patterns. Use for races the plugin author - * has analysed and accepts (e.g. ENOENT on a freshly-deleted file the - * metadata cache still had pending). Matched against both - * `console.error` text and `pageerror` messages. - */ - extraTransientPatterns?: readonly RegExp[]; - /** - * Spec-level expected errors. Use for resilience suites that - * deliberately induce broken state (corrupt data.json, EACCES file - * reads, ICS subscription failures). Same matching rules as - * `extraTransientPatterns`. - */ - expectedErrorPatterns?: readonly RegExp[]; -} - -export interface ConsoleErrorGuard { - /** Subscribe both `console` and `pageerror` listeners. */ - attach(page: Page): void; - /** Remove both listeners. Safe to call multiple times. */ - detach(page: Page): void; - /** Throw if any non-allowlisted error was seen. */ - throwIfErrors(): void; -} - -/** - * Builds a `console.error` + `pageerror` guard preconfigured with the - * always-allowed transient patterns (Obsidian mid-rewrite JSON reads, - * teardown-time `_workspaceLeaves` races). Plugins layer on their own - * transient patterns and any spec-level expected-error patterns. - * - * Typical usage inside a Playwright fixture: - * - * const guard = createConsoleErrorGuard({ ... }); - * guard.attach(handle.page); - * try { - * await use(handle); - * } finally { - * guard.detach(handle.page); - * await handle.close(); - * } - * guard.throwIfErrors(); // surface accumulated errors as spec failure - */ -export function createConsoleErrorGuard(options: ConsoleErrorGuardOptions = {}): ConsoleErrorGuard { - const consoleErrors: string[] = []; - const isAllowed = (text: string): boolean => { - if (isTransientObsidianJsonReadError(text)) return true; - if (isTransientObsidianTeardownError(text)) return true; - if (options.extraTransientPatterns?.some((re) => re.test(text))) return true; - if (options.expectedErrorPatterns?.some((re) => re.test(text))) return true; - return false; - }; - - const onConsole = (msg: ConsoleMessage): void => { - if (msg.type() !== "error") return; - const text = msg.text(); - if (isAllowed(text)) return; - consoleErrors.push(text); - }; - const onPageError = (err: Error): void => { - if (isAllowed(err.message)) return; - consoleErrors.push(`pageerror: ${err.message}`); - }; - - return { - attach(page) { - page.on("console", onConsole); - page.on("pageerror", onPageError); - }, - detach(page) { - page.off("console", onConsole); - page.off("pageerror", onPageError); - }, - throwIfErrors() { - if (consoleErrors.length > 0) { - throw new Error(`renderer emitted ${consoleErrors.length} error(s):\n${consoleErrors.join("\n")}`); - } - }, - }; -} diff --git a/shared/src/testing/e2e/seed.ts b/shared/src/testing/e2e/seed.ts deleted file mode 100644 index 79a07b7c..00000000 --- a/shared/src/testing/e2e/seed.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, type Page } from "@playwright/test"; - -import type { ObsidianWindow } from "./types"; - -/** - * Create a markdown file in the vault through Obsidian's `app.vault.create` - * (so the metadata cache observes it), then poll `metadataCache.getFileCache` - * until it returns a non-null entry. Returns once the file is fully indexed - * and any handler that reads the metadata cache will see it. - * - * Why not `writeFileSync`: writing directly to disk bypasses Obsidian's - * vault adapter and the metadata cache only catches up on the next filesystem - * scan. Tests that seed a file and immediately invoke an action which reads - * frontmatter via `metadataCache.getFileCache` race the cache and surface as - * "MacroCommand: All 1 operations failed" or similar half-state errors. - * - * Use from any spec that seeds a fresh markdown file before invoking a - * window-API action or command that depends on the metadata cache. - */ -export async function seedMarkdownNote(page: Page, relativePath: string, content: string): Promise<void> { - await page.evaluate( - async ({ path, body }) => { - const w = window as unknown as ObsidianWindow; - const vault = w.app.vault; - if (!vault || typeof vault.create !== "function") throw new Error("app.vault.create not available"); - // Must call through the vault instance — `vault.create` uses - // `this.checkPath` internally, so extracting it to a free variable - // drops the binding and crashes with "Cannot read properties of - // undefined (reading 'checkPath')". - await vault.create(path, body); - }, - { path: relativePath, body: content } - ); - - await expect - .poll( - () => - page.evaluate((path) => { - const w = window as unknown as ObsidianWindow; - const vault = w.app.vault; - const metadataCache = w.app.metadataCache; - if (!vault || !metadataCache) return false; - if (typeof vault.getAbstractFileByPath !== "function" || typeof metadataCache.getFileCache !== "function") - return false; - // Call through the instance — both methods reference `this` - // (`vault.fileMap`, `metadataCache.fileCache`) internally. - const file = vault.getAbstractFileByPath(path); - if (!file) return false; - return metadataCache.getFileCache(file) !== null; - }, relativePath), - { message: `seedMarkdownNote: ${relativePath} never appeared in the metadata cache` } - ) - .toBe(true); -} diff --git a/shared/src/testing/e2e/types.ts b/shared/src/testing/e2e/types.ts deleted file mode 100644 index dc2c35a4..00000000 --- a/shared/src/testing/e2e/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Shared renderer-side Obsidian shape used across every E2E helper that runs -// inside `page.evaluate(...)`. Obsidian doesn't ship type definitions for the -// internal `app.plugins` / `app.setting` / `app.commands` surfaces we rely on, -// so this interface is the single source of truth. Keep it minimal — add a -// field only when a helper actually needs it, and narrow with optionals where -// Obsidian versions diverge. - -export type ObsidianPluginsRegistry = { - setEnable?: (enable: boolean) => Promise<void> | void; - enablePlugin?: (id: string) => Promise<void>; - enablePluginAndSave?: (id: string) => Promise<void>; - loadManifests?: () => Promise<void>; - isEnabled?: () => boolean; - plugins: Record<string, unknown>; - manifests?: Record<string, unknown>; - enabledPlugins?: Set<string>; -}; - -export type ObsidianApp = { - commands: { - executeCommandById: (id: string) => boolean; - commands?: Record<string, unknown>; - }; - workspace: { - openLinkText: (link: string, source: string, newLeaf?: boolean) => Promise<void>; - layoutReady?: boolean; - on?: (event: string, cb: () => void) => unknown; - offref?: (ref: unknown) => void; - }; - plugins: ObsidianPluginsRegistry; - setting: { - open: () => void; - openTabById: (id: string) => void; - }; - vault?: { - adapter?: { basePath?: string }; - create?: (path: string, content: string) => Promise<unknown>; - getAbstractFileByPath?: (path: string) => unknown; - }; - metadataCache?: { - getFileCache?: (file: unknown) => unknown; - }; -}; - -export type ObsidianWindow = { - app: ObsidianApp; - /** Set by E2E bootstrap to signal test mode to plugin code. */ - E2E?: boolean; - /** Electron's `require` — available in the renderer when nodeIntegration is on. */ - require?: (module: string) => unknown; -}; - -// Usage inside `page.evaluate(...)`: -// -// page.evaluate((arg) => { -// const w = window as unknown as ObsidianWindow; -// w.app.commands.executeCommandById(arg); -// }, commandId); -// -// The type is imported at the *Node* side so TypeScript can check the body of -// the evaluate callback — the cast itself runs in the browser. You cannot -// import a helper function and call it inside evaluate: Playwright serializes -// the callback to source, so only values captured through the second argument -// make it into the page context. diff --git a/shared/src/testing/e2e/vault-prefix.ts b/shared/src/testing/e2e/vault-prefix.ts deleted file mode 100644 index e8d144c5..00000000 --- a/shared/src/testing/e2e/vault-prefix.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { basename } from "node:path"; - -// Shared convention for per-run vault directory naming across every plugin's -// E2E suite. The shared bootstrap composes the final vault dir as -// `YYYY-MM-DD-HHmm-<prefix>-<uuid>`; this module produces the `<prefix>` portion -// from the Playwright `testInfo.file` + `testInfo.title` so `ls e2e/.cache/vaults/` -// tells you which test produced which retained vault. - -const SPEC_SUFFIX_RE = /\.(spec|test)\.(ts|tsx|js|mjs|mts|cjs)$/; - -// Linux caps filenames at 255 bytes. The full vault dir is -// `YYYY-MM-DD-HHmm-<prefix>-<uuid8>` (25 fixed chars + prefix), so the prefix -// can go well past 200 chars without risk. These defaults aim for "readable at -// a glance" — long enough to not truncate real test titles, short enough to -// keep `ls` output aligned. -const DEFAULT_SPEC_MAX_LENGTH = 48; -const DEFAULT_TITLE_MAX_LENGTH = 80; - -export type VaultPrefixOptions = { - /** Max length of the spec-file slug segment. Defaults to 48. */ - specMaxLength?: number; - /** Max length of the test-title slug segment. Defaults to 80. */ - titleMaxLength?: number; - /** Fallback prefix when both slugs collapse to an empty string. Defaults to "spec". */ - fallback?: string; -}; - -/** - * Build the `<prefix>` portion of a per-run vault directory name from a - * Playwright `testInfo.file` + `testInfo.title`. Produces a kebab-slug of - * `<spec-file>-<test-title>`, truncated to stay well under the 255-byte - * filename limit once the bootstrap prepends the timestamp and appends the - * uuid. - */ -export function buildVaultPrefix(file: string, title: string, options: VaultPrefixOptions = {}): string { - const specMaxLength = options.specMaxLength ?? DEFAULT_SPEC_MAX_LENGTH; - const titleMaxLength = options.titleMaxLength ?? DEFAULT_TITLE_MAX_LENGTH; - const fallback = options.fallback ?? "spec"; - const spec = basename(file).replace(SPEC_SUFFIX_RE, ""); - const joined = [slug(spec, specMaxLength), slug(title, titleMaxLength)].filter(Boolean).join("-"); - return joined || fallback; -} - -export function slug(value: string, maxLength: number): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, maxLength) - .replace(/-+$/g, ""); -} diff --git a/shared/src/testing/stress/artifact-paths.ts b/shared/src/testing/stress/artifact-paths.ts deleted file mode 100644 index 79f6e573..00000000 --- a/shared/src/testing/stress/artifact-paths.ts +++ /dev/null @@ -1,27 +0,0 @@ -import path from "node:path"; - -export interface RunStemParts { - profile: string; - scenario: string; - date?: Date; -} - -/** Filesystem-safe ISO timestamp (colons/dots replaced with dashes). */ -export function isoStamp(date: Date = new Date()): string { - return date.toISOString().replace(/[:.]/g, "-"); -} - -/** `<iso>_<profile>_<scenario>` — the per-run artifact directory name. */ -export function buildRunStem({ profile, scenario, date }: RunStemParts): string { - return `${isoStamp(date)}_${profile}_${scenario}`; -} - -/** Absolute artifact directory for a run under `root`. */ -export function buildArtifactDir(root: string, stem: string): string { - return path.join(root, stem); -} - -/** `<scenario>.<profile>.json` — the committed baseline filename. */ -export function baselineFileName(scenario: string, profile: string): string { - return `${scenario}.${profile}.json`; -} diff --git a/shared/src/testing/stress/budgets.ts b/shared/src/testing/stress/budgets.ts deleted file mode 100644 index f994cc94..00000000 --- a/shared/src/testing/stress/budgets.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { BudgetFailure, MetricRecord, StressBudget, StressBudgetRule } from "./types"; - -function violates(actual: number, rule: StressBudgetRule): boolean { - switch (rule.comparison) { - case "max": - return actual > rule.value; - case "min": - return actual < rule.value; - case "exact": - return actual !== rule.value; - } -} - -/** - * Compare a flattened metric record against per-metric budget rules - * (`max`/`min`/`exact`). A budget key with no matching metric is skipped (the - * metric wasn't collected this run) rather than treated as pass or fail. - */ -export function evaluateBudgets(metrics: MetricRecord, budget: StressBudget): BudgetFailure[] { - const failures: BudgetFailure[] = []; - for (const [metric, rule] of Object.entries(budget)) { - const actual = metrics[metric]; - if (actual === undefined) continue; - if (!violates(actual, rule)) continue; - - const delta = actual - rule.value; - // A percentage is meaningless for exact assertions or a zero reference. - const deltaPct = rule.comparison === "exact" || rule.value === 0 ? null : (delta / rule.value) * 100; - - failures.push({ - metric, - comparison: rule.comparison, - actual, - expected: rule.value, - delta, - deltaPct, - ...(rule.unit !== undefined ? { unit: rule.unit } : {}), - }); - } - return failures; -} diff --git a/shared/src/testing/stress/collectors/cdp-metrics.ts b/shared/src/testing/stress/collectors/cdp-metrics.ts deleted file mode 100644 index db39222d..00000000 --- a/shared/src/testing/stress/collectors/cdp-metrics.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CDPSession } from "@playwright/test"; - -// External runtime metrics straight from Chrome DevTools Protocol. The harness -// already connects to Obsidian via connectOverCDP, so `page.context().newCDPSession(page)` -// hands back a session these helpers drive. See `Performance.getMetrics` for the -// full metric list (ScriptDuration, LayoutDuration, JSHeapUsedSize, Nodes, …). - -export type CdpMetricRecord = Record<string, number>; - -export interface CdpMetricsCapture { - before: CdpMetricRecord; - after: CdpMetricRecord; - delta: CdpMetricRecord; -} - -export async function readCdpPerformanceMetrics(session: CDPSession): Promise<CdpMetricRecord> { - await session.send("Performance.enable"); - const { metrics } = await session.send("Performance.getMetrics"); - const out: CdpMetricRecord = {}; - for (const metric of metrics) { - out[metric.name] = metric.value; - } - return out; -} - -/** Subtract two metric maps; only keys present in both are emitted. */ -export function diffCdpMetrics(before: CdpMetricRecord, after: CdpMetricRecord): CdpMetricRecord { - const delta: CdpMetricRecord = {}; - for (const [name, value] of Object.entries(after)) { - const prior = before[name]; - if (prior !== undefined) delta[name] = value - prior; - } - return delta; -} - -/** Capture CDP metrics before and after an action, plus the delta. */ -export async function captureCdpMetrics<T>( - session: CDPSession, - fn: () => Promise<T> -): Promise<{ result: T; capture: CdpMetricsCapture }> { - const before = await readCdpPerformanceMetrics(session); - const result = await fn(); - const after = await readCdpPerformanceMetrics(session); - return { result, capture: { before, after, delta: diffCdpMetrics(before, after) } }; -} - -/** Prefix CDP metric keys (e.g. `cdp.JSHeapUsedSize`) for the run's count bag. */ -export function namespaceCdpMetrics(metrics: CdpMetricRecord, prefix = "cdp"): CdpMetricRecord { - const out: CdpMetricRecord = {}; - for (const [name, value] of Object.entries(metrics)) { - out[`${prefix}.${name}`] = value; - } - return out; -} diff --git a/shared/src/testing/stress/collectors/cpu-profiler.ts b/shared/src/testing/stress/collectors/cpu-profiler.ts deleted file mode 100644 index 830620b9..00000000 --- a/shared/src/testing/stress/collectors/cpu-profiler.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import type { CDPSession } from "@playwright/test"; - -import type { CpuProfile } from "../profile-digest"; - -// CDP CPU profiler — the "explain" half of the two-pass measurement. Run it in a -// SEPARATE pass from the clean gating pass: the sampler adds overhead that would -// contaminate the budgeted timings, so it must never wrap the action whose -// numbers feed budgets/baselines. Its output feeds the self-time digest only. - -const DEFAULT_SAMPLING_INTERVAL_US = 1000; - -export interface CpuProfilerOptions { - /** Sampler interval in microseconds — 1000 (1ms) is low-overhead/coarse; drop to 100 for finer hot-function resolution. */ - samplingIntervalUs?: number; -} - -/** Wrap an action in a CDP CPU profile and return both its result and the raw profile. */ -export async function collectCpuProfile<T>( - session: CDPSession, - fn: () => Promise<T>, - options: CpuProfilerOptions = {} -): Promise<{ result: T; profile: CpuProfile }> { - await session.send("Profiler.enable"); - await session.send("Profiler.setSamplingInterval", { - interval: options.samplingIntervalUs ?? DEFAULT_SAMPLING_INTERVAL_US, - }); - await session.send("Profiler.start"); - try { - const result = await fn(); - const { profile } = await session.send("Profiler.stop"); - return { result, profile: profile as CpuProfile }; - } catch (error) { - await session.send("Profiler.stop").catch(() => undefined); - throw error; - } -} - -/** Persist a raw V8 CPU profile as a `.cpuprofile` (opens in DevTools / Speedscope). */ -export async function writeCpuProfile(filePath: string, profile: CpuProfile): Promise<void> { - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(profile), "utf8"); -} diff --git a/shared/src/testing/stress/collectors/heap-profiler.ts b/shared/src/testing/stress/collectors/heap-profiler.ts deleted file mode 100644 index ba7c2299..00000000 --- a/shared/src/testing/stress/collectors/heap-profiler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import type { CDPSession } from "@playwright/test"; - -// CDP heap profiler — the memory half of the leak harness. `collectGarbage` -// forces a GC so anything still retained afterwards is a real leak, not a -// not-yet-collected temporary. `takeHeapSnapshot` streams the `.heapsnapshot` -// (V8 serialises it in chunks via `addHeapSnapshotChunk`) to disk for DevTools -// post-mortem; the cheap gating signal (`JSHeapUsedSize`) comes from -// `Performance.getMetrics`, and the structured signal from `digestHeapSnapshot`. - -const HEAP_SNAPSHOT_CHUNK_EVENT = "HeapProfiler.addHeapSnapshotChunk"; - -/** Force a full GC so a follow-up snapshot / `JSHeapUsedSize` reflects only retained memory. */ -export async function collectGarbage(session: CDPSession): Promise<void> { - await session.send("HeapProfiler.enable"); - await session.send("HeapProfiler.collectGarbage"); -} - -/** - * Capture a heap snapshot, streaming it to `filePath`, and return its byte size. - * The `.heapsnapshot` opens in DevTools → Memory; parse it with - * `digestHeapSnapshot` for an agent-readable summary (detached nodes, top types). - */ -export async function takeHeapSnapshot(session: CDPSession, filePath: string): Promise<number> { - await session.send("HeapProfiler.enable"); - await mkdir(path.dirname(filePath), { recursive: true }); - - const chunks: string[] = []; - const onChunk = (event: { chunk: string }): void => { - chunks.push(event.chunk); - }; - session.on(HEAP_SNAPSHOT_CHUNK_EVENT, onChunk); - try { - await session.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: false }); - } finally { - session.off(HEAP_SNAPSHOT_CHUNK_EVENT, onChunk); - } - - const content = chunks.join(""); - await writeFile(filePath, content, "utf8"); - return Buffer.byteLength(content, "utf8"); -} diff --git a/shared/src/testing/stress/collectors/perf-bridge.ts b/shared/src/testing/stress/collectors/perf-bridge.ts deleted file mode 100644 index 0d371c9a..00000000 --- a/shared/src/testing/stress/collectors/perf-bridge.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { PERF_BRIDGE_GLOBAL_KEY } from "../../../perf/bridge"; -import type { PerfSnapshot } from "../../../perf/types"; - -// Drains the in-app perf bridge a plugin installs on `globalThis` (see -// `installPerfBridge` in @real1ty-obsidian-plugins/perf). Plugin-agnostic — the -// global key is shared, overridable per call. - -/** Clear the plugin's recorded timings/counters before a measured pass. */ -export async function resetPerfBridge(page: Page, key: string = PERF_BRIDGE_GLOBAL_KEY): Promise<void> { - await page.evaluate((bridgeKey) => { - const bridge = (window as unknown as Record<string, unknown>)[bridgeKey] as { reset?: () => void } | undefined; - bridge?.reset?.(); - }, key); -} - -/** Drain the plugin's current perf snapshot. Throws if the bridge is absent. */ -export async function readPerfBridge(page: Page, key: string = PERF_BRIDGE_GLOBAL_KEY): Promise<PerfSnapshot> { - const snapshot = await page.evaluate((bridgeKey) => { - const bridge = (window as unknown as Record<string, unknown>)[bridgeKey] as { snapshot: () => unknown } | undefined; - if (!bridge) { - throw new Error( - `Perf bridge not installed at globalThis[${JSON.stringify(bridgeKey)}] — build the plugin with perf instrumentation and run with stress/E2E mode enabled` - ); - } - return bridge.snapshot(); - }, key); - return snapshot as PerfSnapshot; -} diff --git a/shared/src/testing/stress/environment.ts b/shared/src/testing/stress/environment.ts deleted file mode 100644 index 343d5689..00000000 --- a/shared/src/testing/stress/environment.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { execFileSync } from "node:child_process"; -import os from "node:os"; - -import type { EnvironmentInfo, GitInfo } from "./types"; - -/** - * Capture the host environment so every report records the machine it ran on — - * baselines are same-machine only, so the report header is how a human spots a - * baseline captured on different hardware. Tool/plugin versions are filled in by - * the caller (the spec) when known. - */ -export function captureEnvironment(): EnvironmentInfo { - const cpus = os.cpus(); - return { - os: `${os.type()} ${os.release()}`, - arch: os.arch(), - cpuModel: cpus[0]?.model ?? "unknown", - cpuCount: cpus.length, - totalMemoryBytes: os.totalmem(), - nodeVersion: process.version, - }; -} - -// execFile (no shell) with static args — no injection surface. -function git(args: readonly string[], cwd: string): string { - return execFileSync("git", args as string[], { cwd, encoding: "utf8" }).trim(); -} - -/** Capture branch/commit/dirty so a report ties to an exact tree state. */ -export function captureGitInfo(cwd: string = process.cwd()): GitInfo { - try { - return { - branch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd), - commit: git(["rev-parse", "HEAD"], cwd), - dirty: git(["status", "--porcelain"], cwd) !== "", - }; - } catch { - return { branch: "unknown", commit: "unknown", dirty: false }; - } -} diff --git a/shared/src/testing/stress/heap-digest.ts b/shared/src/testing/stress/heap-digest.ts deleted file mode 100644 index 846dcbd8..00000000 --- a/shared/src/testing/stress/heap-digest.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { HeapDigest, HeapDigestEntry, RetainerEntry } from "./types"; - -// Parses a V8 `.heapsnapshot` (the file `HeapProfiler.takeHeapSnapshot` streams) -// into a structured, agent-readable summary — the memory analogue of the CPU -// profile digest. The snapshot stores nodes as a flat int array; each node's -// fields are laid out per `meta.node_fields`, and the `type` field indexes into -// `meta.node_types[typeFieldIndex]`. Field positions are resolved by name (not -// hardcoded) so the parser survives V8 layout changes. Pure (no @playwright/test -// import) so it stays unit-testable in a plain node environment. - -/** The `meta` block of a heap snapshot — describes the flat `nodes`/`edges` layout. */ -export interface HeapSnapshotMeta { - node_fields: string[]; - /** Per field: an array of enum names for the `type` field, a bare type name otherwise. */ - node_types: Array<string[] | string>; - edge_fields?: string[]; - edge_types?: Array<string[] | string>; -} - -/** Minimal mirror of the `.heapsnapshot` JSON — only what the digest reads. */ -export interface HeapSnapshot { - snapshot: { meta: HeapSnapshotMeta; node_count: number; edge_count: number }; - nodes: number[]; - /** Flat edge array, grouped by source node in node order. Absent in trimmed fixtures. */ - edges?: number[]; - strings: string[]; -} - -export interface HeapDigestOptions { - /** How many object types to keep in the ranked list (default 10). */ - topTypes?: number; - /** Also trace what retains the detached nodes (requires `edges`; default false — it's an extra full edge pass). */ - retainers?: boolean; - /** How many retainer keys to keep (default 10). */ - topRetainers?: number; -} - -const DEFAULT_TOP_TYPES = 10; -const DEFAULT_TOP_RETAINERS = 10; -const DETACHED_NAME_PREFIX = "Detached "; -// `detachedness` field values: 0 unknown, 1 attached, 2 detached. -const DETACHEDNESS_DETACHED = 2; -// Element/hidden edges are array indices — bucket them so the index noise collapses. -const INDEXED_EDGE_TYPES = new Set(["element", "hidden"]); -const INDEXED_EDGE_NAME = "[]"; - -interface NodeLayout { - stride: number; - typeIdx: number; - nameIdx: number; - sizeIdx: number; - detachIdx: number; - edgeCountIdx: number; - typeNames: string[]; -} - -function resolveNodeLayout(meta: HeapSnapshotMeta): NodeLayout { - const typeIdx = meta.node_fields.indexOf("type"); - const typeNamesField = typeIdx >= 0 ? meta.node_types[typeIdx] : undefined; - return { - stride: meta.node_fields.length, - typeIdx, - nameIdx: meta.node_fields.indexOf("name"), - sizeIdx: meta.node_fields.indexOf("self_size"), - detachIdx: meta.node_fields.indexOf("detachedness"), - edgeCountIdx: meta.node_fields.indexOf("edge_count"), - typeNames: Array.isArray(typeNamesField) ? typeNamesField : [], - }; -} - -function isNodeDetached(nodes: number[], strings: string[], base: number, layout: NodeLayout): boolean { - if (layout.detachIdx >= 0) return nodes[base + layout.detachIdx] === DETACHEDNESS_DETACHED; - if (layout.nameIdx < 0) return false; - return (strings[nodes[base + layout.nameIdx] ?? -1] ?? "").startsWith(DETACHED_NAME_PREFIX); -} - -/** - * Walk the edge array once and aggregate, for every detached node, the external - * holder that pins it: keyed by `holderType.edgeName`. Edges are grouped by source - * node in node order, so a running cursor maps each edge back to its source. - * Self-edges inside the detached subgraph are skipped — only the boundary that - * keeps the leak reachable is counted. This is the "what's retaining it?" answer. - */ -function computeTopRetainers( - snapshot: HeapSnapshot, - isDetached: Uint8Array, - layout: NodeLayout, - topN: number -): RetainerEntry[] { - const { edges, nodes, strings } = snapshot; - const edgeFields = snapshot.snapshot.meta.edge_fields; - if (!edges || edges.length === 0 || !edgeFields || edgeFields.length === 0) return []; - - const edgeStride = edgeFields.length; - const edgeTypeIdx = edgeFields.indexOf("type"); - const edgeNameIdx = edgeFields.indexOf("name_or_index"); - const edgeToIdx = edgeFields.indexOf("to_node"); - if (edgeToIdx < 0) return []; - const edgeTypesField = edgeTypeIdx >= 0 ? snapshot.snapshot.meta.edge_types?.[edgeTypeIdx] : undefined; - const edgeTypeNames = Array.isArray(edgeTypesField) ? edgeTypesField : []; - - const counts = new Map<string, number>(); - const nodeCount = isDetached.length; - let edgeCursor = 0; - for (let i = 0; i < nodeCount; i++) { - const nodeBase = i * layout.stride; - const edgeCount = layout.edgeCountIdx >= 0 ? (nodes[nodeBase + layout.edgeCountIdx] ?? 0) : 0; - const sourceDetached = isDetached[i] === 1; - const sourceType = - layout.typeIdx >= 0 ? (layout.typeNames[nodes[nodeBase + layout.typeIdx] ?? -1] ?? "unknown") : "unknown"; - for (let e = 0; e < edgeCount; e++) { - const edgeBase = (edgeCursor + e) * edgeStride; - const toOrdinal = Math.floor((edges[edgeBase + edgeToIdx] ?? -1) / layout.stride); - if (toOrdinal < 0 || toOrdinal >= nodeCount || isDetached[toOrdinal] !== 1 || sourceDetached) continue; - const edgeTypeName = edgeTypeIdx >= 0 ? (edgeTypeNames[edges[edgeBase + edgeTypeIdx] ?? -1] ?? "?") : "?"; - const edgeName = INDEXED_EDGE_TYPES.has(edgeTypeName) - ? INDEXED_EDGE_NAME - : (strings[edges[edgeBase + edgeNameIdx] ?? -1] ?? "?"); - const key = `${sourceType}.${edgeName}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - edgeCursor += edgeCount; - } - - return [...counts.entries()] - .map(([retainer, count]) => ({ retainer, count })) - .sort((a, b) => b.count - a.count) - .slice(0, topN); -} - -/** - * Summarize a heap snapshot: node/edge totals, summed self size, the count of - * detached DOM nodes (the classic retained-view leak signal), the heaviest object - * types by self size, and — when `retainers` is set — what holds the detached nodes. - */ -export function digestHeapSnapshot(snapshot: HeapSnapshot, options: HeapDigestOptions = {}): HeapDigest { - const topTypesN = options.topTypes ?? DEFAULT_TOP_TYPES; - const wantRetainers = options.retainers ?? false; - const { edge_count: edgeCount } = snapshot.snapshot; - const layout = resolveNodeLayout(snapshot.snapshot.meta); - if (layout.stride <= 0) { - return { nodeCount: 0, edgeCount, totalSizeBytes: 0, detachedNodeCount: 0, topTypes: [], topRetainers: [] }; - } - - const { nodes, strings } = snapshot; - const nodeCount = Math.floor(nodes.length / layout.stride); - const isDetached = wantRetainers ? new Uint8Array(nodeCount) : undefined; - const byType = new Map<string, { count: number; selfSizeBytes: number }>(); - let totalSizeBytes = 0; - let detachedNodeCount = 0; - - for (let i = 0; i < nodeCount; i++) { - const base = i * layout.stride; - const typeName = - layout.typeIdx >= 0 ? (layout.typeNames[nodes[base + layout.typeIdx] ?? -1] ?? "unknown") : "unknown"; - const selfSize = layout.sizeIdx >= 0 ? (nodes[base + layout.sizeIdx] ?? 0) : 0; - totalSizeBytes += selfSize; - - const aggregate = byType.get(typeName); - if (aggregate) { - aggregate.count += 1; - aggregate.selfSizeBytes += selfSize; - } else { - byType.set(typeName, { count: 1, selfSizeBytes: selfSize }); - } - - if (isNodeDetached(nodes, strings, base, layout)) { - detachedNodeCount += 1; - if (isDetached) isDetached[i] = 1; - } - } - - const topTypes: HeapDigestEntry[] = [...byType.entries()] - .map(([type, aggregate]) => ({ type, count: aggregate.count, selfSizeBytes: aggregate.selfSizeBytes })) - .sort((a, b) => b.selfSizeBytes - a.selfSizeBytes) - .slice(0, topTypesN); - - const topRetainers = isDetached - ? computeTopRetainers(snapshot, isDetached, layout, options.topRetainers ?? DEFAULT_TOP_RETAINERS) - : []; - - return { nodeCount, edgeCount, totalSizeBytes, detachedNodeCount, topTypes, topRetainers }; -} diff --git a/shared/src/testing/stress/index.ts b/shared/src/testing/stress/index.ts deleted file mode 100644 index c475e305..00000000 --- a/shared/src/testing/stress/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -export { buildArtifactDir, baselineFileName, buildRunStem, isoStamp, type RunStemParts } from "./artifact-paths"; -export { evaluateBudgets } from "./budgets"; -export { - captureCdpMetrics, - type CdpMetricRecord, - type CdpMetricsCapture, - diffCdpMetrics, - namespaceCdpMetrics, - readCdpPerformanceMetrics, -} from "./collectors/cdp-metrics"; -export { collectCpuProfile, type CpuProfilerOptions, writeCpuProfile } from "./collectors/cpu-profiler"; -export { collectGarbage, takeHeapSnapshot } from "./collectors/heap-profiler"; -export { readPerfBridge, resetPerfBridge } from "./collectors/perf-bridge"; -export { captureEnvironment, captureGitInfo } from "./environment"; -export { digestHeapSnapshot, type HeapDigestOptions, type HeapSnapshot, type HeapSnapshotMeta } from "./heap-digest"; -export { flattenMetrics, flattenTimings, mergeTimings, summarizeSampleGroups } from "./metrics"; -export { - type CpuProfile, - type CpuProfileCallFrame, - type CpuProfileNode, - digestCpuProfile, - type DigestOptions, - type FrameResolver, - type RawFrame, - resolveCallFrame, - type ResolvedFrame, -} from "./profile-digest"; -export { - buildProfileTree, - heaviestStack, - type ProfileTreeNode, - type ProfileTreeOptions, - pruneProfileTree, -} from "./profile-tree"; -export { compareToBaseline, DEFAULT_REGRESSION_RULE, hasRegression } from "./regression"; -export { - readBaseline, - reportToBaseline, - type WriteRunReportsOptions, - type WrittenReport, - writeBaseline, - writeRunReports, -} from "./reporters/json"; -export { type HtmlReportOptions, renderHtmlReport } from "./reporters/html"; -export { renderMarkdownReport } from "./reporters/markdown"; -export { type RepeatOptions, type RepeatPhase, runRepeats } from "./runner/repeat"; -export { createSeededRandom, type SeededRandom } from "./seeded-random"; -export { - createFrameResolver, - type FrameResolverOptions, - loadBundleSourceMap, - type LoadBundleSourceMapOptions, - type SourceMapLookup, -} from "./source-map"; -export { - BudgetComparisonSchema, - BudgetFailureSchema, - EnvironmentInfoSchema, - GitInfoSchema, - HeapDigestEntrySchema, - HeapDigestSchema, - MetricKindSchema, - MetricRecordSchema, - MetricSummarySchema, - MetricUnitSchema, - ProfileDigestEntrySchema, - ProfileDigestSchema, - RegressionFindingSchema, - RegressionRuleSchema, - RetainerEntrySchema, - StressArtifactSchema, - StressBaselineSchema, - StressBudgetRuleSchema, - StressBudgetSchema, - StressRunConfigSchema, - StressRunReportSchema, - VaultProfileSchema, -} from "./types"; -export type { - BudgetComparison, - BudgetFailure, - EnvironmentInfo, - GitInfo, - HeapDigest, - HeapDigestEntry, - MetricKind, - MetricRecord, - MetricSummary, - MetricUnit, - ProfileDigest, - ProfileDigestEntry, - RegressionFinding, - RegressionRule, - RetainerEntry, - StressArtifact, - StressBaseline, - StressBudget, - StressBudgetRule, - StressRunConfig, - StressRunReport, - VaultProfile, -} from "./types"; -export { generateVault, type GeneratedEvent, type GenerateVaultOptions, type VaultManifest } from "./vault/generate"; diff --git a/shared/src/testing/stress/metrics.ts b/shared/src/testing/stress/metrics.ts deleted file mode 100644 index b542c646..00000000 --- a/shared/src/testing/stress/metrics.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { summarizeSamples } from "../../perf/stats"; -import type { MetricRecord, MetricSummary } from "./types"; - -/** The timing-summary fields exposed as flattened budget/regression keys. */ -const FLATTENED_STATS = ["count", "totalMs", "avgMs", "minMs", "maxMs", "p50Ms", "p95Ms"] as const; - -/** Summarize each group of raw duration samples into a timing summary. */ -export function summarizeSampleGroups(groups: Record<string, readonly number[]>): Record<string, MetricSummary> { - const out: Record<string, MetricSummary> = {}; - for (const [name, samples] of Object.entries(groups)) { - out[name] = summarizeSamples(samples); - } - return out; -} - -/** - * Flatten a `{ name -> summary }` map into `{ "name.stat" -> value }` so budgets - * and baselines can reference a specific statistic, e.g. `nav.next.p95Ms`. - */ -export function flattenTimings(timings: Record<string, MetricSummary>): MetricRecord { - const out: MetricRecord = {}; - for (const [name, summary] of Object.entries(timings)) { - for (const stat of FLATTENED_STATS) { - out[`${name}.${stat}`] = summary[stat]; - } - } - return out; -} - -/** - * Full flat view of a run's measurements: every timing statistic plus every - * raw count, in one record keyed for budget evaluation. - */ -export function flattenMetrics(timings: Record<string, MetricSummary>, counts: MetricRecord): MetricRecord { - return { ...flattenTimings(timings), ...counts }; -} - -/** Merge two timing maps; the right side wins on key collision. */ -export function mergeTimings( - base: Record<string, MetricSummary>, - extra: Record<string, MetricSummary> -): Record<string, MetricSummary> { - return { ...base, ...extra }; -} diff --git a/shared/src/testing/stress/profile-digest.ts b/shared/src/testing/stress/profile-digest.ts deleted file mode 100644 index 4e623f2e..00000000 --- a/shared/src/testing/stress/profile-digest.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { ProfileDigest, ProfileDigestEntry } from "./types"; - -// Parses a raw V8 CPU profile (the object CDP's `Profiler.stop` returns) into a -// ranked self-time digest. This is the agent-autonomy unlock: a `.cpuprofile` is -// JSON, so instead of opening a flame chart by hand we rank functions by self -// time and write the top-N into the run report. Pure (no @playwright/test import) -// so it stays unit-testable in a plain node environment. - -/** Minimal mirror of `Protocol.Runtime.CallFrame` — only what the digest reads. */ -export interface CpuProfileCallFrame { - functionName: string; - url: string; - /** 0-based, as V8 emits it. */ - lineNumber: number; - columnNumber?: number; - scriptId?: string; -} - -/** Minimal mirror of `Protocol.Profiler.ProfileNode`. */ -export interface CpuProfileNode { - id: number; - callFrame: CpuProfileCallFrame; - hitCount?: number; - children?: number[]; -} - -/** Minimal mirror of `Protocol.Profiler.Profile` (timings in microseconds). */ -export interface CpuProfile { - nodes: CpuProfileNode[]; - startTime: number; - endTime: number; - samples: number[]; - timeDeltas: number[]; -} - -/** A minified call frame as V8 emits it (line/column 0-based). */ -export interface RawFrame { - url: string; - lineNumber: number; - columnNumber?: number; -} - -/** A frame resolved back to original source through a sourcemap. */ -export interface ResolvedFrame { - /** Original function name, when the map carries one (its `names` table). */ - functionName?: string; - /** Original source path, as it appears in the map's `sources`. */ - source: string; - /** 1-based original line. */ - line: number; - /** 0-based original column, when known. */ - column?: number; -} - -/** - * Maps a minified call frame back to its original source position, or null when - * the frame isn't part of the mapped bundle (native / library / app frames). - * Built from the bundle's `.map` by `loadBundleSourceMap` (source-map.ts) and - * injected here, so this parser stays free of fs / sourcemap-library imports and - * unit-testable with a fake resolver. - */ -export type FrameResolver = (frame: RawFrame) => ResolvedFrame | null; - -export interface DigestOptions { - /** How many functions to keep in the ranked list (default 15). */ - topN?: number; - /** Keep V8 bookkeeping frames — `(idle)`, `(garbage collector)`, … (default false). */ - includeSynthetic?: boolean; - /** - * When set, each minified frame is mapped back to original source before - * aggregation — so the ranking names `getNextOccurrence recurring-event-manager.ts:611` - * instead of `RP main.js:3828`. Frames the resolver returns null for keep - * their minified identity. - */ - resolveFrame?: FrameResolver; -} - -const DEFAULT_TOP_N = 15; -const ANONYMOUS = "(anonymous)"; -const US_PER_MS = 1000; - -// V8 emits these synthetic frames for non-code time. They're excluded from the -// "hot functions" ranking by default but still counted toward total profiled -// time, so a function's self-% stays honest relative to the whole window. -const SYNTHETIC_FRAME_NAMES = new Set(["(root)", "(program)", "(idle)", "(garbage collector)", "(metadata)"]); - -interface FrameAggregate { - functionName: string; - url: string; - line: number; - selfUs: number; - hitCount: number; -} - -function basename(url: string): string { - const slash = url.lastIndexOf("/"); - return slash >= 0 ? url.slice(slash + 1) : url; -} - -function formatLocation(functionName: string, url: string, line: number): string { - if (!url) return functionName === ANONYMOUS ? "(native)" : functionName; - const file = basename(url); - return line > 0 ? `${file}:${line}` : file; -} - -/** - * Resolve a call frame to its display identity — apply the optional sourcemap - * resolver, else fall back to the minified name/url/line. Shared by the self-time - * digest and the call-tree builder so both label frames identically. - */ -export function resolveCallFrame( - callFrame: CpuProfileCallFrame, - resolveFrame?: FrameResolver -): { name: string; url: string; line: number; location: string } { - const resolved = resolveFrame?.({ - url: callFrame.url, - lineNumber: callFrame.lineNumber, - ...(callFrame.columnNumber !== undefined ? { columnNumber: callFrame.columnNumber } : {}), - }); - const name = resolved?.functionName ?? (callFrame.functionName || ANONYMOUS); - const url = resolved ? resolved.source : callFrame.url; - const line = resolved ? resolved.line : callFrame.lineNumber >= 0 ? callFrame.lineNumber + 1 : 0; - return { name, url, line, location: formatLocation(name, url, line) }; -} - -/** - * Rank a CPU profile's functions by self time. Self time per node = the sum of - * `timeDeltas[i]` for every sample where that node was on top of the stack; - * nodes that share a call frame (same function reached via different paths) are - * collapsed into a single row. - */ -export function digestCpuProfile(profile: CpuProfile, options: DigestOptions = {}): ProfileDigest { - const topN = options.topN ?? DEFAULT_TOP_N; - const includeSynthetic = options.includeSynthetic ?? false; - const resolveFrame = options.resolveFrame; - - const nodeById = new Map<number, CpuProfileNode>(); - for (const node of profile.nodes) nodeById.set(node.id, node); - - const selfUsByNode = new Map<number, number>(); - const sampleCount = Math.min(profile.samples.length, profile.timeDeltas.length); - let totalUs = 0; - for (let i = 0; i < sampleCount; i++) { - const nodeId = profile.samples[i]; - const delta = profile.timeDeltas[i]; - if (nodeId === undefined || delta === undefined) continue; - selfUsByNode.set(nodeId, (selfUsByNode.get(nodeId) ?? 0) + delta); - totalUs += delta; - } - - const byFrame = new Map<string, FrameAggregate>(); - for (const [nodeId, selfUs] of selfUsByNode) { - const node = nodeById.get(nodeId); - if (!node) continue; - const { name, url, line } = resolveCallFrame(node.callFrame, resolveFrame); - if (!includeSynthetic && SYNTHETIC_FRAME_NAMES.has(name)) continue; - const key = `${name} ${url} ${line}`; - const existing = byFrame.get(key); - if (existing) { - existing.selfUs += selfUs; - existing.hitCount += node.hitCount ?? 0; - } else { - byFrame.set(key, { functionName: name, url, line, selfUs, hitCount: node.hitCount ?? 0 }); - } - } - - const topSelfTime: ProfileDigestEntry[] = [...byFrame.values()] - .sort((a, b) => b.selfUs - a.selfUs) - .slice(0, topN) - .map((frame) => ({ - functionName: frame.functionName, - url: frame.url, - line: frame.line, - location: formatLocation(frame.functionName, frame.url, frame.line), - selfTimeMs: frame.selfUs / US_PER_MS, - selfPct: totalUs > 0 ? (frame.selfUs / totalUs) * 100 : 0, - hitCount: frame.hitCount, - })); - - return { - sampleCount, - durationMs: (profile.endTime - profile.startTime) / US_PER_MS, - totalSelfTimeMs: totalUs / US_PER_MS, - topSelfTime, - }; -} diff --git a/shared/src/testing/stress/profile-tree.ts b/shared/src/testing/stress/profile-tree.ts deleted file mode 100644 index c8032e83..00000000 --- a/shared/src/testing/stress/profile-tree.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { resolveCallFrame, type CpuProfile, type FrameResolver } from "./profile-digest"; - -// Builds the aggregated call tree from a V8 CPU profile — the data behind the -// flame chart. The profile's `nodes`/`children` already form the aggregated call -// tree (each node is a unique call path); we attach self time (from samples/ -// timeDeltas) and roll up totals, resolving frame names through the optional -// sourcemap resolver so the chart reads source names, not minified ones. - -const US_PER_MS = 1000; - -/** One frame in the call tree. `totalMs` (self + descendants) is the flame width. */ -export interface ProfileTreeNode { - name: string; - location: string; - selfMs: number; - totalMs: number; - children: ProfileTreeNode[]; -} - -export interface ProfileTreeOptions { - /** Maps minified frames back to source before labelling (same resolver the digest uses). */ - resolveFrame?: FrameResolver; -} - -function selfTimeByNode(profile: CpuProfile): Map<number, number> { - const selfUs = new Map<number, number>(); - const sampleCount = Math.min(profile.samples.length, profile.timeDeltas.length); - for (let i = 0; i < sampleCount; i++) { - const id = profile.samples[i]; - const delta = profile.timeDeltas[i]; - if (id === undefined || delta === undefined) continue; - selfUs.set(id, (selfUs.get(id) ?? 0) + delta); - } - return selfUs; -} - -/** The node no other node lists as a child — V8's synthetic `(root)`. */ -function findRootId(profile: CpuProfile): number | undefined { - const childIds = new Set<number>(); - for (const node of profile.nodes) { - for (const child of node.children ?? []) childIds.add(child); - } - return profile.nodes.find((node) => !childIds.has(node.id))?.id; -} - -/** - * Build the call tree rooted at V8's `(root)`. Each node carries self time and a - * rolled-up total. The `onStack` set guards the (theoretically impossible) cyclic - * profile so a malformed input can't infinite-loop. - */ -export function buildProfileTree(profile: CpuProfile, options: ProfileTreeOptions = {}): ProfileTreeNode { - const nodeById = new Map(profile.nodes.map((node) => [node.id, node])); - const selfUs = selfTimeByNode(profile); - const onStack = new Set<number>(); - - const build = (id: number): ProfileTreeNode => { - const node = nodeById.get(id); - const callFrame = node?.callFrame ?? { functionName: "(unknown)", url: "", lineNumber: -1 }; - const { name, location } = resolveCallFrame(callFrame, options.resolveFrame); - const selfMs = (selfUs.get(id) ?? 0) / US_PER_MS; - - onStack.add(id); - const children = (node?.children ?? []) - .filter((childId) => !onStack.has(childId) && nodeById.has(childId)) - .map(build); - onStack.delete(id); - - const totalMs = children.reduce((sum, child) => sum + child.totalMs, selfMs); - return { name, location, selfMs, totalMs, children }; - }; - - const rootId = findRootId(profile) ?? profile.nodes[0]?.id; - if (rootId === undefined) { - return { name: "(root)", location: "(root)", selfMs: 0, totalMs: 0, children: [] }; - } - return build(rootId); -} - -/** - * Drop subtrees thinner than `minFraction` of the root's total time, keeping the - * inlined flame-chart JSON small (a 12k-sample profile is thousands of nodes; the - * sub-pixel ones add bytes without being readable). Returns a fresh tree. - */ -export function pruneProfileTree(root: ProfileTreeNode, minFraction = 0.005): ProfileTreeNode { - const threshold = root.totalMs * minFraction; - const prune = (node: ProfileTreeNode): ProfileTreeNode => ({ - ...node, - children: node.children.filter((child) => child.totalMs >= threshold).map(prune), - }); - return prune(root); -} - -/** - * The dominant call chain: from the root, repeatedly descend into the heaviest - * child (by total time) until a leaf. The textual "hot path" — the single stack - * the profiler spent the most time under, for a compact report line. - */ -export function heaviestStack(root: ProfileTreeNode): ProfileTreeNode[] { - const stack: ProfileTreeNode[] = []; - let node: ProfileTreeNode | undefined = root; - while (node) { - stack.push(node); - node = node.children.reduce<ProfileTreeNode | undefined>( - (heaviest, child) => (heaviest === undefined || child.totalMs > heaviest.totalMs ? child : heaviest), - undefined - ); - } - return stack; -} diff --git a/shared/src/testing/stress/regression.ts b/shared/src/testing/stress/regression.ts deleted file mode 100644 index 04dd765e..00000000 --- a/shared/src/testing/stress/regression.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - RegressionRuleSchema, - type MetricSummary, - type MetricUnit, - type RegressionFinding, - type RegressionRule, - type StressBaseline, - type StressRunReport, -} from "./types"; - -/** Canonical rule — the default values live on `RegressionRuleSchema`. */ -export const DEFAULT_REGRESSION_RULE: RegressionRule = RegressionRuleSchema.parse({}); - -function pct(delta: number, base: number): number | null { - return base === 0 ? null : (delta / base) * 100; -} - -/** - * Compare a run report against a same-machine baseline. - * - * - **Timings** use the candidate's p95 and fail only when it exceeds the - * baseline by both `rule.ratio` and `rule.minAbsoluteDelta` — noise alone - * never trips a regression. - * - **Counts** are deterministic, so any difference is a regression. - * - * Metrics present in the baseline but missing from the report are skipped. - */ -export function compareToBaseline( - report: StressRunReport, - baseline: StressBaseline, - rule: RegressionRule = DEFAULT_REGRESSION_RULE -): RegressionFinding[] { - const findings: RegressionFinding[] = []; - const timings = report.timings as Record<string, MetricSummary | undefined>; - const counts = report.counts as Record<string, number | undefined>; - - function compareSection( - baselineSlice: Record<string, number>, - kind: RegressionFinding["kind"], - unit: MetricUnit, - candidateAt: (metric: string) => number | undefined, - regressedWhen: (baseValue: number, candidate: number) => boolean - ): void { - for (const [metric, baseValue] of Object.entries(baselineSlice)) { - const candidate = candidateAt(metric); - if (candidate === undefined) continue; - - const delta = candidate - baseValue; - findings.push({ - metric, - kind, - baseline: baseValue, - candidate, - delta, - deltaPct: pct(delta, baseValue), - unit, - regressed: regressedWhen(baseValue, candidate), - }); - } - } - - compareSection( - baseline.timings, - "timing", - "ms", - (metric) => timings[metric]?.p95Ms, - (baseValue, candidate) => candidate > baseValue * rule.ratio && candidate - baseValue > rule.minAbsoluteDelta - ); - - compareSection( - baseline.counts, - "count", - "count", - (metric) => counts[metric], - (baseValue, candidate) => candidate !== baseValue - ); - - return findings; -} - -/** True when any finding regressed — the harness uses this to fail the run. */ -export function hasRegression(findings: readonly RegressionFinding[]): boolean { - return findings.some((finding) => finding.regressed); -} diff --git a/shared/src/testing/stress/reporters/html.ts b/shared/src/testing/stress/reporters/html.ts deleted file mode 100644 index ffc8b203..00000000 --- a/shared/src/testing/stress/reporters/html.ts +++ /dev/null @@ -1,399 +0,0 @@ -import type { ProfileTreeNode } from "../profile-tree"; -import type { BudgetFailure, RegressionFinding, StressArtifact, StressRunReport } from "../types"; - -// Self-contained HTML report — opens in any browser offline (and prints to PDF). -// Carries the same tables as the Markdown report plus an interactive flame chart -// of the CPU profile (the headline: see hot paths without DevTools) and a bar -// view of stage timings. CSS + the flame renderer are inlined; the call tree is -// inlined as JSON. Mirrors tools/change-report's single-file approach. - -function round(value: number, places = 1): number { - if (!Number.isFinite(value)) return value; - const factor = 10 ** places; - return Math.round(value * factor) / factor; -} - -function esc(value: string): string { - return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); -} - -/** A literal `</script>`/`</style>` inside inlined text would close the tag early. */ -function inlineSafe(text: string): string { - return text.replace(/<\/(script|style)/gi, "<\\/$1"); -} - -function fmtPct(value: number | null): string { - if (value === null) return "—"; - if (!Number.isFinite(value)) return "∞"; - return `${value > 0 ? "+" : ""}${round(value)}%`; -} - -type Align = "left" | "right"; - -function htmlTable( - header: readonly string[], - rows: readonly (readonly string[])[], - align: readonly Align[] = [] -): string { - if (rows.length === 0) return `<p class="muted">—</p>`; - const th = header.map((h, i) => `<th class="${align[i] ?? "left"}">${esc(h)}</th>`).join(""); - const body = rows - .map((row) => `<tr>${row.map((cell, i) => `<td class="${align[i] ?? "left"}">${cell}</td>`).join("")}</tr>`) - .join(""); - return `<table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table>`; -} - -function cardsHtml(report: StressRunReport): string { - const navP95 = report.timings["scenario.navigateStep"]?.p95Ms; - const cards: [string, string][] = [ - ["Status", report.status.toUpperCase()], - ["Scenario", report.scenario], - ["Profile", report.profile], - ["Repeats", `${report.config.repeats} (warmup ${report.config.warmup})`], - ["Budget failures", String(report.budgetFailures.length)], - ["Regressions", String(report.regressions.filter((r) => r.regressed).length)], - ]; - if (navP95 !== undefined) cards.push(["navigateStep p95", `${round(navP95)} ms`]); - const cls = (label: string): string => (label === "Status" ? (report.status === "pass" ? "ok" : "bad") : ""); - return cards - .map(([l, v]) => `<div class="card ${cls(l)}"><div class="v">${esc(v)}</div><div class="l">${esc(l)}</div></div>`) - .join(""); -} - -function timingsHtml(timings: StressRunReport["timings"]): string { - const entries = Object.entries(timings).sort((a, b) => b[1].p95Ms - a[1].p95Ms); - if (entries.length === 0) return `<p class="muted">No timing stages recorded.</p>`; - const maxP95 = Math.max(...entries.map(([, s]) => s.p95Ms), 1); - const rows = entries.map(([name, s]) => [ - `<code>${esc(name)}</code>`, - String(s.count), - String(round(s.avgMs)), - String(round(s.p50Ms)), - `<div class="bar"><span style="width:${round((s.p95Ms / maxP95) * 100)}%"></span><b>${round(s.p95Ms)}</b></div>`, - String(round(s.maxMs)), - ]); - return htmlTable(["Stage", "Count", "Avg ms", "P50 ms", "P95 ms", "Max ms"], rows, [ - "left", - "right", - "right", - "right", - "left", - "right", - ]); -} - -function digestHtml(digest: StressRunReport["profileDigest"]): string { - if (!digest) return `<p class="muted">No CPU profile captured.</p>`; - if (digest.topSelfTime.length === 0) { - return `<p class="muted">CPU profile captured, but no non-idle frames were sampled (action too fast — try a larger profile).</p>`; - } - const rows = digest.topSelfTime.map((e, i) => [ - String(i + 1), - `<code>${esc(e.functionName)}</code>`, - `<code>${esc(e.location)}</code>`, - `${round(e.selfPct)}%`, - String(round(e.selfTimeMs)), - String(e.hitCount), - ]); - return ( - `<p class="muted">Profiled ${round(digest.durationMs)} ms · ${digest.sampleCount} samples</p>` + - htmlTable(["#", "Function", "Location", "Self %", "Self ms", "Hits"], rows, [ - "right", - "left", - "left", - "right", - "right", - "right", - ]) - ); -} - -function heapHtml(digest: StressRunReport["heapDigest"]): string { - if (!digest) return `<p class="muted">No heap snapshot captured.</p>`; - const mb = (bytes: number): string => `${round(bytes / 1_000_000)} MB`; - const parts = [ - `<p class="muted">Nodes ${digest.nodeCount} · Edges ${digest.edgeCount} · Retained ${mb(digest.totalSizeBytes)} · Detached nodes ${digest.detachedNodeCount}</p>`, - ]; - if (digest.topTypes.length > 0) { - parts.push( - "<h3>Top types</h3>", - htmlTable( - ["#", "Type", "Count", "Self size"], - digest.topTypes.map((e, i) => [ - String(i + 1), - `<code>${esc(e.type)}</code>`, - String(e.count), - mb(e.selfSizeBytes), - ]), - ["right", "left", "right", "right"] - ) - ); - } - if (digest.topRetainers.length > 0) { - parts.push( - "<h3>Top retainers of detached nodes</h3>", - htmlTable( - ["#", "Holder.edge", "Detached held"], - digest.topRetainers.map((e, i) => [String(i + 1), `<code>${esc(e.retainer)}</code>`, String(e.count)]), - ["right", "left", "right"] - ) - ); - } - return parts.join("\n"); -} - -function regressionsHtml(regressions: readonly RegressionFinding[]): string { - if (regressions.length === 0) return `<p class="muted">No baseline comparison (no baseline committed yet).</p>`; - return htmlTable( - ["Metric", "Kind", "Baseline", "Candidate", "Delta", "Status"], - regressions.map((r) => [ - `<code>${esc(r.metric)}</code>`, - r.kind, - String(round(r.baseline)), - String(round(r.candidate)), - `${round(r.delta)} (${fmtPct(r.deltaPct)})`, - r.regressed ? `<span class="bad">❌ REGRESSED</span>` : "✅", - ]), - ["left", "left", "right", "right", "right", "left"] - ); -} - -function budgetsHtml(failures: readonly BudgetFailure[]): string { - if (failures.length === 0) return `<p class="muted">All budgets within limits.</p>`; - return htmlTable( - ["Metric", "Rule", "Actual", "Expected", "Delta"], - failures.map((f) => [ - `<code>${esc(f.metric)}</code>`, - f.comparison, - String(round(f.actual)), - String(round(f.expected)), - `${round(f.delta)} (${fmtPct(f.deltaPct)})`, - ]), - ["left", "left", "right", "right", "right"] - ); -} - -function countsHtml(counts: StressRunReport["counts"]): string { - const entries = Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])); - if (entries.length === 0) return `<p class="muted">No counts recorded.</p>`; - return htmlTable( - ["Metric", "Value"], - entries.map(([name, value]) => [`<code>${esc(name)}</code>`, String(value)]), - ["left", "right"] - ); -} - -function artifactsHtml(artifacts: readonly StressArtifact[]): string { - if (artifacts.length === 0) return `<p class="muted">No artifacts.</p>`; - return htmlTable( - ["Kind", "Path", "Description"], - artifacts.map((a) => [ - a.kind, - `<a href="file://${esc(a.path)}"><code>${esc(a.path)}</code></a>`, - esc(a.description ?? ""), - ]), - ["left", "left", "left"] - ); -} - -const STYLE = ` -:root { color-scheme: dark; } -* { box-sizing: border-box; } -body { margin: 0; font: 13px/1.5 -apple-system, system-ui, "Segoe UI", sans-serif; color: #c9d1d9; background: #0d1117; } -.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; } -.flamewrap { max-width: none; } -h1 { font-size: 20px; margin: 0 0 4px; } -h2 { font-size: 15px; margin: 28px 0 10px; border-bottom: 1px solid #21262d; padding-bottom: 6px; } -h3 { font-size: 13px; margin: 16px 0 6px; color: #8b949e; } -.meta { color: #8b949e; font-size: 12px; } -.muted { color: #8b949e; } -code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; } -a { color: #58a6ff; text-decoration: none; } -a:hover { text-decoration: underline; } -.cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; } -.card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 10px 14px; min-width: 110px; } -.card .v { font-size: 18px; font-weight: 600; } -.card .l { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: .04em; } -.card.ok .v { color: #3fb950; } -.card.bad .v { color: #f85149; } -.bad { color: #f85149; } -table { border-collapse: collapse; width: 100%; margin: 4px 0; font-size: 12px; } -th, td { padding: 5px 9px; border-bottom: 1px solid #21262d; vertical-align: middle; } -th { color: #8b949e; font-weight: 600; text-align: left; } -td.right, th.right { text-align: right; } -tbody tr:hover { background: #161b22; } -.bar { position: relative; min-width: 90px; height: 16px; background: #161b22; border-radius: 3px; } -.bar span { position: absolute; left: 0; top: 0; bottom: 0; background: #1f6feb; border-radius: 3px; } -.bar b { position: relative; padding: 0 5px; font-weight: 600; } -.flametools { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; } -.flametools button { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px; } -.flametools button:hover { background: #30363d; } -#crumb { color: #8b949e; font-size: 12px; } -#crumb span { cursor: pointer; color: #58a6ff; } -#flame { width: 100%; border: 1px solid #21262d; border-radius: 6px; overflow: hidden; user-select: none; } -.fl-node { display: flex; flex-direction: column; min-width: 0; } -.fl-label { height: 19px; line-height: 19px; font-size: 11px; padding: 0 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; border: 1px solid rgba(13,17,23,.55); cursor: pointer; color: #0d1117; } -.fl-label:hover { outline: 1px solid #fff; outline-offset: -1px; } -.fl-children { display: flex; flex-direction: row; align-items: flex-start; } -#tip { position: fixed; pointer-events: none; z-index: 10; background: #1c2128; border: 1px solid #30363d; border-radius: 6px; padding: 6px 9px; font-size: 12px; max-width: 460px; display: none; box-shadow: 0 4px 14px rgba(0,0,0,.5); } -#tip .n { font-weight: 600; color: #fff; } -#tip .loc { color: #8b949e; } -`; - -// Client flame-chart renderer. Authored with string concatenation (no template -// literals) so it can live inside this TS module's backtick block without `${}` -// being interpolated at build time, and using only safe DOM APIs (textContent / -// createElement / replaceChildren — never innerHTML). Reads window.__STRESS__.tree. -const FLAME_JS = ` -(function () { - var data = window.__STRESS__; - if (!data || !data.tree) return; - var root = data.tree; - var flame = document.getElementById("flame"); - var tip = document.getElementById("tip"); - var crumb = document.getElementById("crumb"); - var path = [root]; - - function colorFor(s) { - var h = 0; - for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; } - var hue = ((h % 360) + 360) % 360; - return "hsl(" + hue + ", 58%, 62%)"; - } - function fmt(n) { return Math.round(n * 10) / 10; } - - function div(cls) { var d = document.createElement("div"); if (cls) d.className = cls; return d; } - - function showTip(e, node) { - var pct = path[0].totalMs > 0 ? fmt((node.totalMs / path[0].totalMs) * 100) : 0; - var n = div("n"); n.textContent = node.name; - var loc = div("loc"); loc.textContent = node.location; - var stat = div(); stat.textContent = "total " + fmt(node.totalMs) + " ms · self " + fmt(node.selfMs) + " ms · " + pct + "% of view"; - tip.replaceChildren(n, loc, stat); - tip.style.display = "block"; - moveTip(e); - } - function moveTip(e) { - var x = e.clientX + 14, y = e.clientY + 14; - if (x + tip.offsetWidth > window.innerWidth) x = e.clientX - tip.offsetWidth - 14; - if (y + tip.offsetHeight > window.innerHeight) y = e.clientY - tip.offsetHeight - 14; - tip.style.left = x + "px"; tip.style.top = y + "px"; - } - function hideTip() { tip.style.display = "none"; } - - function makeNode(node, parentTotal) { - var el = div("fl-node"); - var pct = parentTotal > 0 ? (node.totalMs / parentTotal) * 100 : 100; - el.style.flex = "0 0 " + pct + "%"; - - var label = div("fl-label"); - label.style.background = colorFor(node.location || node.name); - label.textContent = node.name; - label.title = node.name + " " + node.location; - label.addEventListener("mouseenter", function (e) { showTip(e, node); }); - label.addEventListener("mousemove", moveTip); - label.addEventListener("mouseleave", hideTip); - label.addEventListener("click", function () { zoom(node); }); - el.appendChild(label); - - if (node.children && node.children.length) { - var kids = div("fl-children"); - var sorted = node.children.slice().sort(function (a, b) { return b.totalMs - a.totalMs; }); - for (var i = 0; i < sorted.length; i++) { kids.appendChild(makeNode(sorted[i], node.totalMs)); } - el.appendChild(kids); - } - return el; - } - - function findPathTo(node) { - var out = []; - (function walk(n, trail) { - var t = trail.concat([n]); - if (n === node) { out = t; return true; } - if (n.children) { for (var i = 0; i < n.children.length; i++) { if (walk(n.children[i], t)) return true; } } - return false; - })(root, []); - return out.length ? out : [root]; - } - - function zoom(node) { path = findPathTo(node); draw(); } - - function draw() { - var view = path[path.length - 1]; - flame.replaceChildren(makeNode(view, view.totalMs)); - var crumbNodes = []; - path.forEach(function (n, i) { - if (i > 0) crumbNodes.push(document.createTextNode(" › ")); - var s = document.createElement("span"); - s.textContent = n.name; - s.addEventListener("click", function () { path = path.slice(0, i + 1); draw(); }); - crumbNodes.push(s); - }); - crumb.replaceChildren.apply(crumb, crumbNodes); - } - - var reset = document.getElementById("flreset"); - if (reset) reset.addEventListener("click", function () { path = [root]; draw(); }); - draw(); -})(); -`; - -function flameSection(tree: ProfileTreeNode | undefined): string { - if (!tree) return ""; - return ` - <h2>Flame chart — call tree</h2> - <p class="muted">Width ∝ total time; the gap under a frame is its own self time. Click a frame to zoom, the breadcrumb to step back, hover for detail.</p> - <div class="flametools"> - <button id="flreset">Reset zoom</button> - <span id="crumb"></span> - </div> - <div id="flame"></div> - <div id="tip"></div>`; -} - -export interface HtmlReportOptions { - /** The CPU-profile call tree, prune-ready, for the inlined flame chart. */ - profileTree?: ProfileTreeNode; -} - -export function renderHtmlReport(report: StressRunReport, options: HtmlReportOptions = {}): string { - const env = report.environment; - const git = report.git; - const flame = flameSection(options.profileTree); - const treeBlob = options.profileTree - ? JSON.stringify({ tree: options.profileTree }).replace(/</g, "\\u003c") - : "null"; - const wrapClass = options.profileTree ? "wrap flamewrap" : "wrap"; - - return `<!doctype html> -<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Stress Report — ${esc(report.scenario)} (${esc(report.profile)}) - - - -
-

Stress Report — ${esc(report.scenario)} (${esc(report.profile)})

-
Run ${esc(report.runId)} · ${esc(report.startedAt)} → ${esc(report.finishedAt)}
-
Git ${esc(git.branch)} @ ${esc(git.commit.slice(0, 8))}${git.dirty ? " (dirty)" : ""} · ${esc(env.os)} · ${esc(env.arch)} · ${esc(env.cpuModel)} ×${env.cpuCount} · node ${esc(env.nodeVersion)}${env.pluginVersion ? ` · plugin ${esc(env.pluginVersion)}` : ""}
-
${cardsHtml(report)}
- ${flame} -

Stage timings (load & loops)

- ${timingsHtml(report.timings)} -

Top self-time (CPU profile)

- ${digestHtml(report.profileDigest)} -

Heap

- ${heapHtml(report.heapDigest)} -

Regressions vs baseline (same machine)

- ${regressionsHtml(report.regressions)} -

Budget failures

- ${budgetsHtml(report.budgetFailures)} -

Counts

- ${countsHtml(report.counts)} -

Artifacts

- ${artifactsHtml(report.artifacts)} -
- - - -`; -} diff --git a/shared/src/testing/stress/reporters/json.ts b/shared/src/testing/stress/reporters/json.ts deleted file mode 100644 index 3f7bb2dc..00000000 --- a/shared/src/testing/stress/reporters/json.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import type { ProfileTreeNode } from "../profile-tree"; -import { StressBaselineSchema, type StressBaseline, type StressRunReport } from "../types"; -import { renderHtmlReport } from "./html"; -import { renderMarkdownReport } from "./markdown"; - -export interface WrittenReport { - jsonPath: string; - markdownPath: string; - htmlPath: string; -} - -export interface WriteRunReportsOptions { - /** CPU-profile call tree for the HTML report's interactive flame chart. */ - profileTree?: ProfileTreeNode; -} - -/** Write `run.json` + `report.md` + `report.html` into the run's artifact directory. */ -export function writeRunReports( - dir: string, - report: StressRunReport, - options: WriteRunReportsOptions = {} -): WrittenReport { - mkdirSync(dir, { recursive: true }); - const jsonPath = path.join(dir, "run.json"); - const markdownPath = path.join(dir, "report.md"); - const htmlPath = path.join(dir, "report.html"); - writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); - writeFileSync(markdownPath, renderMarkdownReport(report), "utf8"); - writeFileSync( - htmlPath, - renderHtmlReport(report, options.profileTree ? { profileTree: options.profileTree } : {}), - "utf8" - ); - return { jsonPath, markdownPath, htmlPath }; -} - -export function readBaseline(filePath: string): StressBaseline | null { - if (!existsSync(filePath)) return null; - // Validate on read — a corrupt or stale-shape baseline should surface loudly, - // not silently skew the regression verdict. - return StressBaselineSchema.parse(JSON.parse(readFileSync(filePath, "utf8"))); -} - -export function writeBaseline(filePath: string, baseline: StressBaseline): void { - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(baseline, null, 2)}\n`, "utf8"); -} - -/** Derive a fresh baseline (p95 per timing + counts) from a run report. */ -export function reportToBaseline(report: StressRunReport): StressBaseline { - const timings: Record = {}; - for (const [name, summary] of Object.entries(report.timings)) { - timings[name] = summary.p95Ms; - } - return { - scenario: report.scenario, - profile: report.profile, - capturedAt: new Date().toISOString(), - environment: report.environment, - timings, - counts: { ...report.counts }, - }; -} diff --git a/shared/src/testing/stress/reporters/markdown.ts b/shared/src/testing/stress/reporters/markdown.ts deleted file mode 100644 index 7883b5e1..00000000 --- a/shared/src/testing/stress/reporters/markdown.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { BudgetFailure, RegressionFinding, StressArtifact, StressRunReport } from "../types"; - -// Canonical, agent-readable report. An agent reads this Markdown to find the -// hottest stage and the regression verdict without opening any GUI. - -function round(value: number, places = 1): number { - if (!Number.isFinite(value)) return value; - const factor = 10 ** places; - return Math.round(value * factor) / factor; -} - -function fmtPct(value: number | null): string { - if (value === null) return "—"; - if (!Number.isFinite(value)) return "∞"; - const sign = value > 0 ? "+" : ""; - return `${sign}${round(value)}%`; -} - -function fmtDelta(delta: number, deltaPct: number | null): string { - const base = String(round(delta)); - return deltaPct === null ? base : `${base} (${fmtPct(deltaPct)})`; -} - -function table(header: readonly string[], rows: readonly (readonly string[])[]): string { - const head = `| ${header.join(" | ")} |`; - const sep = `| ${header.map(() => "---").join(" | ")} |`; - const body = rows.map((row) => `| ${row.join(" | ")} |`).join("\n"); - return [head, sep, body].join("\n"); -} - -function regressionsSection(regressions: readonly RegressionFinding[]): string { - if (regressions.length === 0) return "_No baseline comparison (no baseline committed yet)._"; - const rows = regressions.map((r) => [ - r.metric, - r.kind, - String(round(r.baseline)), - String(round(r.candidate)), - fmtDelta(r.delta, r.deltaPct), - r.regressed ? "❌ REGRESSED" : "✅", - ]); - return table(["Metric", "Kind", "Baseline", "Candidate", "Delta", "Status"], rows); -} - -function budgetSection(failures: readonly BudgetFailure[]): string { - if (failures.length === 0) return "_All budgets within limits._"; - const rows = failures.map((f) => [ - f.metric, - f.comparison, - String(round(f.actual)), - String(round(f.expected)), - fmtDelta(f.delta, f.deltaPct), - ]); - return table(["Metric", "Rule", "Actual", "Expected", "Delta"], rows); -} - -function timingsSection(timings: StressRunReport["timings"]): string { - const entries = Object.entries(timings).sort((a, b) => b[1].totalMs - a[1].totalMs); - if (entries.length === 0) return "_No timing stages recorded._"; - const rows = entries.map(([name, s]) => [ - name, - String(s.count), - String(round(s.avgMs)), - String(round(s.p50Ms)), - String(round(s.p95Ms)), - String(round(s.maxMs)), - ]); - return table(["Stage", "Count", "Avg ms", "P50 ms", "P95 ms", "Max ms"], rows); -} - -function profileDigestSection(digest: StressRunReport["profileDigest"]): string { - if (!digest) return "_No CPU profile captured._"; - if (digest.topSelfTime.length === 0) { - return "_CPU profile captured, but no non-idle frames were sampled — the action is too fast to profile (try a larger profile)._"; - } - const caption = `Profiled ${round(digest.durationMs)}ms · ${digest.sampleCount} samples`; - const rows = digest.topSelfTime.map((entry, index) => [ - String(index + 1), - entry.functionName, - entry.location, - `${round(entry.selfPct)}%`, - String(round(entry.selfTimeMs)), - String(entry.hitCount), - ]); - return [caption, "", table(["#", "Function", "Location", "Self %", "Self ms", "Hits"], rows)].join("\n"); -} - -function heapDigestSection(digest: StressRunReport["heapDigest"]): string { - if (!digest) return "_No heap snapshot captured._"; - const mb = (bytes: number): string => `${round(bytes / 1_000_000)} MB`; - const caption = `Nodes ${digest.nodeCount} · Edges ${digest.edgeCount} · Retained ${mb(digest.totalSizeBytes)} · Detached nodes ${digest.detachedNodeCount}`; - const parts = [caption]; - if (digest.topTypes.length > 0) { - const rows = digest.topTypes.map((entry, index) => [ - String(index + 1), - entry.type, - String(entry.count), - mb(entry.selfSizeBytes), - ]); - parts.push("", "**Top types**", "", table(["#", "Type", "Count", "Self size"], rows)); - } - if (digest.topRetainers.length > 0) { - const rows = digest.topRetainers.map((entry, index) => [String(index + 1), entry.retainer, String(entry.count)]); - parts.push("", "**Top retainers of detached nodes**", "", table(["#", "Holder.edge", "Detached held"], rows)); - } - return parts.join("\n"); -} - -function countsSection(counts: StressRunReport["counts"]): string { - const entries = Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])); - if (entries.length === 0) return "_No counts recorded._"; - const rows = entries.map(([name, value]) => [name, String(value)]); - return table(["Metric", "Value"], rows); -} - -function artifactsSection(artifacts: readonly StressArtifact[]): string { - if (artifacts.length === 0) return "_No artifacts._"; - const rows = artifacts.map((a) => [a.kind, a.path, a.description ?? ""]); - return table(["Kind", "Path", "Description"], rows); -} - -export function renderMarkdownReport(report: StressRunReport): string { - const env = report.environment; - const git = report.git; - const lines = [ - `# Stress Report — ${report.scenario} (${report.profile})`, - "", - `Run: ${report.runId} · Status: **${report.status.toUpperCase()}**`, - `Seed: ${report.config.seed} · Repeats: ${report.config.repeats} (warmup ${report.config.warmup}) · Collectors: ${report.config.collectors.join(", ") || "none"}`, - `Started: ${report.startedAt} · Finished: ${report.finishedAt}`, - `Git: ${git.branch} @ ${git.commit.slice(0, 8)}${git.dirty ? " (dirty)" : ""}`, - `Env: ${env.os} · ${env.arch} · ${env.cpuModel} ×${env.cpuCount} · node ${env.nodeVersion}${env.pluginVersion ? ` · plugin ${env.pluginVersion}` : ""}`, - "", - "## Regressions vs baseline (same machine)", - "", - regressionsSection(report.regressions), - "", - "## Budget failures", - "", - budgetSection(report.budgetFailures), - "", - "## Timings", - "", - timingsSection(report.timings), - "", - "## Top self-time (CPU profile)", - "", - profileDigestSection(report.profileDigest), - "", - "## Heap", - "", - heapDigestSection(report.heapDigest), - "", - "## Counts", - "", - countsSection(report.counts), - "", - "## Artifacts", - "", - artifactsSection(report.artifacts), - "", - ]; - return lines.join("\n"); -} diff --git a/shared/src/testing/stress/runner/repeat.ts b/shared/src/testing/stress/runner/repeat.ts deleted file mode 100644 index c909ed46..00000000 --- a/shared/src/testing/stress/runner/repeat.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface RepeatOptions { - warmup: number; - repeats: number; -} - -export type RepeatPhase = "warmup" | "measured"; - -/** - * Run an action `warmup` times (results discarded) then `repeats` times - * (results collected). Warmup absorbs JIT warm-up and cache fill so the - * measured samples are steady-state. - */ -export async function runRepeats( - options: RepeatOptions, - fn: (index: number, phase: RepeatPhase) => Promise -): Promise { - for (let index = 0; index < options.warmup; index++) { - await fn(index, "warmup"); - } - const results: T[] = []; - for (let index = 0; index < options.repeats; index++) { - results.push(await fn(index, "measured")); - } - return results; -} diff --git a/shared/src/testing/stress/seeded-random.ts b/shared/src/testing/stress/seeded-random.ts deleted file mode 100644 index 5e69ae0a..00000000 --- a/shared/src/testing/stress/seeded-random.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Deterministic PRNG so generated vaults are byte-identical across runs — the -// foundation of reproducible stress measurement. mulberry32: tiny, fast, and -// stable; never use Math.random() in the harness. Do not change the `next()` -// implementation without updating any byte-identical fixture expectations. - -export interface SeededRandom { - /** Next pseudo-random float in [0, 1). */ - next(): number; - - /** Integer in [min, max] inclusive. */ - int(min: number, max: number): number; - - /** Pick a pseudo-random element; throws on empty input. */ - pick(items: readonly T[]): T; - - /** True with probability `p` in [0, 1]. Default 0.5. */ - bool(p?: number): boolean; -} - -export function createSeededRandom(seed: number): SeededRandom { - if (!Number.isFinite(seed) || !Number.isInteger(seed)) { - throw new Error(`createSeededRandom() seed must be a finite integer. Received: ${seed}`); - } - - let state = seed >>> 0; - - const next = (): number => { - state = (state + 0x6d2b79f5) | 0; - let t = Math.imul(state ^ (state >>> 15), 1 | state); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - - return { - next, - - int(min, max) { - if (!Number.isInteger(min) || !Number.isInteger(max)) { - throw new Error(`SeededRandom.int() bounds must be integers. Received: ${min}, ${max}`); - } - - if (max < min) { - throw new Error(`SeededRandom.int() max must be >= min. Received: ${min}, ${max}`); - } - - return min + Math.floor(next() * (max - min + 1)); - }, - - pick(items) { - if (items.length === 0) { - throw new Error("SeededRandom.pick() called on an empty array"); - } - - const index = Math.floor(next() * items.length); - const choice = items[index]; - if (choice === undefined) { - throw new Error("SeededRandom.pick() invariant violated: index out of range"); - } - return choice; - }, - - bool(p = 0.5) { - if (!Number.isFinite(p) || p < 0 || p > 1) { - throw new Error(`SeededRandom.bool() probability must be in [0, 1]. Received: ${p}`); - } - - return next() < p; - }, - }; -} diff --git a/shared/src/testing/stress/source-map.ts b/shared/src/testing/stress/source-map.ts deleted file mode 100644 index e978b75c..00000000 --- a/shared/src/testing/stress/source-map.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { readFileSync } from "node:fs"; -import { SourceMap } from "node:module"; - -import type { FrameResolver, RawFrame, ResolvedFrame } from "./profile-digest"; - -// Builds a FrameResolver from a bundle's external `.map`, so the CPU-profile -// digest can rank `getNextOccurrence recurring-event-manager.ts:611` instead of -// the minified `RP main.js:3828`. Uses Node's built-in `node:module` SourceMap -// (no extra dependency); `findEntry` takes 0-based line/column — exactly what V8 -// emits in a CPU profile call frame — and returns the original position. - -/** The slice of `node:module`'s SourceMap that the resolver depends on. */ -export interface SourceMapLookup { - findEntry( - lineOffset: number, - columnOffset: number - ): { - originalSource?: string; - originalLine?: number; - originalColumn?: number; - name?: string; - }; -} - -export interface FrameResolverOptions { - /** - * True when a frame's `url` belongs to the bundle this map describes. Frames - * from other scripts (Obsidian's app.js, electron, node internals) MUST be - * rejected — feeding their coordinates through the wrong map yields plausible - * but bogus positions. - */ - matchesBundle: (url: string) => boolean; -} - -/** - * Wrap a parsed source map as a frame resolver. Separate from file I/O so it can - * be unit-tested with a fake lookup (no real `.map` / VLQ decoding needed). - */ -export function createFrameResolver(sourceMap: SourceMapLookup, options: FrameResolverOptions): FrameResolver { - return (frame: RawFrame): ResolvedFrame | null => { - if (frame.lineNumber < 0 || !options.matchesBundle(frame.url)) return null; - const entry = sourceMap.findEntry(frame.lineNumber, frame.columnNumber ?? 0); - if (entry.originalSource === undefined || entry.originalLine === undefined) return null; - return { - ...(entry.name ? { functionName: entry.name } : {}), - source: entry.originalSource, - line: entry.originalLine + 1, - ...(entry.originalColumn !== undefined ? { column: entry.originalColumn } : {}), - }; - }; -} - -export interface LoadBundleSourceMapOptions extends FrameResolverOptions { - /** Path to the bundle's external source map (e.g. `/main.js.map`). */ - mapPath: string; -} - -/** - * Load a bundle's `.map` and return a frame resolver, or null if the map is - * missing or unparseable — callers fall back to the unmapped (minified) digest, - * so a missing map degrades gracefully instead of failing the run. - */ -export function loadBundleSourceMap(options: LoadBundleSourceMapOptions): FrameResolver | null { - let sourceMap: SourceMapLookup; - try { - sourceMap = new SourceMap(JSON.parse(readFileSync(options.mapPath, "utf8"))); - } catch { - return null; - } - return createFrameResolver(sourceMap, { matchesBundle: options.matchesBundle }); -} diff --git a/shared/src/testing/stress/types.ts b/shared/src/testing/stress/types.ts deleted file mode 100644 index 2fc4ba31..00000000 --- a/shared/src/testing/stress/types.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { z } from "zod"; - -import type { PerfTimingSummary } from "../../perf/types"; - -// Single source of truth for the deterministic stress / performance harness. -// Schemas own the shape AND the defaults; every TS type is inferred via -// `z.infer` so there's nothing to keep in sync by hand. Schemas that cross a -// parse boundary (baselines read off disk) get real validation; output-only -// shapes (the run report) use the schema purely for inference + centralization. -// This runtime is test-only and consumed via `@real1ty-obsidian-plugins/testing/stress`. - -/** Unit a metric is expressed in — drives report formatting + delta semantics. */ -export const MetricUnitSchema = z.enum(["ms", "count", "bytes", "ratio"]); -export type MetricUnit = z.infer; - -/** Family of a metric — not every regression is measured in milliseconds. */ -export const MetricKindSchema = z.enum(["timing", "count", "memory", "ratio"]); -export type MetricKind = z.infer; - -/** How a budget value is interpreted against the actual measurement. */ -export const BudgetComparisonSchema = z.enum(["max", "min", "exact"]); -export type BudgetComparison = z.infer; - -/** A flat bag of named numeric measurements (ms or unit-less counts). */ -export const MetricRecordSchema = z.record(z.string(), z.number()); -export type MetricRecord = z.infer; - -/** - * Aggregated stats for one metric — identical to the perf tracker's summary, - * which owns the canonical shape. `z.custom` keeps that single definition while - * still letting the report schema reference it (the report is output-only, so no - * runtime validation of this field is needed). - */ -export type MetricSummary = PerfTimingSummary; -export const MetricSummarySchema = z.custom(); - -export const GitInfoSchema = z.object({ - branch: z.string(), - commit: z.string(), - dirty: z.boolean(), -}); -export type GitInfo = z.infer; - -/** The host + toolchain a run was captured on — baselines are same-machine. */ -export const EnvironmentInfoSchema = z.object({ - os: z.string(), - arch: z.string(), - cpuModel: z.string(), - cpuCount: z.number(), - totalMemoryBytes: z.number(), - nodeVersion: z.string(), - playwrightVersion: z.string().optional(), - obsidianVersion: z.string().optional(), - electronVersion: z.string().optional(), - chromiumVersion: z.string().optional(), - pluginVersion: z.string().optional(), -}); -export type EnvironmentInfo = z.infer; - -/** - * Base shape every plugin's vault profile satisfies. Plugins extend this with - * their own knobs (large notes, multi-weekday recurrences, …) and pass the - * extended type through `generateVault`'s generic. - */ -export const VaultProfileSchema = z.object({ - name: z.string(), - events: z.number(), - recurring: z.number(), -}); -export type VaultProfile = z.infer; - -/** - * A budget rule for one metric. `max` (must stay under), `min` (must stay over), - * or `exact` (must equal) — so a single budget map covers timings, counts, - * memory, and leak assertions. - */ -export const StressBudgetRuleSchema = z.object({ - comparison: BudgetComparisonSchema, - value: z.number(), - unit: MetricUnitSchema.optional(), - description: z.string().optional(), -}); -export type StressBudgetRule = z.infer; - -export const StressBudgetSchema = z.record(z.string(), StressBudgetRuleSchema); -export type StressBudget = z.infer; - -export const BudgetFailureSchema = z.object({ - metric: z.string(), - comparison: BudgetComparisonSchema, - actual: z.number(), - expected: z.number(), - delta: z.number(), - /** null when a percentage is undefined (exact comparison, or zero baseline). */ - deltaPct: z.number().nullable(), - unit: MetricUnitSchema.optional(), -}); -export type BudgetFailure = z.infer; - -/** - * Regression is judged against a same-machine baseline: a metric fails only - * when the candidate exceeds the baseline by BOTH a ratio and an absolute floor - * (so machine noise alone never trips it). Counts must match exactly. The - * defaults live here — `RegressionRuleSchema.parse({})` yields the canonical rule. - */ -export const RegressionRuleSchema = z.object({ - ratio: z.number().default(1.25), - minAbsoluteDelta: z.number().default(30), - unit: MetricUnitSchema.optional(), -}); -export type RegressionRule = z.infer; - -export const RegressionFindingSchema = z.object({ - metric: z.string(), - kind: MetricKindSchema, - baseline: z.number(), - candidate: z.number(), - delta: z.number(), - /** null when a percentage is undefined (zero baseline). */ - deltaPct: z.number().nullable(), - unit: MetricUnitSchema.optional(), - regressed: z.boolean(), -}); -export type RegressionFinding = z.infer; - -/** Committed, user-blessed reference numbers for one scenario × profile. */ -export const StressBaselineSchema = z.object({ - scenario: z.string(), - profile: z.string(), - capturedAt: z.string(), - environment: EnvironmentInfoSchema, - /** p95 (ms) per timing metric — the regression anchor. */ - timings: z.record(z.string(), z.number()), - /** Deterministic counts. */ - counts: z.record(z.string(), z.number()), -}); -export type StressBaseline = z.infer; - -export const StressRunConfigSchema = z.object({ - seed: z.number().default(42), - repeats: z.number().default(5), - warmup: z.number().default(1), - collectors: z.array(z.string()).default([]), -}); -export type StressRunConfig = z.infer; - -/** - * One function's self-time, parsed from a CDP CPU profile (`.cpuprofile`). Many - * call-tree nodes share a call frame; the digest collapses them into one row. - */ -export const ProfileDigestEntrySchema = z.object({ - functionName: z.string(), - url: z.string(), - /** 1-based source line (0 when the frame has no location, e.g. native code). */ - line: z.number(), - /** `file.ts:NN` or the bare function name for native/synthetic frames. */ - location: z.string(), - selfTimeMs: z.number(), - /** Share of total profiled time (0–100). */ - selfPct: z.number(), - hitCount: z.number(), -}); -export type ProfileDigestEntry = z.infer; - -/** - * Top self-time functions from a CPU profile — the agent-readable bottleneck map - * that turns "stage X is slow" into "function:line is N% of it" without a GUI. - */ -export const ProfileDigestSchema = z.object({ - sampleCount: z.number(), - durationMs: z.number(), - totalSelfTimeMs: z.number(), - topSelfTime: z.array(ProfileDigestEntrySchema), -}); -export type ProfileDigest = z.infer; - -/** - * One object type's footprint in a heap snapshot — live-node count + summed self - * size. The leak-harness analogue of a `ProfileDigestEntry`. - */ -export const HeapDigestEntrySchema = z.object({ - type: z.string(), - count: z.number(), - selfSizeBytes: z.number(), -}); -export type HeapDigestEntry = z.infer; - -/** - * What pins detached nodes alive: the holder's type + the edge (field) the - * reference travels through, with how many detached nodes it retains — the - * structured "what's leaking this?" answer (e.g. `object._listeners` × 12000). - */ -export const RetainerEntrySchema = z.object({ - retainer: z.string(), - count: z.number(), -}); -export type RetainerEntry = z.infer; - -/** - * Structured summary of a `.heapsnapshot` — node/edge totals, retained self size, - * the detached-DOM count (the classic retained-view leak signal), the heaviest - * object types, and the dominant retainers of the detached set — so an agent reads - * the leak shape (and its cause) without opening DevTools. - */ -export const HeapDigestSchema = z.object({ - nodeCount: z.number(), - edgeCount: z.number(), - totalSizeBytes: z.number(), - detachedNodeCount: z.number(), - topTypes: z.array(HeapDigestEntrySchema), - topRetainers: z.array(RetainerEntrySchema), -}); -export type HeapDigest = z.infer; - -/** A heavy artifact a run produced, so an agent can find the profile/trace/heap. */ -export const StressArtifactSchema = z.object({ - kind: z.enum([ - "json", - "markdown", - "html", - "cpu-profile", - "trace", - "heap-snapshot", - "playwright-trace", - "log", - "screenshot", - ]), - path: z.string(), - description: z.string().optional(), -}); -export type StressArtifact = z.infer; - -export const StressRunReportSchema = z.object({ - runId: z.string(), - scenario: z.string(), - profile: z.string(), - startedAt: z.string(), - finishedAt: z.string(), - status: z.enum(["pass", "fail"]), - git: GitInfoSchema, - environment: EnvironmentInfoSchema, - config: StressRunConfigSchema, - /** Aggregated timing metrics across repeats (ms). */ - timings: z.record(z.string(), MetricSummarySchema), - /** Deterministic counts captured for the run. */ - counts: MetricRecordSchema, - budgetFailures: z.array(BudgetFailureSchema), - regressions: z.array(RegressionFindingSchema), - artifacts: z.array(StressArtifactSchema), - /** Self-time digest from the profiled "explain" pass; absent on clean-only runs. */ - profileDigest: ProfileDigestSchema.optional(), - /** Heap-snapshot digest from a memory scenario; absent on non-memory runs. */ - heapDigest: HeapDigestSchema.optional(), -}); -export type StressRunReport = z.infer; diff --git a/shared/src/testing/stress/vault/generate.ts b/shared/src/testing/stress/vault/generate.ts deleted file mode 100644 index e672b6f5..00000000 --- a/shared/src/testing/stress/vault/generate.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import { createSeededRandom, type SeededRandom } from "../seeded-random"; -import type { VaultProfile } from "../types"; - -// Generic, deterministic vault generator. The plugin supplies a `buildEvent` -// that turns an index + seeded RNG into a markdown file; the engine here owns -// the loop, directory creation, and manifest. Same seed → byte-identical files. - -export interface GeneratedEvent { - /** Path relative to the target `dir` (e.g. `Event-0001.md`). */ - relativePath: string; - content: string; -} - -export interface VaultManifest { - profile: string; - seed: number; - events: number; - recurring: number; - generatorVersion: number; - createdAt: string; -} - -export interface GenerateVaultOptions

{ - /** Absolute target directory; created if missing. */ - dir: string; - profile: P; - seed: number; - buildEvent: (rng: SeededRandom, index: number, profile: P) => GeneratedEvent; - /** - * Optional builder for recurring source events; called `profile.recurring` - * times after the plain events (sharing the same RNG, so output stays - * deterministic). Omit it and `profile.recurring` files are simply not written. - */ - buildRecurringEvent?: (rng: SeededRandom, index: number, profile: P) => GeneratedEvent; - generatorVersion?: number; - /** Optional path to write the manifest JSON. */ - manifestPath?: string; -} - -export function generateVault

(options: GenerateVaultOptions

): VaultManifest { - const { dir, profile, seed, buildEvent, buildRecurringEvent, manifestPath } = options; - const generatorVersion = options.generatorVersion ?? 1; - - mkdirSync(dir, { recursive: true }); - const rng = createSeededRandom(seed); - - const write = (event: GeneratedEvent): void => { - const full = path.join(dir, event.relativePath); - mkdirSync(path.dirname(full), { recursive: true }); - writeFileSync(full, event.content, "utf8"); - }; - - for (let index = 0; index < profile.events; index++) { - write(buildEvent(rng, index, profile)); - } - if (buildRecurringEvent) { - for (let index = 0; index < profile.recurring; index++) { - write(buildRecurringEvent(rng, index, profile)); - } - } - - const manifest: VaultManifest = { - profile: profile.name, - seed, - events: profile.events, - recurring: profile.recurring, - generatorVersion, - createdAt: new Date().toISOString(), - }; - if (manifestPath) { - mkdirSync(path.dirname(manifestPath), { recursive: true }); - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); - } - return manifest; -} diff --git a/shared/tests/stress/artifact-paths.test.ts b/shared/tests/stress/artifact-paths.test.ts deleted file mode 100644 index 939a88a6..00000000 --- a/shared/tests/stress/artifact-paths.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { baselineFileName, buildArtifactDir, buildRunStem, isoStamp } from "../../src/testing/stress/artifact-paths"; - -const FIXED = new Date("2026-05-21T18:30:00.000Z"); - -describe("isoStamp", () => { - it("replaces colons and dots so the stamp is filesystem-safe", () => { - expect(isoStamp(FIXED)).toBe("2026-05-21T18-30-00-000Z"); - }); -}); - -describe("buildRunStem", () => { - it("joins stamp, profile and scenario", () => { - expect(buildRunStem({ profile: "medium", scenario: "nav", date: FIXED })).toBe( - "2026-05-21T18-30-00-000Z_medium_nav" - ); - }); -}); - -describe("buildArtifactDir", () => { - it("joins root and stem", () => { - expect(buildArtifactDir("/tmp/perf", "stem")).toBe("/tmp/perf/stem"); - }); -}); - -describe("baselineFileName", () => { - it("is ..json", () => { - expect(baselineFileName("calendar-navigation", "medium")).toBe("calendar-navigation.medium.json"); - }); -}); diff --git a/shared/tests/stress/budgets.test.ts b/shared/tests/stress/budgets.test.ts deleted file mode 100644 index 226efedb..00000000 --- a/shared/tests/stress/budgets.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { evaluateBudgets } from "../../src/testing/stress/budgets"; -import type { StressBudget } from "../../src/testing/stress/types"; - -const NAV_BUDGET: StressBudget = { - "nav.p95Ms": { comparison: "max", value: 120, unit: "ms" }, -}; - -describe("evaluateBudgets — max", () => { - it("returns no failures when under the ceiling", () => { - expect(evaluateBudgets({ "nav.p95Ms": 100 }, NAV_BUDGET)).toEqual([]); - }); - - it("does not flag a metric exactly at the ceiling", () => { - expect(evaluateBudgets({ "nav.p95Ms": 120 }, NAV_BUDGET)).toEqual([]); - }); - - it("flags a metric over the ceiling with delta + pct", () => { - const failures = evaluateBudgets({ "nav.p95Ms": 180 }, NAV_BUDGET); - expect(failures).toHaveLength(1); - expect(failures[0]).toEqual({ - metric: "nav.p95Ms", - comparison: "max", - actual: 180, - expected: 120, - delta: 60, - deltaPct: 50, - unit: "ms", - }); - }); -}); - -describe("evaluateBudgets — min", () => { - it("flags a metric below the floor", () => { - const failures = evaluateBudgets( - { "cache.hitRatio": 0.5 }, - { "cache.hitRatio": { comparison: "min", value: 0.9 } } - ); - expect(failures[0]).toMatchObject({ comparison: "min", actual: 0.5, expected: 0.9 }); - }); -}); - -describe("evaluateBudgets — exact", () => { - it("flags any difference and reports null pct", () => { - const failures = evaluateBudgets( - { "leak.activeViews": 2 }, - { "leak.activeViews": { comparison: "exact", value: 0, unit: "count" } } - ); - expect(failures[0]).toMatchObject({ comparison: "exact", actual: 2, expected: 0, delta: 2, deltaPct: null }); - }); - - it("passes when exactly equal", () => { - expect( - evaluateBudgets({ "leak.activeViews": 0 }, { "leak.activeViews": { comparison: "exact", value: 0 } }) - ).toEqual([]); - }); -}); - -describe("evaluateBudgets — general", () => { - it("skips budget keys with no matching metric", () => { - expect(evaluateBudgets({ "nav.p95Ms": 999 }, { "missing.metric": { comparison: "max", value: 1 } })).toEqual([]); - }); - - it("reports null pct when the reference value is zero", () => { - const failures = evaluateBudgets({ x: 5 }, { x: { comparison: "max", value: 0 } }); - expect(failures[0]?.deltaPct).toBeNull(); - }); -}); diff --git a/shared/tests/stress/heap-digest.test.ts b/shared/tests/stress/heap-digest.test.ts deleted file mode 100644 index 1a54bf8b..00000000 --- a/shared/tests/stress/heap-digest.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { digestHeapSnapshot, type HeapSnapshot } from "../../src/testing/stress/heap-digest"; - -// Two object types (object, native), two of which are detached DOM nodes flagged -// via the modern `detachedness` field (2 = detached). Field positions are -// deliberately ordered as V8 emits them so the by-name index resolution is exercised. -function makeSnapshot(): HeapSnapshot { - return { - snapshot: { - meta: { - node_fields: ["type", "name", "id", "self_size", "edge_count", "detachedness"], - node_types: [["object", "native", "string"], "string", "number", "number", "number", "number"], - }, - node_count: 4, - edge_count: 3, - }, - strings: ["", "Window", "Detached HTMLDivElement", "Foo"], - nodes: [ - 0, - 1, - 1, - 100, - 0, - 1, // object Window, attached - 1, - 2, - 2, - 40, - 0, - 2, // native Detached…, detached - 0, - 3, - 3, - 60, - 0, - 0, // object Foo - 1, - 2, - 4, - 40, - 0, - 2, // native Detached…, detached - ], - }; -} - -// Older snapshots omit `detachedness`; detached DOM is then detected by the -// "Detached " name prefix V8 / DevTools assigns. -function makeSnapshotWithoutDetachedness(): HeapSnapshot { - return { - snapshot: { - meta: { - node_fields: ["type", "name", "id", "self_size", "edge_count"], - node_types: [["native", "object"], "string", "number", "number", "number"], - }, - node_count: 2, - edge_count: 0, - }, - strings: ["", "Detached HTMLDivElement", "Window"], - nodes: [ - 0, - 1, - 1, - 30, - 0, // native Detached… - 1, - 2, - 2, - 50, - 0, // object Window - ], - }; -} - -// Two attached holders (n0, n3) each point at a detached node via a named -// property edge; n1→n2 is internal to the detached subgraph and must be ignored. -// Exercises the edge-walk retainer aggregation. -function makeSnapshotWithEdges(): HeapSnapshot { - return { - snapshot: { - meta: { - node_fields: ["type", "name", "id", "self_size", "edge_count", "detachedness"], - node_types: [["object", "native"], "string", "number", "number", "number", "number"], - edge_fields: ["type", "name_or_index", "to_node"], - edge_types: [["context", "element", "property", "internal", "hidden"], "string_or_number", "node"], - }, - node_count: 4, - edge_count: 3, - }, - strings: ["", "Window", "Detached HTMLDivElement", "_view", "child", "_owner"], - nodes: [ - 0, - 1, - 1, - 100, - 1, - 1, // n0 object Window, attached, 1 edge - 1, - 2, - 2, - 40, - 1, - 2, // n1 detached, 1 edge - 1, - 2, - 3, - 30, - 0, - 2, // n2 detached, leaf - 0, - 1, - 4, - 50, - 1, - 1, // n3 object Window, attached, 1 edge - ], - // 3 property edges (type 2), grouped by source node: n0→n1 "_view", n1→n2 "child", n3→n2 "_owner". - edges: [2, 3, 6, 2, 4, 12, 2, 5, 12], - }; -} - -describe("digestHeapSnapshot", () => { - it("counts nodes, edges and summed retained size", () => { - const digest = digestHeapSnapshot(makeSnapshot()); - expect(digest.nodeCount).toBe(4); - expect(digest.edgeCount).toBe(3); - expect(digest.totalSizeBytes).toBe(240); // 100 + 40 + 60 + 40 - }); - - it("counts detached DOM nodes via the detachedness field", () => { - expect(digestHeapSnapshot(makeSnapshot()).detachedNodeCount).toBe(2); - }); - - it("falls back to the 'Detached ' name prefix when detachedness is absent", () => { - expect(digestHeapSnapshot(makeSnapshotWithoutDetachedness()).detachedNodeCount).toBe(1); - }); - - it("ranks object types by summed self size", () => { - const { topTypes } = digestHeapSnapshot(makeSnapshot()); - expect(topTypes).toEqual([ - { type: "object", count: 2, selfSizeBytes: 160 }, - { type: "native", count: 2, selfSizeBytes: 80 }, - ]); - }); - - it("truncates to topTypes", () => { - const { topTypes } = digestHeapSnapshot(makeSnapshot(), { topTypes: 1 }); - expect(topTypes).toEqual([{ type: "object", count: 2, selfSizeBytes: 160 }]); - }); - - it("does not trace retainers unless asked", () => { - expect(digestHeapSnapshot(makeSnapshotWithEdges()).topRetainers).toEqual([]); - }); - - it("aggregates the external holders that retain detached nodes", () => { - const digest = digestHeapSnapshot(makeSnapshotWithEdges(), { retainers: true }); - expect(digest.detachedNodeCount).toBe(2); - // n1→n2 is inside the detached subgraph and excluded; only the attached holders count. - expect(digest.topRetainers).toEqual([ - { retainer: "object._view", count: 1 }, - { retainer: "object._owner", count: 1 }, - ]); - }); - - it("returns no retainers when the snapshot has no edges", () => { - expect(digestHeapSnapshot(makeSnapshot(), { retainers: true }).topRetainers).toEqual([]); - }); - - it("returns an empty digest for a snapshot with no nodes", () => { - const empty: HeapSnapshot = { - snapshot: { - meta: { node_fields: ["type", "name", "id", "self_size", "edge_count"], node_types: [[], "string"] }, - node_count: 0, - edge_count: 0, - }, - strings: [], - nodes: [], - }; - const digest = digestHeapSnapshot(empty); - expect(digest.nodeCount).toBe(0); - expect(digest.totalSizeBytes).toBe(0); - expect(digest.detachedNodeCount).toBe(0); - expect(digest.topTypes).toEqual([]); - }); -}); diff --git a/shared/tests/stress/html-report.test.ts b/shared/tests/stress/html-report.test.ts deleted file mode 100644 index ab0c219a..00000000 --- a/shared/tests/stress/html-report.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ProfileTreeNode } from "../../src/testing/stress/profile-tree"; -import { renderHtmlReport } from "../../src/testing/stress/reporters/html"; -import { makeRunReport, summary } from "./stress-fixtures"; - -describe("renderHtmlReport", () => { - it("renders a self-contained document with header, status and stage timings", () => { - const html = renderHtmlReport( - makeRunReport({ - status: "fail", - timings: { "scenario.navigateStep": summary(144, { count: 24 }) }, - }) - ); - expect(html.startsWith("")).toBe(true); - expect(html).toContain("Stress Report — calendar-navigation"); - expect(html).toContain("FAIL"); - expect(html).toContain("scenario.navigateStep"); - }); - - it("omits the flame chart when no profile tree is supplied", () => { - const html = renderHtmlReport(makeRunReport()); - expect(html).not.toContain('id="flame"'); - expect(html).toContain("window.__STRESS__ = null"); - }); - - it("embeds the flame chart and inlines the call tree when a tree is supplied", () => { - const tree: ProfileTreeNode = { - name: "(root)", - location: "(root)", - selfMs: 0, - totalMs: 10, - children: [ - { name: "getNextOccurrence", location: "recurring-event-manager.ts:611", selfMs: 8, totalMs: 8, children: [] }, - ], - }; - const html = renderHtmlReport(makeRunReport(), { profileTree: tree }); - expect(html).toContain('id="flame"'); - expect(html).toContain("getNextOccurrence"); - expect(html).toContain('"totalMs":10'); - }); - - it("escapes angle brackets in inlined tree JSON so a name can't break out of the script tag", () => { - const tree: ProfileTreeNode = { - name: "", - location: "x", - selfMs: 1, - totalMs: 1, - children: [], - }; - const html = renderHtmlReport(makeRunReport(), { profileTree: tree }); - expect(html).not.toContain(""); - expect(html).toContain("\\u003c/script>\\u003cx>"); - }); -}); diff --git a/shared/tests/stress/markdown-report.test.ts b/shared/tests/stress/markdown-report.test.ts deleted file mode 100644 index bd751d3a..00000000 --- a/shared/tests/stress/markdown-report.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { renderMarkdownReport } from "../../src/testing/stress/reporters/markdown"; -import { makeRunReport, summary } from "./stress-fixtures"; - -describe("renderMarkdownReport", () => { - it("renders the header with scenario, profile and status", () => { - const md = renderMarkdownReport(makeRunReport({ status: "fail" })); - expect(md).toContain("# Stress Report — calendar-navigation (small)"); - expect(md).toContain("Status: **FAIL**"); - }); - - it("renders a regressed row with the marker", () => { - const md = renderMarkdownReport( - makeRunReport({ - regressions: [ - { - metric: "nav", - kind: "timing", - baseline: 100, - candidate: 200, - delta: 100, - deltaPct: 100, - unit: "ms", - regressed: true, - }, - ], - }) - ); - expect(md).toContain("REGRESSED"); - expect(md).toContain("+100%"); - }); - - it("shows the no-baseline message when there are no regressions", () => { - const md = renderMarkdownReport(makeRunReport()); - expect(md).toContain("No baseline comparison"); - }); - - it("shows the all-clear message when there are no budget failures", () => { - expect(renderMarkdownReport(makeRunReport())).toContain("All budgets within limits"); - }); - - it("lists timing stages and counts", () => { - const md = renderMarkdownReport( - makeRunReport({ - timings: { "recurrence.expand": summary(144, { count: 24 }) }, - counts: { "events.indexed": 5000 }, - }) - ); - expect(md).toContain("recurrence.expand"); - expect(md).toContain("events.indexed"); - expect(md).toContain("5000"); - }); - - it("renders the top self-time digest with a function row and location", () => { - const md = renderMarkdownReport( - makeRunReport({ - profileDigest: { - sampleCount: 120, - durationMs: 350, - totalSelfTimeMs: 350, - topSelfTime: [ - { - functionName: "getNextOccurrence", - url: "file:///x/recurring-event-manager.ts", - line: 611, - location: "recurring-event-manager.ts:611", - selfTimeMs: 712, - selfPct: 38.2, - hitCount: 712, - }, - ], - }, - }) - ); - expect(md).toContain("Top self-time (CPU profile)"); - expect(md).toContain("getNextOccurrence"); - expect(md).toContain("recurring-event-manager.ts:611"); - expect(md).toContain("38.2%"); - }); - - it("shows the no-profile message when no digest is attached", () => { - expect(renderMarkdownReport(makeRunReport())).toContain("No CPU profile captured"); - }); -}); diff --git a/shared/tests/stress/metrics.test.ts b/shared/tests/stress/metrics.test.ts deleted file mode 100644 index c2b5b732..00000000 --- a/shared/tests/stress/metrics.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { flattenMetrics, flattenTimings, mergeTimings, summarizeSampleGroups } from "../../src/testing/stress/metrics"; -import { summary } from "./stress-fixtures"; - -describe("summarizeSampleGroups", () => { - it("summarizes each named group", () => { - const result = summarizeSampleGroups({ nav: [10, 20, 30], open: [5] }); - expect(result["nav"]).toMatchObject({ count: 3, totalMs: 60, avgMs: 20 }); - expect(result["open"]?.count).toBe(1); - }); -}); - -describe("flattenTimings", () => { - it("emits one key per statistic", () => { - const flat = flattenTimings({ nav: summary(50, { count: 12 }) }); - expect(flat["nav.p95Ms"]).toBe(50); - expect(flat["nav.count"]).toBe(12); - expect(flat["nav.maxMs"]).toBe(50); - }); -}); - -describe("flattenMetrics", () => { - it("merges flattened timings with raw counts", () => { - const flat = flattenMetrics({ nav: summary(50) }, { "events.indexed": 5000 }); - expect(flat["nav.p95Ms"]).toBe(50); - expect(flat["events.indexed"]).toBe(5000); - }); -}); - -describe("mergeTimings", () => { - it("lets the right side win on key collision", () => { - const merged = mergeTimings({ nav: summary(10) }, { nav: summary(20), open: summary(5) }); - expect(merged["nav"]?.p95Ms).toBe(20); - expect(merged["open"]?.p95Ms).toBe(5); - }); -}); diff --git a/shared/tests/stress/profile-digest.test.ts b/shared/tests/stress/profile-digest.test.ts deleted file mode 100644 index a2834d15..00000000 --- a/shared/tests/stress/profile-digest.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { digestCpuProfile, type CpuProfile, type FrameResolver } from "../../src/testing/stress/profile-digest"; - -const A_URL = "file:///home/x/a.ts"; -const B_URL = "file:///home/x/b.ts"; - -// functionA is reached via two nodes (2 + 5) so the digest must collapse them. -// Synthetic frames (root/idle) and one anonymous native frame exercise the -// exclusion + labelling paths. timeDeltas are microseconds. -function makeProfile(): CpuProfile { - return { - startTime: 0, - endTime: 1775, - nodes: [ - { id: 1, callFrame: { functionName: "(root)", url: "", lineNumber: -1 } }, - { id: 2, callFrame: { functionName: "functionA", url: A_URL, lineNumber: 9 }, hitCount: 2 }, - { id: 3, callFrame: { functionName: "functionB", url: B_URL, lineNumber: 19 }, hitCount: 1 }, - { id: 4, callFrame: { functionName: "(idle)", url: "", lineNumber: -1 } }, - { id: 5, callFrame: { functionName: "functionA", url: A_URL, lineNumber: 9 }, hitCount: 3 }, - { id: 6, callFrame: { functionName: "", url: "", lineNumber: -1 } }, - ], - samples: [2, 2, 3, 5, 4, 1, 6], - timeDeltas: [100, 100, 200, 300, 1000, 50, 25], - }; -} - -const EMPTY_PROFILE: CpuProfile = { nodes: [], startTime: 0, endTime: 0, samples: [], timeDeltas: [] }; - -describe("digestCpuProfile", () => { - it("collapses nodes that share a call frame into one self-time row", () => { - const digest = digestCpuProfile(makeProfile()); - const a = digest.topSelfTime.find((entry) => entry.functionName === "functionA"); - expect(a?.selfTimeMs).toBe(0.5); // (200 + 300) µs - expect(a?.hitCount).toBe(5); // 2 + 3 - }); - - it("ranks functions by self time descending", () => { - const names = digestCpuProfile(makeProfile()).topSelfTime.map((entry) => entry.functionName); - expect(names).toEqual(["functionA", "functionB", "(anonymous)"]); - }); - - it("excludes synthetic frames (root/idle) by default", () => { - const names = digestCpuProfile(makeProfile()).topSelfTime.map((entry) => entry.functionName); - expect(names).not.toContain("(idle)"); - expect(names).not.toContain("(root)"); - }); - - it("includes synthetic frames when asked, ranking idle by its self time", () => { - const digest = digestCpuProfile(makeProfile(), { includeSynthetic: true }); - expect(digest.topSelfTime[0]?.functionName).toBe("(idle)"); // 1000µs dominates - }); - - it("truncates to topN", () => { - const digest = digestCpuProfile(makeProfile(), { topN: 1 }); - expect(digest.topSelfTime).toHaveLength(1); - expect(digest.topSelfTime[0]?.functionName).toBe("functionA"); - }); - - it("formats source location 1-based as file:line", () => { - const digest = digestCpuProfile(makeProfile()); - expect(digest.topSelfTime.find((entry) => entry.functionName === "functionA")?.location).toBe("a.ts:10"); - expect(digest.topSelfTime.find((entry) => entry.functionName === "functionB")?.location).toBe("b.ts:20"); - }); - - it("labels anonymous native frames", () => { - const anon = digestCpuProfile(makeProfile()).topSelfTime.find((entry) => entry.functionName === "(anonymous)"); - expect(anon?.location).toBe("(native)"); - }); - - it("reports self-% relative to total profiled time", () => { - const digest = digestCpuProfile(makeProfile()); - const a = digest.topSelfTime.find((entry) => entry.functionName === "functionA"); - expect(a?.selfPct).toBeCloseTo((500 / 1775) * 100, 5); - expect(digest.totalSelfTimeMs).toBeCloseTo(1.775, 5); - expect(digest.durationMs).toBeCloseTo(1.775, 5); - expect(digest.sampleCount).toBe(7); - }); - - it("maps frames back to source through a resolver, keeping unmapped frames minified", () => { - // functionA (A_URL) maps to source; functionB does not — it stays minified. - const resolveFrame: FrameResolver = (frame) => - frame.url === A_URL - ? { functionName: "expandVisibleRange", source: "src/core/recurrence.ts", line: 611, column: 4 } - : null; - const digest = digestCpuProfile(makeProfile(), { resolveFrame }); - const mapped = digest.topSelfTime.find((entry) => entry.functionName === "expandVisibleRange"); - expect(mapped?.location).toBe("recurrence.ts:611"); - expect(mapped?.selfTimeMs).toBe(0.5); // both functionA nodes still collapse into one row - expect(digest.topSelfTime.find((entry) => entry.functionName === "functionB")?.location).toBe("b.ts:20"); - }); - - it("returns an empty digest for a profile with no samples", () => { - const digest = digestCpuProfile(EMPTY_PROFILE); - expect(digest.topSelfTime).toEqual([]); - expect(digest.totalSelfTimeMs).toBe(0); - expect(digest.sampleCount).toBe(0); - }); -}); diff --git a/shared/tests/stress/profile-tree.test.ts b/shared/tests/stress/profile-tree.test.ts deleted file mode 100644 index 8286b3c0..00000000 --- a/shared/tests/stress/profile-tree.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { CpuProfile, FrameResolver } from "../../src/testing/stress/profile-digest"; -import { buildProfileTree, heaviestStack, pruneProfileTree } from "../../src/testing/stress/profile-tree"; - -// root(1) → A(2) → B(3); root(1) → C(4). Self (µs): A=200, B=300, C=100, root=0. -// Totals roll up: B=300, A=500, C=100, root=600. -function makeProfile(): CpuProfile { - return { - startTime: 0, - endTime: 600, - nodes: [ - { id: 1, callFrame: { functionName: "(root)", url: "", lineNumber: -1 }, children: [2, 4] }, - { id: 2, callFrame: { functionName: "A", url: "plugin:x", lineNumber: 9 }, children: [3] }, - { id: 3, callFrame: { functionName: "B", url: "plugin:x", lineNumber: 19 }, children: [] }, - { id: 4, callFrame: { functionName: "C", url: "plugin:x", lineNumber: 29 }, children: [] }, - ], - samples: [2, 2, 3, 3, 3, 4], - timeDeltas: [100, 100, 100, 100, 100, 100], - }; -} - -describe("buildProfileTree", () => { - it("rolls up self and total time through the call tree", () => { - const root = buildProfileTree(makeProfile()); - expect(root.totalMs).toBe(0.6); - expect(root.selfMs).toBe(0); - - const a = root.children.find((c) => c.name === "A"); - expect(a?.selfMs).toBe(0.2); - expect(a?.totalMs).toBe(0.5); // 0.2 self + 0.3 child B - expect(a?.children[0]?.name).toBe("B"); - expect(a?.children[0]?.totalMs).toBe(0.3); - - expect(root.children.find((c) => c.name === "C")?.totalMs).toBe(0.1); - }); - - it("labels frames through the sourcemap resolver", () => { - const resolveFrame: FrameResolver = (frame) => - frame.lineNumber === 9 ? { functionName: "expandRange", source: "src/core/recurrence.ts", line: 42 } : null; - const root = buildProfileTree(makeProfile(), { resolveFrame }); - const mapped = root.children.find((c) => c.name === "expandRange"); - expect(mapped?.location).toBe("recurrence.ts:42"); - }); -}); - -describe("heaviestStack", () => { - it("descends into the heaviest child at each level", () => { - const names = heaviestStack(buildProfileTree(makeProfile())).map((n) => n.name); - expect(names).toEqual(["(root)", "A", "B"]); - }); -}); - -describe("pruneProfileTree", () => { - it("drops subtrees thinner than the fraction of root total", () => { - // threshold = 0.6 * 0.5 = 0.3 ms → C (0.1) dropped, A (0.5) kept, B (0.3) kept. - const pruned = pruneProfileTree(buildProfileTree(makeProfile()), 0.5); - expect(pruned.children.map((c) => c.name)).toEqual(["A"]); - expect(pruned.children[0]?.children.map((c) => c.name)).toEqual(["B"]); - }); - - it("keeps the whole tree at the default fraction for this fixture", () => { - const pruned = pruneProfileTree(buildProfileTree(makeProfile())); - expect(pruned.children.map((c) => c.name).sort()).toEqual(["A", "C"]); - }); -}); diff --git a/shared/tests/stress/regression.test.ts b/shared/tests/stress/regression.test.ts deleted file mode 100644 index b7ca985d..00000000 --- a/shared/tests/stress/regression.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { compareToBaseline, hasRegression } from "../../src/testing/stress/regression"; -import type { RegressionRule, StressBaseline } from "../../src/testing/stress/types"; -import { makeRunReport, summary, TEST_ENVIRONMENT } from "./stress-fixtures"; - -const RULE: RegressionRule = { ratio: 1.25, minAbsoluteDelta: 30 }; - -function baseline(overrides: Partial = {}): StressBaseline { - return { - scenario: "calendar-navigation", - profile: "small", - capturedAt: "2026-05-21T00:00:00.000Z", - environment: TEST_ENVIRONMENT, - timings: {}, - counts: {}, - ...overrides, - }; -} - -describe("compareToBaseline — timings", () => { - it("does not regress within the tolerance band", () => { - const report = makeRunReport({ timings: { nav: summary(110) } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 100 } }), RULE); - expect(findings).toHaveLength(1); - expect(findings[0]?.regressed).toBe(false); - }); - - it("regresses when over BOTH the ratio and the absolute floor", () => { - const report = makeRunReport({ timings: { nav: summary(200) } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 100 } }), RULE); - expect(findings[0]?.regressed).toBe(true); - expect(findings[0]?.delta).toBe(100); - }); - - it("does not regress when over the ratio but under the absolute floor", () => { - // 25 -> 35 is +40% (over ratio) but only +10ms (under the 30ms floor) - const report = makeRunReport({ timings: { nav: summary(35) } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 25 } }), RULE); - expect(findings[0]?.regressed).toBe(false); - }); - - it("skips baseline metrics absent from the report", () => { - const report = makeRunReport({ timings: { other: summary(10) } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 100 } }), RULE); - expect(findings).toEqual([]); - }); -}); - -describe("compareToBaseline — counts", () => { - it("regresses on any difference", () => { - const report = makeRunReport({ counts: { "events.indexed": 4999 } }); - const findings = compareToBaseline(report, baseline({ counts: { "events.indexed": 5000 } }), RULE); - expect(findings[0]).toMatchObject({ kind: "count", regressed: true, delta: -1 }); - }); - - it("does not regress when counts match exactly", () => { - const report = makeRunReport({ counts: { "events.indexed": 5000 } }); - const findings = compareToBaseline(report, baseline({ counts: { "events.indexed": 5000 } }), RULE); - expect(findings[0]?.regressed).toBe(false); - }); -}); - -describe("hasRegression", () => { - it("is true when any finding regressed", () => { - const report = makeRunReport({ timings: { nav: summary(200) }, counts: { c: 1 } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 100 }, counts: { c: 1 } }), RULE); - expect(hasRegression(findings)).toBe(true); - }); - - it("is false when nothing regressed", () => { - const report = makeRunReport({ timings: { nav: summary(105) } }); - const findings = compareToBaseline(report, baseline({ timings: { nav: 100 } }), RULE); - expect(hasRegression(findings)).toBe(false); - }); -}); diff --git a/shared/tests/stress/repeat.test.ts b/shared/tests/stress/repeat.test.ts deleted file mode 100644 index 42b8335c..00000000 --- a/shared/tests/stress/repeat.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { runRepeats, type RepeatPhase } from "../../src/testing/stress/runner/repeat"; - -describe("runRepeats", () => { - it("runs warmup passes first, then collects measured results", async () => { - const phases: RepeatPhase[] = []; - const results = await runRepeats({ warmup: 2, repeats: 3 }, async (index, phase) => { - phases.push(phase); - return index; - }); - expect(phases).toEqual(["warmup", "warmup", "measured", "measured", "measured"]); - expect(results).toEqual([0, 1, 2]); - }); - - it("collects nothing when repeats is zero", async () => { - const results = await runRepeats({ warmup: 1, repeats: 0 }, async (index) => index); - expect(results).toEqual([]); - }); - - it("runs sequentially (each call awaits the previous)", async () => { - const order: number[] = []; - await runRepeats({ warmup: 0, repeats: 3 }, async (index) => { - await Promise.resolve(); - order.push(index); - }); - expect(order).toEqual([0, 1, 2]); - }); -}); diff --git a/shared/tests/stress/seeded-random.test.ts b/shared/tests/stress/seeded-random.test.ts deleted file mode 100644 index 83428b3e..00000000 --- a/shared/tests/stress/seeded-random.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createSeededRandom } from "../../src/testing/stress/seeded-random"; - -function sequence(seed: number, n: number): number[] { - const rng = createSeededRandom(seed); - return Array.from({ length: n }, () => rng.next()); -} - -describe("createSeededRandom", () => { - it("produces an identical sequence for the same seed", () => { - expect(sequence(42, 10)).toEqual(sequence(42, 10)); - }); - - it("produces a different sequence for a different seed", () => { - expect(sequence(42, 10)).not.toEqual(sequence(43, 10)); - }); - - it("emits floats in [0, 1)", () => { - const rng = createSeededRandom(7); - for (let i = 0; i < 100; i++) { - const value = rng.next(); - expect(value).toBeGreaterThanOrEqual(0); - expect(value).toBeLessThan(1); - } - }); - - it("int() stays within inclusive bounds and is deterministic", () => { - const a = createSeededRandom(1); - const b = createSeededRandom(1); - for (let i = 0; i < 50; i++) { - const value = a.int(5, 8); - expect(value).toBe(b.int(5, 8)); - expect(value).toBeGreaterThanOrEqual(5); - expect(value).toBeLessThanOrEqual(8); - } - }); - - it("pick() is deterministic for a given seed", () => { - const items = ["a", "b", "c", "d"] as const; - const a = createSeededRandom(99); - const b = createSeededRandom(99); - expect(Array.from({ length: 20 }, () => a.pick(items))).toEqual(Array.from({ length: 20 }, () => b.pick(items))); - }); - - it("pick() throws on an empty array", () => { - const rng = createSeededRandom(1); - expect(() => rng.pick([])).toThrow(/empty/); - }); - - it("bool() respects the probability extremes", () => { - const rng = createSeededRandom(3); - expect(rng.bool(1)).toBe(true); - expect(rng.bool(0)).toBe(false); - }); -}); diff --git a/shared/tests/stress/source-map.test.ts b/shared/tests/stress/source-map.test.ts deleted file mode 100644 index c2615aab..00000000 --- a/shared/tests/stress/source-map.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { createFrameResolver, loadBundleSourceMap, type SourceMapLookup } from "../../src/testing/stress/source-map"; - -// A real, minimal external source map. Its mappings decode (verified via -// node:module SourceMap) to: generated (line0, col0) → recurring-event-manager.ts -// originalLine 610 (0-based) / col 0 / name "getNextOccurrence". -const REAL_MAP = { - version: 3, - file: "main.js", - sources: ["src/core/recurring-event-manager.ts"], - names: ["getNextOccurrence"], - mappings: "AAkmBAA,oBAKA", - sourcesContent: [null], -}; - -function lookupReturning(entry: ReturnType): { - lookup: SourceMapLookup; - calls: Array<[number, number]>; -} { - const calls: Array<[number, number]> = []; - return { - calls, - lookup: { - findEntry(line, column) { - calls.push([line, column]); - return entry; - }, - }, - }; -} - -describe("createFrameResolver", () => { - it("resolves a matching frame, passing 0-based line/col and converting line to 1-based", () => { - const { lookup, calls } = lookupReturning({ - originalSource: "src/x.ts", - originalLine: 610, - originalColumn: 8, - name: "foo", - }); - const resolve = createFrameResolver(lookup, { matchesBundle: () => true }); - - const resolved = resolve({ url: "plugin:prisma-calendar", lineNumber: 3827, columnNumber: 120 }); - - expect(resolved).toEqual({ functionName: "foo", source: "src/x.ts", line: 611, column: 8 }); - expect(calls).toEqual([[3827, 120]]); - }); - - it("defaults the column to 0 when the frame has none", () => { - const { lookup, calls } = lookupReturning({ originalSource: "src/x.ts", originalLine: 0 }); - createFrameResolver(lookup, { matchesBundle: () => true })({ url: "main.js", lineNumber: 5 }); - expect(calls).toEqual([[5, 0]]); - }); - - it("omits functionName and column when the map lacks them", () => { - const { lookup } = lookupReturning({ originalSource: "src/y.ts", originalLine: 0 }); - const resolved = createFrameResolver(lookup, { matchesBundle: () => true })({ url: "main.js", lineNumber: 0 }); - expect(resolved).toEqual({ source: "src/y.ts", line: 1 }); - }); - - it("returns null for frames outside the bundle without consulting the map", () => { - const { lookup, calls } = lookupReturning({ originalSource: "src/x.ts", originalLine: 0 }); - const resolve = createFrameResolver(lookup, { matchesBundle: (url) => url.endsWith("main.js") }); - expect(resolve({ url: "app://obsidian.md/app.js", lineNumber: 1 })).toBeNull(); - expect(calls).toEqual([]); - }); - - it("returns null for synthetic frames (negative line) without consulting the map", () => { - const { lookup, calls } = lookupReturning({ originalSource: "src/x.ts", originalLine: 0 }); - const resolve = createFrameResolver(lookup, { matchesBundle: () => true }); - expect(resolve({ url: "main.js", lineNumber: -1 })).toBeNull(); - expect(calls).toEqual([]); - }); - - it("returns null when the position isn't in the map", () => { - const { lookup } = lookupReturning({}); - const resolve = createFrameResolver(lookup, { matchesBundle: () => true }); - expect(resolve({ url: "main.js", lineNumber: 9_999, columnNumber: 0 })).toBeNull(); - }); -}); - -describe("loadBundleSourceMap", () => { - it("loads a real .map file and resolves a generated position back to source", () => { - const dir = mkdtempSync(path.join(tmpdir(), "stress-srcmap-")); - const mapPath = path.join(dir, "main.js.map"); - writeFileSync(mapPath, JSON.stringify(REAL_MAP)); - try { - const resolve = loadBundleSourceMap({ mapPath, matchesBundle: () => true }); - expect(resolve).not.toBeNull(); - const resolved = resolve?.({ url: "plugin:prisma-calendar", lineNumber: 0, columnNumber: 0 }); - expect(resolved).toEqual({ - functionName: "getNextOccurrence", - source: "src/core/recurring-event-manager.ts", - line: 611, - column: 0, - }); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it("returns null when the map file is missing", () => { - expect(loadBundleSourceMap({ mapPath: "/no/such/main.js.map", matchesBundle: () => true })).toBeNull(); - }); -}); diff --git a/shared/tests/stress/stress-fixtures.ts b/shared/tests/stress/stress-fixtures.ts deleted file mode 100644 index c890bd61..00000000 --- a/shared/tests/stress/stress-fixtures.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { EnvironmentInfo, MetricSummary, StressRunReport } from "../../src/testing/stress/types"; - -export const TEST_ENVIRONMENT: EnvironmentInfo = { - os: "Linux 6.0", - arch: "x64", - cpuModel: "Test CPU", - cpuCount: 8, - totalMemoryBytes: 16_000_000_000, - nodeVersion: "v20.0.0", -}; - -/** A flat timing summary where every percentile equals `value` (test convenience). */ -export function summary(value: number, overrides: Partial = {}): MetricSummary { - return { - count: 1, - totalMs: value, - avgMs: value, - minMs: value, - maxMs: value, - p50Ms: value, - p95Ms: value, - ...overrides, - }; -} - -export function makeRunReport(overrides: Partial = {}): StressRunReport { - return { - runId: "run-1", - scenario: "calendar-navigation", - profile: "small", - startedAt: "2026-05-21T00:00:00.000Z", - finishedAt: "2026-05-21T00:00:01.000Z", - status: "pass", - environment: TEST_ENVIRONMENT, - config: { seed: 42, repeats: 5, warmup: 1, collectors: ["prisma", "cdp"] }, - git: { branch: "main", commit: "abc1234def5678", dirty: false }, - timings: {}, - counts: {}, - budgetFailures: [], - regressions: [], - artifacts: [], - ...overrides, - }; -} diff --git a/shared/tests/stress/vault-generate.test.ts b/shared/tests/stress/vault-generate.test.ts deleted file mode 100644 index da6d2036..00000000 --- a/shared/tests/stress/vault-generate.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { SeededRandom } from "../../src/testing/stress/seeded-random"; -import type { VaultProfile } from "../../src/testing/stress/types"; -import { generateVault, type GeneratedEvent } from "../../src/testing/stress/vault/generate"; - -const PROFILE: VaultProfile = { name: "tiny", events: 5, recurring: 1 }; - -const buildEvent = (rng: SeededRandom, index: number): GeneratedEvent => ({ - relativePath: `Event-${String(index).padStart(3, "0")}.md`, - content: `---\nstart: 2026-01-${String((index % 28) + 1).padStart(2, "0")}\nweight: ${rng.int(0, 100)}\n---\n# Event ${index}\n`, -}); - -const buildRecurringEvent = (rng: SeededRandom, index: number): GeneratedEvent => ({ - relativePath: `Recurring-${String(index).padStart(3, "0")}.md`, - content: `---\nRRule: daily\nweight: ${rng.int(0, 100)}\n---\n# Recurring ${index}\n`, -}); - -const tmpDirs: string[] = []; -function freshDir(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), "stress-vault-")); - tmpDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("generateVault", () => { - it("writes exactly profile.events files", () => { - const dir = freshDir(); - generateVault({ dir, profile: PROFILE, seed: 42, buildEvent }); - expect(readdirSync(dir)).toHaveLength(5); - }); - - it("also writes profile.recurring files when a recurring builder is given", () => { - const dir = freshDir(); - generateVault({ dir, profile: PROFILE, seed: 42, buildEvent, buildRecurringEvent }); - const names = readdirSync(dir); - expect(names).toHaveLength(6); // 5 events + 1 recurring - expect(names.filter((n) => n.startsWith("Recurring-"))).toHaveLength(1); - }); - - it("is byte-identical for the same seed", () => { - const a = freshDir(); - const b = freshDir(); - generateVault({ dir: a, profile: PROFILE, seed: 42, buildEvent }); - generateVault({ dir: b, profile: PROFILE, seed: 42, buildEvent }); - for (let i = 0; i < PROFILE.events; i++) { - const name = `Event-${String(i).padStart(3, "0")}.md`; - expect(readFileSync(path.join(b, name), "utf8")).toBe(readFileSync(path.join(a, name), "utf8")); - } - }); - - it("differs for a different seed", () => { - const a = freshDir(); - const b = freshDir(); - generateVault({ dir: a, profile: PROFILE, seed: 42, buildEvent }); - generateVault({ dir: b, profile: PROFILE, seed: 7, buildEvent }); - const name = "Event-000.md"; - expect(readFileSync(path.join(b, name), "utf8")).not.toBe(readFileSync(path.join(a, name), "utf8")); - }); - - it("returns a manifest describing the generated vault", () => { - const dir = freshDir(); - const manifest = generateVault({ dir, profile: PROFILE, seed: 42, buildEvent, generatorVersion: 3 }); - expect(manifest).toMatchObject({ - profile: "tiny", - seed: 42, - events: 5, - recurring: 1, - generatorVersion: 3, - }); - }); -}); diff --git a/stress/README.md b/stress/README.md deleted file mode 100644 index 93e68817..00000000 --- a/stress/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Prisma-Calendar stress / performance suite - -Deterministic, agent-runnable performance measurement — separate from the -correctness `e2e/` suite. Reproduces a large-vault scenario, captures objective -metrics (internal stage timings + CDP), writes a report, and compares against a -same-machine baseline. See [docs/stress-testing.md](../../docs/stress-testing.md) -and [the spec](../../docs/specs/2026-05-21-120008-performance-stress-harness.md). - -**Not wired into CI.** This is a local / agent-driven loop. - -## Layout - -``` -stress/ - playwright.config.ts # serial, no-retry, generous timeout - stress.config.ts # seed, repeats, profiles, budgets, paths - vaults/ - profiles.ts # small / medium vault profiles - event-builder.ts # deterministic Prisma event factory (frozen anchor year) - generate-vault.ts # standalone CLI to pre-seed a vault - scenarios/navigate-months.ts - specs/calendar-navigation.perf.spec.ts - baselines/ # committed, user-blessed (see baselines/README.md) -``` - -The reusable engine lives in `@real1ty-obsidian-plugins/testing/stress` -(harness runtime) and `@real1ty-obsidian-plugins/perf` (in-app tracker), so other -plugins adopt the same flow. - -## Run - -```bash -# Run the navigation scenario (builds the plugin first), writes a report. -pnpm --filter prisma-calendar run stress -- stress/specs/calendar-navigation.perf.spec.ts - -# Capture / refresh the same-machine baseline. -PERF_BLESS=1 pnpm --filter prisma-calendar run stress -- stress/specs/calendar-navigation.perf.spec.ts - -# Generate a vault on disk standalone (inspection / future cold-start flow). -pnpm --filter prisma-calendar run stress:generate -- --profile small --seed 42 -``` - -Reports land in `logs/perf-reports//report.md` (+ `run.json`), gitignored. - -## How it works - -1. `generateVault` writes a deterministic set of event files into the test - vault; the spec waits for the indexer to reach the exact count. -2. The scenario drives **real toolbar clicks** (next/prev), using the in-app - `calendar.buildEvents` counter as the settle signal. -3. Internal stage timings (`recurrence.expandVisibleRange`, `eventStore.getEvents`, - `calendar.buildEvents`) come from `window.__PRISMA_PERF__`; external metrics - from CDP `Performance.getMetrics`. -4. The run is summarized (p95 over repeats), checked against budgets + baseline, - and written to a Markdown + JSON report. The test fails on a budget breach or - a regression. diff --git a/stress/baselines/.gitkeep b/stress/baselines/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/stress/baselines/README.md b/stress/baselines/README.md deleted file mode 100644 index edf837b1..00000000 --- a/stress/baselines/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Stress baselines - -Committed, **user-blessed** performance baselines — one JSON per scenario × -profile (e.g. `calendar-navigation.small.json`). They are the regression anchor: -a run compares its p95 timings and exact counts against the matching baseline on -the **same machine**. - -- Captured/refreshed only by running with `PERF_BLESS=1` (the `stress:bless` - flow). Never auto-written by a normal run. -- Treated like visual `__baselines__` — the human owns them. Agents must not - delete or silently overwrite them. -- A run with no matching baseline reports timings but does not flag regressions. diff --git a/stress/playwright.config.ts b/stress/playwright.config.ts deleted file mode 100644 index 18f855fd..00000000 --- a/stress/playwright.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -// Stress/perf config — distinct from e2e/playwright.config.ts. Key differences: -// - serial (workers: 1, no parallelism) so measurements never contend for CPU. -// - no retries — a perf regression must surface, not be retried away. -// - generous timeout — seeding a large vault + many navigations is slow. -const DEBUG_ON = !!process.env["PWDEBUG"] || process.argv.includes("--ui") || process.argv.includes("--debug"); - -// Heavy (large-profile) runs can exceed the default — raise via STRESS_TIMEOUT_MS. -const TEST_TIMEOUT = DEBUG_ON ? 0 : Number(process.env["STRESS_TIMEOUT_MS"]) || 300_000; -const EXPECT_TIMEOUT = DEBUG_ON ? 0 : 60_000; -const ACTION_TIMEOUT = DEBUG_ON ? 0 : 60_000; - -export default defineConfig({ - outputDir: "./test-results", - fullyParallel: false, - workers: 1, - timeout: TEST_TIMEOUT, - expect: { timeout: EXPECT_TIMEOUT }, - forbidOnly: !!process.env["CI"], - retries: 0, - reporter: [["line"], ["html", { open: "never", outputFolder: "./playwright-report" }]], - use: { - trace: "retain-on-failure", - screenshot: "only-on-failure", - actionTimeout: ACTION_TIMEOUT, - }, - projects: [ - { - name: "stress", - testDir: "./specs", - testMatch: /.*\.perf\.spec\.ts$/, - }, - ], -}); diff --git a/stress/report.ts b/stress/report.ts deleted file mode 100644 index d360c2f3..00000000 --- a/stress/report.ts +++ /dev/null @@ -1,135 +0,0 @@ -import path from "node:path"; - -import { - baselineFileName, - buildArtifactDir, - buildProfileTree, - buildRunStem, - captureEnvironment, - captureGitInfo, - compareToBaseline, - digestCpuProfile, - evaluateBudgets, - flattenMetrics, - hasRegression, - loadBundleSourceMap, - pruneProfileTree, - readBaseline, - reportToBaseline, - writeBaseline, - writeRunReports, - type CpuProfile, - type FrameResolver, - type HeapDigest, - type StressArtifact, - type StressRunReport, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { BUDGETS, STRESS_CONFIG } from "./stress.config"; - -// Shared report assembly for every Prisma stress spec. Each spec owns its -// measurement + assertions; this owns the boilerplate they'd otherwise all -// duplicate — runId/dir, sourcemap-resolved CPU digest + flame tree, env/git, -// budget eval, baseline compare, and writing run.json/report.md/report.html. - -/** Build a resolver from the stress build's external sourcemap, or undefined if absent. */ -export function loadPrismaSourceMap(): FrameResolver | undefined { - return ( - loadBundleSourceMap({ - mapPath: path.join(process.cwd(), "main.js.map"), - matchesBundle: (url) => url.includes("prisma-calendar") || url.endsWith("main.js"), - }) ?? undefined - ); -} - -/** Allocate a run id + its artifact directory up front, so the spec can write heavy artifacts into it before the report. */ -export function createStressRun(scenario: string, profileName: string): { runId: string; artifactDir: string } { - const runId = buildRunStem({ profile: profileName, scenario }); - return { runId, artifactDir: buildArtifactDir(STRESS_CONFIG.artifactRoot, runId) }; -} - -export interface StressReportInput { - runId: string; - artifactDir: string; - scenario: string; - profileName: string; - startedAt: string; - finishedAt: string; - timings: StressRunReport["timings"]; - counts: StressRunReport["counts"]; - collectors: string[]; - repeats?: number | undefined; - warmup?: number | undefined; - pluginVersion?: string | undefined; - /** When provided, drives the self-time digest + flame-chart call tree. */ - cpuProfile?: CpuProfile | undefined; - resolveFrame?: FrameResolver | undefined; - heapDigest?: HeapDigest | undefined; - /** Heavy artifacts (cpu profile, heap snapshots) the spec already wrote. */ - extraArtifacts?: StressArtifact[] | undefined; -} - -export interface WrittenStressReport { - report: StressRunReport; - markdownPath: string; - htmlPath: string; -} - -export function writeStressReport(input: StressReportInput): WrittenStressReport { - const artifacts: StressArtifact[] = [ - { kind: "json", path: path.join(input.artifactDir, "run.json") }, - { kind: "markdown", path: path.join(input.artifactDir, "report.md") }, - { - kind: "html", - path: path.join(input.artifactDir, "report.html"), - description: "Interactive report + flame chart", - }, - ...(input.extraArtifacts ?? []), - ]; - - const digestOptions = input.resolveFrame ? { resolveFrame: input.resolveFrame } : {}; - const profileDigest = input.cpuProfile ? digestCpuProfile(input.cpuProfile, digestOptions) : undefined; - const profileTree = input.cpuProfile - ? pruneProfileTree(buildProfileTree(input.cpuProfile, digestOptions)) - : undefined; - - const environment = { - ...captureEnvironment(), - ...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}), - }; - - const report: StressRunReport = { - runId: input.runId, - scenario: input.scenario, - profile: input.profileName, - startedAt: input.startedAt, - finishedAt: input.finishedAt, - status: "pass", - git: captureGitInfo(), - environment, - config: { - seed: STRESS_CONFIG.seed, - repeats: input.repeats ?? STRESS_CONFIG.repeats, - warmup: input.warmup ?? STRESS_CONFIG.warmup, - collectors: input.collectors, - }, - timings: input.timings, - counts: input.counts, - budgetFailures: evaluateBudgets(flattenMetrics(input.timings, input.counts), BUDGETS[input.scenario] ?? {}), - regressions: [], - artifacts, - ...(profileDigest ? { profileDigest } : {}), - ...(input.heapDigest ? { heapDigest: input.heapDigest } : {}), - }; - - const baselinePath = path.join(STRESS_CONFIG.baselineDir, baselineFileName(input.scenario, input.profileName)); - const baseline = readBaseline(baselinePath); - if (baseline) report.regressions = compareToBaseline(report, baseline); - report.status = report.budgetFailures.length > 0 || hasRegression(report.regressions) ? "fail" : "pass"; - - const { markdownPath, htmlPath } = writeRunReports(input.artifactDir, report, profileTree ? { profileTree } : {}); - if (process.env["PERF_BLESS"] === "1") { - writeBaseline(baselinePath, reportToBaseline(report)); - } - return { report, markdownPath, htmlPath }; -} diff --git a/stress/scenarios/edit-storm.ts b/stress/scenarios/edit-storm.ts deleted file mode 100644 index e964c3ad..00000000 --- a/stress/scenarios/edit-storm.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { seedBulkEvents, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; - -// A write storm: drop a burst of event files onto disk all at once, then wait for -// the indexer + calendar to converge. This stresses the batch-ingest + render- -// coalescing path — the real concern isn't raw throughput but whether N near- -// simultaneous file events collapse into a few re-renders (good) or N (a storm). -// The caller wraps this in a CPU profile and reads the `calendar.buildEvents` -// count delta to measure the coalescing ratio. - -export interface WriteStormOptions { - /** How many event files to drop in the burst. */ - burst: number; - /** Indexer event count to converge on (baseline + burst). */ - targetIndex: number; - /** Title prefix for the burst files (default "Storm"). */ - prefix?: string; -} - -/** Drop `burst` event files at once and wait for the indexer to reach `targetIndex`. */ -export async function runWriteStorm(page: Page, vaultDir: string, options: WriteStormOptions): Promise { - seedBulkEvents(vaultDir, options.burst, { prefix: options.prefix ?? "Storm" }); - await waitForIndexerToReach(page, options.targetIndex); -} diff --git a/stress/scenarios/navigate-months.ts b/stress/scenarios/navigate-months.ts deleted file mode 100644 index bff83268..00000000 --- a/stress/scenarios/navigate-months.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Page } from "@playwright/test"; -import { PERF_BRIDGE_GLOBAL_KEY } from "@real1ty-obsidian-plugins/perf"; - -import { ACTIVE_CALENDAR_LEAF } from "../../e2e/fixtures/constants"; -import { sel, TID } from "../../e2e/fixtures/testids"; - -// The instrumented `calendar.buildEvents` count is our settle signal AND our -// pure render-cost metric: every toolbar navigation triggers exactly one build, -// so waiting for the count to advance proves the re-render completed before we -// record the step's wall-clock. The bridge is read by its generic global key. - -const BUILD_METRIC = "calendar.buildEvents"; - -async function readBuildCount(page: Page): Promise { - return page.evaluate( - ({ key, metric }) => { - const bridge = (window as Record)[key] as - | { snapshot: () => { timings: Record } } - | undefined; - return bridge?.snapshot().timings[metric]?.count ?? 0; - }, - { key: PERF_BRIDGE_GLOBAL_KEY, metric: BUILD_METRIC } - ); -} - -/** Switch the active calendar to month view for a wide, populated window. */ -export async function setMonthView(page: Page): Promise { - await page - .locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar("view-month"))}`) - .first() - .click(); - await page.waitForFunction( - ({ key, metric }) => { - const bridge = (window as Record)[key] as - | { snapshot: () => { timings: Record } } - | undefined; - return (bridge?.snapshot().timings[metric]?.count ?? 0) > 0; - }, - { key: PERF_BRIDGE_GLOBAL_KEY, metric: BUILD_METRIC } - ); -} - -export type RecordStep = (durationMs: number) => void; - -/** - * Oscillate next/prev `steps` times to stay within the populated date window, - * recording each navigation's wall-clock. Each click waits for the calendar to - * finish rebuilding before the next. - */ -export async function navigateMonths(page: Page, steps: number, record: RecordStep): Promise { - const next = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar("next"))}`).first(); - const prev = page.locator(`${ACTIVE_CALENDAR_LEAF} ${sel(TID.toolbar("prev"))}`).first(); - - for (let i = 0; i < steps; i++) { - const button = i % 2 === 0 ? next : prev; - const before = await readBuildCount(page); - const startedAt = performance.now(); - await button.click(); - await page.waitForFunction( - ({ key, metric, baseline }) => { - const bridge = (window as Record)[key] as - | { snapshot: () => { timings: Record } } - | undefined; - return (bridge?.snapshot().timings[metric]?.count ?? 0) > baseline; - }, - { key: PERF_BRIDGE_GLOBAL_KEY, metric: BUILD_METRIC, baseline: before } - ); - record(performance.now() - startedAt); - } -} diff --git a/stress/scenarios/open-close-view-loop.ts b/stress/scenarios/open-close-view-loop.ts deleted file mode 100644 index d7528387..00000000 --- a/stress/scenarios/open-close-view-loop.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { PLUGIN_ID } from "../../e2e/fixtures/constants"; -import type { PrismaPlugin, PrismaWindow } from "../../e2e/fixtures/window-types"; -import { openCalendarView } from "../../e2e/specs/events/events-helpers"; - -// Drives the calendar view's mount/unmount lifecycle for the memory-leak -// scenario. Opening reuses the real `activateCalendarView` path (via -// `openCalendarView`); closing detaches every calendar leaf — the same teardown -// Obsidian runs when a user closes the tab, firing the view's `onClose`. A leak -// shows up as heap that never returns after GC, or `activeViews > 0` afterwards. - -/** Detach every calendar-view leaf (the real unmount path) and wait for them to clear. */ -export async function closeCalendarLeaves(page: Page): Promise { - await page.evaluate((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - for (const bundle of plugin?.calendarBundles ?? []) { - for (const leaf of w.app.workspace.getLeavesOfType(bundle.viewType)) leaf.detach(); - } - }, PLUGIN_ID); - await page.waitForFunction((pid) => { - const w = window as unknown as PrismaWindow; - const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined; - return (plugin?.calendarBundles ?? []).every( - (bundle) => w.app.workspace.getLeavesOfType(bundle.viewType).length === 0 - ); - }, PLUGIN_ID); -} - -/** Open then fully close the calendar view `iterations` times. */ -export async function openCloseViewLoop(page: Page, iterations: number): Promise { - for (let i = 0; i < iterations; i++) { - await openCalendarView(page); - await closeCalendarLeaves(page); - } -} diff --git a/stress/scenarios/render-views.ts b/stress/scenarios/render-views.ts deleted file mode 100644 index 5c878aa6..00000000 --- a/stress/scenarios/render-views.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { - DASHBOARD_RANKING_TID, - HEATMAP_CONTAINER_TID, - sel, - TID, - TIMELINE_CONTAINER_TID, - type ViewTabKey, -} from "../../e2e/fixtures/testids"; - -// Drives a cold first-render of each heavy analytics view. Every cycle returns to -// the calendar tab first, so each switch measures a from-scratch mount of the -// target view against the seeded event set — not a warm re-activation. The settle -// signal is the view's own content container becoming visible (proof it painted), -// so the recorded wall-clock spans click → first paint. - -const GANTT_TAB_TID = "prisma-gantt-tab"; - -interface HeavyView { - name: string; - tab: ViewTabKey; - /** Group tab to open first (dashboard children live behind a dropdown). */ - group?: ViewTabKey; - /** Testid of the content element that proves the view painted. */ - container: string; -} - -const HEAVY_VIEWS: readonly HeavyView[] = [ - { name: "timeline", tab: "timeline", container: TIMELINE_CONTAINER_TID }, - { name: "heatmap", tab: "heatmap", container: HEATMAP_CONTAINER_TID }, - { name: "gantt", tab: "gantt", container: GANTT_TAB_TID }, - { name: "dashboard-by-name", tab: "dashboard-by-name", group: "dashboard", container: DASHBOARD_RANKING_TID }, -]; - -export type RecordView = (name: string, durationMs: number) => void; - -async function clickTab(page: Page, tab: ViewTabKey): Promise { - const el = page.locator(sel(TID.viewTab(tab))).first(); - await el.waitFor({ state: "visible" }); - await el.click(); -} - -/** Render each heavy view once (cold from calendar), recording its click→paint wall-clock. */ -export async function renderHeavyViews(page: Page, record: RecordView): Promise { - for (const view of HEAVY_VIEWS) { - await clickTab(page, "calendar"); - const startedAt = performance.now(); - if (view.group) await clickTab(page, view.group); - await clickTab(page, view.tab); - await page.locator(sel(view.container)).first().waitFor({ state: "visible" }); - record(view.name, performance.now() - startedAt); - } -} - -export const HEAVY_VIEW_NAMES = HEAVY_VIEWS.map((v) => v.name); diff --git a/stress/specs/calendar-navigation.perf.spec.ts b/stress/specs/calendar-navigation.perf.spec.ts deleted file mode 100644 index e949e6da..00000000 --- a/stress/specs/calendar-navigation.perf.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import path from "node:path"; - -import { - collectCpuProfile, - diffCdpMetrics, - generateVault, - mergeTimings, - namespaceCdpMetrics, - readCdpPerformanceMetrics, - readPerfBridge, - resetPerfBridge, - runRepeats, - summarizeSampleGroups, - writeCpuProfile, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; -import { createStressRun, loadPrismaSourceMap, writeStressReport } from "../report"; -import { navigateMonths, setMonthView } from "../scenarios/navigate-months"; -import { STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent, buildPrismaRecurringEvent } from "../vaults/event-builder"; -import { PROFILES, type ProfileName } from "../vaults/profiles"; - -const SCENARIO = "calendar-navigation"; -// Profile is env-selectable so the same spec runs at small (default, fast), -// medium, or large. Agents stick to small/medium; large is the user's call. -const PROFILE = PROFILES[(process.env["STRESS_PROFILE"] as ProfileName) ?? "small"] ?? PROFILES.small; -const NOOP = (): void => {}; - -test.describe("stress: calendar navigation", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - // Seed a deterministic vault, then wait for the indexer to ingest it all. - // Tolerate any pre-seeded events by anchoring on the pre-generation count. - const baselineCount = await indexerEventCount(page); - // SOURCE events = plain + recurring. This gate runs PRE-render: once the - // calendar paints, recurrences expand to (and materialize) extra instances, - // so neither the runtime store nor the on-disk file count stays at the source - // total. `waitForIndexerToReach` settles on sources before any of that. - const expectedCount = baselineCount + PROFILE.events + PROFILE.recurring; - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - buildRecurringEvent: buildPrismaRecurringEvent, - }); - await waitForIndexerToReach(page, expectedCount); - - await setMonthView(page); - - // Warmup passes are discarded (JIT warm-up, cache fill). - for (let i = 0; i < STRESS_CONFIG.warmup; i++) { - await navigateMonths(page, STRESS_CONFIG.navSteps, NOOP); - } - - const cdp = await page.context().newCDPSession(page); - await resetPerfBridge(page); - const cdpBefore = await readCdpPerformanceMetrics(cdp); - - const startedAt = new Date().toISOString(); - const navSamples: number[] = []; - await runRepeats({ warmup: 0, repeats: STRESS_CONFIG.repeats }, async () => { - await navigateMonths(page, STRESS_CONFIG.navSteps, (ms) => navSamples.push(ms)); - return 0; - }); - const finishedAt = new Date().toISOString(); - - const snapshot = await readPerfBridge(page); - const cdpAfter = await readCdpPerformanceMetrics(cdp); - - const timings = mergeTimings(summarizeSampleGroups({ "scenario.navigateStep": navSamples }), snapshot.timings); - const counts: Record = { - ...snapshot.counters, - ...namespaceCdpMetrics(cdpAfter, "cdp"), - ...namespaceCdpMetrics(diffCdpMetrics(cdpBefore, cdpAfter), "cdp.delta"), - }; - - const { runId, artifactDir } = createStressRun(SCENARIO, PROFILE.name); - const cpuProfilePath = path.join(artifactDir, "cpu.cpuprofile"); - - // Pass B (explain): one more navigation under a CDP CPU profile, kept - // separate from the gated repeats above so the sampler's overhead never - // skews the budgeted timings. Feeds the self-time digest + flame chart only. - const { profile: cpuProfile } = await collectCpuProfile(cdp, () => - navigateMonths(page, STRESS_CONFIG.navSteps, NOOP) - ); - await writeCpuProfile(cpuProfilePath, cpuProfile); - - const pluginVersion = snapshot.metadata?.["pluginVersion"]; - const { markdownPath, report } = writeStressReport({ - runId, - artifactDir, - scenario: SCENARIO, - profileName: PROFILE.name, - startedAt, - finishedAt, - timings, - counts, - collectors: ["prisma", "cdp"], - cpuProfile, - resolveFrame: loadPrismaSourceMap(), - pluginVersion: typeof pluginVersion === "string" ? pluginVersion : undefined, - extraArtifacts: [{ kind: "cpu-profile", path: cpuProfilePath, description: "V8 CPU profile (explain pass)" }], - }); - console.log(`[stress] report: ${markdownPath}`); - - // Internal stage timings must have been recorded by the in-app tracker, incl. - // recurrence expansion — proof the recurring sources actually drove work. - expect(snapshot.timings["calendar.buildEvents"]).toBeDefined(); - expect(snapshot.timings["recurrence.expandVisibleRange"]).toBeDefined(); - // Fail on a budget breach or a regression vs the committed baseline. - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/specs/event-edit-storm.perf.spec.ts b/stress/specs/event-edit-storm.perf.spec.ts deleted file mode 100644 index cd319b51..00000000 --- a/stress/specs/event-edit-storm.perf.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import path from "node:path"; - -import { - collectCpuProfile, - diffCdpMetrics, - generateVault, - mergeTimings, - namespaceCdpMetrics, - readCdpPerformanceMetrics, - readPerfBridge, - summarizeSampleGroups, - writeCpuProfile, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; -import { createStressRun, loadPrismaSourceMap, writeStressReport } from "../report"; -import { runWriteStorm } from "../scenarios/edit-storm"; -import { STORM_CONFIG, STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent } from "../vaults/event-builder"; -import { PROFILES, type ProfileName } from "../vaults/profiles"; - -const SCENARIO = "event-edit-storm"; -const PROFILE = PROFILES[(process.env["STRESS_PROFILE"] as ProfileName) ?? "small"] ?? PROFILES.small; -const BUILD_METRIC = "calendar.buildEvents"; - -test.describe("stress: event edit storm", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - // Phase 1 — establish a populated, settled baseline (plain events). - const baselineCount = await indexerEventCount(page); - const baseTarget = baselineCount + PROFILE.events; - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - }); - await waitForIndexerToReach(page, baseTarget); - - const cdp = await page.context().newCDPSession(page); - const { runId, artifactDir } = createStressRun(SCENARIO, PROFILE.name); - const cpuProfilePath = path.join(artifactDir, "cpu.cpuprofile"); - - const before = await readPerfBridge(page); - const cdpBefore = await readCdpPerformanceMetrics(cdp); - const buildsBefore = before.timings[BUILD_METRIC]?.count ?? 0; - - // Phase 2 — storm: drop a burst of files at once, profile the indexer + - // render churn as it converges. The coalescing ratio (files ÷ re-renders) - // is the headline: ideally many file events fold into few calendar builds. - const burst = STORM_CONFIG.burstByProfile[PROFILE.name] ?? STORM_CONFIG.burstByProfile["small"] ?? 200; - const stormTarget = baseTarget + burst; - - const startedAt = new Date().toISOString(); - const stormStart = performance.now(); - const { profile: cpuProfile } = await collectCpuProfile(cdp, () => - runWriteStorm(page, vaultDir, { burst, targetIndex: stormTarget }) - ); - const convergeMs = performance.now() - stormStart; - const finishedAt = new Date().toISOString(); - await writeCpuProfile(cpuProfilePath, cpuProfile); - - const after = await readPerfBridge(page); - const cdpAfter = await readCdpPerformanceMetrics(cdp); - const pluginVersion = after.metadata?.["pluginVersion"]; - const buildsDuringStorm = (after.timings[BUILD_METRIC]?.count ?? 0) - buildsBefore; - - const timings = mergeTimings(summarizeSampleGroups({ "editStorm.converge": [convergeMs] }), after.timings); - const counts: Record = { - ...after.counters, - "editStorm.burstFiles": burst, - "editStorm.buildsTriggered": buildsDuringStorm, - // ×1000 so the integer-keyed counts table keeps precision (files per build). - "editStorm.filesPerBuildX1000": buildsDuringStorm > 0 ? Math.round((burst / buildsDuringStorm) * 1000) : 0, - ...namespaceCdpMetrics(cdpAfter, "cdp"), - ...namespaceCdpMetrics(diffCdpMetrics(cdpBefore, cdpAfter), "cdp.delta"), - }; - - const { markdownPath, htmlPath, report } = writeStressReport({ - runId, - artifactDir, - scenario: SCENARIO, - profileName: PROFILE.name, - startedAt, - finishedAt, - timings, - counts, - collectors: ["prisma", "cdp"], - cpuProfile, - resolveFrame: loadPrismaSourceMap(), - pluginVersion: typeof pluginVersion === "string" ? pluginVersion : undefined, - extraArtifacts: [{ kind: "cpu-profile", path: cpuProfilePath, description: "V8 CPU profile (write storm)" }], - }); - console.log(`[stress] report: ${markdownPath}`); - console.log(`[stress] html: ${htmlPath}`); - - // The burst must have driven at least one build, and far fewer builds than - // files — proof the indexer coalesces rather than re-rendering per file. - expect(buildsDuringStorm).toBeGreaterThan(0); - expect(buildsDuringStorm).toBeLessThan(burst); - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/specs/memory-leak.perf.spec.ts b/stress/specs/memory-leak.perf.spec.ts deleted file mode 100644 index 6c1385e2..00000000 --- a/stress/specs/memory-leak.perf.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import { - buildArtifactDir, - buildRunStem, - captureEnvironment, - captureGitInfo, - collectGarbage, - digestHeapSnapshot, - evaluateBudgets, - flattenMetrics, - generateVault, - hasRegression, - readCdpPerformanceMetrics, - readPerfBridge, - takeHeapSnapshot, - writeRunReports, - type HeapDigest, - type StressArtifact, - type StressRunReport, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; -import { openCloseViewLoop } from "../scenarios/open-close-view-loop"; -import { BUDGETS, MEMORY_CONFIG, STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent } from "../vaults/event-builder"; -import { SMALL_PROFILE } from "../vaults/profiles"; - -const SCENARIO = "memory-leak"; -// A leak lives in the open/close lifecycle, not the data volume — the small -// profile keeps heap snapshots cheap to capture and parse. -const PROFILE = SMALL_PROFILE; -const HEAP_USED_METRIC = "JSHeapUsedSize"; - -test.describe("stress: memory leak", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - const baselineCount = await indexerEventCount(page); - const expectedCount = baselineCount + PROFILE.events; - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - }); - await waitForIndexerToReach(page, expectedCount); - - const cdp = await page.context().newCDPSession(page); - const runId = buildRunStem({ profile: PROFILE.name, scenario: SCENARIO }); - const artifactDir = buildArtifactDir(STRESS_CONFIG.artifactRoot, runId); - - // Warmup cycles reach steady state (lazy module init, first-mount caches) - // so the measured loop's growth reflects per-cycle retention, not one-time setup. - await openCloseViewLoop(page, MEMORY_CONFIG.warmupCycles); - - await collectGarbage(cdp); - const before = await readCdpPerformanceMetrics(cdp); - const beforeSnapshotPath = path.join(artifactDir, "heap-before.heapsnapshot"); - await takeHeapSnapshot(cdp, beforeSnapshotPath); - // Digest the before-snapshot now so its parsed JSON can be GC'd before the - // (larger) after-snapshot is parsed — keeps only one big snapshot in memory. - const beforeDigest = digestHeapSnapshot(JSON.parse(readFileSync(beforeSnapshotPath, "utf8"))); - - const startedAt = new Date().toISOString(); - await openCloseViewLoop(page, MEMORY_CONFIG.cycles); - const finishedAt = new Date().toISOString(); - - // Force GC before the "after" reading so anything still on the heap is - // genuinely retained, not merely uncollected. - await collectGarbage(cdp); - const after = await readCdpPerformanceMetrics(cdp); - const afterSnapshotPath = path.join(artifactDir, "heap-after.heapsnapshot"); - await takeHeapSnapshot(cdp, afterSnapshotPath); - // Trace retainers on the after-snapshot — what's pinning any leaked nodes. - const heapDigest: HeapDigest = digestHeapSnapshot(JSON.parse(readFileSync(afterSnapshotPath, "utf8")), { - retainers: true, - }); - - const snapshot = await readPerfBridge(page); - const jsHeapBefore = before[HEAP_USED_METRIC] ?? 0; - const jsHeapAfter = after[HEAP_USED_METRIC] ?? 0; - const counts: Record = { - ...snapshot.counters, - "heap.jsHeapUsedBefore": jsHeapBefore, - "heap.jsHeapUsedAfter": jsHeapAfter, - "heap.growthBytes": jsHeapAfter - jsHeapBefore, - "heap.nodesBefore": beforeDigest.nodeCount, - "heap.nodesAfter": heapDigest.nodeCount, - "heap.nodeGrowth": heapDigest.nodeCount - beforeDigest.nodeCount, - "heap.detachedBefore": beforeDigest.detachedNodeCount, - "heap.detachedAfter": heapDigest.detachedNodeCount, - "heap.detachedGrowth": heapDigest.detachedNodeCount - beforeDigest.detachedNodeCount, - "heap.retainedGrowthBytes": heapDigest.totalSizeBytes - beforeDigest.totalSizeBytes, - }; - - const artifacts: StressArtifact[] = [ - { kind: "json", path: path.join(artifactDir, "run.json") }, - { kind: "markdown", path: path.join(artifactDir, "report.md") }, - { kind: "heap-snapshot", path: path.join(artifactDir, "heap-before.heapsnapshot"), description: "pre-loop heap" }, - { kind: "heap-snapshot", path: afterSnapshotPath, description: "post-loop heap (post-GC)" }, - ]; - - const pluginVersion = snapshot.metadata?.["pluginVersion"]; - const environment = { - ...captureEnvironment(), - ...(typeof pluginVersion === "string" ? { pluginVersion } : {}), - }; - - const report: StressRunReport = { - runId, - scenario: SCENARIO, - profile: PROFILE.name, - startedAt, - finishedAt, - status: "pass", - git: captureGitInfo(), - environment, - config: { - seed: STRESS_CONFIG.seed, - repeats: MEMORY_CONFIG.cycles, - warmup: MEMORY_CONFIG.warmupCycles, - collectors: ["prisma", "cdp", "heap"], - }, - timings: {}, - counts, - budgetFailures: evaluateBudgets(flattenMetrics({}, counts), BUDGETS[SCENARIO] ?? {}), - regressions: [], - artifacts, - heapDigest, - }; - report.status = report.budgetFailures.length > 0 || hasRegression(report.regressions) ? "fail" : "pass"; - - const { markdownPath } = writeRunReports(artifactDir, report); - console.log(`[stress] report: ${markdownPath}`); - - expect(snapshot.counters["index.eventsIndexed"]).toBe(expectedCount); - // Every calendar leaf must be gone after the final teardown. - expect(snapshot.counters["resources.activeViews"]).toBe(0); - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/specs/startup.perf.spec.ts b/stress/specs/startup.perf.spec.ts deleted file mode 100644 index 314ce94c..00000000 --- a/stress/specs/startup.perf.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import path from "node:path"; - -import { - collectCpuProfile, - diffCdpMetrics, - generateVault, - mergeTimings, - namespaceCdpMetrics, - readCdpPerformanceMetrics, - readPerfBridge, - summarizeSampleGroups, - writeCpuProfile, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; -import { createStressRun, loadPrismaSourceMap, writeStressReport } from "../report"; -import { STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent } from "../vaults/event-builder"; -import { PROFILES, type ProfileName } from "../vaults/profiles"; - -const SCENARIO = "startup"; -const PROFILE = PROFILES[(process.env["STRESS_PROFILE"] as ProfileName) ?? "small"] ?? PROFILES.small; - -test.describe("stress: startup (cold ingest)", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - const baselineCount = await indexerEventCount(page); - const expectedCount = baselineCount + PROFILE.events; - - const cdp = await page.context().newCDPSession(page); - const { runId, artifactDir } = createStressRun(SCENARIO, PROFILE.name); - const cpuProfilePath = path.join(artifactDir, "cpu.cpuprofile"); - const cdpBefore = await readCdpPerformanceMetrics(cdp); - - // Cold ingest: drop the whole vault on disk and profile the indexer + first - // paint as it ingests from empty → target. This is the plugin's load-time - // hot path — frontmatter parse → event store → calendar build — captured as - // one flame chart. Plain events only (recurrence's materialization cost is - // covered by the navigation scenario, and its on-disk overshoot would fight - // the exact ingest gate here). - const startedAt = new Date().toISOString(); - const ingestStart = performance.now(); - const { profile: cpuProfile } = await collectCpuProfile(cdp, async () => { - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - }); - // Large ingests far exceed the default 60s expect-poll — give the heavy - // tier room (the test timeout is raised via STRESS_TIMEOUT_MS for large). - await waitForIndexerToReach(page, expectedCount, PROFILE.name === "large" ? 900_000 : undefined); - }); - const ingestMs = performance.now() - ingestStart; - const finishedAt = new Date().toISOString(); - await writeCpuProfile(cpuProfilePath, cpuProfile); - - const snapshot = await readPerfBridge(page); - const cdpAfter = await readCdpPerformanceMetrics(cdp); - const pluginVersion = snapshot.metadata?.["pluginVersion"]; - - const timings = mergeTimings(summarizeSampleGroups({ "startup.coldIngest": [ingestMs] }), snapshot.timings); - const counts: Record = { - ...snapshot.counters, - "startup.eventsIngested": expectedCount - baselineCount, - ...namespaceCdpMetrics(cdpAfter, "cdp"), - ...namespaceCdpMetrics(diffCdpMetrics(cdpBefore, cdpAfter), "cdp.delta"), - }; - - const { markdownPath, htmlPath, report } = writeStressReport({ - runId, - artifactDir, - scenario: SCENARIO, - profileName: PROFILE.name, - startedAt, - finishedAt, - timings, - counts, - collectors: ["prisma", "cdp"], - cpuProfile, - resolveFrame: loadPrismaSourceMap(), - pluginVersion: typeof pluginVersion === "string" ? pluginVersion : undefined, - extraArtifacts: [{ kind: "cpu-profile", path: cpuProfilePath, description: "V8 CPU profile (cold ingest)" }], - }); - console.log(`[stress] report: ${markdownPath}`); - console.log(`[stress] html: ${htmlPath}`); - - // The cold ingest must have driven a calendar build (proof events ingested + painted). - expect(snapshot.timings["calendar.buildEvents"]).toBeDefined(); - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/specs/view-render.perf.spec.ts b/stress/specs/view-render.perf.spec.ts deleted file mode 100644 index cd6b9440..00000000 --- a/stress/specs/view-render.perf.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import path from "node:path"; - -import { - collectCpuProfile, - diffCdpMetrics, - generateVault, - mergeTimings, - namespaceCdpMetrics, - readCdpPerformanceMetrics, - readPerfBridge, - summarizeSampleGroups, - writeCpuProfile, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToReach } from "../../e2e/fixtures/stress-helpers"; -import { createStressRun, loadPrismaSourceMap, writeStressReport } from "../report"; -import { HEAVY_VIEW_NAMES, renderHeavyViews } from "../scenarios/render-views"; -import { STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent } from "../vaults/event-builder"; -import { PROFILES, type ProfileName } from "../vaults/profiles"; - -const SCENARIO = "view-render"; -const PROFILE = PROFILES[(process.env["STRESS_PROFILE"] as ProfileName) ?? "small"] ?? PROFILES.small; - -test.describe("stress: heavy view render", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - const baselineCount = await indexerEventCount(page); - const expectedCount = baselineCount + PROFILE.events; - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - }); - await waitForIndexerToReach(page, expectedCount); - - // Heatmap / gantt / dashboard are Pro-gated — without this they render the - // upgrade banner instead of the view, and the container never appears. - await calendar.unlockPro(); - - const cdp = await page.context().newCDPSession(page); - const { runId, artifactDir } = createStressRun(SCENARIO, PROFILE.name); - const cpuProfilePath = path.join(artifactDir, "cpu.cpuprofile"); - - // Warmup cycle (lazy view-module init, first-mount caches) discarded. - await renderHeavyViews(page, () => {}); - - const samplesByView: Record = Object.fromEntries(HEAVY_VIEW_NAMES.map((name) => [name, []])); - const cdpBefore = await readCdpPerformanceMetrics(cdp); - const startedAt = new Date().toISOString(); - for (let i = 0; i < STRESS_CONFIG.repeats; i++) { - await renderHeavyViews(page, (name, ms) => samplesByView[name]?.push(ms)); - } - const finishedAt = new Date().toISOString(); - - // Explain pass: one more cycle under a CPU profile, separate from the gated - // samples above so the sampler's overhead never skews the recorded timings. - const { profile: cpuProfile } = await collectCpuProfile(cdp, () => renderHeavyViews(page, () => {})); - await writeCpuProfile(cpuProfilePath, cpuProfile); - - const snapshot = await readPerfBridge(page); - const cdpAfter = await readCdpPerformanceMetrics(cdp); - const pluginVersion = snapshot.metadata?.["pluginVersion"]; - - const viewGroups = Object.fromEntries( - Object.entries(samplesByView).map(([name, samples]) => [`view.${name}`, samples]) - ); - const timings = mergeTimings(summarizeSampleGroups(viewGroups), snapshot.timings); - const counts: Record = { - ...snapshot.counters, - ...namespaceCdpMetrics(cdpAfter, "cdp"), - ...namespaceCdpMetrics(diffCdpMetrics(cdpBefore, cdpAfter), "cdp.delta"), - }; - - const { markdownPath, htmlPath, report } = writeStressReport({ - runId, - artifactDir, - scenario: SCENARIO, - profileName: PROFILE.name, - startedAt, - finishedAt, - timings, - counts, - collectors: ["prisma", "cdp"], - cpuProfile, - resolveFrame: loadPrismaSourceMap(), - pluginVersion: typeof pluginVersion === "string" ? pluginVersion : undefined, - extraArtifacts: [{ kind: "cpu-profile", path: cpuProfilePath, description: "V8 CPU profile (view renders)" }], - }); - console.log(`[stress] report: ${markdownPath}`); - console.log(`[stress] html: ${htmlPath}`); - - // Every heavy view painted and was timed across the repeats. - for (const name of HEAVY_VIEW_NAMES) { - expect(timings[`view.${name}`]?.count).toBe(STRESS_CONFIG.repeats); - } - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/specs/year-view.perf.spec.ts b/stress/specs/year-view.perf.spec.ts deleted file mode 100644 index ef113292..00000000 --- a/stress/specs/year-view.perf.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import path from "node:path"; - -import type { Page } from "@playwright/test"; -import { PERF_BRIDGE_GLOBAL_KEY } from "@real1ty-obsidian-plugins/perf"; -import { - collectCpuProfile, - diffCdpMetrics, - generateVault, - mergeTimings, - namespaceCdpMetrics, - readCdpPerformanceMetrics, - readPerfBridge, - resetPerfBridge, - summarizeSampleGroups, - writeCpuProfile, -} from "@real1ty-obsidian-plugins/testing/stress"; - -import { expect, test } from "../../e2e/fixtures/electron"; -import { indexerEventCount, waitForIndexerToSettle } from "../../e2e/fixtures/stress-helpers"; -import { createStressRun, loadPrismaSourceMap, writeStressReport } from "../report"; -import { STRESS_CONFIG } from "../stress.config"; -import { buildPrismaEvent, buildPrismaRecurringEvent } from "../vaults/event-builder"; -import { PROFILES, type ProfileName } from "../vaults/profiles"; - -const SCENARIO = "year-view"; -const PROFILE = PROFILES[(process.env["STRESS_PROFILE"] as ProfileName) ?? "small"] ?? PROFILES.small; -const EXPAND_METRIC = "recurrence.expandVisibleRange"; - -/** Wait until the calendar has expanded recurrence for the current (year) range. */ -async function waitForExpansion(page: Page): Promise { - await page.waitForFunction( - ({ key, metric }) => { - const bridge = (globalThis as Record)[key] as - | { snapshot: () => { timings: Record } } - | undefined; - return (bridge?.snapshot().timings[metric]?.count ?? 0) > 0; - }, - { key: PERF_BRIDGE_GLOBAL_KEY, metric: EXPAND_METRIC } - ); -} - -test.describe("stress: year view", () => { - test(`${SCENARIO} @ ${PROFILE.name}`, async ({ calendar }) => { - const { page, vaultDir } = calendar; - - // Recurrence is the point: a year window expands every source's occurrences. - // Settle on >= sources (materialization overshoots the exact count). - const baselineCount = await indexerEventCount(page); - const sourceTotal = baselineCount + PROFILE.events + PROFILE.recurring; - generateVault({ - dir: path.join(vaultDir, PROFILE.directory), - profile: PROFILE, - seed: STRESS_CONFIG.seed, - buildEvent: buildPrismaEvent, - buildRecurringEvent: buildPrismaRecurringEvent, - }); - await waitForIndexerToSettle(page, sourceTotal); - - const cdp = await page.context().newCDPSession(page); - await resetPerfBridge(page); - const cdpBefore = await readCdpPerformanceMetrics(cdp); - - // Measure the single switch-to-year render. The toolbar destabilizes under the - // heavy year layout, so we don't drive nav clicks — switching once and reading - // the recurrence-expansion stage timing captures the O(range × sources) cost - // (and the CPU flame shows where it goes) without racing FullCalendar's layout. - const startedAt = new Date().toISOString(); - const renderStart = performance.now(); - const { profile: cpuProfile } = await collectCpuProfile(cdp, async () => { - await calendar.switchMode("year"); - await waitForExpansion(page); - }); - const yearRenderMs = performance.now() - renderStart; - const finishedAt = new Date().toISOString(); - - const { runId, artifactDir } = createStressRun(SCENARIO, PROFILE.name); - const cpuProfilePath = path.join(artifactDir, "cpu.cpuprofile"); - await writeCpuProfile(cpuProfilePath, cpuProfile); - - const snapshot = await readPerfBridge(page); - const cdpAfter = await readCdpPerformanceMetrics(cdp); - const pluginVersion = snapshot.metadata?.["pluginVersion"]; - - const timings = mergeTimings(summarizeSampleGroups({ "view.yearRender": [yearRenderMs] }), snapshot.timings); - const counts: Record = { - ...snapshot.counters, - ...namespaceCdpMetrics(cdpAfter, "cdp"), - ...namespaceCdpMetrics(diffCdpMetrics(cdpBefore, cdpAfter), "cdp.delta"), - }; - - const { markdownPath, htmlPath, report } = writeStressReport({ - runId, - artifactDir, - scenario: SCENARIO, - profileName: PROFILE.name, - startedAt, - finishedAt, - timings, - counts, - collectors: ["prisma", "cdp"], - cpuProfile, - resolveFrame: loadPrismaSourceMap(), - pluginVersion: typeof pluginVersion === "string" ? pluginVersion : undefined, - extraArtifacts: [{ kind: "cpu-profile", path: cpuProfilePath, description: "V8 CPU profile (year render)" }], - }); - console.log(`[stress] report: ${markdownPath}`); - console.log(`[stress] html: ${htmlPath}`); - - // The year render must have expanded recurrence over the year window. - expect(snapshot.timings[EXPAND_METRIC]).toBeDefined(); - expect(report.status, `see ${markdownPath}`).toBe("pass"); - }); -}); diff --git a/stress/stress.config.ts b/stress/stress.config.ts deleted file mode 100644 index 7256a2d7..00000000 --- a/stress/stress.config.ts +++ /dev/null @@ -1,75 +0,0 @@ -import path from "node:path"; - -import type { StressBudget } from "@real1ty-obsidian-plugins/testing/stress"; - -import { PROFILES } from "./vaults/profiles"; - -// Playwright transpiles plugin specs as CJS (no "type":"module"), so use -// __dirname — `import.meta.url` is unavailable in that scope. Matches e2e. -const STRESS_DIR = __dirname; - -export const STRESS_CONFIG = { - seed: 42, - repeats: 5, - warmup: 1, - /** Toolbar navigations per repeat (alternating next/prev). */ - navSteps: 12, - /** Run artifacts (gitignored). Mirrors logs/change-reports. */ - artifactRoot: path.resolve(STRESS_DIR, "../../logs/perf-reports"), - /** Committed, user-blessed baselines. */ - baselineDir: path.resolve(STRESS_DIR, "baselines"), -} as const; - -/** Write-storm burst size per profile — files dropped at once after the base seed. */ -export const STORM_CONFIG = { - burstByProfile: { small: 200, medium: 1000, large: 5000 } as Record, -} as const; - -/** Memory-leak scenario knobs — open/close cycles, separate from nav repeats. */ -export const MEMORY_CONFIG = { - /** Discarded cycles to reach steady state before the measured loop. */ - warmupCycles: 2, - /** Measured open/close cycles between the before/after heap snapshots. */ - cycles: 20, - /** Catastrophic-only ceiling on post-GC live-heap growth (JSHeapUsedSize, bytes). */ - growthBudgetBytes: 50_000_000, -} as const; - -export { PROFILES }; - -/** - * Per-metric budget rules per scenario. Advisory cross-machine — same-machine - * baselines carry regression detection — but they catch catastrophic blow-ups - * anywhere. - */ -export const BUDGETS: Record = { - // Pure in-page render costs only. `scenario.navigateStep` (wall-clock incl. - // click + CDP round-trips) is reported but not budgeted — too noisy to gate. - "calendar-navigation": { - "calendar.buildEvents.p95Ms": { comparison: "max", value: 250, unit: "ms" }, - "eventStore.getEvents.p95Ms": { comparison: "max", value: 200, unit: "ms" }, - }, - // Leak gates: every leaf must be gone after teardown (exact) and live heap must - // not balloon (catastrophic ceiling). Detached-DOM growth + retainers + node - // growth are REPORTED every run (see the Heap section) but NOT gated: there is a - // known, small per-cycle detached-DOM leak (FullCalendar/Preact + bound - // listeners) that is deliberately deprioritized — performance work outranks it. - // See docs/specs/2026-05-22-stress-real-load.md (carry-over). Re-gate - // `heap.detachedGrowth` once the teardown is fixed and a clean level is measured. - "memory-leak": { - "resources.activeViews": { comparison: "exact", value: 0, unit: "count" }, - "heap.growthBytes": { comparison: "max", value: MEMORY_CONFIG.growthBudgetBytes, unit: "bytes" }, - }, - // Exploratory scenarios: a single catastrophic wall-clock ceiling each (so a - // total hang fails the run) — all the real signal lives in the flame chart + - // stage timings, which are reported, not gated. - startup: { - "startup.coldIngest.p95Ms": { comparison: "max", value: 180_000, unit: "ms" }, - }, - "event-edit-storm": { - "editStorm.converge.p95Ms": { comparison: "max", value: 120_000, unit: "ms" }, - }, - "year-view": { - "view.yearRender.p95Ms": { comparison: "max", value: 120_000, unit: "ms" }, - }, -}; diff --git a/stress/tsconfig.json b/stress/tsconfig.json deleted file mode 100644 index 44f0c098..00000000 --- a/stress/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["node"], - "noUnusedLocals": false, - "noUnusedParameters": false, - "exactOptionalPropertyTypes": false, - "noPropertyAccessFromIndexSignature": false - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "test-results", "playwright-report", ".vaults"] -} diff --git a/stress/vaults/event-builder.ts b/stress/vaults/event-builder.ts deleted file mode 100644 index 3a6aa395..00000000 --- a/stress/vaults/event-builder.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { GeneratedEvent, SeededRandom } from "@real1ty-obsidian-plugins/testing/stress"; - -import { buildEventMarkdown, type SeedEventInput } from "../../e2e/fixtures/seed-events"; -import type { PrismaVaultProfile } from "./profiles"; - -// Deterministic Prisma event factory. Events are spread across a FROZEN anchor -// year (never `today`) so the generated vault is byte-identical run-to-run. -// Generic mock data only — never real names/categories. - -const ANCHOR_YEAR = 2026; -const EVENT_TITLES = ["Team Meeting", "Workout", "Project Planning", "Weekly Review", "Standup", "Focus Block"]; -const CATEGORIES = ["Work", "Personal", "Fitness", "Errands"]; - -// Prisma recurrence DSL (src/types/recurring.ts): presets or `FREQ;INTERVAL=N`. -// Weighted toward bounded frequencies so a month view doesn't expand to millions -// of instances — daily is the heaviest per-range, so it's a minority. -const RECURRENCE_TYPES = ["weekly", "monthly", "daily", "MONTHLY;INTERVAL=2", "weekly"] as const; -const WEEKLY_TYPES = new Set(["weekly", "bi-weekly"]); -const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"]; -// How many years before the anchor a recurring series starts — exercises the -// visible-range fast-forward (`advanceOccurrenceToRangeStart`). -const MIN_YEARS_BACK = 3; -const MAX_YEARS_BACK = 6; - -function pad(value: number): string { - return String(value).padStart(2, "0"); -} - -export function buildPrismaEvent(rng: SeededRandom, index: number, _profile: PrismaVaultProfile): GeneratedEvent { - const dayOfYear = index % 365; - const date = new Date(Date.UTC(ANCHOR_YEAR, 0, 1 + dayOfYear)); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hour = 8 + rng.int(0, 9); - - const input: SeedEventInput = { - title: `${rng.pick(EVENT_TITLES)} ${String(index + 1).padStart(5, "0")}`, - startDate: `${ANCHOR_YEAR}-${month}-${day}T${pad(hour)}:00`, - endDate: `${ANCHOR_YEAR}-${month}-${day}T${pad(hour + 1)}:00`, - category: rng.pick(CATEGORIES), - }; - - return { - relativePath: `Event-${String(index).padStart(5, "0")}.md`, - content: buildEventMarkdown(input), - }; -} - -/** - * Deterministic recurring source. Open-ended (no `RRuleUntil`) and started years - * before the anchor so every visible range expands occurrences. `RRuleID` is set - * explicitly via `extra` so the plugin never generates a UUID and writes it back - * (which would mutate the vault and break determinism). - */ -export function buildPrismaRecurringEvent( - rng: SeededRandom, - index: number, - _profile: PrismaVaultProfile -): GeneratedEvent { - const rrule = rng.pick(RECURRENCE_TYPES); - const startYear = ANCHOR_YEAR - (MIN_YEARS_BACK + rng.int(0, MAX_YEARS_BACK - MIN_YEARS_BACK)); - const dayOfYear = index % 365; - const date = new Date(Date.UTC(startYear, 0, 1 + dayOfYear)); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hour = 8 + rng.int(0, 9); - - const input: SeedEventInput = { - title: `${rng.pick(EVENT_TITLES)} Recurring ${String(index + 1).padStart(5, "0")}`, - startDate: `${startYear}-${month}-${day}T${pad(hour)}:00`, - endDate: `${startYear}-${month}-${day}T${pad(hour + 1)}:00`, - category: rng.pick(CATEGORIES), - rrule, - ...(WEEKLY_TYPES.has(rrule) ? { rruleSpec: rng.pick(WEEKDAYS) } : {}), - extra: { RRuleID: `rec-${String(index).padStart(5, "0")}` }, - }; - - return { - relativePath: `Recurring-${String(index).padStart(5, "0")}.md`, - content: buildEventMarkdown(input), - }; -} diff --git a/stress/vaults/generate-vault.ts b/stress/vaults/generate-vault.ts deleted file mode 100644 index e334de72..00000000 --- a/stress/vaults/generate-vault.ts +++ /dev/null @@ -1,49 +0,0 @@ -import path from "node:path"; - -import { generateVault } from "@real1ty-obsidian-plugins/testing/stress"; - -import { buildPrismaEvent } from "./event-builder"; -import { PROFILES, type ProfileName } from "./profiles"; - -// Standalone vault generator CLI. Useful for pre-seeding a vault on disk for -// inspection or for the future bootstrap-seed-dir cold-start flow. The -// navigation spec generates inline; this script is the manual entry point. -// -// tsx stress/vaults/generate-vault.ts --profile small --seed 42 --out /tmp/v - -function parseArgs(argv: readonly string[]): Map { - const args = new Map(); - for (let i = 0; i < argv.length; i++) { - const token = argv[i]; - if (token?.startsWith("--")) { - args.set(token.slice(2), argv[i + 1] ?? ""); - i++; - } - } - return args; -} - -function main(): void { - const args = parseArgs(process.argv.slice(2)); - const profileName = (args.get("profile") ?? "small") as ProfileName; - const profile = PROFILES[profileName]; - if (!profile) { - throw new Error(`Unknown profile "${profileName}". Available: ${Object.keys(PROFILES).join(", ")}`); - } - - const seed = Number.parseInt(args.get("seed") ?? "42", 10); - const outDir = args.get("out") ?? path.resolve(process.cwd(), "stress", ".vaults", profileName); - const targetDir = path.join(outDir, profile.directory); - - const manifest = generateVault({ - dir: targetDir, - profile, - seed, - buildEvent: buildPrismaEvent, - manifestPath: path.join(outDir, ".obsidian", "prisma-stress-manifest.json"), - }); - - console.log(`[stress] generated ${manifest.events} events (${profileName}, seed ${seed}) -> ${targetDir}`); -} - -main(); diff --git a/stress/vaults/profiles.ts b/stress/vaults/profiles.ts deleted file mode 100644 index b137de31..00000000 --- a/stress/vaults/profiles.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { VaultProfile } from "@real1ty-obsidian-plugins/testing/stress"; - -export interface PrismaVaultProfile extends VaultProfile { - /** Vault subdirectory the calendar bundle indexes. */ - directory: string; -} - -// Each profile generates `events` plain timed events + `recurring` recurring -// sources (open-ended, started years before the frozen anchor), so the indexer -// source count is exactly `events + recurring` — the deterministic gate the -// navigation spec waits on. Recurring sources expand to virtual instances on -// render, which is what drives `recurrence.expandVisibleRange`. - -export const SMALL_PROFILE: PrismaVaultProfile = { - name: "small", - events: 500, - recurring: 50, - directory: "Events", -}; - -export const MEDIUM_PROFILE: PrismaVaultProfile = { - name: "medium", - events: 2000, - recurring: 200, - directory: "Events", -}; - -// Heavy tier — the user's call (agents stick to small/medium). 50k plain + 10k -// recurring sources reproduces a real power-user vault. -export const LARGE_PROFILE: PrismaVaultProfile = { - name: "large", - events: 50_000, - recurring: 10_000, - directory: "Events", -}; - -export const PROFILES = { small: SMALL_PROFILE, medium: MEDIUM_PROFILE, large: LARGE_PROFILE } as const; - -export type ProfileName = keyof typeof PROFILES;