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