chore: full resync of prisma-calendar from monorepo

This commit is contained in:
Real1ty 2026-06-09 10:58:36 +02:00
parent 2640797d55
commit 60e16bba4f
300 changed files with 5 additions and 31444 deletions

View file

@ -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/<area>/`.
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.

View file

@ -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<void> {
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<void> {
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();
}
}

View file

@ -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<TApi>` 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<PrismaCalendarApi>(page, "PrismaCalendar");
}
/** Wait until a single file is indexed by the event repository. */
export async function waitForApiIndex(api: PrismaCalendarApi, filePath: string): Promise<void> {
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<void> {
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<void> {
await expect
.poll(
async () =>
page.evaluate(
({ key, name }) => {
const api = (window as unknown as Record<string, unknown>)[key] as Record<string, unknown> | 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<void> {
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 };

View file

@ -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<string> {
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<void> {
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<void> {
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<Locator> {
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<ViewMode, string> = {
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<void> {
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();
}

View file

@ -1,15 +0,0 @@
import { expect, type Locator } from "@playwright/test";
export async function expectBackgroundColor(locator: Locator, hex: string): Promise<void> {
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);
}

View file

@ -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<void> {
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<void> {
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);
}

View file

@ -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";

View file

@ -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())}`;
}

View file

@ -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<void>;
confirm(): Promise<void>;
exit(): Promise<void>;
}
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<BatchHandle> {
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();
},
};
}

View file

@ -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(...)` 46× 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<void> {
for (const e of events) await e.expectExists(yes);
}
export async function expectAllFrontmatter(
events: readonly EventHandle[],
key: string,
matcher: (v: unknown) => boolean
): Promise<void> {
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<void> {
await Promise.all(events.map((e) => e.expectColor(color)));
}
export async function expectAllVisible(page: Page, events: readonly EventHandle[]): Promise<void> {
await expectEventsVisibleByTitle(
page,
events.map((e) => e.title)
);
}
export async function expectAllHidden(page: Page, events: readonly EventHandle[]): Promise<void> {
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<void> {
for (const e of events) await expectTitleCount(page, e.title, n);
}

View file

@ -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<EventHandle>;
/** Seed N events with auto-generated titles. Stable titles: `<prefix> 1` … `<prefix> N`. */
seedEvents(count: number, options?: SeedOptions): Promise<EventHandle[]>;
/** Seed arbitrary-titled events via the UI and drain the success-notice stack. */
seedMany(inputs: readonly EventCreate[]): Promise<EventHandle[]>;
/**
* 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<string, string | boolean>,
options?: { awaitRender?: boolean }
): Promise<EventHandle>;
/**
* 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<string, string | boolean>): Promise<EventHandle>;
/**
* 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<EventHandle[]>;
/**
* 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<void>;
batch(events: readonly EventHandle[]): Promise<BatchHandle>;
undo(times?: number): Promise<void>;
redo(times?: number): Promise<void>;
/** Wait until the on-disk Events/ tree holds exactly `n` files. */
expectEventCount(n: number): Promise<void>;
/**
* 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<EventHandle>;
/** Click a page-header toolbar action (create-event / daily-stats / refresh / …). */
clickToolbar(action: ToolbarActionKey): Promise<void>;
/**
* 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<EventsModalHandle>;
/** Switch the analytics view tab. For group tabs use `switchToGroupChild`. */
switchView(tab: ViewTabKey): Promise<void>;
/** Drill into a child inside a group tab (e.g. dashboard → dashboard-by-name). */
switchToGroupChild(group: ViewTabKey, child: ViewTabKey): Promise<void>;
/** Click the FullCalendar view-mode button (month/week/day/list). */
switchMode(mode: ViewMode): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
/** Flip the license to Pro via the `__setProForTesting` seam (guarded by `window.E2E`). */
unlockPro(): Promise<void>;
/**
* 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<void>;
/**
* 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<number>;
/**
* 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<Record<string, { count: number; titles: string[] }>>;
/**
* 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<Record<string, boolean>>;
/**
* 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<string>;
/** Open a new workspace leaf hosting the active bundle's calendar view. */
openInNewLeaf(): Promise<void>;
/** Count workspace leaves currently rendering the active bundle's view type. */
leafCount(): Promise<number>;
/**
* 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<void>;
/** Collapse the left workspace sidebar — used by visual specs that need maximum canvas width. */
collapseLeftSidebar(): Promise<void>;
/** Open a markdown file in reading (preview) mode — required for code-block processors to mount. */
openFileInReadingMode(path: string): Promise<void>;
/** Click the untracked-events dropdown toggle in the toolbar. */
openUntrackedDropdown(): Promise<void>;
/** Fire a registered Obsidian command via the command palette. */
runCommand(name: string): Promise<void>;
/** Wait for the shared confirmation modal and click Confirm. Used by destructive flows. */
confirmDeletion(): Promise<void>;
/**
* 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<string | null>;
/** Assert a timeline item rendering `title` is present (or absent) in the timeline view. */
expectTimelineItem(title: string, present?: boolean): Promise<void>;
/** Assert the heatmap cell for `iso` carries `data-count="<count>"`. */
expectHeatmapCount(iso: string, count: number): Promise<void>;
/** Assert a dashboard ranking row for `title` is present (or absent) in the active group. */
expectDashboardItem(group: DashboardGroup, title: string, present?: boolean): Promise<void>;
}
/**
* 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<void> {
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<void> => {
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<string, number>();
const baseline = await getEventCount(page);
for (const input of events) {
const fm: Record<string, string | boolean | string[]> = {};
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<string, unknown> = {};
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<string, { count: number; titles: string[] }> = {};
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<string, boolean> = {};
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);
},
};
}

View file

@ -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<string>;
openRename(): Promise<CategoryRenameModalHandle>;
openDelete(): Promise<CategoryDeleteModalHandle>;
}
export interface CategoryRenameModalHandle extends RenameModalHandle {
readonly toggleUntracked: Locator;
setIncludeUntracked(checked: boolean): Promise<void>;
}
export interface CategoryDeleteModalHandle extends ConfirmationModalHandle {
readonly toggleUntracked: Locator;
setIncludeUntracked(checked: boolean): Promise<void>;
}
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<T extends object>(
modal: T,
toggleUntracked: Locator
): T & {
readonly toggleUntracked: Locator;
setIncludeUntracked(checked: boolean): Promise<void>;
} {
return {
...modal,
toggleUntracked,
async setIncludeUntracked(checked: boolean) {
if (checked) await toggleUntracked.check();
else await toggleUntracked.uncheck();
},
};
}

View file

@ -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<void> {
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<void> {
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<void> {
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<BoundingBox> {
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 };
}

View file

@ -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<void>;
rightClick(item: ContextMenuItemKey): Promise<void>;
/**
* 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<void>;
readFrontmatter<T = unknown>(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<string>;
expectExists(yes: boolean): Promise<void>;
expectFrontmatter(key: string, matcher: (v: unknown) => boolean, message?: string): Promise<void>;
/**
* 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<void>;
/**
* Replace the note body (everything after frontmatter) via `app.vault.modify`.
* Preserves frontmatter only the markdown body is swapped.
*/
writeNoteBody(body: string): Promise<void>;
/** Hover over the event block in the active calendar leaf. */
hover(): Promise<void>;
/** Read the native `title` attribute (tooltip text) from the event block. */
getTooltip(): Promise<string | null>;
/** Wait for (or assert the absence of) the event block in the active calendar leaf. */
expectVisible(yes?: boolean): Promise<void>;
/**
* 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<void>;
}
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<T>(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);
},
};
}

View file

@ -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<void> {
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<void>;
/** Assert the active tab and (optionally) pin its label text — e.g. `"Recurring (1)"`. */
expectTabActive(tab: EventsModalTab, label?: string): Promise<void>;
/** Assert the count chip text (`"2 category groups"`, `"1 event"`, …). */
expectGroupCountText(text: string): Promise<void>;
/** 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<SeriesModalHandle>;
/**
* 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<SeriesModalHandle>;
/** Type into the events-modal search input (filters group items by title). */
search(query: string): Promise<void>;
/** Set the sort dropdown to one of the four supported modes. */
setSort(mode: EventsModalSortMode): Promise<void>;
/** Recurring-tab only: set the type filter dropdown. */
setRecurringTypeFilter(filter: RecurringTypeFilter): Promise<void>;
/**
* 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<void>;
/** Whether the "Show disabled only" toggle is currently rendered. */
hasShowDisabledOnlyToggle(): Promise<boolean>;
/** 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<void>;
/** Trigger the Nav button — modal closes, calendar navigates to source. */
clickNav(): Promise<void>;
/**
* Click the disable/enable toggle. Same button regardless of pool its
* label flips between "Disable" and "Enable" based on the active pool.
*/
clickToggle(): Promise<void>;
/** Ctrl/Cmd+click the row to open the source file in a new workspace tab. */
openInNewTab(): Promise<void>;
/** Plain click — opens the EventSeriesModal for this recurring source. */
open(): Promise<SeriesModalHandle>;
/** Pin the recurrence type stamped on `data-recurring-type`. */
expectType(type: string): Promise<void>;
/** Pin the visible badge text (e.g. "Weekly", "Daily", "Custom Interval"). */
expectBadgeLabel(label: string): Promise<void>;
/** Pin the "N instance(s)" subtitle. */
expectInstanceCountText(count: number): Promise<void>;
}
export interface SeriesModalHandle {
readonly modal: Locator;
/** All event rows in the series modal. */
rows(): Locator;
/** Exact row-count assertion. */
expectRowCount(n: number): Promise<void>;
/** Stats-footer assertion: `Total: N`. */
expectTotal(n: number): Promise<void>;
/**
* 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<void>;
/** Pin the recurrence "extra info" banner text (only present on the recurring tab). */
expectRecurrenceInfo(text: string | RegExp): Promise<void>;
/** Every visible row's title equals `title`. */
expectAllTitles(title: string): Promise<void>;
/** No tab bar rendered — `tabs.length < 2` in `event-series-modal-content.tsx`. */
expectNoTabBar(): Promise<void>;
/** Pin a series-modal tab as visible and active (carries `is-active`). */
expectTabActive(tab: SeriesModalTab): Promise<void>;
/** Pin a series-modal tab as visible but not active. */
expectTabInactive(tab: SeriesModalTab): Promise<void>;
/** Pin the modal root's `--source-category-color` CSS var (set when drilling a colored category). */
expectCategoryColorVar(color: string): Promise<void>;
/** A Bases-footer button for `view` is visible. */
expectBasesAction(view: SeriesBasesView): Promise<void>;
/** Click a Bases-footer button. Caller drives whatever child modal opens. */
pickBasesView(view: SeriesBasesView): Promise<void>;
/** Click the source-title heading — opens the source markdown file. */
clickTitle(): Promise<void>;
/** Toggle the "Hide past events" checkbox. */
toggleHidePast(): Promise<void>;
/** Toggle the "Hide skipped events" checkbox. */
toggleHideSkipped(): Promise<void>;
/** Type into the per-tab search input (only rendered on tabs that pass `showSearch`). */
search(query: string): Promise<void>;
/** 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<void>;
/** Visible row titles in document order — useful when asserting a `startsWith` set. */
titles(): Promise<string[]>;
/** 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<void>;
/** Multi-category picker: click the option for `value`. */
pickCategory(value: string): Promise<void>;
/** "← Back to categories" returns to the picker. */
backToCategories(): Promise<void>;
}
/** 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<void>;
/** Assert the row carries (or does not carry) the `prisma-recurring-event-past` class. */
expectPast(yes?: boolean): Promise<void>;
/** Assert the row carries (or does not carry) the `prisma-recurring-event-skipped` class. */
expectSkipped(yes?: boolean): Promise<void>;
/** Pin the row's `data-event-file-path` attribute. */
expectFilePath(path: string | RegExp): Promise<void>;
/** Pin the row's title text. */
expectTitle(title: string): Promise<void>;
}
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<SeriesModalHandle> => {
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<SeriesModalHandle> {
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);
},
};
}

View file

@ -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";

View file

@ -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<void>;
/** Click the visibility toggle for `id`. */
toggle(id: string): Promise<void>;
/** Click the Reset-to-defaults button (does NOT confirm — caller drives confirmation modal). */
clickReset(): Promise<void>;
/** Dismiss via Escape and wait for the modal to unmount. */
close(): Promise<void>;
}
/** Open the page-header action manager modal. Requires a calendar view visible. */
export async function openActionManager(page: Page): Promise<ActionManagerHandle> {
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<void>;
toggle(id: string): Promise<void>;
rename(id: string): Promise<void>;
clickReset(): Promise<void>;
close(): Promise<void>;
}
export async function openTabManager(page: Page): Promise<TabManagerHandle> {
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<void>;
clickReset(): Promise<void>;
close(): Promise<void>;
}
/**
* 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<ItemManagerHandle> {
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<void>;
cancel(): Promise<void>;
}
/**
* 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<ConfirmationModalHandle> {
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<void>;
submit(): Promise<void>;
cancel(): Promise<void>;
}
/**
* 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<RenameModalHandle> {
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<void>;
}
export async function expectProgressModal(page: Page): Promise<ProgressModalHandle> {
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<void>;
submit(): Promise<void>;
}
/**
* 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<AssignmentModalHandle> {
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<boolean>;
expand(): Promise<void>;
collapse(): Promise<void>;
expectExpanded(yes: boolean): Promise<void>;
}
/** 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<boolean> => {
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);
},
};
}

View file

@ -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<void>;
/** Assert the post-mutate state — called after mutate AND after redo. */
mutated: () => Promise<void>;
/** Assert the pre-mutate state — called after undo. */
baseline: () => Promise<void>;
}
/**
* 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<void> {
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<void>;
/** Assert the baseline state — called after undo. */
baseline: () => Promise<void>;
/** 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<void> {
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();
}

View file

@ -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<Record<string, unknown>>;
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<string, unknown>;
}
// 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<void> {
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<BootstrappedObsidian> {
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<void> }>;
ensureCalendarBundlesReady?: () => Promise<void>;
};
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<void>;
// 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<void> {
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<void>
): Promise<void> => {
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;

View file

@ -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<string, string | boolean>;
/** 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<void> {
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<string, unknown>,
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]));
}
}

View file

@ -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-<key>"` 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-<key>` — 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<void> {
await locator.waitFor({ state: "visible", timeout: 10_000 });
await locator.scrollIntoViewIfNeeded();
}
export async function setSchemaToggle(page: Page, key: string, on: boolean): Promise<void> {
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<void> {
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<void> {
// Bounded integer fields (`z.number().int().min().max()`) auto-infer to the
// slider widget (`<input type="range">`). Unbounded fields render a native
// `<input type="number">`. 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<void> {
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<void> {
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<void> {
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<void> {
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-<calendarId>` 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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-<id>` 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<void> {
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-<childId>` testid
* as leaf tabs, so the test locator is stable.
*/
export async function switchToGroupChild(page: Page, groupId: string, childId: string): Promise<void> {
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<void> {
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-<id>"` 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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="<name>"`.
*/
export async function assignCategoriesViaModal(page: Page, categories: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<void> {
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);
}

View file

@ -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<number> {
return page.locator(SELECTED_EVENT_IN_LEAF).count();
}
/** Count of all visible events in the active calendar leaf. */
export function visibleEventCount(page: Page): Promise<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<void> {
for (const title of titles) {
await expect(eventByTitle(page, title)).toHaveClass(/prisma-batch-selectable/);
}
}
export async function exitBatchMode(page: Page): Promise<void> {
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<void> {
const event = page
.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"][data-event-title="${title}"]`)
.first();
await event.waitFor({ state: "visible" });
await event.click();
}
/** Click a batch-mode toolbar action button by its stamped suffix. */
export async function clickBatchButton(page: Page, suffix: string): Promise<void> {
const btn = page.locator(`${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-batch-${suffix}"]`).first();
await btn.waitFor({ state: "visible" });
await btn.click();
}
/** Confirm the destructive-action modal (currently only batch delete). */
export async function confirmBatchAction(page: Page): Promise<void> {
const confirm = page.locator(BATCH_CONFIRM_SUBMIT).first();
await confirm.waitFor({ state: "visible" });
await confirm.click();
await confirm.waitFor({ state: "hidden" });
}
export async function undoViaPalette(page: Page, times = 1): Promise<void> {
for (let i = 0; i < times; i++) await runCommand(page, "Prisma Calendar: Undo");
}
export async function redoViaPalette(page: Page, times = 1): Promise<void> {
for (let i = 0; i < times; i++) await runCommand(page, "Prisma Calendar: Redo");
}
// ── Disk waiters ─────────────────────────────────────────────────────────
// Assertions against persisted state must read the vault, not the DOM: the
// metadata cache lags file writes and FC event state is not authoritative.
export async function waitForEventFileCount(vaultDir: string, n: number): Promise<void> {
await expect.poll(() => listEventFiles(vaultDir).length, { message: `expected ${n} event files` }).toBe(n);
}
export async function waitForFrontmatter(
vaultDir: string,
filePath: string,
key: string,
matcher: (v: unknown) => boolean
): Promise<void> {
await expect
.poll(() => matcher(readEventFrontmatter(vaultDir, filePath)[key]), {
message: `frontmatter ${key} did not match in ${filePath}`,
})
.toBe(true);
}
export async function waitForFileExists(vaultDir: string, filePath: string, shouldExist: boolean): Promise<void> {
await expect
.poll(() => listEventFiles(vaultDir).some((abs) => abs.endsWith(`/${filePath}`)), {
message: `${filePath} existence != ${shouldExist}`,
})
.toBe(shouldExist);
}
export { isoLocal } from "./dates";

View file

@ -1,172 +0,0 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import ICAL from "ical.js";
// Local HTTP server that mimics a remote ICS feed. Binds to 127.0.0.1 on an
// OS-assigned port, serves whatever body the spec last `setBody`'d, records
// every request for assertion, and lets the spec swap the status code to
// exercise error paths (403 for auth failures, 500 for server errors, etc.).
// Obsidian's `requestUrl` API (used by ics-subscription/sync.ts) hits this
// over plain HTTP on the loopback interface — no CORS, no cert trust, no
// docker required.
const DEFAULT_CONTENT_TYPE = "text/calendar; charset=utf-8";
const OK = 200;
export interface RecordedRequest {
method: string;
path: string;
headers: Readonly<Record<string, string | string[] | undefined>>;
}
export interface IcsServerState {
body: string;
status: number;
contentType: string;
}
export interface IcsServer {
/** Full URL the plugin should subscribe to. */
readonly url: string;
/** All requests observed since start(). Cleared by `resetRequests()`. */
readonly requests: readonly RecordedRequest[];
/** Replace the served body; takes effect on the next request. */
setBody(ics: string): void;
/** Swap the HTTP status code. Handy for 403/500 error-path specs. */
setStatus(status: number): void;
resetRequests(): void;
close(): Promise<void>;
}
/**
* Start a mock ICS server. Call `close()` in a `test.afterEach` /
* `test.afterAll` hook to release the port Playwright's serial runner will
* otherwise leak a listener per spec.
*/
export async function startIcsServer(initialBody: string): Promise<IcsServer> {
const state: IcsServerState = {
body: initialBody,
status: OK,
contentType: DEFAULT_CONTENT_TYPE,
};
const recorded: RecordedRequest[] = [];
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
recorded.push({
method: req.method ?? "GET",
path: req.url ?? "/",
headers: req.headers,
});
res.writeHead(state.status, { "Content-Type": state.contentType });
res.end(state.body);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const addr = server.address() as AddressInfo;
const url = `http://127.0.0.1:${addr.port}/calendar.ics`;
return {
url,
get requests() {
return recorded;
},
setBody(ics) {
state.body = ics;
},
setStatus(status) {
state.status = status;
},
resetRequests() {
recorded.length = 0;
},
close: () =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
// ── ICS body builders ───────────────────────────────────────────────────
// Delegate RFC 5545 serialization to `ical.js` — the same library the plugin
// uses for export/import. Hand-rolling line protocol in tests would just
// duplicate that library's edge cases (line folding, value escaping, TZID
// emission) and eventually drift from what real clients produce.
export interface VEventInput {
uid: string;
summary: string;
/** Timed events: `YYYYMMDDTHHmmssZ`. All-day events: `YYYYMMDD`. */
dtstart: string;
dtend?: string;
/** Emit `DTSTART;VALUE=DATE` instead of a timestamp. `dtstart` should be `YYYYMMDD`. */
allDay?: boolean;
categories?: string;
location?: string;
description?: string;
}
function toICALTime(stamp: string, allDay: boolean): ICAL.Time {
// `YYYYMMDD[THHmmss[Z]]` — the compact form used in ICS wire output.
const year = Number(stamp.slice(0, 4));
const month = Number(stamp.slice(4, 6));
const day = Number(stamp.slice(6, 8));
if (allDay) {
return new ICAL.Time({ year, month, day, isDate: true }, ICAL.Timezone.utcTimezone);
}
const hour = Number(stamp.slice(9, 11));
const minute = Number(stamp.slice(11, 13));
const second = Number(stamp.slice(13, 15));
return new ICAL.Time({ year, month, day, hour, minute, second, isDate: false }, ICAL.Timezone.utcTimezone);
}
// Tracks the last LAST-MODIFIED ms emitted across all buildIcs() calls so each
// successive call is guaranteed to be strictly greater than every prior one,
// even when fired in the same wall-clock second. Without this monotonic
// counter the sync planner can't tell two consecutive ICS bodies apart and
// treats real changes as "skip-unchanged" — see ICSSubscriptionSyncPlan.
let lastModifiedMs = 0;
function nextLastModifiedTime(): ICAL.Time {
const ms = Math.max(Date.now(), lastModifiedMs + 1000);
lastModifiedMs = ms;
return ICAL.Time.fromJSDate(new Date(ms), true);
}
function buildVEvent(v: VEventInput): ICAL.Component {
const vevent = new ICAL.Component("vevent");
vevent.addPropertyWithValue("uid", v.uid);
vevent.addPropertyWithValue("summary", v.summary);
vevent.addPropertyWithValue("dtstart", toICALTime(v.dtstart, Boolean(v.allDay)));
if (v.dtend) {
vevent.addPropertyWithValue("dtend", toICALTime(v.dtend, Boolean(v.allDay)));
}
vevent.addPropertyWithValue("last-modified", nextLastModifiedTime());
if (v.categories) vevent.addPropertyWithValue("categories", v.categories);
if (v.location) vevent.addPropertyWithValue("location", v.location);
if (v.description) vevent.addPropertyWithValue("description", v.description);
return vevent;
}
/**
* Wrap VEVENTs in a minimal VCALENDAR envelope. `ICAL.Component#toString()`
* produces byte-for-byte-correct RFC 5545 output (folded lines, escaped
* commas/semicolons, canonical property order) the same output path the
* plugin's own exporter uses.
*/
export function buildIcs(events: readonly VEventInput[]): string {
const vcalendar = new ICAL.Component("vcalendar");
vcalendar.addPropertyWithValue("version", "2.0");
vcalendar.addPropertyWithValue("prodid", "-//Prisma-Calendar-E2E//EN");
vcalendar.addPropertyWithValue("calscale", "GREGORIAN");
for (const event of events) {
vcalendar.addSubcomponent(buildVEvent(event));
}
return vcalendar.toString() + "\r\n";
}

View file

@ -1,182 +0,0 @@
import { existsSync, readdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { readEventFrontmatter, type BootstrappedObsidian } from "@real1ty-obsidian-plugins/testing/e2e";
import { runCommand } from "./commands";
import { expect } from "./electron";
import { unlockPro } from "./helpers";
import { buildIcs, startIcsServer, type IcsServer, type VEventInput } from "./ics-server";
import type { PrismaPlugin, PrismaWindow } from "./window-types";
// High-level DSL for ICS subscription specs. Owns the mock HTTP server,
// secret-storage stub, subscription seed, vault clean-up, and exposes polling
// helpers so specs read as a sequence of remote-state changes and on-disk
// assertions instead of repeating the same 100-line boilerplate per file.
const EVENTS_DIR = "Events";
const PLUGIN_ID = "prisma-calendar";
const SYNC_COMMAND = "Prisma Calendar: Sync ICS subscriptions";
const DEFAULT_SUBSCRIPTION_ID = "e2e-subscription";
const DEFAULT_SUBSCRIPTION_NAME = "E2E Feed";
const DEFAULT_CALENDAR_ID = "default";
const DEFAULT_URL_SECRET_NAME = "e2e-ics-subscription-url";
const DEFAULT_TIMEZONE = "UTC";
const DEFAULT_SYNC_INTERVAL_MINUTES = 60;
export interface IcsSubscriptionConfig {
id?: string;
name?: string;
calendarId?: string;
urlSecretName?: string;
timezone?: string;
enabled?: boolean;
}
export interface SetupIcsOptions {
/** Initial VEVENTs to serve from the mock server. Default: empty feed. */
initial?: readonly VEventInput[];
/** Override any subscription field; defaults serve a single enabled UTC sub. */
subscription?: IcsSubscriptionConfig;
}
export interface IcsSubscriptionHandle {
readonly server: IcsServer;
/** Replace the served feed body with a fresh VEVENT set. */
setRemoteEvents(events: readonly VEventInput[]): void;
/** Serve an arbitrary body — used by malformed-input error-path specs. */
setRawBody(body: string): void;
/** Swap the HTTP status code (e.g., 403 / 500). */
setStatus(status: number): void;
/** Clear the recorded request log on the mock server. */
resetRequests(): void;
/** Trigger the sync command via the palette and wait for it to dismiss. */
sync(): Promise<void>;
/** Poll until the server has observed at least `minRequests` requests. */
waitForRequest(minRequests?: number): Promise<void>;
/** Snapshot of `.md` filenames under the Events dir, minus virtual-event files. */
listEventFiles(): string[];
findEventFile(summarySubstring: string): string | undefined;
/** Like `findEventFile` but throws with a helpful diff if no match exists. */
expectEventFile(summarySubstring: string): string;
readFrontmatter(summarySubstring: string): Record<string, unknown> | undefined;
expectFileCount(expected: number): Promise<void>;
close(): Promise<void>;
}
export async function setupIcsSubscription(
obsidian: BootstrappedObsidian,
options: SetupIcsOptions = {}
): Promise<IcsSubscriptionHandle> {
const config: Required<IcsSubscriptionConfig> = {
id: options.subscription?.id ?? DEFAULT_SUBSCRIPTION_ID,
name: options.subscription?.name ?? DEFAULT_SUBSCRIPTION_NAME,
calendarId: options.subscription?.calendarId ?? DEFAULT_CALENDAR_ID,
urlSecretName: options.subscription?.urlSecretName ?? DEFAULT_URL_SECRET_NAME,
timezone: options.subscription?.timezone ?? DEFAULT_TIMEZONE,
enabled: options.subscription?.enabled ?? true,
};
const server = await startIcsServer(buildIcs(options.initial ?? []));
deleteAllEventFiles(obsidian.vaultDir);
await stubSecretStorage(obsidian.page, config.urlSecretName, server.url);
await unlockPro(obsidian.page);
await addSubscription(obsidian.page, config);
return {
server,
setRemoteEvents: (events) => server.setBody(buildIcs(events)),
setRawBody: (body) => server.setBody(body),
setStatus: (status) => server.setStatus(status),
resetRequests: () => server.resetRequests(),
sync: () => triggerSync(obsidian.page),
waitForRequest: (minRequests = 1) => waitForRequest(server, minRequests),
listEventFiles: () => listEventFiles(obsidian.vaultDir),
findEventFile: (summarySubstring) => findEventFile(obsidian.vaultDir, summarySubstring),
expectEventFile(summarySubstring) {
const files = listEventFiles(obsidian.vaultDir);
const match = files.find((f) => f.includes(summarySubstring));
expect(match, `expected an event file containing "${summarySubstring}" in [${files.join(", ")}]`).toBeDefined();
return match!;
},
readFrontmatter(summarySubstring) {
const file = findEventFile(obsidian.vaultDir, summarySubstring);
if (!file) return undefined;
return readEventFrontmatter(obsidian.vaultDir, `${EVENTS_DIR}/${file}`);
},
expectFileCount: (expected) => expect.poll(() => listEventFiles(obsidian.vaultDir).length).toBe(expected),
close: () => server.close(),
};
}
function listEventFiles(vaultDir: string): string[] {
const dir = join(vaultDir, EVENTS_DIR);
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("Virtual Events"));
}
function deleteAllEventFiles(vaultDir: string): void {
const dir = join(vaultDir, EVENTS_DIR);
if (!existsSync(dir)) return;
for (const f of readdirSync(dir).filter((x) => x.endsWith(".md"))) {
unlinkSync(join(dir, f));
}
}
function findEventFile(vaultDir: string, summarySubstring: string): string | undefined {
return listEventFiles(vaultDir).find((f) => f.includes(summarySubstring));
}
// Replace `app.secretStorage.getSecret` in the renderer so the subscription
// reads our test URL without touching the OS keychain. Renderer-scoped — dies
// with the page; can't leak across runs.
async function stubSecretStorage(page: Page, secretName: string, secretValue: string): Promise<void> {
await page.evaluate(
({ name, value }) => {
const w = window as unknown as PrismaWindow;
const original = w.app.secretStorage.getSecret.bind(w.app.secretStorage);
w.app.secretStorage.getSecret = (requested: string) => (requested === name ? value : original(requested));
},
{ name: secretName, value: secretValue }
);
}
// `syncICSSubscription` re-reads from the settings store on every invocation,
// so inserting after boot is safe — no re-bootstrap needed.
async function addSubscription(page: Page, sub: Required<IcsSubscriptionConfig>): Promise<void> {
await page.evaluate(
({ pid, sub, syncIntervalMinutes }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
const store = plugin?.settingsStore;
if (!store?.updateSettings) throw new Error("settingsStore.updateSettings missing");
return store.updateSettings((current) => {
const icsSubs = (current["icsSubscriptions"] as Record<string, unknown> | undefined) ?? {};
const existing = (icsSubs["subscriptions"] as Array<Record<string, unknown>> | undefined) ?? [];
return {
...current,
icsSubscriptions: {
...icsSubs,
subscriptions: [
...existing.filter((s) => s["id"] !== sub.id),
{ ...sub, syncIntervalMinutes, createdAt: Date.now() },
],
},
};
});
},
{ pid: PLUGIN_ID, sub, syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES }
);
}
async function triggerSync(page: Page): Promise<void> {
await runCommand(page, SYNC_COMMAND);
}
// Polling the request log is more robust than polling the file system: a sync
// that fails (auth, parse error, network hiccup) still lands a request, so
// error-path specs can assert regardless of whether files appeared.
async function waitForRequest(server: IcsServer, minRequests: number): Promise<void> {
await expect.poll(() => server.requests.length).toBeGreaterThanOrEqual(minRequests);
}

View file

@ -1,38 +0,0 @@
import { readPluginData } from "@real1ty-obsidian-plugins/testing/e2e";
// Prisma persists per-calendar UI state (pageHeaderState, activeTab,
// contextMenuState, …) under `data.calendars[N]`, keyed by id. Every shared
// spec needs to read "the default calendar entry" after `settleSettings`;
// this helper centralises the `find(id === "default") ?? [0]` fallback so the
// specs can describe what they're asserting, not how to get there.
const PLUGIN_ID = "prisma-calendar";
const DEFAULT_CALENDAR_ID = "default";
type CalendarEntry<Extra extends Record<string, unknown> = Record<string, unknown>> = { id: string } & Extra;
export type { CalendarEntry };
/**
* Read `data.json`, then return the default calendar entry (by id, falling
* back to the first). The generic narrows the return type so callers get the
* plugin-specific shape they care about without casting at every call site.
*/
export function readDefaultCalendar<Extra extends Record<string, unknown>>(
vaultDir: string
): CalendarEntry<Extra> | undefined {
const data = readPluginData(vaultDir, PLUGIN_ID) as {
calendars?: Array<CalendarEntry<Extra>>;
};
const calendars = data.calendars;
return calendars?.find((c) => c.id === DEFAULT_CALENDAR_ID) ?? calendars?.[0];
}
export function getCalendars<Extra extends Record<string, unknown> = Record<string, unknown>>(
vaultDir: string
): Array<CalendarEntry<Extra>> {
const data = readPluginData(vaultDir, PLUGIN_ID) as {
calendars?: Array<CalendarEntry<Extra>>;
};
return data.calendars ?? [];
}

View file

@ -1,288 +0,0 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { PLUGIN_ID } from "./constants";
import { seedEvent, type SeedEventInput } from "./seed-events";
import type { PrismaPlugin, PrismaWindow } from "./window-types";
export function dataJsonPath(vaultDir: string): string {
// test fixture intentionally seeds the Obsidian config directory by path
return join(vaultDir, ".obsidian", "plugins", PLUGIN_ID, "data.json");
}
/**
* Write raw bytes into `<vault>/Events/<fileName>` without running them through
* `seedEvent`'s structured frontmatter builder. Use only when the spec needs
* an intentionally malformed file (invalid YAML, truncated blocks) all
* well-formed seeding should go through `seedEvent`.
*/
export function writeRawEventFile(vaultDir: string, fileName: string, content: string): string {
const relative = join("Events", fileName);
writeFileSync(join(vaultDir, relative), content, "utf8");
return relative;
}
/**
* Reload Obsidian's renderer and wait until prisma-calendar is fully ready
* again: plugin loaded, calendar bundles initialized, layout ready. Use this
* after mutating files on disk (data.json, event frontmatter) to observe how
* the plugin reacts to the new on-disk state from a cold boot.
*
* Repeats the bootstrap dance (`setEnable` `loadManifests` `enablePlugin`)
* because `community-plugins.json` persists the enabled set, but some Obsidian
* builds still start with `setEnable(false)` on a fresh renderer.
*/
export async function reloadAndWaitForPrisma(page: Page): Promise<void> {
// Obsidian debounces `workspace.requestSaveLayout`; if we reload before it
// fires, leaves opened via `setViewState` never reach `workspace.json` and
// the renderer reboots without them. Flush the debouncer synchronously
// before tearing the renderer down.
await page.evaluate(async () => {
await (window as unknown as PrismaWindow).app.workspace.requestSaveLayout.run();
});
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean((window as unknown as { app?: { plugins?: unknown } }).app?.plugins));
await page.evaluate(async (id) => {
const w = window as unknown as PrismaWindow;
const plugins = w.app.plugins;
try {
if (typeof plugins.setEnable === "function") await plugins.setEnable(true);
if (typeof plugins.loadManifests === "function") await plugins.loadManifests();
if (!plugins.plugins[id]) {
if (typeof plugins.enablePluginAndSave === "function") await plugins.enablePluginAndSave(id);
else if (typeof plugins.enablePlugin === "function") await plugins.enablePlugin(id);
}
} catch {
// best-effort; the waitForFunction below is the real gate
}
}, PLUGIN_ID);
await page.waitForFunction((id) => Boolean((window as unknown as PrismaWindow).app.plugins.plugins[id]), PLUGIN_ID);
// Some malformed-state tests may end up with zero bundles — that's a
// valid assertion target rather than a harness failure.
await page
.waitForFunction((id) => {
const plugin = (window as unknown as PrismaWindow).app.plugins.plugins[id] as PrismaPlugin | undefined;
return Boolean(plugin?.calendarBundles?.length);
}, PLUGIN_ID)
.catch(() => {});
await page.evaluate(async (id) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
if (plugin && typeof plugin.ensureCalendarBundlesReady === "function") {
await plugin.ensureCalendarBundlesReady();
}
}, PLUGIN_ID);
}
/** Disable the plugin via the community-plugins runtime API. */
export async function disablePrisma(page: Page): Promise<void> {
await page.evaluate(async (id) => {
const w = window as unknown as PrismaWindow;
const plugins = w.app.plugins;
if (typeof plugins.disablePluginAndSave === "function") await plugins.disablePluginAndSave(id);
else if (typeof plugins.disablePlugin === "function") await plugins.disablePlugin(id);
else throw new Error("no disable fn");
}, PLUGIN_ID);
}
/** Re-enable the plugin via the community-plugins runtime API and wait until bundles are back. */
export async function enablePrisma(page: Page): Promise<void> {
await page.evaluate(async (id) => {
const w = window as unknown as PrismaWindow;
const plugins = w.app.plugins;
if (typeof plugins.enablePluginAndSave === "function") await plugins.enablePluginAndSave(id);
else if (typeof plugins.enablePlugin === "function") await plugins.enablePlugin(id);
else throw new Error("no enable fn");
}, PLUGIN_ID);
await page.waitForFunction(
(id) => {
const plugin = (window as unknown as PrismaWindow).app.plugins.plugins[id] as PrismaPlugin | undefined;
return Boolean(plugin?.calendarBundles?.length);
},
PLUGIN_ID,
{ timeout: 30_000 }
);
}
/**
* Shallow-merge `patch` into the default calendar entry (index 0 by default).
* Every top-level key in `patch` overrides the corresponding key on the
* calendar record. Use for toggling boolean/string/dropdown fields before a
* reload-barrier assertion see reload-preserves-settings.spec.ts.
*/
export async function patchDefaultCalendar(
page: Page,
patch: Record<string, unknown>,
calendarIndex = 0
): Promise<void> {
await page.evaluate(
async ({ id, p, i }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
if (!plugin?.settingsStore?.updateSettings) throw new Error("settingsStore.updateSettings missing");
await plugin.settingsStore.updateSettings((current) => {
const calendars = (current["calendars"] as Array<Record<string, unknown>> | undefined) ?? [];
if (!calendars[i]) throw new Error(`no calendar at index ${i}`);
const next = [...calendars];
next[i] = { ...next[i], ...p };
return { ...current, calendars: next };
});
if (typeof plugin.ensureCalendarBundlesReady === "function") {
await plugin.ensureCalendarBundlesReady();
}
},
{ id: PLUGIN_ID, p: patch, i: calendarIndex }
);
}
/**
* Append a calendar record to the settings store, cloning the first calendar's
* fields as a base so defaults are carried over. `overrides` wins.
*
* Default behaviour runs `refreshCalendarBundles` so the new calendar gets a
* live bundle `ensureCalendarBundlesReady` only initializes existing bundles,
* not new ones.
*
* Pass `{ preserveActiveView: true }` to spin up ONLY the new bundle via
* `plugin.addCalendarBundle`. The primary's mounted view and painted events
* stay untouched required when a spec has already rendered a tile in the
* primary calendar and is about to interact with it (e.g. right-click + move).
*/
export async function addCalendar(
page: Page,
overrides: Record<string, unknown>,
options: { preserveActiveView?: boolean } = {}
): Promise<void> {
const preserveActiveView = options.preserveActiveView ?? false;
await page.evaluate(
async ({ id, o, preserve }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
if (!plugin?.settingsStore?.updateSettings) throw new Error("settingsStore.updateSettings missing");
const targetId = String(o["id"]);
await plugin.settingsStore.updateSettings((current) => {
const calendars = (current["calendars"] as Array<Record<string, unknown>> | undefined) ?? [];
if (calendars.some((c) => c["id"] === targetId)) return current;
const base = calendars[0] ?? {};
return { ...current, calendars: [...calendars, { ...base, ...o }] };
});
if (preserve) {
if (typeof plugin.addCalendarBundle !== "function") throw new Error("plugin.addCalendarBundle missing");
await plugin.addCalendarBundle(targetId);
} else if (typeof plugin.refreshCalendarBundles === "function") {
await plugin.refreshCalendarBundles();
} else if (typeof plugin.ensureCalendarBundlesReady === "function") {
await plugin.ensureCalendarBundlesReady();
}
},
{ id: PLUGIN_ID, o: overrides, preserve: preserveActiveView }
);
}
/**
* Read the currently-active view tab id from the live settings store.
* Returns `null` when the settings haven't been written yet (e.g. first boot
* before any tab switch).
*/
export async function readActiveTab(page: Page): Promise<string | null> {
return page.evaluate((id) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
const settings = plugin?.settingsStore?.currentSettings;
if (!settings) return null;
const activeTab = settings["activeTab"] as { visibleTabIds?: string[]; activeTabIndex?: number } | undefined;
if (!activeTab?.visibleTabIds || activeTab.activeTabIndex === undefined) return null;
return activeTab.visibleTabIds[activeTab.activeTabIndex] ?? null;
}, PLUGIN_ID);
}
/** Read the Nth (default: 0) calendar entry from the live settings store. */
export async function readLiveCalendar(page: Page, index = 0): Promise<Record<string, unknown> | null> {
return page.evaluate(
({ id, i }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
const calendars = plugin?.settingsStore?.currentSettings?.["calendars"] as
| Array<Record<string, unknown>>
| undefined;
return calendars?.[i] ?? null;
},
{ id: PLUGIN_ID, i: index }
);
}
export async function activateCalendar(page: Page, calendarId: string): Promise<void> {
// `refreshCalendarBundles` resolves the promise before the new bundle's
// `initialize()` runs; the bundle array updates synchronously but the
// `activateCalendarView` path isn't wired up until init completes. Poll
// rather than relying on the prior await to finish everything.
await page.waitForFunction(
({ id, pid }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
return Boolean(plugin?.calendarBundles?.some((b) => b.calendarId === id));
},
{ id: calendarId, pid: PLUGIN_ID }
);
await page.evaluate(
async ({ id, pid }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
const bundle = plugin?.calendarBundles?.find((b) => b.calendarId === id);
if (!bundle?.activateCalendarView) throw new Error(`no bundle for ${id}`);
await bundle.activateCalendarView();
},
{ id: calendarId, pid: PLUGIN_ID }
);
}
export async function countLeavesOfType(page: Page, viewType: string): Promise<number> {
return page.evaluate((t) => {
const w = window as unknown as PrismaWindow;
return w.app.workspace.getLeavesOfType(t).length;
}, viewType);
}
/**
* Return vault paths whose frontmatter has `ZettelID` matching `zettelId`.
* Compares after `String()` coercion so an unquoted-numeric ZettelID that
* YAML parsed as a number still matches a string-shaped query.
*/
export async function resolveByZettelId(page: Page, zettelId: string): Promise<string[]> {
return page.evaluate((id) => {
const w = window as unknown as PrismaWindow;
const winners: string[] = [];
for (const f of w.app.vault.getFiles()) {
// frontmatter is untyped in Obsidian API
const fm = w.app.metadataCache.getFileCache(f)?.frontmatter;
// frontmatter fields are dynamically keyed
if (fm && String(fm["ZettelID"]) === id) winners.push(f.path);
}
return winners;
}, zettelId);
}
/** For each `endsWith` needle, return whether any vault path ends with it. */
export async function vaultHasFilesEndingWith(page: Page, needles: readonly string[]): Promise<boolean[]> {
return page.evaluate((ends) => {
const w = window as unknown as PrismaWindow;
const haystack = w.app.vault.getFiles().map((f) => f.path);
return ends.map((n) => haystack.some((h) => h.endsWith(n)));
}, needles);
}
/** Seed a batch of events on disk via `seedEvent` and return the relative paths. */
export function seedEventFiles(vaultDir: string, events: readonly SeedEventInput[]): string[] {
return events.map((e) => seedEvent(vaultDir, e));
}
export { PLUGIN_ID };

View file

@ -1,195 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { expect, type Page } from "@playwright/test";
import { PLUGIN_ID } from "./constants";
import type { PrismaPlugin, PrismaWindow } from "./window-types";
// Disk-level event seeding + runtime introspection. These utilities bypass
// the create-event modal so specs that focus on integrations (ICS export,
// filter presets) or edge cases (DST, year boundary, 500-event stress) can
// arrive at a known event set without clicking through the UI. The modal
// flow itself is already covered by the specs under `specs/events/`.
export interface SeedEventInput {
title: string;
startDate?: string;
endDate?: string;
date?: string;
allDay?: boolean;
category?: string;
location?: string;
participants?: string[];
rrule?: string;
rruleSpec?: string;
extra?: Record<string, unknown>;
body?: string;
subdir?: string;
}
function frontmatterValue(value: unknown): string {
if (value === null || value === undefined) return '""';
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
return `\n${value.map((v) => ` - ${String(v)}`).join("\n")}`;
}
if (typeof value === "string") {
return value.includes(":") || value.includes("#")
? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
: value;
}
return String(value);
}
export function seedEvent(vaultDir: string, event: SeedEventInput): string {
const subdir = event.subdir ?? "Events";
const filename = `${event.title.replace(/[/\\:*?"<>|]/g, "-")}.md`;
const relative = join(subdir, filename);
const absolute = join(vaultDir, relative);
mkdirSync(dirname(absolute), { recursive: true });
writeFileSync(absolute, buildEventMarkdown(event), "utf8");
return relative;
}
/**
* Serialize a `SeedEventInput` into the on-disk markdown an event file needs
* frontmatter (`Start Date`, `End Date`, ) plus body. Extracted from
* `seedEvent` so the deterministic stress vault generator produces byte-for-byte
* the same shape without duplicating the frontmatter contract.
*/
export function buildEventMarkdown(event: SeedEventInput): string {
const fm: Record<string, unknown> = {};
if (event.startDate) fm["Start Date"] = event.startDate;
if (event.endDate) fm["End Date"] = event.endDate;
if (event.date) fm["Date"] = event.date;
if (event.allDay) fm["All Day"] = true;
if (event.category) fm["Category"] = event.category;
if (event.location) fm["Location"] = event.location;
if (event.participants?.length) fm["Participants"] = event.participants;
if (event.rrule) fm["RRule"] = event.rrule;
if (event.rruleSpec) fm["RRuleSpec"] = event.rruleSpec;
if (event.extra) {
for (const [k, v] of Object.entries(event.extra)) fm[k] = v;
}
const fmLines = Object.entries(fm)
.map(([k, v]) => `${k}: ${frontmatterValue(v)}`)
.join("\n");
const body = event.body ?? `# ${event.title}\n`;
return `---\n${fmLines}\n---\n\n${body}`;
}
export function seedEvents(vaultDir: string, events: readonly SeedEventInput[]): string[] {
return events.map((e) => seedEvent(vaultDir, e));
}
/**
* Mutate a single frontmatter field on an already-indexed vault file through
* Obsidian's `processFrontMatter`. This is the ONLY write path that atomically
* updates bytes AND the metadata cache in one step.
*
* Do NOT replace this with `writeFileSync` + a refresh/nudge. That older
* pattern raced: if the vault watcher hadn't ingested the raw write yet, the
* plugin's own background writes (or a "nudge" `processFrontMatter` call)
* would read stale cached YAML and rewrite the file from the stale cache
* silently clobbering the change. Use raw `writeFileSync` only for SEEDING
* (before the plugin indexes the file) where cache can't be stale.
*/
export async function setFrontmatterField(
page: Page,
relativePath: string,
field: string,
value: unknown
): Promise<void> {
await page.evaluate(
async ({ path, key, val }) => {
const w = window as unknown as PrismaWindow;
const file = w.app.vault.getAbstractFileByPath(path);
if (!file) throw new Error(`setFrontmatterField: no file at ${path}`);
await w.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = val;
});
},
{ path: relativePath, key: field, val: value }
);
}
/** Read the default-calendar bundle's live settings snapshot. */
export async function readCalendarSettings(page: Page): Promise<Record<string, unknown>> {
return page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0];
if (!bundle) throw new Error("No calendar bundle");
return bundle.settingsStore.currentSettings;
}, PLUGIN_ID);
}
/** Shallow-merge a settings patch into the default-calendar bundle. */
export async function updateCalendarSettings(page: Page, patch: Record<string, unknown>): Promise<void> {
await page.evaluate(
async ({ p, pid }) => {
const w = window as unknown as PrismaWindow;
const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0];
if (!bundle) throw new Error("No calendar bundle");
await bundle.settingsStore.updateSettings((current) => ({ ...current, ...p }));
},
{ p: patch, pid: PLUGIN_ID }
);
}
export async function waitForEventCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await expect
.poll(() => getEventCount(page), {
timeout,
message: `indexer never reached ${count} events`,
})
.toBe(count);
}
/** Count events the plugin currently sees via the event store. */
export async function getEventCount(page: Page): Promise<number> {
return page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0];
if (!bundle) throw new Error("No calendar bundle");
return bundle.eventStore.getAllEvents().length;
}, PLUGIN_ID);
}
export async function waitForUntrackedEventCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await expect
.poll(() => getUntrackedEventCount(page), {
timeout,
message: `untracked store never reached ${count} events`,
})
.toBe(count);
}
/**
* Count dateless events the plugin tracks in the untracked store. Dateless
* events (no Start/End/Date) never land in `eventStore` the indexer routes
* them here so this is the readiness signal when seeding an untracked event.
*/
export async function getUntrackedEventCount(page: Page): Promise<number> {
return page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
const bundle = (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.[0];
if (!bundle) throw new Error("No calendar bundle");
return bundle.untrackedEventStore.getUntrackedEvents().length;
}, PLUGIN_ID);
}
export async function waitForCalendarCount(page: Page, count: number): Promise<void> {
await expect
.poll(
() =>
page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
return (w.app.plugins.plugins[pid] as PrismaPlugin | undefined)?.calendarBundles?.length ?? 0;
}, PLUGIN_ID),
{ message: `waiting for ${count} calendar bundles` }
)
.toBe(count);
}

View file

@ -1,39 +0,0 @@
// Centralised locators. React migration will churn the DOM; keeping every
// selector in one file limits the blast radius of those churns.
//
// Prefer role-based selectors where possible. Fall back to Obsidian's own
// class names (workspace-*, modal-*, setting-*) — they're stable across plugin
// versions.
export const SELECTORS = {
workspace: ".workspace",
workspaceLeaf: ".workspace-leaf",
commandPalette: {
prompt: ".prompt",
input: ".prompt-input",
suggestions: ".suggestion-item",
},
settings: {
modal: ".modal-container .mod-settings",
sidebarTab: ".vertical-tab-header-group .vertical-tab-nav-item",
prismaTabName: "Prisma Calendar",
content: ".vertical-tab-content",
heading: ".setting-item-heading",
toggle: ".checkbox-container",
},
modal: {
root: ".modal",
title: ".modal-title",
closeButton: ".modal-close-button",
},
notice: ".notice",
} as const;
export const PRISMA_COMMANDS = {
createEvent: "Prisma Calendar: Create event",
openCalendar: "Prisma Calendar: Open calendar",
} as const;

View file

@ -1,258 +0,0 @@
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { expect, type Page } from "@playwright/test";
import { ACTIVE_CALENDAR_LEAF, PLUGIN_ID } from "./constants";
import { seedEvent, updateCalendarSettings, type SeedEventInput } from "./seed-events";
import type { PrismaPlugin, PrismaWindow } from "./window-types";
// Helpers for high-volume / reactivity stress specs. The premise: the calendar
// must be *eventually consistent* — after any batch mutation (move, clone,
// skip, delete, undo/redo) the DOM-rendered event set must converge to match
// the on-disk event set, regardless of how many files changed at once. A
// "partial refresh" (some events move, some stay stale) is a prod-breaking
// bug. Every helper here polls on both the indexer state and the DOM so
// assertions catch divergence rather than race against it.
const EVENT_IN_LEAF = `${ACTIVE_CALENDAR_LEAF} [data-testid="prisma-cal-event"]`;
export interface BulkSeedOptions {
/** Prefix for auto-generated titles (default "Bulk"). */
prefix?: string;
/** First event's day offset from today (0 = today). Default 0. */
startDayOffset?: number;
/** Number of distinct days the events span. Events loop around via modulo. Default 7. */
spreadDays?: number;
/** First slot's hour-of-day (default 9). Subsequent slots stack +1h within each day. */
startHour?: number;
/** Optional subdir under vaultDir (default "Events"). */
subdir?: string;
}
/**
* Days-from-today offset pointing at the Sunday that starts the current week.
* FullCalendar's default `firstDay` is Sunday. Seeds anchored here land events
* inside the current `listWeek` / `timeGridWeek` window regardless of what day
* of the week the test happens to run on.
*/
export function currentWeekStartOffset(): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
return -today.getDay();
}
/**
* Bump per-slot stacking / per-day caps to the max allowed values so stress
* tests can render 10+ events in one time cell without FullCalendar
* collapsing them into a "+more" link. Does NOT affect correctness only
* DOM visibility so specs can count `[data-testid="prisma-cal-event"]`
* nodes directly.
*/
export async function raiseRenderCapsForStress(page: Page): Promise<void> {
await updateCalendarSettings(page, {
eventMaxStack: 10,
desktopMaxEventsPerDay: 0,
});
}
/**
* Race-safe event-file count. The shared `listEventFiles` helper walks the
* Events/ tree with a `readdirSync` + per-entry `statSync`; under bulk undo
* or batch delete the plugin deletes files in quick succession, and a stat
* on an entry that vanished between readdir and stat throws ENOENT. This
* wrapper tolerates that race by retrying once after a short pause
* callers are already polling via `expect.poll` so an occasional retry is
* cheap.
*/
export function safeEventFileCount(vaultDir: string, subdir = "Events"): number {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return countMdFilesRecursive(join(vaultDir, subdir));
} catch (err) {
if (!isEnoent(err)) throw err;
// tight retry — the race window is sub-ms; poll caller provides the outer timing
}
}
// Final attempt — let the error propagate if the vault is truly broken.
return countMdFilesRecursive(join(vaultDir, subdir));
}
function countMdFilesRecursive(dir: string): number {
let total = 0;
let entries: string[];
try {
entries = readdirSync(dir);
} catch (err) {
if (isEnoent(err)) return 0;
throw err;
}
for (const entry of entries) {
const full = join(dir, entry);
let stat;
try {
stat = statSync(full);
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
if (stat.isDirectory()) {
total += countMdFilesRecursive(full);
} else if (stat.isFile() && entry.endsWith(".md") && entry !== "Virtual Events.md") {
total += 1;
}
}
return total;
}
function isEnoent(err: unknown): boolean {
return typeof err === "object" && err !== null && "code" in err && (err as { code: unknown }).code === "ENOENT";
}
/**
* Seed `count` timed events to disk, spread across `spreadDays` consecutive
* days. Titles are deterministic and zero-padded (`<prefix> 001` `<prefix> N`)
* so lexical ordering matches numeric ordering handy when per-title
* assertions loop a slice of the batch.
*
* Writes bypass the create-event modal: this is a disk-level seed, indexed the
* same way any on-disk event file is. Pair with `waitForIndexerToReach` to
* gate the test on indexer convergence before the first mutation.
*/
export function seedBulkEvents(vaultDir: string, count: number, options: BulkSeedOptions = {}): string[] {
const prefix = options.prefix ?? "Bulk";
const spread = Math.max(1, options.spreadDays ?? 7);
const startOffset = options.startDayOffset ?? 0;
const baseHour = options.startHour ?? 9;
const subdir = options.subdir ?? "Events";
const width = Math.max(3, String(count).length);
const paths: string[] = [];
const base = new Date();
base.setHours(0, 0, 0, 0);
for (let i = 0; i < count; i++) {
const dayIndex = i % spread;
const slotInDay = Math.floor(i / spread);
// 8 slots per day cap keeps events within 09:0017:00 and avoids
// crossing midnight when spread is small relative to count.
const hour = baseHour + (slotInDay % 8);
const day = new Date(base);
day.setDate(base.getDate() + startOffset + dayIndex);
const ymd = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`;
const title = `${prefix} ${String(i + 1).padStart(width, "0")}`;
const input: SeedEventInput = {
title,
startDate: `${ymd}T${String(hour).padStart(2, "0")}:00`,
endDate: `${ymd}T${String(hour + 1).padStart(2, "0")}:00`,
subdir,
};
paths.push(seedEvent(vaultDir, input));
}
return paths;
}
/**
* Count unique events rendered in the active calendar leaf. Dedups FullCalendar's
* overflow / multi-day clones by `data-event-file-path`: month view injects
* extra DOM nodes with the same path for "+N more" popovers and multi-day
* spans, inflating the raw `[data-testid="prisma-cal-event"]` count.
*
* In week / day / list views this matches the intuitive count (one DOM node
* per event). In month view it counts distinct events, ignoring overflow
* clone-rendering.
*/
export async function uniqueVisibleEventCount(page: Page): Promise<number> {
return page.evaluate((selector) => {
const elements = Array.from(document.querySelectorAll(selector));
const paths = new Set<string>();
let untitledWithoutPath = 0;
for (const el of elements) {
const fp = (el as HTMLElement).getAttribute("data-event-file-path");
if (fp) {
paths.add(fp);
} else {
untitledWithoutPath += 1;
}
}
return paths.size + untitledWithoutPath;
}, EVENT_IN_LEAF);
}
/** Count events the plugin's indexer currently holds for the default bundle. */
export async function indexerEventCount(page: Page): Promise<number> {
return page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
const bundle = plugin?.calendarBundles?.[0];
return bundle?.eventStore.getAllEvents().length ?? 0;
}, PLUGIN_ID);
}
/**
* Poll until the indexer reaches exactly `expected` events. Large seeded vaults
* take noticeably longer to ingest than the 500ms refresh window bakes in, so
* the tolerance here is generous by default.
*/
export async function waitForIndexerToReach(page: Page, expected: number, timeoutMs?: number): Promise<void> {
await expect
.poll(() => indexerEventCount(page), {
message: `indexer never reached ${expected} events`,
...(timeoutMs ? { timeout: timeoutMs } : {}),
})
.toBe(expected);
}
/**
* Poll until the indexer reaches **at least** `atLeast` events AND the count holds
* steady across two consecutive reads. For recurrence scenarios the source ingest
* is followed by physical-instance materialization that pushes the count *past* the
* source total, so the exact-count gate (`waitForIndexerToReach`) can't settle
* this waits for "all sources in + materialization quiesced" instead.
*/
export async function waitForIndexerToSettle(page: Page, atLeast: number): Promise<void> {
let previous = -1;
await expect
.poll(
async () => {
const count = await indexerEventCount(page);
const settled = count >= atLeast && count === previous;
previous = count;
return settled;
},
{ message: `indexer never settled at >= ${atLeast} events` }
)
.toBe(true);
}
/**
* Assert the rendered DOM converges to exactly `expected` unique events in
* the active calendar leaf. Uses `expect.poll` so a momentarily stale render
* (diff in progress, RAF not yet fired) doesn't fail the spec but a
* persistently stale render does. Prefer this over `visibleEventCount`
* followed by `toBe`, which races the DOM and flakes.
*/
export async function expectUniqueVisibleEventCount(page: Page, expected: number): Promise<void> {
await expect
.poll(() => uniqueVisibleEventCount(page), {
message: `calendar DOM never converged to ${expected} unique visible events`,
})
.toBe(expected);
}
/**
* Joint assertion: indexer AND DOM converge on the expected counts. The whole
* point of the stress suite if either diverges we've found a reactivity
* regression. `indexer` is always the source of truth for "how many events
* the plugin thinks exist". `visible` is the DOM-rendered subset within the
* currently-visible view range; for an all-today seed in week/day/list view
* the two should match, for events scattered across months `visible` < `indexer`.
*/
export async function expectCalendarConsistent(
page: Page,
options: { indexer: number; visible: number }
): Promise<void> {
await waitForIndexerToReach(page, options.indexer);
await expectUniqueVisibleEventCount(page, options.visible);
}

View file

@ -1,414 +0,0 @@
// Single source of truth for every `data-testid` stamped by plugin source.
// Specs import `TID.*` instead of splicing `prisma-*` strings by hand. When
// the plugin renames a test-id, updating the matching entry here recompiles
// every consumer — TypeScript catches the breakage at build time, not as a
// flaky runtime selector miss.
//
// Key types are string-literal unions. New ids get added here and the plugin
// source simultaneously; if a spec tries to address an unknown id it fails
// to compile.
export type ContextMenuItemKey =
| "enlarge"
| "preview"
| "editEvent"
| "assignCategories"
| "assignPrerequisites"
| "duplicateEvent"
| "moveBy"
| "moveToCalendar"
| "markDone"
| "moveToNextWeek"
| "cloneToNextWeek"
| "moveToPreviousWeek"
| "cloneToPreviousWeek"
| "deleteEvent"
| "skipEvent"
| "openFile"
| "openFileNewWindow"
| "viewNameSeries"
| "viewCategorySeries"
| "viewRecurringSeries";
export type EventControlKey =
| "title"
| "all-day"
| "start"
| "end"
| "date"
| "duration"
| "location"
| "icon"
| "skip"
| "markAsDone"
| "breakMinutes"
| "notify-before"
| "rrule"
| "rrule-type"
| "custom-freq"
| "custom-interval"
| "rrule-until"
| "future-instances-count"
| "generate-past-events"
| "preset"
| "participants"
| "virtual";
export type EventFieldKey = "title" | "categories" | "prerequisites" | "participants";
export type EventBtnKey =
| "save"
| "cancel"
| "minimize"
| "save-preset"
| "assign-categories"
| "assign-prerequisites"
| "add-participant"
| "add-custom-prop-other"
| "add-custom-prop-display"
| "remove-custom-prop-other"
| "remove-custom-prop-display";
export type ToolbarKey =
| "create"
| "view-year"
| "view-month"
| "view-week"
| "view-day"
| "view-list"
| "next"
| "prev"
| "today"
| "goto-now"
| "batch-select"
| "batch-exit";
/** Calendar "view mode" for FullCalendar — picks the toolbar view-<mode> button. */
export type ViewMode = "year" | "month" | "week" | "day" | "list";
/**
* Page-header toolbar action ids. Every entry in `buildPageHeaderActions()`
* stamps a `data-testid="prisma-toolbar-<id>"` on its button. Specs reach for
* these via `calendar.clickToolbar(id)`.
*/
export type ToolbarActionKey =
| "create-event"
| "create-untracked"
| "go-to-today"
| "scroll-to-now"
| "navigate-back"
| "navigate-forward"
| "global-search"
| "toggle-batch"
| "daily-stats"
| "weekly-stats"
| "monthly-stats"
| "alltime-stats"
| "toggle-prerequisites"
| "refresh"
| "show-recurring";
/**
* Analytics view-tab ids stamped as `data-testid="prisma-view-tab-<id>"`.
* Leaf tabs (calendar / timeline / heatmap / gantt / ) activate directly;
* group tabs (`dashboard`) open a dropdown whose children use the same
* testid pattern see `switchToGroupChild`.
*/
export type ViewTabKey =
| "calendar"
| "timeline"
| "heatmap"
| "gantt"
| "dashboard"
| "dashboard-by-name"
| "dashboard-by-category"
| "dashboard-recurring"
| "daily-stats"
| "dual-daily"
| "heatmap-monthly-stats"
| "monthly-calendar-stats";
export type BatchBtnKey =
| "select-all"
| "clear"
| "duplicate"
| "move-by"
| "mark-done"
| "mark-not-done"
| "categories"
| "frontmatter"
| "clone-next"
| "clone-prev"
| "move-next"
| "move-prev"
| "open-all"
| "skip"
| "make-virtual"
| "make-real"
| "delete";
export const EVENT_BLOCK_TID = "prisma-cal-event";
export const BATCH_COUNTER_TID = "prisma-cal-batch-counter";
export const BATCH_CONFIRM_TID = "prisma-batch-confirm-submit";
// ── Shared-library testids ─────────────────────────────────────────────────
// The shared/ package stamps its own testids, distinct from the Prisma-Calendar
// family above. Every `prisma-` here was added by the shared component, not
// by plugin code — renaming any of them would require a shared-library update
// and an audit across every plugin that consumes it.
/** Page header manage-actions modal — sub-parts keyed off the host `prisma-page-header-*` root. */
export const PAGE_HEADER_MANAGE_BTN = "prisma-page-header-manage";
export const ACTION_MANAGER_MODAL = "prisma-action-manager-modal";
export const ACTION_MANAGER_RESET_BTN = "prisma-action-manager-reset";
/**
* Page-header overflow trigger (the "More actions" button) + the menu it opens.
* When the responsive header trims actions off the bar they're reachable here.
* Menu items are the shared `ContextMenu`'s `prisma-ctx-item-<action>` entries.
*/
export const PAGE_HEADER_OVERFLOW_TID = "prisma-page-header-overflow";
export const PAGE_HEADER_OVERFLOW_MENU_TID = "prisma-ctx-menu";
export const overflowMenuItem = (key: ToolbarActionKey): string => `prisma-ctx-item-${key}`;
/** Tabbed-container tab-manager modal parts. */
export const TABBED_CONTAINER_MANAGE_BTN = "prisma-tabbed-container-manage";
export const TAB_MANAGER_MODAL = "prisma-tab-manager-modal";
export const TAB_MANAGER_RESET_BTN = "prisma-tab-manager-reset";
/** Context-menu item-manager modal (shown via the menu's "Manage items…" entry). */
export const ITEM_MANAGER_MODAL = "prisma-item-manager-modal";
export const ITEM_MANAGER_RESET_BTN = "prisma-item-manager-reset";
/** Shared reset-to-defaults confirmation modal testIdPrefix (matches `ResetToDefaultsButton`). */
export const RESET_CONFIRMATION_TID_PREFIX = "prisma-reset-to-defaults-";
/** Generic assignment picker (used by Assign Categories / Assign Prerequisites). */
export const ASSIGN_MODAL_ROOT = ".prisma-assignment-modal";
/** Shared visual icon picker modal — opened when editing an icon in any manager. */
export const ICON_PICKER_GRID_TID = "shared-icon-picker-grid";
export const ICON_PICKER_SEARCH_TID = "shared-icon-picker-search";
export const ICON_PICKER_NO_ICON_TID = "shared-icon-picker-no-icon";
/** Plugin-agnostic shared confirmation modal (unprefixed by design). */
export const CONFIRMATION_MODAL_TID = "confirmation-modal-container";
export const CONFIRMATION_MODAL_CONFIRM_TID = "confirmation-modal-confirm";
export const CONFIRMATION_MODAL_CANCEL_TID = "confirmation-modal-cancel";
/** Plugin-agnostic shared rename modal (unprefixed by design). */
export const RENAME_MODAL_TID = "rename-modal-container";
export const RENAME_INPUT_TID = "rename-input";
export const RENAME_SUBMIT_TID = "rename-submit";
export const RENAME_CANCEL_TID = "rename-cancel";
/** Onboarding tour (shared-react react-joyride engine). The popover renders into
* a body-level portal, so these are addressed off `page`, not the calendar root. */
export const TOUR_TOOLTIP_TID = "prisma-tour-tooltip";
export const TOUR_NEXT_TID = "prisma-tour-next";
export const TOUR_BACK_TID = "prisma-tour-back";
export const TOUR_SKIP_TID = "prisma-tour-skip";
export const TOUR_CLOSE_TID = "prisma-tour-close";
export const TOUR_PROGRESS_TID = "prisma-tour-progress";
/** Shared progress modal used by ICS import and other batched flows. */
export const PROGRESS_MODAL_TID = "prisma-progress-modal";
export const PROGRESS_STATUS_TID = "prisma-progress-status";
export const PROGRESS_BAR_TID = "prisma-progress-bar";
export const PROGRESS_DETAILS_TID = "prisma-progress-details";
/**
* Row-ID-scoped shared testids. Each function returns the full testid for a
* specific row inside its manager/component. Using these instead of inlining
* template strings means a shared-library rename only needs one edit here.
*/
/** Prefix constants for shared-library row testids — useful for "list every row" queries. */
export const SHARED_ROW_PREFIX = {
actionRow: "prisma-action-manager-row-",
actionUp: "prisma-action-manager-up-",
actionToggle: "prisma-action-manager-toggle-",
tabManagerRow: "prisma-tab-manager-row-",
tabManagerUp: "prisma-tab-manager-up-",
tabManagerToggle: "prisma-tab-manager-toggle-",
tabManagerEdit: "prisma-tab-manager-edit-",
tabManagerIconBtn: "prisma-tab-manager-icon-btn-",
tabManagerRename: "prisma-tab-manager-rename-",
itemManagerToggle: "prisma-item-manager-toggle-",
itemManagerEdit: "prisma-item-manager-edit-",
itemManagerIconBtn: "prisma-item-manager-icon-btn-",
collapsibleHeader: "prisma-collapsible-header-",
collapsibleBody: "prisma-collapsible-body-",
collapsibleToggle: "prisma-collapsible-toggle-",
pageHeaderToolbar: "prisma-toolbar-",
viewTab: "prisma-view-tab-",
} as const;
export const sharedTID = {
actionRow: (id: string): string => `${SHARED_ROW_PREFIX.actionRow}${id}`,
actionUp: (id: string): string => `${SHARED_ROW_PREFIX.actionUp}${id}`,
actionToggle: (id: string): string => `${SHARED_ROW_PREFIX.actionToggle}${id}`,
tabManagerRow: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerRow}${id}`,
tabManagerUp: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerUp}${id}`,
tabManagerToggle: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerToggle}${id}`,
tabManagerEdit: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerEdit}${id}`,
tabManagerIconBtn: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerIconBtn}${id}`,
tabManagerRename: (id: string): string => `${SHARED_ROW_PREFIX.tabManagerRename}${id}`,
itemManagerToggle: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerToggle}${id}`,
itemManagerEdit: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerEdit}${id}`,
itemManagerIconBtn: (id: string): string => `${SHARED_ROW_PREFIX.itemManagerIconBtn}${id}`,
collapsibleHeader: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleHeader}${id}`,
collapsibleBody: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleBody}${id}`,
collapsibleToggle: (id: string): string => `${SHARED_ROW_PREFIX.collapsibleToggle}${id}`,
assignSearch: (): string => "prisma-assign-search",
assignItem: (): string => "prisma-assign-item",
assignCreateNew: (): string => "prisma-assign-create-new",
assignSubmit: (): string => "prisma-assign-submit",
} as const;
// ── Plugin-specific integration + filter + stopwatch + ICS testids ──────────
// These aren't "shared" — they live in Prisma source — but they're referenced
// outside the existing TID families, so collect them here so specs don't
// scatter raw literals across suites.
export const FILTER_SEARCH_TID = "prisma-filter-search";
export const FILTER_EXPRESSION_TID = "prisma-filter-expression";
export const FILTER_PRESET_TID = "prisma-filter-preset";
export const STOPWATCH_TIME_TID = "prisma-stopwatch-time";
/** Stopwatch action buttons (start/continue/pause/stop/resume). */
export type StopwatchBtnKey = "start" | "continue" | "pause" | "stop" | "resume";
export const stopwatchBtn = (key: StopwatchBtnKey): string => `prisma-stopwatch-btn-${key}`;
/** Slug used by the stopwatch's CollapsibleSection — passed to `collapsibleSection(page, ...)`. */
export const STOPWATCH_COLLAPSIBLE_SLUG = "time-tracker";
export const ICS_EXPORT_SUBMIT_TID = "prisma-ics-export-submit";
export const ICS_IMPORT_FILE_TID = "prisma-ics-import-file";
export const ICS_IMPORT_SUBMIT_TID = "prisma-ics-import-submit";
// ── Categories settings ────────────────────────────────────────────────────
export const CATEGORY_ROW_TID = "prisma-category-settings-item";
export const CATEGORY_RENAME_BTN_TID = "prisma-category-settings-rename-button";
export const CATEGORY_DELETE_BTN_TID = "prisma-category-settings-delete-button";
export const CATEGORY_COUNT_CLASS = "prisma-category-settings-count";
/** Test-id prefixes passed to shared rename / confirmation modals from the categories tab. */
export const CATEGORY_RENAME_PREFIX = "prisma-category-";
export const CATEGORY_DELETE_PREFIX = "prisma-category-delete-";
export const CATEGORY_INCLUDE_UNTRACKED_TOGGLE_TID = "prisma-category-include-untracked-toggle";
export const UNTRACKED_BUTTON_TID = "prisma-untracked-dropdown-button";
export const UNTRACKED_DROPDOWN_TID = "prisma-untracked-dropdown";
export const UNTRACKED_ITEM_TID = "prisma-untracked-dropdown-item";
export const UNTRACKED_ITEM_START_TID = "prisma-untracked-dropdown-item-start";
export const UNTRACKED_CREATE_BTN_TID = "prisma-untracked-create";
export const UNTRACKED_SEARCH_TID = "prisma-untracked-search";
export const UNTRACKED_CREATE_MODAL_TID = "prisma-modal-untracked-event-create";
export const UNTRACKED_CREATE_NAME_TID = "prisma-untracked-event-control-name";
// ── Cross-view + analytics surfaces ────────────────────────────────────────
// CSS-class anchors and `data-testid`s that show up across more than one
// spec. Keep raw selectors out of spec bodies — when the plugin renames a
// CSS hook these constants give us one edit point.
/** Timeline tab — individual item element. CSS class because the renderer doesn't stamp a testid. */
export const TIMELINE_ITEM_CLASS = ".prisma-timeline-item";
/** Timeline tab — root container `data-testid`. */
export const TIMELINE_CONTAINER_TID = "prisma-timeline-container";
/** Heatmap tab — container + cell testids. */
export const HEATMAP_CONTAINER_TID = "prisma-heatmap-container";
export const HEATMAP_CELL_TID = "prisma-heatmap-cell";
/** Dashboard ranking cell that hosts the per-title rows. */
export const DASHBOARD_RANKING_TID = "prisma-dashboard-cell-ranking";
/** Stats / placeholder containers. */
export const STATS_EMPTY_TID = "prisma-stats-empty";
export const STATS_DATE_LABEL_TID = "prisma-stats-date-label";
/** FullCalendar toolbar title (week-of label, etc.). */
export const FC_TOOLBAR_TITLE = ".fc-toolbar-title";
/** Prisma right-click context menu wrapper used by Obsidian. */
export const OBSIDIAN_MENU_ROOT = ".menu";
/** Connection-arrow overlay drawn between connected events on the calendar. */
export const CONNECTION_ARROW_TID = "prisma-connection-arrow";
/** Banner that appears while the prereq-selection workflow is active. */
export const PREREQ_SELECTION_BANNER_CLASS = ".prisma-prereq-selection-banner";
/** Pro-gate component testid template — call `proGate(area)` for the full id. */
export const proGate = (area: string): string => `prisma-pro-gate-${area}`;
/** Notice / toast container injected by Obsidian's Notice API. */
export const NOTICE_SELECTOR = ".notice-container .notice";
/** License-status row sub-elements (settings → general tab). */
export const LICENSE_ACTIVATIONS_BADGE_CLASS = ".prisma-license-activations-badge";
/** Virtual-events code-block renderer. */
export const VIRTUAL_EVENTS_BLOCK_CLASS = ".prisma-virtual-events-block";
export const VIRTUAL_EVENTS_TABLE_CLASS = ".prisma-virtual-events-table";
/** CalDAV integrations tab surfaces. */
export const CALDAV_MODAL_TID = "prisma-modal-caldav-add";
export const CALDAV_TEST_CONNECTION_TID = "prisma-caldav-test-connection";
export const CALDAV_PRESET_TID = "prisma-caldav-preset";
export const CALDAV_ADD_ACCOUNT_BTN_CLASS = ".prisma-caldav-add-account-button";
export const caldavField = (key: string): string => `prisma-caldav-add-control-${key}`;
/** Clickable "Guide ↗" doc link on the CalDAV / ICS section headings. */
export const integrationHeadingDoc = (section: "caldav" | "ics"): string => `prisma-settings-${section}-heading-doc`;
/** Inline "Google Calendar setup guide" link inside the CalDAV / ICS sections. */
export const integrationDocsLink = (section: "caldav" | "ics"): string =>
`prisma-settings-${section}-docs-google-calendar`;
/** Move-by modal sub-elements. */
export const MOVE_BY_MODAL_TID = "prisma-modal-move-by";
export const MOVE_BY_VALUE_TID = "prisma-move-by-value";
export const MOVE_BY_INCREMENT_TID = "prisma-move-by-increment";
export const MOVE_BY_DECREMENT_TID = "prisma-move-by-decrement";
export const MOVE_BY_TOGGLE_SIGN_TID = "prisma-move-by-toggle-sign";
export const moveByUnit = (unit: string): string => `prisma-move-by-unit-${unit}`;
/** Batch-frontmatter modal sub-elements. */
export const BATCH_FM_MODAL_TID = "prisma-modal-batch-frontmatter";
export const BATCH_FM_ROW_TID = "prisma-batch-property-row";
export const BATCH_FM_KEY_TID = "prisma-batch-property-key";
export const BATCH_FM_VALUE_TID = "prisma-batch-property-value";
export const BATCH_FM_REMOVE_TID = "prisma-batch-property-remove";
export const BATCH_FM_ADD_TID = "prisma-batch-add-property";
export const BATCH_FM_DELETION_MARKED_CLASS = "prisma-batch-frontmatter-marked-deletion";
/** Generic SchemaForm submit button used by every modal-form (move-by, batch-frontmatter, …). */
export const FORM_SUBMIT_TID = "prisma-form-submit";
/** Shared event-list modal — used by Show skipped / filtered / global-search / recurring. */
export const LIST_MODAL_TID = "prisma-list-modal";
export const LIST_EMPTY_TID = "prisma-list-empty";
export const LIST_SEARCH_TID = "prisma-list-search";
/** Selector helpers — keep `data-event-title="…"` and friends out of spec bodies. */
export const eventTileByTitle = (title: string): string => `${sel(EVENT_BLOCK_TID)}[data-event-title="${title}"]`;
export const dashboardItemByTitle = (title: string): string => `[data-item-title="${title}"]`;
export const TID = {
event: {
control: (key: EventControlKey): string => `prisma-event-control-${key}`,
field: (key: EventFieldKey): string => `prisma-event-field-${key}`,
btn: (key: EventBtnKey): string => `prisma-event-btn-${key}`,
},
stopwatch: {
time: STOPWATCH_TIME_TID,
btn: stopwatchBtn,
collapsibleSlug: STOPWATCH_COLLAPSIBLE_SLUG,
},
toolbar: (key: ToolbarKey): string => `prisma-cal-toolbar-${key}`,
/** Page-header toolbar action (create-event, daily-stats, refresh, …). */
pageHeader: (key: ToolbarActionKey): string => `prisma-toolbar-${key}`,
/** Analytics view tab — leaf or group child. */
viewTab: (key: ViewTabKey): string => `prisma-view-tab-${key}`,
batch: (key: BatchBtnKey): string => `prisma-cal-batch-${key}`,
batchCounter: BATCH_COUNTER_TID,
batchConfirm: BATCH_CONFIRM_TID,
ctxMenu: (key: ContextMenuItemKey): string => `prisma-context-menu-item-${key}`,
ribbon: (calendarId: string): string => `prisma-ribbon-open-${calendarId}`,
block: EVENT_BLOCK_TID,
} as const;
/** CSS attribute selector for a testid, e.g. `sel("prisma-cal-event")` → `[data-testid="prisma-cal-event"]`. */
export function sel(testId: string): string {
return `[data-testid="${testId}"]`;
}

View file

@ -1,83 +0,0 @@
import type { Page } from "@playwright/test";
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { PRISMA_TOUR_STEP_IDS, SAMPLE_EVENT_TITLE } from "../../src/react/onboarding/tour-constants";
import { expect } from "./electron";
import { eventTileByTitle, sel, TOUR_NEXT_TID, TOUR_PROGRESS_TID, TOUR_TOOLTIP_TID } from "./testids";
/** Step ids + title come straight from src so the DSL can never drift from the tour. */
export { SAMPLE_EVENT_TITLE };
export const TOUR_STEP_IDS = PRISMA_TOUR_STEP_IDS;
export const TOUR_STEP_COUNT = TOUR_STEP_IDS.length;
export const SAMPLE_EVENT_TILE = eventTileByTitle(SAMPLE_EVENT_TITLE);
/** Click Next from the current step until the tour shows `step / TOUR_STEP_COUNT`. */
export async function advanceTourTo(page: Page, step: number): Promise<void> {
const next = page.locator(sel(TOUR_NEXT_TID));
for (let s = 2; s <= step; s++) {
await next.click();
await expect(page.locator(sel(TOUR_PROGRESS_TID))).toHaveText(`${s} / ${TOUR_STEP_COUNT}`);
}
}
// Candidate hours to drop the event on, in preference order. All are within the
// default-scrolled visible range of the week view and away from the 10:00 seed.
const DROP_TIME_CANDIDATES = ["14:00:00", "08:00:00", "15:00:00", "07:00:00", "16:00:00"];
/**
* Drag the highlighted sample event to a different time *within its own day-column*
* and return the file + the Start-Date substring it should now contain. Dragging
* vertically (same column) avoids FullCalendar's drag-to-edge week navigation that
* a horizontal cross-day drag triggers, and the target slot is picked clear of the
* floating tour tooltip. The drag only reaches the grid because the "drag-and-drop"
* step removed the blocking overlay (interaction: "page"). Caller asserts the
* resulting on-disk Start Date.
*/
export async function dragSampleEventToNewTime(
page: Page,
vaultDir: string
): Promise<{ filePath: string; expectedStart: string }> {
const tile = page.locator(SAMPLE_EVENT_TILE).first();
await expect(tile).toBeVisible();
const filePath = await tile.getAttribute("data-event-file-path");
if (!filePath) throw new Error("sample event tile is missing data-event-file-path");
const dayPart = String(readEventFrontmatter(vaultDir, filePath)["Start Date"]).slice(0, 10);
const blockBox = await tile.boundingBox();
if (!blockBox) throw new Error("could not resolve the sample event geometry");
const tooltipBox = await page.locator(sel(TOUR_TOOLTIP_TID)).boundingBox();
const pickupX = blockBox.x + blockBox.width / 2;
const pickupY = blockBox.y + 5;
const clearsTooltip = (y: number): boolean =>
!tooltipBox ||
pickupX < tooltipBox.x ||
pickupX > tooltipBox.x + tooltipBox.width ||
y < tooltipBox.y ||
y > tooltipBox.y + tooltipBox.height;
let dropY: number | undefined;
let targetTime: string | undefined;
for (const time of DROP_TIME_CANDIDATES) {
const lane = await page.locator(`.fc-timegrid-slot-lane[data-time="${time}"]`).first().boundingBox();
if (!lane) continue;
const y = lane.y + lane.height / 2;
if (clearsTooltip(y)) {
dropY = y;
targetTime = time.slice(0, 5);
break;
}
}
if (dropY === undefined || !targetTime) throw new Error("no time slot clear of the tour tooltip to drag onto");
await page.mouse.move(pickupX, pickupY);
await page.mouse.down();
await page.waitForTimeout(50);
// Jitter past FC's eventDragMinDistance so dragstart fires instead of a click.
await page.mouse.move(pickupX, pickupY + 8, { steps: 4 });
await page.mouse.move(pickupX, dropY, { steps: 25 });
await page.waitForTimeout(100);
await page.mouse.up();
return { filePath, expectedStart: `${dayPart}T${targetTime}` };
}

View file

@ -1,4 +0,0 @@
# Welcome
This is the seed vault used by Prisma-Calendar end-to-end tests. Do not edit
by hand — regenerate by updating `Prisma-Calendar/e2e/fixtures/vault-seed/`.

View file

@ -1,239 +0,0 @@
import type { Page } from "@playwright/test";
import { ACTIVE_CALENDAR_LEAF } from "./constants";
/** A typical phone portrait viewport (iPhone-class logical px). */
export const MOBILE_VIEWPORT = { width: 390, height: 844 } as const;
interface ObsidianSplit {
collapse?: () => void;
}
interface ObsidianAppWindow {
app?: { workspace?: { leftSplit?: ObsidianSplit; rightSplit?: ObsidianSplit } };
}
/**
* Put the renderer into a phone layout.
*
* Playwright's `page.setViewportSize` is a no-op against a CDP-connected
* Electron page, so we drive the renderer's reported metrics directly via
* `Emulation.setDeviceMetricsOverride`. That shrinks `window.innerWidth`, which
* makes both `shouldUseMobileLayout` (JS) and every `@media (width<=…)` rule
* (CSS) activate against the *real* plugin. Then collapse both sidebars so the
* plugin pane fills the device width the way it does on an actual phone (where
* Obsidian auto-collapses them).
*/
export async function enterMobileLayout(
page: Page,
size: { width: number; height: number } = MOBILE_VIEWPORT
): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: size.width,
height: size.height,
deviceScaleFactor: 1,
mobile: false,
screenWidth: size.width,
screenHeight: size.height,
});
await page.evaluate(() => {
const w = window as unknown as ObsidianAppWindow;
w.app?.workspace?.leftSplit?.collapse?.();
w.app?.workspace?.rightSplit?.collapse?.();
});
}
export interface MobileOverflow {
/** Pane width (px). */
paneWidth: number;
/** Horizontal overflow of the `.view-content` scroll box. */
viewContentPx: number;
/**
* The largest px any view-tab extends beyond the pane's edges (left or right).
* `> 0` means at least one tab is cropped laid out outside the visible pane,
* so a real user can't see or tap it. This is measured by bounding rect at the
* RESTING scroll position, deliberately: Playwright's `click()` auto-scrolls a
* cropped tab into view, so a "scrollable" strip passes a click test while a
* human still can't reach the tab. Wrapping keeps every tab within the pane, so
* this stays 0. See docs/specs/2026-05-22-mobile-responsiveness-findings-and-plan.md.
*/
tabCroppedPx: number;
/** Number of view-tabs found in the strip (sanity: a real strip has several). */
tabCount: number;
/**
* Px the toolbar's "Search events…" filter input extends beyond the pane's right
* (or left) edge, or `null` if this view has no filter bar. `> 0` means the
* search is laid out off-screen the toolbar clipped it with `overflow: hidden`
* instead of wrapping it onto its own row. Same resting-rect rationale as
* `tabCroppedPx`. Timeline / Heatmap / Gantt render the filter bar, so a toolbar
* that stops wrapping re-breaks here.
*/
filterSearchCroppedPx: number | null;
/**
* Px the page-header "Manage" button extends beyond the pane edges, or `null` if
* it isn't rendered. `> 0` means the header actions overflowed and pushed Manage
* off-screen unreachable. The action bar trims to what fits and keeps Manage,
* so this stays 0 even with more actions configured than fit at phone width.
*/
headerManageCroppedPx: number | null;
}
/**
* Measure the mobile-layout symptoms that matter on a phone: the pane content
* escaping its scroll box, and any view-tab cropped outside the visible pane
* (unreachable without programmatic scroll).
*/
export async function measureMobileOverflow(page: Page): Promise<MobileOverflow | null> {
return page.evaluate((leafSelector) => {
const leaf = document.querySelector(leafSelector);
if (!leaf) return null;
const paneRect = leaf.getBoundingClientRect();
const content = leaf.querySelector(".view-content");
const viewContentPx = content ? Math.max(0, content.scrollWidth - content.clientWidth) : 0;
const tabs = Array.from(leaf.querySelectorAll('[data-testid^="prisma-view-tab-"]'));
let tabCroppedPx = 0;
for (const t of tabs) {
const r = t.getBoundingClientRect();
tabCroppedPx = Math.max(tabCroppedPx, r.right - paneRect.right, paneRect.left - r.left);
}
// Inactive view tabs stay mounted (hidden) in the same leaf, so there can be
// several "prisma-filter-search" inputs; measure the one that's actually
// displayed (the active view's). A search with a 0×0 rect is hidden and
// skipped — that covers both inactive tabs and the calendar toolbar, which
// intentionally collapses its filter controls behind a "Filters" toggle on
// mobile. `null` therefore means "no visible search here" (calendar / split /
// dashboard views); Timeline / Heatmap / Gantt show it inline and get measured.
const searches = Array.from(leaf.querySelectorAll('[data-testid="prisma-filter-search"]'));
let filterSearchCroppedPx: number | null = null;
for (const s of searches) {
const r = s.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
filterSearchCroppedPx = Math.max(0, Math.round(Math.max(r.right - paneRect.right, paneRect.left - r.left)));
break;
}
}
const manage = leaf.querySelector('[data-testid="prisma-page-header-manage"]');
const manageRect = manage?.getBoundingClientRect();
const headerManageCroppedPx =
manageRect && manageRect.width > 0 && manageRect.height > 0
? Math.max(0, Math.round(Math.max(manageRect.right - paneRect.right, paneRect.left - manageRect.left)))
: null;
return {
paneWidth: Math.round(paneRect.width),
viewContentPx,
tabCroppedPx: Math.max(0, Math.round(tabCroppedPx)),
tabCount: tabs.length,
filterSearchCroppedPx,
headerManageCroppedPx,
};
}, ACTIVE_CALENDAR_LEAF);
}
export interface HeatmapScroll {
/**
* The grid SVG's intrinsic width (its `width` attribute) the full extent the
* 12 months are drawn to. The reference the rendered width must match.
*/
contentWidth: number;
/**
* The SVG's actually-rendered width. `< contentWidth` means a `max-width` cap
* (Obsidian's global `svg { max-width: 100% }`) shrank the element below its
* content and since there's no viewBox, the trailing months are hard-clipped
* with no scroll to reach them. Must equal `contentWidth`.
*/
renderedWidth: number;
/** Visible width of the scroll container. */
clientWidth: number;
/** Computed `overflow-x` is `auto`/`scroll` — i.e. any overflow is scrollable. */
scrollable: boolean;
/**
* Px the grid's left edge sits OUTSIDE the container at the resting scroll
* position. `> 0` means the start (January) is stranded off the left with no way
* back `scrollLeft` can't go negative, so `justify-content: center` on an
* overflowing grid clips the first months. `safe center`/`flex-start` anchors them.
*/
startClippedPx: number;
}
/**
* Measure whether the heatmap's yearly grid is reachable on a phone. `.view-content`
* overflow can't see this the grid clips inside its own scroll container so this
* inspects that container (the `.prisma-heatmap-container` scroll box) and the SVG.
*/
export async function measureHeatmapScroll(page: Page): Promise<HeatmapScroll | null> {
return page.evaluate((leafSelector) => {
const leaf = document.querySelector(leafSelector);
const container = leaf?.querySelector('[data-testid="prisma-heatmap-container"]') as HTMLElement | null;
const svg = container?.querySelector("svg");
if (!container || !svg) return null;
const cRect = container.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
const overflowX = getComputedStyle(container).overflowX;
return {
contentWidth: Math.round(parseFloat(svg.getAttribute("width") ?? "0")),
renderedWidth: Math.round(svgRect.width),
clientWidth: Math.round(container.clientWidth),
scrollable: overflowX === "auto" || overflowX === "scroll",
startClippedPx: Math.max(0, Math.round(cRect.left - svgRect.left)),
};
}, ACTIVE_CALENDAR_LEAF);
}
export interface StatsChartReach {
/** Whether a visible distribution-chart container was found in this view. */
found: boolean;
/**
* Px of the distribution chart that sit below the clip edge of a non-scrollable
* `overflow:hidden` ancestor i.e. cropped with no way to scroll to them. `> 0`
* is the bug: in a stacked ~50vh grid cell the chart was hidden past the cell's
* bottom. The walk stops at the first scrollable ancestor (which CAN reveal the
* overflow), so a normally-scrolling tab reads 0. Stays 0 once the stats panel
* flows to full height with the clipping `overflow:hidden` lifted on mobile.
*/
clippedPx: number;
}
/**
* Measure whether a stats tab's distribution chart is reachable on a phone. The
* chart lives in a stacked grid cell only ~50vh tall; if an `overflow:hidden`
* ancestor between it and the nearest scroll container crops it, the bottom of the
* pie is unreachable. Returns `found:false` for views without a stats chart.
*/
export async function measureStatsChartReach(page: Page): Promise<StatsChartReach> {
return page.evaluate((leafSelector) => {
const leaf = document.querySelector(leafSelector);
const views = Array.from(leaf?.querySelectorAll(".prisma-interval-stats-view") ?? []);
let container: HTMLElement | null = null;
for (const v of views) {
// querySelector returns Element | null; HTMLElement narrowing is required to call getBoundingClientRect
const c = v.querySelector(".prisma-stats-chart-container") as HTMLElement | null;
if (c && c.getBoundingClientRect().height > 0) {
container = c;
break;
}
}
if (!container) return { found: false, clippedPx: 0 };
const chartBottom = container.getBoundingClientRect().bottom;
let clippedPx = 0;
let el = container.parentElement;
while (el && el !== document.body) {
const cs = getComputedStyle(el);
// A scrollable ancestor can reveal anything below it — reachability is safe
// from here up, so stop.
if (cs.overflowY === "auto" || cs.overflowY === "scroll") break;
if (cs.overflowY === "hidden" || cs.overflowY === "clip") {
clippedPx = Math.max(clippedPx, chartBottom - el.getBoundingClientRect().bottom);
}
el = el.parentElement;
}
return { found: true, clippedPx: Math.max(0, Math.round(clippedPx)) };
}, ACTIVE_CALENDAR_LEAF);
}

View file

@ -1,156 +0,0 @@
// Typed surfaces for Prisma's renderer-side runtime objects, used inside
// `page.evaluate(...)` callbacks. The base `ObsidianWindow` comes from the
// shared library; `PrismaWindow` extends it with the workspace + vault fields
// the calendar DSL touches, so consumers cast once instead of re-typing
// `getLeaf` / `getLeavesOfType` / `leftSplit` per evaluate call. The plugin
// entry is still cast to `PrismaPlugin` because `plugins.plugins` upstream is
// `Record<string, unknown>`.
//
// Usage inside an evaluate callback:
//
// page.evaluate((pid) => {
// const w = window as unknown as PrismaWindow;
// const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
// const bundle = plugin?.calendarBundles?.[0];
// ...
// }, PLUGIN_ID);
//
// Playwright serialises the callback source so the types live on the Node
// side only — they're erased at runtime.
//
// Maintenance contract: every renderer-side shape an e2e helper needs lives
// here. If a helper needs a new field, add it to the appropriate type below —
// do NOT re-declare a local `RendererWindow` / `SettingsStoreWindow` / etc.
// next to the evaluate call. A hookify rule enforces this on edit (see
// `.claude/hookify/rules/e2e-window-types.json`).
import type { ObsidianWindow as BaseObsidianWindow } from "@real1ty-obsidian-plugins/testing/e2e";
export type { BaseObsidianWindow as ObsidianWindow };
export interface WorkspaceLeaf {
setViewState: (state: unknown) => Promise<void>;
detach: () => void;
}
export interface PrismaWindow extends Omit<BaseObsidianWindow, "app"> {
app: Omit<BaseObsidianWindow["app"], "workspace" | "vault" | "plugins"> & {
workspace: Omit<BaseObsidianWindow["app"]["workspace"], "openLinkText"> & {
openLinkText: (
link: string,
src: string,
newLeaf?: boolean,
state?: { state?: { mode?: string } }
) => Promise<void>;
getLeaf: (newLeaf: boolean | "tab" | "split") => WorkspaceLeaf;
getLeavesOfType: (type: string) => WorkspaceLeaf[];
getActiveFile: () => { path: string } | null;
leftSplit?: { collapse: () => void; collapsed?: boolean };
onLayoutReady: (cb: () => void) => void;
// Obsidian `Debouncer`'s `.run()` — flushes any pending workspace-layout
// save synchronously. Needed before reloads so leaves persist to
// `workspace.json` instead of being lost to the debounce window.
requestSaveLayout: { run: () => Promise<void> };
};
vault: {
adapter?: {
basePath?: string;
exists: (path: string) => Promise<boolean>;
};
getMarkdownFiles: () => Array<{ path: string }>;
getFiles: () => Array<{ path: string }>;
getAbstractFileByPath: (path: string) => unknown;
create: (path: string, content: string) => Promise<unknown>;
createFolder: (path: string) => Promise<void>;
read: (file: unknown) => Promise<string>;
modify: (file: unknown, content: string) => Promise<void>;
};
plugins: BaseObsidianWindow["app"]["plugins"] & {
disablePlugin?: (id: string) => Promise<void>;
disablePluginAndSave?: (id: string) => Promise<void>;
};
commands: Omit<BaseObsidianWindow["app"]["commands"], "commands"> & {
commands: Record<string, { name: string } | undefined>;
};
metadataCache: {
getFileCache: (file: { path: string }) => { frontmatter?: Record<string, unknown> } | null;
};
fileManager: {
processFrontMatter: (file: unknown, fn: (fm: Record<string, unknown>) => void) => Promise<void>;
};
secretStorage: {
getSecret: (name: string) => unknown;
};
};
}
export interface EventRef {
title: string;
ref: { filePath: string };
}
export interface VirtualEventInput {
title: string;
start: string;
end: string | null;
allDay: boolean;
properties: Record<string, unknown>;
}
export interface PluginSettingsStore {
currentSettings?: Record<string, unknown>;
updateSettings?: (updater: (current: Record<string, unknown>) => Record<string, unknown>) => Promise<void>;
}
export interface CalendarBundle {
calendarId: string;
viewType: string;
initialize: () => Promise<void>;
activateCalendarView?: () => Promise<void>;
eventStore: { getAllEvents: () => EventRef[] };
untrackedEventStore: { getUntrackedEvents: () => EventRef[] };
settingsStore: {
currentSettings: Record<string, unknown>;
updateSettings: (updater: (current: Record<string, unknown>) => Record<string, unknown>) => Promise<void>;
};
prerequisiteTracker: { isConnected: (filePath: string) => boolean };
virtualEventStore: {
add: (e: VirtualEventInput) => Promise<{ id: string }>;
getAll: () => Array<{ id: string; title: string; start: string; end: string | null }>;
};
commandManager: {
whenIdle: () => Promise<void>;
};
viewRef?: {
calendarComponent?: {
calendar?: {
gotoDate: (d: string) => void;
getDate?: () => Date;
};
} | null;
};
}
export interface LicenseStatusPayload {
state: string;
activationsCurrent: number;
activationsLimit: number;
expiresAt: string | null;
errorMessage: string | null;
}
export interface LicenseManager {
status$: { next: (v: LicenseStatusPayload) => void };
__setProForTesting?: (v: boolean) => void;
}
export interface PrismaPlugin {
manifest?: { version?: string };
calendarBundles?: CalendarBundle[];
ensureCalendarBundlesReady?: () => Promise<void>;
refreshCalendarBundles?: () => Promise<void>;
addCalendarBundle?: (calendarId: string) => Promise<void>;
settingsStore?: PluginSettingsStore;
licenseManager?: LicenseManager;
lastUsedCalendarId?: string | null;
}

View file

@ -1,4 +0,0 @@
{
"appVersion": "1.12.7",
"installerVersion": "1.8.10"
}

View file

@ -1,99 +0,0 @@
import { defineConfig } from "@playwright/test";
// Two-project split, matching the incremental-reading-obsidian pattern:
// - "bootstrap" runs first, a single test that proves Obsidian launches
// with prisma-calendar loaded and configured. This is the gate.
// - "specs" is the actual suite. It depends on "bootstrap" — Playwright
// will not run specs unless bootstrap passes first.
// Timeouts are mode-dependent:
// - Debug modes (PWDEBUG=1 / --ui via E2E_UI_MODE) disable all timeouts so a
// human-at-the-keyboard can pause without getting cut off.
// - Demo mode (PW_DEMO=1) slows every action and holds the window open after
// each spec. A 15-field fill at 250ms slowMo + matching demoPause + hold
// easily crosses the normal envelope, so triple it.
// - Default headless runs (CI + dev `pnpm test:e2e`) get a 90s budget. Each
// test spawns a fresh Obsidian process — bootstrap is typically 1-3s but
// can spike to 30-35s under disk/memory pressure during long serial runs.
// 90s leaves ample room for spike + test body while still failing fast on
// genuine hangs.
const DEMO_ON = !!process.env.PW_DEMO && process.env.PW_DEMO !== "0" && process.env.PW_DEMO !== "false";
// --ui and --debug put a human at the keyboard; disable timeouts in both.
// PWDEBUG is the official env var Playwright sets for the Inspector; argv
// catches `--ui` and `--debug` passed directly to `playwright test`.
const DEBUG_ON = !!process.env.PWDEBUG || process.argv.includes("--ui") || process.argv.includes("--debug");
// PRISMA_PREVIEW=1 turns on the onboarding-walkthrough preview project: it records
// video + per-step screenshots of the full tour into e2e/.preview/ so humans and
// agents can review it. Off by default, the preview project is omitted entirely —
// it never runs in CI, the full suite, or test:e2e:changed (which only scans
// e2e/specs/, not e2e/preview/).
const PREVIEW_ON =
!!process.env.PRISMA_PREVIEW && process.env.PRISMA_PREVIEW !== "0" && process.env.PRISMA_PREVIEW !== "false";
const TEST_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 1_800_000 : 45_000;
// 5s assertion budget. If a UI element is genuinely going to appear, it does
// so well within 5s under the test harness — the indexer is fast, debounces
// are flipped to 0 by `window.E2E` (see `debounceMsForEnv`), and there are no
// network calls. The previous 10s default just made failures take twice as
// long without catching any real-world flake.
const EXPECT_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 120_000 : 5_000;
// `actionTimeout` caps every `waitFor` / `click` / `fill` that doesn't pass its
// own `timeout`. Mirrors EXPECT_TIMEOUT so specs can omit per-call timeouts
// entirely — see feedback_e2e_no_timeout_overrides. Without this, actions fall
// back to testTimeout and a single hung wait eats the whole spec.
const ACTION_TIMEOUT = DEBUG_ON ? 0 : DEMO_ON ? 120_000 : 5_000;
export default defineConfig({
outputDir: "./test-results",
fullyParallel: !DEBUG_ON,
workers: DEBUG_ON ? 1 : 3,
timeout: TEST_TIMEOUT,
expect: { timeout: EXPECT_TIMEOUT },
forbidOnly: !!process.env.CI,
// One retry locally / two on CI absorbs residual bootstrap timing spikes
// that the 45s budget doesn't cover (extreme disk pressure, OOM thrashing).
retries: process.env.CI ? 2 : 1,
// `line` keeps output to one line per test (not per attempt) so the summary
// is readable even when several specs run. Full detail still lands in the
// HTML report + `.cache/last-run.log`.
// `infra-error-reporter` collapses a "node_modules rebuilt mid-run" pile of
// per-spec resolution failures into one ENVIRONMENT IN FLUX verdict instead
// of dozens of look-alike test failures (see the shared module + the at-launch
// preflight in shared/scripts/e2e/check-e2e-deps.mjs).
reporter: [
["line"],
["html", { open: "never", outputFolder: "./playwright-report" }],
["../../shared/src/testing/e2e/infra-error-reporter.ts"],
],
use: {
trace: "retain-on-failure",
screenshot: PREVIEW_ON ? "on" : "only-on-failure",
video: PREVIEW_ON ? "on" : "retain-on-failure",
actionTimeout: ACTION_TIMEOUT,
},
projects: [
{
name: "bootstrap",
testDir: "./setup",
testMatch: /bootstrap\.spec\.ts$/,
},
{
name: "specs",
testDir: "./specs",
testMatch: /.*\.spec\.ts$/,
dependencies: ["bootstrap"],
},
// Only present under PRISMA_PREVIEW=1 — keeps the walkthrough out of the
// real suite while still sharing the bootstrap gate when explicitly run.
...(PREVIEW_ON
? [
{
name: "preview",
testDir: "./preview",
testMatch: /.*\.spec\.ts$/,
dependencies: ["bootstrap"],
},
]
: []),
],
});

View file

@ -1,58 +0,0 @@
// Post-run collector for `pnpm run preview:tutorial`. The preview spec writes one
// PNG per tour step into e2e/.preview/tutorial/; Playwright can't record real video
// for the Obsidian harness (it connects over CDP, not a browser context), so this
// stitches those frames into a watchable/embeddable walkthrough.mp4 + walkthrough.gif
// with ffmpeg and prints every artifact location.
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
const DIR = "e2e/.preview/tutorial";
const MP4 = join(DIR, "walkthrough.mp4");
const GIF = join(DIR, "walkthrough.gif");
const SECONDS_PER_FRAME = 1.6;
const frames = existsSync(DIR)
? readdirSync(DIR)
.filter((f) => f.endsWith(".png"))
.sort()
: [];
function ffmpegAvailable() {
return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
}
function assembleVideos() {
const input = ["-y", "-framerate", String(1 / SECONDS_PER_FRAME), "-pattern_type", "glob", "-i", join(DIR, "*.png")];
const mp4 = spawnSync(
"ffmpeg",
[...input, "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", "-r", "30", MP4],
{ stdio: "ignore" }
);
const gif = spawnSync(
"ffmpeg",
[...input, "-vf", "scale=900:-2:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", GIF],
{ stdio: "ignore" }
);
return { mp4: mp4.status === 0, gif: gif.status === 0 };
}
console.log("\n────────────────────────────────────────────────────────");
console.log("🎬 Tutorial walkthrough preview");
console.log("────────────────────────────────────────────────────────");
if (frames.length === 0) {
console.log(" ⚠️ No frames found — did the preview run write screenshots?");
} else {
console.log(` Frames: ${DIR}/ (${frames.length})`);
for (const f of frames) console.log(`${f}`);
if (ffmpegAvailable()) {
const { mp4, gif } = assembleVideos();
if (mp4) console.log(` Video (mp4): ${MP4}`);
if (gif) console.log(` Video (gif): ${GIF}`);
if (!mp4 && !gif) console.log(" ⚠️ ffmpeg failed to assemble the video — review the frames above.");
} else {
console.log(" Install ffmpeg to auto-assemble the frames into walkthrough.mp4 / .gif.");
}
}
console.log(` Report (UI): pnpm --filter prisma-calendar exec playwright show-report e2e/playwright-report`);
console.log("────────────────────────────────────────────────────────\n");

View file

@ -1,50 +0,0 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { expect, testOnboarding } from "../fixtures/electron";
import { sel, TOUR_NEXT_TID, TOUR_PROGRESS_TID, TOUR_TOOLTIP_TID } from "../fixtures/testids";
import { dragSampleEventToNewTime, TOUR_STEP_COUNT, TOUR_STEP_IDS } from "../fixtures/tour";
// On-demand walkthrough for humans + agents — NOT part of the real suite. It only
// runs under `PRISMA_PREVIEW=1` (see playwright.config.ts, which adds the "preview"
// project) via `pnpm run preview:tutorial`. It drives the tour exactly as a user
// would and writes one numbered screenshot per step; the run's collect-artifacts.mjs
// step then stitches those frames into a video + gif so you can review the whole
// flow without launching Obsidian by hand. (Playwright can't record real video here
// — the Obsidian harness connects over CDP rather than launching a browser context.)
const PREVIEW_DIR = "e2e/.preview/tutorial";
const DRAG_STEP = TOUR_STEP_IDS.indexOf("drag-and-drop") + 1;
testOnboarding.describe("preview: onboarding tutorial walkthrough", () => {
testOnboarding("captures every step and exercises the interactive drag", async ({ calendar }, testInfo) => {
const { page, vaultDir } = calendar;
mkdirSync(PREVIEW_DIR, { recursive: true });
const shot = (name: string): Promise<Buffer> => page.screenshot({ path: join(PREVIEW_DIR, `${name}.png`) });
// The tour auto-starts (testOnboarding seeds tutorialCompleted: false).
await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toBeVisible();
const next = page.locator(sel(TOUR_NEXT_TID));
for (let step = 1; step <= TOUR_STEP_COUNT; step++) {
await expect(page.locator(sel(TOUR_PROGRESS_TID))).toHaveText(`${step} / ${TOUR_STEP_COUNT}`);
const id = TOUR_STEP_IDS[step - 1];
await shot(`${String(step).padStart(2, "0")}-${id}`);
if (step === DRAG_STEP) {
const { expectedStart } = await dragSampleEventToNewTime(page, vaultDir);
// "Nb-" sorts right after "0N-" so the before/after drag frames stay in order.
await shot(`${String(step).padStart(2, "0")}b-${id}-after`);
testInfo.annotations.push({ type: "preview", description: `dragged sample event → ${expectedStart}` });
}
if (step < TOUR_STEP_COUNT) await next.click();
}
await expect(next).toHaveText("Done");
await next.click();
await expect(page.locator(sel(TOUR_TOOLTIP_TID))).toHaveCount(0);
testInfo.annotations.push({ type: "preview", description: `frames written to ${PREVIEW_DIR}/` });
});
});

View file

@ -1,36 +0,0 @@
import { bootstrapObsidian, expect, test } from "../fixtures/electron";
import type { PrismaPlugin, PrismaWindow } from "../fixtures/window-types";
test.describe("bootstrap", () => {
test("obsidian launches with prisma-calendar ready", async () => {
const ob = await bootstrapObsidian({ prefix: "bootstrap" });
try {
// Renderer side: plugin visible in registry and at least one bundle initialized.
const summary = await ob.page.evaluate((id) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[id] as PrismaPlugin | undefined;
const calendars = plugin?.settingsStore?.currentSettings?.["calendars"] as
| Array<{ directory?: string }>
| undefined;
return {
pluginLoaded: Boolean(plugin),
version: plugin?.manifest?.version ?? null,
bundleIds: plugin?.calendarBundles?.map((b) => b.calendarId) ?? [],
directory: calendars?.[0]?.directory ?? null,
prismaCommands: Object.keys(w.app.commands.commands).filter((c) => c.startsWith(`${id}:`)),
};
}, "prisma-calendar");
expect(summary.pluginLoaded, "prisma-calendar must be loaded").toBe(true);
expect(summary.bundleIds.length, "at least one bundle must exist").toBeGreaterThan(0);
expect(summary.directory, "data.json directory should be seeded to Events").toBe("Events");
expect(summary.prismaCommands.length, "prisma-calendar should register commands").toBeGreaterThan(0);
const welcome = ob.readVaultFile("Welcome.md");
expect(welcome).toContain("Welcome");
} finally {
await ob.close();
}
});
});

View file

@ -1,67 +0,0 @@
import { fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { assignPrerequisiteViaUI } from "../../fixtures/helpers";
import { sel } from "../../fixtures/testids";
// Prerequisite connections are a Pro feature. The calendar view draws SVG
// arrows between dependent/prerequisite events via `ConnectionRenderer`:
// full cubic arrows when both sides are in-frame, dashed stubs when one
// side is off-screen. This spec proves both flows end-to-end: same-week
// (full arrow) and cross-week (stub arrow, because the prereq lives in a
// different week than the dependant).
//
// Seeds use `fromAnchor(...)` (past-Wednesday anchor) + `calendar.goToAnchor()`
// so the rendered week always contains the Upstream/Downstream pair regardless
// of what day-of-week the suite runs on — see
// `docs/specs/e2e-date-anchor-robustness.md`.
const ARROW = sel("prisma-connection-arrow");
const STUB = `${sel("prisma-connection-arrow")}[data-arrow-stub="true"]`;
test.describe("analytics: prerequisite connections on calendar view", () => {
test("full arrow renders when both events sit in the same week", async ({ calendar }) => {
// Disk-seed first, then navigate: modal-based `seedMany` opens the
// create modal per event and the save re-render snaps FullCalendar
// back to today's week, losing any prior `goToAnchor()`. The anchor
// week (past-Wednesday) is usually different from today's week, so
// the seeded tiles would end up off-screen.
await calendar.seedOnDiskMany([
{ title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Downstream Task", start: fromAnchor(1, 14, 0), end: fromAnchor(1, 15, 0) },
]);
await calendar.switchMode("week");
await calendar.goToAnchor();
await calendar.eventByTitle("Downstream Task").then((e) => e.expectVisible());
await assignPrerequisiteViaUI(calendar.page, "Downstream Task", "Upstream Task");
await calendar.unlockPro();
await calendar.clickToolbar("toggle-prerequisites");
await expect(calendar.page.locator(ARROW)).toHaveCount(1);
await expect(calendar.page.locator(STUB)).toHaveCount(0);
});
test("stub arrow renders when prerequisite lives in a different week", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Downstream Task", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) },
]);
// Month view first so both tiles are clickable while wiring the prereq.
await calendar.switchMode("month");
await calendar.goToAnchor();
await calendar.eventByTitle("Downstream Task").then((e) => e.expectVisible());
await assignPrerequisiteViaUI(calendar.page, "Downstream Task", "Upstream Task");
// Back to week view anchored on the upstream — with 10 days between the
// two events, at most one is visible at a time, so the renderer must
// draw a dashed stub.
await calendar.switchMode("week");
await calendar.goToAnchor();
await calendar.unlockPro();
await calendar.clickToolbar("toggle-prerequisites");
await expect(calendar.page.locator(STUB)).toHaveCount(1);
});
});

View file

@ -1,28 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// E2E value: prove the indicator label round-trips through the real RxJS
// pipeline — on-disk events → metadataCache → eventStore.changes$ → the
// debounced subscription → label format. Capacity math itself is covered by
// tests/utils/stats/capacity*.test.ts; the indicator's render behaviour with
// mocked observables is covered by tests/components/views/capacity-indicator.
// What only E2E can catch: the wiring between those three layers in the
// shipped bundle.
test.describe("analytics: capacity indicator (live)", () => {
test("indicator label reflects used / inferred-capacity / percent from on-disk events", async ({ calendar }) => {
// Seed two timed events: 09:0010:00 and 11:0012:00.
// inferBoundaries → start=9, end=12 → capacity = 3h.
// Used = 1h + 1h = 2h. Percent = (2/3)*100 = 66.67 → toFixed(0) = "67".
// Day view ensures the indicator's range is exactly today.
await calendar.switchMode("day");
await calendar.seedOnDiskMany([
{ title: "Capacity One", start: todayStamp(9, 0), end: todayStamp(10, 0) },
{ title: "Capacity Two", start: todayStamp(11, 0), end: todayStamp(12, 0) },
]);
const indicator = calendar.page.locator(sel("prisma-capacity-indicator")).first();
await expect(indicator).toHaveText("⏱ 2h / 3h (67%)");
});
});

View file

@ -1,43 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { updateCalendarSettings } from "../../fixtures/seed-events";
// Categories are assigned via the shared assignment modal, stamped with
// `prisma-assign-item` + `data-assign-name` rows. The DSL's `createEvent`
// accepts `categories` and drives the full flow including the "Create new"
// fallback when a category doesn't exist yet. This spec asserts the
// frontmatter side-effect (the value lands on disk under the configured
// category property), that the tile still renders after assignment, and
// that the matching colour rule paints the tile — i.e. the full create →
// indexer → VaultTable → EventStore → render chain lights up under a
// real category pick.
const RULE_COLOR = "#7744ff";
test.describe("categories assignment", () => {
test("assigns a category via the picker, persists it in frontmatter, and paints the tile via a matching colour rule", async ({
calendar,
}) => {
await updateCalendarSettings(calendar.page, {
colorRules: [
{
id: "rule-work",
expression: "Category.includes('Work')",
color: RULE_COLOR,
enabled: true,
},
],
});
const evt = await calendar.createEvent({
title: "Categorised Task",
start: todayStamp(9, 0),
end: todayStamp(10, 0),
categories: ["Work"],
});
await evt.expectVisible();
expect(evt.readCategory()).toEqual(["Work"]);
await evt.expectColor(RULE_COLOR);
});
});

View file

@ -1,37 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { test } from "../../fixtures/electron";
import { updateCalendarSettings } from "../../fixtures/seed-events";
// Color rules live under `colorRules: ColorRule[]` on the calendar settings
// and are evaluated by `colorEvaluator` at event-render time. The matched
// colour surfaces on each tile as the `--event-color` CSS variable written
// by `applyEventMountStyling`. Seeding the rule via the settings store (not
// the Rules settings-tab UI) keeps the spec focused on the runtime colour-
// evaluation path. The read lives on the EventHandle so every colour-aware
// spec goes through the same DSL method.
const RULE_COLOR = "#ff00aa";
test.describe("color rules", () => {
test("a color rule with a matching expression applies its colour to the event tile", async ({ calendar }) => {
await updateCalendarSettings(calendar.page, {
colorRules: [
{
id: "rule-urgent",
expression: "Category === 'Urgent'",
color: RULE_COLOR,
enabled: true,
},
],
});
const evt = await calendar.createEvent({
title: "Urgent Ticket",
start: todayStamp(9, 0),
end: todayStamp(10, 0),
categories: ["Urgent"],
});
await evt.expectVisible();
await evt.expectColor(RULE_COLOR);
});
});

View file

@ -1,63 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Dashboard (Pro) has three children — By Name, By Category, Recurring — each
// rendering a chart/ranking/table grid populated by events in the vault.
// This spec seeds deterministic data via the UI, then walks every child and
// asserts the ranking/table rows contain the expected entries.
test.describe("analytics: dashboard charts (populated)", () => {
test("every dashboard child surfaces the seeded events grouped by its axis", async ({ calendar }) => {
// Seed 3× "Weekly Review" (Work) + 2× "Workout" (Fitness) for deterministic
// per-name and per-category rankings. Modal-based seeding is required here
// because same-titled events need the plugin's file-dedup logic, and the
// category assignment goes through the chip-list flow.
await calendar.seedMany([
{ title: "Weekly Review", start: todayStamp(9, 0), end: todayStamp(10, 0), categories: ["Work"] },
{ title: "Weekly Review", start: todayStamp(11, 0), end: todayStamp(12, 0), categories: ["Work"] },
{ title: "Weekly Review", start: todayStamp(14, 0), end: todayStamp(15, 0), categories: ["Work"] },
{ title: "Workout", start: todayStamp(7, 0), end: todayStamp(8, 0), categories: ["Fitness"] },
{ title: "Workout", start: todayStamp(18, 0), end: todayStamp(19, 0), categories: ["Fitness"] },
]);
await calendar.unlockPro();
// Dashboard child panels coexist in the DOM once visited — the tabbed
// container just toggles visibility. Scope every cell selector to the
// visible one so `.first()` can't pick up a stale sibling panel.
const visibleRanking = calendar.page.locator(`${sel("prisma-dashboard-cell-ranking")}:visible`).first();
const visibleTable = calendar.page.locator(`${sel("prisma-dashboard-cell-table")}:visible`).first();
// ─── By Name ───
await calendar.switchToGroupChild("dashboard", "dashboard-by-name");
await expect(calendar.page.locator(`${sel("prisma-dashboard-cell-chart")}:visible`).first()).toBeVisible();
await expect(visibleRanking).toBeVisible();
await expect(visibleTable).toBeVisible();
await expect(visibleRanking.locator('[data-item-title="Weekly review"]')).toBeVisible();
await expect(visibleRanking.locator('[data-item-title="Workout"]')).toBeVisible();
await expect(visibleTable.locator('[data-item-title="Weekly review"]')).toBeVisible();
await expect(visibleTable.locator('[data-item-title="Workout"]')).toBeVisible();
// ─── By Category ───
await calendar.switchToGroupChild("dashboard", "dashboard-by-category");
await expect(visibleRanking.locator('[data-item-title="Work"]')).toBeVisible();
await expect(visibleRanking.locator('[data-item-title="Fitness"]')).toBeVisible();
await expect(visibleTable.locator('[data-item-title="Work"]')).toBeVisible();
await expect(visibleTable.locator('[data-item-title="Fitness"]')).toBeVisible();
await expect(
calendar.page.locator(`${sel("prisma-dashboard-stat-value-Total Events")}:visible`).first()
).toHaveText("5");
// ─── Recurring ───
// No recurring events seeded, so rankings are empty (empty-state text)
// but the stats card for "Rules" still renders at zero.
await calendar.switchToGroupChild("dashboard", "dashboard-recurring");
await expect(calendar.page.locator(`${sel("prisma-dashboard-cell-chart")}:visible`).first()).toBeVisible();
await expect(calendar.page.locator(`${sel("prisma-dashboard-stat-value-Rules")}:visible`).first()).toHaveText("0");
});
});

View file

@ -1,70 +0,0 @@
import { expect, test } from "../../fixtures/electron";
import { createEventViaToolbar } from "../../fixtures/helpers";
import { updateCalendarSettings } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
// Event presets live on the calendar's settings under `eventPresets[]` and
// are surfaced in the event modal through a <select> stamped with
// `prisma-event-control-preset`. Picking a preset fires `applyPreset`, which
// rewrites modal state from the preset's fields. This spec verifies that
// EVERY preset field that has a corresponding modal control is applied —
// not just title — so a regression dropping (e.g.) `location` propagation
// fails immediately.
test.describe("event presets", () => {
test("selecting a preset populates title, location, and break minutes", async ({ calendar }) => {
const now = Date.now();
await updateCalendarSettings(calendar.page, {
eventPresets: [
{
id: "preset-standup",
name: "Daily Standup",
title: "Daily Standup",
location: "Room A",
breakMinutes: 5,
createdAt: now,
},
],
});
await createEventViaToolbar(calendar.page);
const modal = calendar.page.locator(".modal").first();
const select = modal.locator(sel(TID.event.control("preset")));
await expect(select).toBeVisible();
await select.selectOption({ value: "preset-standup" });
await expect(modal.locator(sel(TID.event.control("title")))).toHaveValue("Daily Standup");
await expect(modal.locator(sel(TID.event.control("location")))).toHaveValue("Room A");
await expect(modal.locator(sel(TID.event.control("breakMinutes")))).toHaveValue("5");
await modal.locator(sel(TID.event.btn("cancel"))).click();
});
test("selecting a preset toggles allDay on and reveals the date input", async ({ calendar }) => {
const now = Date.now();
await updateCalendarSettings(calendar.page, {
eventPresets: [
{
id: "preset-allday",
name: "All Day Block",
title: "Off Day",
allDay: true,
createdAt: now,
},
],
});
await createEventViaToolbar(calendar.page);
const modal = calendar.page.locator(".modal").first();
await modal.locator(sel(TID.event.control("preset"))).selectOption({ value: "preset-allday" });
await expect(modal.locator(sel(TID.event.control("all-day")))).toBeChecked();
// Date input is the all-day-mode replacement for start/end; must be
// visible after the toggle flips on.
await expect(modal.locator(sel(TID.event.control("date")))).toBeVisible();
await expect(modal.locator(sel(TID.event.control("title")))).toHaveValue("Off Day");
await modal.locator(sel(TID.event.btn("cancel"))).click();
});
});

View file

@ -1,52 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// The "Enlarge" context-menu item opens a read-only preview modal via
// `showEventPreviewModal` (the "Preview" item is Obsidian's hover card,
// a different code path). The modal stamps `prisma-event-preview-modal`
// on its root and `prisma-event-preview-title` on the <h2> so specs
// don't depend on CSS class names.
test.describe("event preview modal", () => {
test("right-click → Enlarge opens a modal with the event title", async ({ calendar }) => {
const evt = await calendar.createEvent({
title: "Preview Target",
start: todayStamp(9, 0),
end: todayStamp(10, 0),
});
await evt.rightClick("enlarge");
const modal = calendar.page.locator(sel("prisma-event-preview-modal")).first();
await expect(modal).toBeVisible();
await expect(modal.locator(sel("prisma-event-preview-title"))).toHaveText("Preview Target");
});
});
test.describe("note content tooltip", () => {
test("hover tooltip shows note body and refreshes after edits", async ({ calendar }) => {
const evt = await calendar.createEvent({
title: "Tooltip Note",
start: todayStamp(11, 0),
end: todayStamp(12, 0),
});
await evt.expectVisible();
// Write initial body and verify it appears in the tooltip
await evt.writeNoteBody("Weekly review\nProject status\nAction items");
await evt.hover();
await expect.poll(() => evt.getTooltip()).toContain("Weekly review");
// Edit the note body and verify the tooltip refreshes
await calendar.page.mouse.move(0, 0);
await evt.writeNoteBody("Meeting agenda\nBudget discussion\nFollow-up notes");
await evt.hover();
await expect.poll(() => evt.getTooltip()).toContain("Meeting agenda");
const tooltip = await evt.getTooltip();
expect(tooltip).toContain("Budget discussion");
expect(tooltip).toContain("Follow-up notes");
});
});

View file

@ -1,159 +0,0 @@
import { test } from "../../fixtures/electron";
import { type SeedEventInput } from "../../fixtures/seed-events";
// Phase-2 coverage from docs/specs/e2e-events-modal-coverage.md.
// Pins the EventSeriesModal's interaction surface — Hide-past / Hide-skipped
// toggles, search input, stats line, past/skipped row classes, and the
// recurring tab's "extra info" banner. Each test seeds a deterministic event
// set so the assertions stay exact regardless of when the suite runs.
//
// Past-vs-future is anchored to fixed ISO timestamps far enough from today
// (>30 days each direction) that test-runner clock drift can't flip the
// classification mid-run.
const FAR_PAST_DATE_A = "2020-01-15";
const FAR_PAST_DATE_B = "2020-02-20";
const FAR_FUTURE_DATE_A = "2099-06-10";
const FAR_FUTURE_DATE_B = "2099-07-15";
function timed(date: string, hour: number): string {
return `${date}T${String(hour).padStart(2, "0")}:00`;
}
test.describe("event series modal — controls (hide past, hide skipped, search, stats)", () => {
test.beforeEach(async ({ calendar }) => {
// 4 events under the same category: 2 past + 2 future, with one
// skipped past instance. This pins:
// - Total: 4
// - Past: 2
// - Skipped: 1 (one of the past events)
// - Hide past → 2 future rows visible
// - Hide skipped → 3 non-skipped rows visible
// - Hide both → 2 rows (future, non-skipped)
const events: SeedEventInput[] = [
{
title: "Alpha Past",
startDate: timed(FAR_PAST_DATE_A, 9),
endDate: timed(FAR_PAST_DATE_A, 10),
category: "Work",
extra: { Skip: true },
},
{
title: "Beta Past",
startDate: timed(FAR_PAST_DATE_B, 9),
endDate: timed(FAR_PAST_DATE_B, 10),
category: "Work",
},
{
title: "Gamma Future",
startDate: timed(FAR_FUTURE_DATE_A, 9),
endDate: timed(FAR_FUTURE_DATE_A, 10),
category: "Work",
},
{
title: "Delta Future",
startDate: timed(FAR_FUTURE_DATE_B, 9),
endDate: timed(FAR_FUTURE_DATE_B, 10),
category: "Work",
},
];
await calendar.seedAndStabilize(events);
});
test("stats line reports Total / Past / Skipped accurately", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
await series.expectRowCount(4);
await series.expectStats({ total: 4, past: 2, skipped: 1 });
});
test("past instances render with the past class; skipped instances with the skipped class", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
// Default sort is desc — newest first. Use rowByDate for stable lookups.
await series.rowByDate(FAR_PAST_DATE_A).expectPast(true);
await series.rowByDate(FAR_PAST_DATE_A).expectSkipped(true);
await series.rowByDate(FAR_PAST_DATE_B).expectPast(true);
await series.rowByDate(FAR_PAST_DATE_B).expectSkipped(false);
await series.rowByDate(FAR_FUTURE_DATE_A).expectPast(false);
await series.rowByDate(FAR_FUTURE_DATE_A).expectSkipped(false);
await series.rowByDate(FAR_FUTURE_DATE_B).expectPast(false);
await series.rowByDate(FAR_FUTURE_DATE_B).expectSkipped(false);
});
test("hide-past toggle drops past rows and keeps stats accurate", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
await series.expectRowCount(4);
await series.toggleHidePast();
await series.expectRowCount(2);
// Only the two future dates remain, in ascending order (hidePast flips sort to asc).
await series.rowByDate(FAR_FUTURE_DATE_A).expectPast(false);
await series.rowByDate(FAR_FUTURE_DATE_B).expectPast(false);
await series.expectRowAbsent(FAR_PAST_DATE_A);
await series.expectRowAbsent(FAR_PAST_DATE_B);
// Stats line is computed pre-filter, so Past stays at 2.
await series.expectStats({ total: 4, past: 2, skipped: 1 });
});
test("hide-skipped toggle drops skipped rows", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
await series.toggleHideSkipped();
await series.expectRowCount(3);
// Alpha Past was the skipped one — must be gone; Beta Past stays.
await series.expectRowAbsent(FAR_PAST_DATE_A);
await series.rowByDate(FAR_PAST_DATE_B).expectSkipped(false);
});
test("hide-past + hide-skipped together leave only future, non-skipped rows", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
await series.toggleHidePast();
await series.toggleHideSkipped();
await series.expectRowCount(2);
// Both past dates gone (one past + one past-skipped); both future dates remain.
await series.expectRowAbsent(FAR_PAST_DATE_A);
await series.expectRowAbsent(FAR_PAST_DATE_B);
await series.rowByDate(FAR_FUTURE_DATE_A).expectPast(false);
await series.rowByDate(FAR_FUTURE_DATE_B).expectPast(false);
});
test("search filters series rows by title", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
await series.expectRowCount(4);
// Match exactly one — "alpha" only hits Alpha Past.
await series.search("alpha");
await series.expectRowCount(1);
await series.rowByDate(FAR_PAST_DATE_A).expectTitle("Alpha Past");
// Match a different one — "future" hits both Gamma + Delta titles.
await series.search("future");
await series.expectRowCount(2);
// Empty restores the full list.
await series.search("");
await series.expectRowCount(4);
});
});

View file

@ -1,206 +0,0 @@
import { closeOpenModal } from "../../fixtures/analytics-helpers";
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { type SeedEventInput } from "../../fixtures/seed-events";
import { sel } from "../../fixtures/testids";
// The FullCalendar "Events" toolbar button (`prisma-cal-toolbar-show-recurring`)
// opens the EventsModal, which has tabs for Recurring / By Category / By
// Name. Drilling into a non-recurring entry opens the EventSeriesModal,
// whose footer exposes 5 visualisation buttons: Table, List, Cards,
// Timeline, Heatmap — each opens a child modal of its own. These specs
// pin the data flowing through both modals so a regression can't silently
// drop or leak events between groups.
const GROUP_SUBTITLE_SEL = ".prisma-generic-event-subtitle";
test.describe("analytics: events-list modal + series visualisations", () => {
test.beforeEach(async ({ calendar }) => {
// Seed two categories, each with a same-titled pair so both surface in
// the byName tab — `getNameBasedSeries()` only returns groups with 2+
// events, so a single Workout would otherwise be filtered out.
// Use `seedAndStabilize` (vault.create + double-refresh) so the
// category and name-series trackers are guaranteed to be flushed
// before the modal opens — `seedMany` returns once the create modal
// closes but trackers can still be a tick behind, which produced
// flaky off-by-one counts in the modal.
const baseZettel = 20260502090000;
const events: SeedEventInput[] = [
{
title: `Team Meeting-${baseZettel}`,
startDate: todayStamp(9, 0),
endDate: todayStamp(10, 0),
category: "Work",
},
{
title: `Team Meeting-${baseZettel + 1}`,
startDate: todayStamp(11, 0),
endDate: todayStamp(12, 0),
category: "Work",
},
{
title: `Workout-${baseZettel + 2}`,
startDate: todayStamp(7, 0),
endDate: todayStamp(8, 0),
category: "Fitness",
},
{
title: `Workout-${baseZettel + 3}`,
startDate: todayStamp(13, 0),
endDate: todayStamp(14, 0),
category: "Fitness",
},
];
await calendar.seedAndStabilize(events);
});
test("Events toolbar button opens the modal with three tabs", async ({ calendar }) => {
await calendar.openEventsModal();
// Modal is present, and all three tab buttons are stamped.
await expect(calendar.page.locator(sel("prisma-events-modal-tab-recurring")).first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-events-modal-tab-by-category")).first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-events-modal-tab-by-name")).first()).toBeVisible();
});
test("By Category tab counts events per category; drilling renders only that category's events", async ({
calendar,
}) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
// Per-group counts: Work has 2 Team Meetings, Fitness has 2 Workouts.
await expect(events.groupItem("Work").locator(GROUP_SUBTITLE_SEL)).toHaveText("2 events");
await expect(events.groupItem("Fitness").locator(GROUP_SUBTITLE_SEL)).toHaveText("2 events");
// Total visible-category-group count is exactly 2 (no extra leaked categories).
await events.expectGroupCountText("2 category groups");
await expect(events.groupItems()).toHaveCount(2);
// Drill into "Work" — opens EventSeriesModal on top with exactly the 2 Team Meeting rows.
const series = await events.drillInto("Work");
await series.expectRowCount(2);
await series.expectAllTitles("Team Meeting");
await series.expectTotal(2);
// Single-tab series modal: the source modal opened with `categoryValues=[Work]`,
// so `tabs.length === 1` and the tab bar is suppressed (event-series-modal-content.tsx
// renders tabs only when `tabs.length >= 2`). A regression that widened the
// payload would surface a name/recurring tab here.
await series.expectNoTabBar();
// Bases footer must expose every visualisation button for this category series.
for (const view of ["table", "list", "cards", "timeline", "heatmap"] as const) {
await series.expectBasesAction(view);
}
});
test("By Name tab counts name groups; drilling renders only that name's events", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-name");
// nameKey is lowercased by `NameSeriesTracker.getNameKey` and only the
// first char is re-uppercased for display, so the byName item titles
// are "Team meeting" / "Workout" — not the original frontmatter casing.
await expect(events.groupItem("Team meeting").locator(GROUP_SUBTITLE_SEL)).toHaveText("2 events");
await expect(events.groupItem("Workout").locator(GROUP_SUBTITLE_SEL)).toHaveText("2 events");
// Exactly two name groups — no third leaked group from missing-title or zettel-id strip bugs.
await events.expectGroupCountText("2 name groups");
await expect(events.groupItems()).toHaveCount(2);
// Drill into "Team meeting" — series modal lists exactly the 2 Team Meeting events.
const series = await events.drillInto("Team meeting");
await series.expectRowCount(2);
await series.expectAllTitles("Team Meeting");
await series.expectTotal(2);
// Single-tab series modal: drilled in with only `nameKey` set, so no
// category/recurring tab and the tab bar is suppressed (see source-level
// `tabs.length >= 2` guard). Pin this so a regression that started passing
// `categoryValues` here can't silently widen the modal.
await series.expectNoTabBar();
// Bases footer is wired for the name series too.
await series.expectBasesAction("timeline");
});
test("Bases visualisations receive the right payload (timeline rows, heatmap pro gate)", async ({ calendar }) => {
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
const series = await events.drillInto("Work");
// Baseline modal count — the Events modal + Series modal are open on top of each other.
const baseline = await calendar.page.locator(".modal").count();
// Timeline: opens with a header `<h2>` carrying the action's title string
// from `createCategorySeriesBasesActions` and renders one
// `.prisma-timeline-item` per event passed in (vis-timeline stamps the
// className onto the wrapping `.vis-item`). Two Work events → exactly two
// items; a regression that leaked Workout events would push this to 4.
await series.pickBasesView("timeline");
await calendar.page.waitForFunction((prev) => document.querySelectorAll(".modal").length > prev, baseline);
const timelineContainer = calendar.page.locator(sel("prisma-timeline-container")).first();
await expect(timelineContainer).toBeVisible();
await expect(calendar.page.locator(".prisma-timeline-modal-header h2").first()).toHaveText(
"Timeline for Category - Work"
);
await expect(timelineContainer.locator(".prisma-timeline-item")).toHaveCount(2);
await closeOpenModal(calendar.page);
await expect(calendar.page.locator(".modal")).toHaveCount(baseline);
// Heatmap: pro-gated. The unlicensed e2e vault (see pro-gates.spec.ts)
// gets the upgrade banner instead of the canvas, stamped with
// `prisma-pro-gate-HEATMAP`. Asserting the banner specifically (not just
// "any modal opened") proves the gate path is wired through the Bases
// footer too.
await series.pickBasesView("heatmap");
await expect(calendar.page.locator(sel("prisma-pro-gate-HEATMAP")).first()).toBeVisible();
await closeOpenModal(calendar.page);
await expect(calendar.page.locator(".modal")).toHaveCount(baseline);
});
});
// Recurring tab lives in its own describe so the daily-source seed (which
// generates two on-disk instances + populates `recurringEventManager`) doesn't
// contaminate the byCategory/byName precise-count assertions above. Default
// `futureInstancesCount` is 2 — see `CalendarSettingsSchema` in settings.ts.
const DEFAULT_FUTURE_INSTANCES = 2;
test.describe("analytics: events-list modal — Recurring tab", () => {
test("Recurring tab lists each source event once with its physical instance count", async ({ calendar }) => {
const todayStr = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${todayStr}T09:00`,
end: `${todayStr}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const events = await calendar.openEventsModal();
// Default tab is whichever has data; recurringCount > 0 → Recurring wins.
await events.expectTabActive("recurring", "Recurring (1)");
// Count chip and rows are owned by `RecurringEventsModalPanel`. One
// source → one row, regardless of how many physical instances exist.
await events.expectGroupCountText("1 event");
const recurringRows = events.listRows();
await expect(recurringRows).toHaveCount(1);
const onlyRow = recurringRows.first();
await expect(onlyRow.locator(".prisma-generic-event-title")).toHaveText("Daily Standup");
await expect(onlyRow.locator(".prisma-generic-event-subtitle")).toHaveText(`${DEFAULT_FUTURE_INSTANCES} instances`);
// Drill into the recurring source — opens the EventSeriesModal listing
// every physical instance. The exact row + Total assertions prove the
// modal surfaces the same files we polled for on disk above; a regression
// that drops or duplicates an instance flips this immediately.
const series = await events.drillIntoRow(onlyRow);
await series.expectRowCount(DEFAULT_FUTURE_INSTANCES);
await series.expectTotal(DEFAULT_FUTURE_INSTANCES);
await series.expectAllTitles("Daily Standup");
});
});

View file

@ -1,221 +0,0 @@
import type { Page } from "@playwright/test";
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expectAssignmentModal, expectSeriesModalOpen } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { updateCalendarSettings, type SeedEventInput } from "../../fixtures/seed-events";
import type { PrismaWindow } from "../../fixtures/window-types";
// Phase-3 coverage from docs/specs/e2e-events-modal-coverage.md.
// Round-trips and link-out behaviour for the EventsModal + EventSeriesModal:
// per-row Category button, per-row Nav button, ctrl-click → new tab, series-
// modal title click, series-row click, multi-category picker, and the
// `--source-category-color` CSS var on the series-modal root.
//
// The "real workspace" assertions read `app.workspace.getActiveFile()?.path`
// directly — opening a file via Obsidian's link API doesn't bubble through
// the calendar grid, so DOM-only assertions miss the path that ships in prod.
const DEFAULT_FUTURE_INSTANCES = 2;
function markdownLeafCount(page: Page): Promise<number> {
return page.evaluate(() => {
const w = window as unknown as PrismaWindow;
return w.app.workspace.getLeavesOfType("markdown").length;
});
}
test.describe("events modal — recurring row action buttons", () => {
test("Category button opens the assign modal and persists the chosen categories to frontmatter", async ({
calendar,
}) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const events = await calendar.openEventsModal();
await events.recurringRow("Daily Standup").clickCategory();
const assign = await expectAssignmentModal(calendar.page);
await assign.pick("Work", { createIfMissing: true });
await assign.submit();
// Disk-truth: the source frontmatter Category field carries our pick.
await evt.expectFrontmatter(
"Category",
(v) => (Array.isArray(v) ? v.includes("Work") : v === "Work"),
"expected Category to contain Work after assign-modal submit"
);
});
test("Nav button closes the events modal and switches the calendar into week view", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
// Land on month view first so Nav has somewhere to navigate FROM.
await calendar.switchMode("month");
await expect(calendar.page.locator(".fc-daygrid").first()).toBeVisible();
const events = await calendar.openEventsModal();
await events.recurringRow("Daily Standup").clickNav();
// Modal goes away; calendar is now in week view (handleNavigate hard-codes timeGridWeek).
await expect(events.modal).toHaveCount(0);
await expect(calendar.page.locator(".fc-timegrid").first()).toBeVisible();
await evt.expectVisible();
});
test("Ctrl/Cmd+click on a recurring row opens the source file in a new workspace leaf", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const baselineLeafCount = await markdownLeafCount(calendar.page);
const events = await calendar.openEventsModal();
await events.recurringRow("Daily Standup").openInNewTab();
// New workspace leaf opened on top of the modal — the source file is now active.
await expect.poll(() => calendar.activeFilePath()).toBe(evt.path);
await expect.poll(() => markdownLeafCount(calendar.page)).toBe(baselineLeafCount + 1);
});
});
test.describe("event series modal — click-to-open + category-color affordances", () => {
test("clicking the source-title heading opens the source markdown file (recurring tab)", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const events = await calendar.openEventsModal();
const series = await events.recurringRow("Daily Standup").open();
await series.clickTitle();
await expect.poll(() => calendar.activeFilePath()).toBe(evt.path);
});
test("clicking an instance row opens that physical instance's file", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const events = await calendar.openEventsModal();
const series = await events.recurringRow("Daily Standup").open();
await series.expectRowCount(DEFAULT_FUTURE_INSTANCES);
// Capture the file path of the first row before clicking — assertion
// then reads the active file and confirms it matches that exact instance.
const firstRow = series.row(0).row;
const targetPath = await firstRow.getAttribute("data-event-file-path");
expect(targetPath).toBeTruthy();
expect(targetPath).not.toBe(evt.path); // physical instance, not source
await series.row(0).click();
await expect.poll(() => calendar.activeFilePath()).toBe(targetPath);
});
test("multi-category event drills into a picker; pick → series → back returns to picker", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Cross-team Sync",
start: `${today}T10:00`,
end: `${today}T11:00`,
categories: ["Work", "Urgent"],
});
await evt.expectVisible();
// Right-click → "View category series" — passes both categoryValues, so
// the modal renders the picker first instead of jumping to one category.
await evt.rightClick("viewCategorySeries");
const series = await expectSeriesModalOpen(calendar.page);
// Picker is the "Select a category" branch — both categories surface as picker rows.
await series.expectPickerVisible();
await expect(series.pickerRows()).toHaveCount(2);
// Drill into "Work" — picker dismisses and the series view appears with the back button.
await series.pickCategory("Work");
await series.expectRowCount(1);
// Back to picker — series rows disappear, picker rows reappear.
await series.backToCategories();
await series.expectRowCount(0);
await expect(series.pickerRows()).toHaveCount(2);
// Pick the other side — proves the picker → drill is repeatable.
await series.pickCategory("Urgent");
await series.expectRowCount(1);
});
test("a colored category drills in with `--source-category-color` set on the modal root", async ({ calendar }) => {
const CATEGORY_COLOR = "#fa00aa";
// Color rule for Category-includes('Work') with our hex — `categoryTracker.getCategoryColor("Work")`
// reads this back through `resolveCategoryColor` and the series modal picks it up.
await updateCalendarSettings(calendar.page, {
colorRules: [
{
id: "rule-work",
expression: "Category.includes('Work')",
color: CATEGORY_COLOR,
enabled: true,
},
],
});
const events: SeedEventInput[] = [
{
title: "Team Meeting",
startDate: todayStamp(9, 0),
endDate: todayStamp(10, 0),
category: "Work",
},
{
title: "Strategy Review",
startDate: todayStamp(11, 0),
endDate: todayStamp(12, 0),
category: "Work",
},
];
await calendar.seedAndStabilize(events);
const eventsModal = await calendar.openEventsModal();
await eventsModal.switchTab("by-category");
const series = await eventsModal.drillInto("Work");
await series.expectCategoryColorVar(CATEGORY_COLOR);
});
});

View file

@ -1,154 +0,0 @@
import { todayISO } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Phase-1 coverage from docs/specs/e2e-events-modal-coverage.md.
// Pins the EventsModal's Recurring-tab controls (type filter, show-disabled
// toggle, auto-flip, disable round-trip) and the cross-tab search/sort/default
// behaviour. Every assertion is precise — counts, frontmatter, button labels —
// because the modal's job is to mirror disk state truthfully.
//
// Each test seeds its own recurring sources from scratch via
// `calendar.createEvent({recurring})` so the recurringEventManager is
// populated through the real path — no shortcuts that bypass the
// production indexer + generator pipeline.
const DEFAULT_FUTURE_INSTANCES = 2;
test.describe("events modal — Recurring-tab controls", () => {
test("type filter narrows the visible rows to the chosen recurrence preset", async ({ calendar }) => {
const today = todayISO();
const daily = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await daily.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const weekly = await calendar.createEvent({
title: "Weekly Review",
start: `${today}T10:00`,
end: `${today}T11:00`,
recurring: { rruleType: "weekly" },
});
await weekly.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
const events = await calendar.openEventsModal();
await events.expectTabActive("recurring", "Recurring (2)");
await events.expectGroupCountText("2 events");
await expect(events.listRows()).toHaveCount(2);
// daily-only — Weekly Review must drop out.
await events.setRecurringTypeFilter("daily");
await events.expectGroupCountText("1 of 2 events");
await expect(events.listRows()).toHaveCount(1);
await events.recurringRow("Daily Standup").expectType("daily");
await expect(events.recurringRow("Weekly Review").row).toHaveCount(0);
// weekly-only — flip again, opposite expectation.
await events.setRecurringTypeFilter("weekly");
await events.expectGroupCountText("1 of 2 events");
await expect(events.listRows()).toHaveCount(1);
await events.recurringRow("Weekly Review").expectType("weekly");
await expect(events.recurringRow("Daily Standup").row).toHaveCount(0);
// Reset shows both again.
await events.setRecurringTypeFilter("all");
await events.expectGroupCountText("2 events");
await expect(events.listRows()).toHaveCount(2);
});
test("show-disabled toggle is hidden until a row is disabled, then flips the active pool", async ({ calendar }) => {
const today = todayISO();
const evt = await calendar.createEvent({
title: "Daily Standup",
start: `${today}T09:00`,
end: `${today}T09:30`,
recurring: { rruleType: "daily" },
});
await evt.expectInstanceCount(DEFAULT_FUTURE_INSTANCES);
// No disabled events yet — toggle should not be in the DOM.
const initial = await calendar.openEventsModal();
expect(await initial.hasShowDisabledOnlyToggle()).toBe(false);
// Disable the only row via its own action button. The pool should empty out
// because there are no enabled rows left, and the modal auto-flips to the
// disabled view (RecurringEventsModalPanel's effect, not a manual click).
const row = initial.recurringRow("Daily Standup");
await row.expectBadgeLabel("Daily");
await row.expectInstanceCountText(DEFAULT_FUTURE_INSTANCES);
await row.clickToggle();
// Disk-truth: the disable button stamps Skip=true on the source frontmatter.
await evt.expectFrontmatter("Skip", (v) => v === true, "expected source Skip=true after Disable");
// Toggle now exists, and we're already viewing the disabled pool — the
// auto-flip kicks in because the enabled pool is empty.
await expect.poll(() => initial.hasShowDisabledOnlyToggle()).toBe(true);
await initial.expectGroupCountText("1 event");
await expect(initial.listRows()).toHaveCount(1);
await initial.recurringRow("Daily Standup").expectBadgeLabel("Daily");
// Re-enable from the disabled view; pool flips back to 0 disabled, 1 enabled.
await initial.recurringRow("Daily Standup").clickToggle();
await evt.expectFrontmatter("Skip", (v) => v !== true, "expected source Skip cleared after Enable");
await expect.poll(() => initial.hasShowDisabledOnlyToggle()).toBe(false);
await initial.expectGroupCountText("1 event");
await expect(initial.listRows()).toHaveCount(1);
});
test("default tab is byCategory when there are no recurring events", async ({ calendar }) => {
const today = todayISO();
// Two distinct categories so byCategory has data; no recurring sources.
await calendar.seedAndStabilize([
{
title: "Team Meeting",
startDate: `${today}T09:00`,
endDate: `${today}T10:00`,
category: "Work",
},
{
title: "Workout",
startDate: `${today}T12:00`,
endDate: `${today}T13:00`,
category: "Fitness",
},
]);
const events = await calendar.openEventsModal();
await events.expectTabActive("by-category");
await events.expectGroupCountText("2 category groups");
});
test("search filters group items in the active tab", async ({ calendar }) => {
const today = todayISO();
await calendar.seedAndStabilize([
{ title: "Alpha Standup", startDate: `${today}T09:00`, endDate: `${today}T10:00`, category: "Work" },
{ title: "Beta Review", startDate: `${today}T10:00`, endDate: `${today}T11:00`, category: "Work" },
{ title: "Gamma Session", startDate: `${today}T11:00`, endDate: `${today}T12:00`, category: "Personal" },
]);
const events = await calendar.openEventsModal();
await events.switchTab("by-category");
await events.expectGroupCountText("2 category groups");
await expect(events.groupItems()).toHaveCount(2);
// "work" matches Work, drops Personal.
await events.search("work");
await expect(events.groupItems()).toHaveCount(1);
await expect(events.groupItem("Work")).toBeVisible();
// "zzz" matches nothing → empty state, count chip drops to 0 of 2.
await events.search("zzz");
await expect(events.groupItems()).toHaveCount(0);
// Clearing brings everything back.
await events.search("");
await expect(events.groupItems()).toHaveCount(2);
});
});

View file

@ -1,43 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { expectUniqueVisibleEventCount } from "../../fixtures/stress-helpers";
import { FILTER_EXPRESSION_TID, LIST_MODAL_TID, sel } from "../../fixtures/testids";
// The "Show filtered events" command opens a list of every event that is
// CURRENTLY hidden by the active filter (search input or expression). It
// proves the filter→hidden-set pipeline: expression filter mutates the
// view's hidden array, command reads it, modal renders the diff.
// No unit/RTL tier sees the bundle wiring between filter input and command.
test.describe("analytics: Show filtered events modal", () => {
test("modal lists the events excluded by the active expression filter", async ({ calendar }) => {
// Three Work + two Personal events. Filter to Work only — Personal pair
// is the hidden set we expect the modal to surface.
await calendar.seedOnDiskMany([
{ title: "Work Alpha", start: todayStamp(9, 0), end: todayStamp(10, 0), category: "Work" },
{ title: "Work Beta", start: todayStamp(10, 0), end: todayStamp(11, 0), category: "Work" },
{ title: "Work Gamma", start: todayStamp(11, 0), end: todayStamp(12, 0), category: "Work" },
{ title: "Personal Delta", start: todayStamp(13, 0), end: todayStamp(14, 0), category: "Personal" },
{ title: "Personal Epsilon", start: todayStamp(14, 0), end: todayStamp(15, 0), category: "Personal" },
]);
const expr = calendar.page.locator(sel(FILTER_EXPRESSION_TID)).first();
await expr.fill("Category === 'Work'");
await expr.press("Enter");
// Calendar now shows 3 Work events.
await expectUniqueVisibleEventCount(calendar.page, 3);
await calendar.runCommand("Prisma Calendar: Show filtered events");
const modal = calendar.page.locator(sel(LIST_MODAL_TID)).first();
await expect(modal).toBeVisible();
// Exactly the two Personal events appear in the filtered list.
await expect(modal.locator(`[data-event-title="Personal Delta"]`)).toHaveCount(1);
await expect(modal.locator(`[data-event-title="Personal Epsilon"]`)).toHaveCount(1);
await expect(modal.locator(`[data-event-title="Work Alpha"]`)).toHaveCount(0);
await expect(modal.locator(`[data-event-title="Work Beta"]`)).toHaveCount(0);
await expect(modal.locator(`[data-event-title="Work Gamma"]`)).toHaveCount(0);
});
});

View file

@ -1,209 +0,0 @@
import { fromAnchor } from "../../fixtures/dates";
import { type CalendarHandle } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { assignPrerequisiteViaUI, ganttBarLocator, rightClickGanttBar } from "../../fixtures/helpers";
import { updateCalendarSettings } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
import type { PrismaWindow } from "../../fixtures/window-types";
// Gantt filters events to only those connected in the prerequisite graph
// (`normalize-events.ts`: `tracker.isConnected(filePath)`), so standalone
// events never produce bars. This suite seeds two connected events via
// real-UI context-menu wiring whenever it needs visible bars. The rest of
// the cases drive the renderer's chrome directly (nav buttons, search,
// presets) against an unlocked-Pro, empty Gantt.
//
// Filter inputs (`prisma-filter-search`, `-preset`, `-expression`) are
// shared between the calendar view's toolbar filters and gantt's own
// view-filter-bar, so both render into the same leaf when tabs swap.
// Every filter locator is scoped under `.prisma-gantt-wrapper` to avoid
// `.first()` landing on the hidden calendar-view copy.
//
// Pro-locked gating for Gantt is covered in pro-gates.spec.ts — this file
// always unlocks Pro before switching to the tab.
const GANTT_SCOPE = ".prisma-gantt-wrapper";
/**
* Collapse Obsidian's left sidebar so the file-explorer pane doesn't overlap
* the gantt canvas in headless runs the sidebar stays open by default and
* intercepts right-clicks on bars rendered near the left edge.
*/
async function collapseLeftSidebar(calendar: CalendarHandle): Promise<void> {
await calendar.page.evaluate(() => {
const w = window as unknown as PrismaWindow;
if (w.app.workspace.leftSplit && !w.app.workspace.leftSplit.collapsed) {
w.app.workspace.leftSplit.collapse();
}
// Collapsed ribbon strip still overlaps the gantt canvas left edge
document.querySelector(".workspace-ribbon.mod-left")?.remove();
});
}
async function openGantt(calendar: CalendarHandle): Promise<void> {
await calendar.unlockPro();
await calendar.switchView("gantt");
await expect(calendar.page.locator(sel("prisma-gantt-create")).first()).toBeVisible();
}
async function setupTwoConnectedEvents(calendar: CalendarHandle): Promise<void> {
// Headless Obsidian keeps the left file-explorer sidebar open, and its
// fixed position can overlap the gantt canvas — right-clicks on bars near
// the left edge get absorbed by the sidebar. Collapse it up-front so the
// gantt pane owns the full width.
await collapseLeftSidebar(calendar);
await calendar.switchMode("month");
// Pin the month viewport to the anchor so both tiles (anchor + anchor+10)
// are rendered regardless of what day-of-week the suite runs on.
await calendar.goToAnchor();
await calendar.seedMany([
{ title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Downstream Task", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) },
]);
await assignPrerequisiteViaUI(calendar.page, "Downstream Task", "Upstream Task");
await calendar.unlockPro();
await calendar.switchView("gantt");
// Gantt viewport centers on today, but events are seeded at the anchor
// (last Wednesday) — pan back so the anchor-dated bars are in view.
await calendar.page.locator('.prisma-gantt-nav button[aria-label="Back 1 week"]').click();
}
test.describe("analytics: gantt", () => {
test("renders toolbar chrome (create, nav, filter bar) when Pro is unlocked", async ({ calendar }) => {
await openGantt(calendar);
await expect(calendar.page.locator(sel("prisma-pro-gate-GANTT"))).toHaveCount(0);
const nav = calendar.page.locator(".prisma-gantt-nav");
await expect(nav.locator('button[aria-label="Back 1 month"]')).toBeVisible();
await expect(nav.locator('button[aria-label="Back 1 week"]')).toBeVisible();
await expect(nav.locator(".prisma-gantt-today-btn")).toBeVisible();
await expect(nav.locator('button[aria-label="Forward 1 week"]')).toBeVisible();
await expect(nav.locator('button[aria-label="Forward 1 month"]')).toBeVisible();
await expect(calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-search")}`)).toBeVisible();
await expect(calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-preset")}`)).toBeVisible();
await expect(calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-expression")}`)).toBeVisible();
});
test("Create button opens the event create modal", async ({ calendar }) => {
await openGantt(calendar);
await calendar.page.locator(sel("prisma-gantt-create")).first().click();
const titleInput = calendar.page.locator(`.modal ${sel(TID.event.control("title"))}`).first();
await expect(titleInput).toBeVisible();
await expect(titleInput).toHaveValue("");
await calendar.page
.locator(`.modal ${sel(TID.event.btn("cancel"))}`)
.first()
.click();
});
test("nav buttons shift the viewport; Today returns to the current month", async ({ calendar }) => {
await openGantt(calendar);
const nav = calendar.page.locator(".prisma-gantt-nav");
const monthLabels = calendar.page.locator(".prisma-gantt-month-label");
const getLabels = () => monthLabels.allInnerTexts();
const initial = await getLabels();
await nav.locator('button[aria-label="Forward 1 month"]').click();
await expect.poll(getLabels).not.toEqual(initial);
await nav.locator(".prisma-gantt-today-btn").click();
await expect.poll(getLabels).toEqual(initial);
await nav.locator('button[aria-label="Back 1 month"]').click();
await expect.poll(getLabels).not.toEqual(initial);
await nav.locator('button[aria-label="Forward 1 month"]').click();
await expect.poll(getLabels).toEqual(initial);
// Weekly nav round trip leaves the viewport exactly where it started.
await nav.locator('button[aria-label="Forward 1 week"]').click();
await nav.locator('button[aria-label="Back 1 week"]').click();
await expect.poll(getLabels).toEqual(initial);
});
test("prerequisite arrow renders between two connected events", async ({ calendar }) => {
await setupTwoConnectedEvents(calendar);
await expect(ganttBarLocator(calendar.page, "Upstream Task")).toBeVisible();
await expect(ganttBarLocator(calendar.page, "Downstream Task")).toBeVisible();
await expect(calendar.page.locator(sel("prisma-gantt-bar"))).toHaveCount(2);
await expect(calendar.page.locator(sel("prisma-gantt-arrow"))).toHaveCount(1);
});
test("right-clicking a bar opens the shared context menu; 'edit' opens the edit modal", async ({ calendar }) => {
await setupTwoConnectedEvents(calendar);
await rightClickGanttBar(calendar.page, "Upstream Task");
await calendar.page.locator(".menu").first().waitFor({ state: "visible" });
// Gantt bar menu uses bare `edit` — calendar-tile menu uses `editEvent`.
// `edit` isn't in ContextMenuItemKey so click by raw testid.
await calendar.page.locator(sel("prisma-context-menu-item-edit")).first().click();
await expect(calendar.page.locator(sel(TID.event.control("title"))).first()).toHaveValue("Upstream Task");
await calendar.page
.locator(sel(TID.event.btn("cancel")))
.first()
.click();
});
test("search input filters bars; arrow disappears when one endpoint is hidden", async ({ calendar }) => {
await setupTwoConnectedEvents(calendar);
await expect(calendar.page.locator(sel("prisma-gantt-bar"))).toHaveCount(2);
await expect(calendar.page.locator(sel("prisma-gantt-arrow"))).toHaveCount(1);
const search = calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-search")}`);
await search.fill("Upstream");
await search.press("Enter");
// Only Upstream survives — Downstream is filtered out, so the arrow's
// target endpoint is gone and the arrow isn't laid out at all.
await expect(calendar.page.locator(sel("prisma-gantt-bar"))).toHaveCount(1);
await expect(ganttBarLocator(calendar.page, "Upstream Task")).toBeVisible();
await expect(calendar.page.locator(sel("prisma-gantt-arrow"))).toHaveCount(0);
await search.fill("");
await search.press("Enter");
await expect(calendar.page.locator(sel("prisma-gantt-bar"))).toHaveCount(2);
await expect(calendar.page.locator(sel("prisma-gantt-arrow"))).toHaveCount(1);
});
test("filter preset dropdown populates and clears the expression input", async ({ calendar }) => {
await updateCalendarSettings(calendar.page, {
filterPresets: [
{ name: "Work only", expression: "Category === 'Work'" },
{ name: "Fitness", expression: "Category === 'Fitness'" },
],
});
await openGantt(calendar);
const presetSelect = calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-preset")}`);
const expressionInput = calendar.page.locator(`${GANTT_SCOPE} ${sel("prisma-filter-expression")}`);
await expect(presetSelect.locator("option")).toContainText(["Clear", "Work only", "Fitness"]);
await expect(expressionInput).toHaveValue("");
await presetSelect.selectOption({ label: "Work only" });
await expect(expressionInput).toHaveValue("Category === 'Work'");
await presetSelect.selectOption({ label: "Fitness" });
await expect(expressionInput).toHaveValue("Category === 'Fitness'");
await presetSelect.selectOption({ label: "Clear" });
await expect(expressionInput).toHaveValue("");
});
});

View file

@ -1,35 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Global search toolbar action (`global-search`) opens a modal that can match
// events by title or frontmatter across the entire vault. This spec seeds a
// few events, opens the modal, types a query, and asserts the search
// surfaces the matching row.
test.describe("analytics: global search modal", () => {
test("opens on toolbar click, lists events, filters on typed query", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Project Planning", start: todayStamp(9, 0), end: todayStamp(10, 0) },
{ title: "Weekly Review", start: todayStamp(11, 0), end: todayStamp(12, 0) },
]);
await calendar.clickToolbar("global-search");
const modalRoot = calendar.page.locator(sel("prisma-list-modal")).first();
await expect(modalRoot).toBeVisible();
const searchInput = modalRoot.locator(sel("prisma-list-search")).first();
await searchInput.waitFor({ state: "visible" });
// Scope to the modal — calendar tiles also use `data-event-title` behind the overlay.
const rowByTitle = (title: string) => modalRoot.locator(`[data-event-title="${title}"]`);
await expect(rowByTitle("Project Planning").first()).toBeVisible();
await expect(rowByTitle("Weekly Review").first()).toBeVisible();
await searchInput.fill("Weekly");
await expect(rowByTitle("Project Planning")).toHaveCount(0);
await expect(rowByTitle("Weekly Review").first()).toBeVisible();
});
});

View file

@ -1,28 +0,0 @@
import { anchorISO, fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Heatmap (Pro) renders one SVG cell per day of the current period with the
// cell's event count stamped as `data-count`. Seeding three events on the
// anchor day (past-Wednesday, always inside the current heatmap window)
// means the anchor cell must surface a count of 3 — see
// `docs/specs/e2e-date-anchor-robustness.md`.
test.describe("analytics: heatmap (populated)", () => {
test("seeded events accumulate on the anchor day's cell", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Morning Standup", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 9, 30) },
{ title: "Design Review", start: fromAnchor(0, 13, 0), end: fromAnchor(0, 14, 0) },
{ title: "Workout", start: fromAnchor(0, 18, 0), end: fromAnchor(0, 19, 0) },
]);
await calendar.unlockPro();
await calendar.switchView("heatmap");
const container = calendar.page.locator(sel("prisma-heatmap-container")).first();
await expect(container).toBeVisible();
const anchorCell = container.locator(`${sel("prisma-heatmap-cell")}[data-date="${anchorISO()}"]`).first();
await expect(anchorCell).toHaveAttribute("data-count", "3");
});
});

View file

@ -1,74 +0,0 @@
import { anchorDayISO, fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
const LEFT_CELL = ".prisma-monthly-calendar-stats-grid-cell[data-col='0']";
// Monthly + Stats is the new default tab — a month-locked FullCalendar on the
// left paired with the monthly stats renderer on the right. This spec covers:
// 1. It is the active tab on first paint (no click needed).
// 2. The legacy Heatmap Monthly + Stats tab is hidden from the tab bar by
// default — users must restore it via the tab manager.
// 3. Seeded events surface in the right-hand stats panel (event count,
// duration stat) for the anchor month.
// 4. Prev/next/today toolbar buttons on the embedded calendar sync the
// stats panel's rendered month — proves the `onDateChange` wire.
// The electron fixture fails any test that emits a renderer console.error,
// so the monthly calendar's full refresh path is implicitly exercised too.
test.describe("analytics: monthly + stats (populated)", () => {
test("seeded anchor-month events surface in the stats panel", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Morning Standup", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 9, 30) },
{ title: "Design Review", start: fromAnchor(0, 13, 0), end: fromAnchor(0, 14, 0) },
{ title: "Workout", start: fromAnchor(0, 18, 0), end: fromAnchor(0, 19, 0) },
]);
await calendar.switchView("monthly-calendar-stats");
await calendar.page.locator(sel("prisma-stats-date-label")).first().waitFor({ state: "visible" });
await calendar.goToEmbeddedAnchor(LEFT_CELL);
const statsCell = calendar.page.locator(".prisma-monthly-calendar-stats-grid-cell[data-col='1']").first();
await expect(calendar.page.locator(sel("prisma-stats-empty"))).toHaveCount(0);
await expect(statsCell.locator(sel("prisma-stats-total-count"))).toContainText("3 events");
await expect(statsCell).toContainText("Morning Standup");
await expect(statsCell).toContainText("Design Review");
await expect(statsCell).toContainText("Workout");
});
test("calendar month navigation syncs the stats panel", async ({ calendar }) => {
const nextMonthOffsetDays = 30;
await calendar.seedOnDiskMany([
{ title: "Anchor Event", start: fromAnchor(0, 10, 0), end: fromAnchor(0, 11, 0) },
{
title: "Next Month Event",
start: fromAnchor(nextMonthOffsetDays, 10, 0),
end: fromAnchor(nextMonthOffsetDays, 11, 0),
},
]);
await calendar.switchView("monthly-calendar-stats");
await calendar.goToEmbeddedAnchor(LEFT_CELL);
const anchorD = new Date(anchorDayISO(0));
const anchorMonthLabel = anchorD.toLocaleDateString("en-US", { month: "long", year: "numeric" });
const dateLabel = calendar.page.locator(sel("prisma-stats-date-label")).first();
const countLabel = calendar.page.locator(sel("prisma-stats-total-count")).first();
const statsCell = calendar.page.locator(".prisma-monthly-calendar-stats-grid-cell[data-col='1']").first();
await expect(dateLabel).toHaveText(anchorMonthLabel);
await expect(countLabel).toHaveText(/1 events/);
await expect(statsCell).toContainText("Anchor Event");
const leftCell = calendar.page.locator(LEFT_CELL).first();
await leftCell.locator(".fc-next-button").click();
await expect(dateLabel).not.toHaveText(anchorMonthLabel);
await expect(statsCell).toContainText("Next Month Event");
await expect(statsCell).not.toContainText("Anchor Event");
await leftCell.locator(".fc-prev-button").click();
await expect(dateLabel).toHaveText(anchorMonthLabel);
await expect(statsCell).toContainText("Anchor Event");
await expect(statsCell).not.toContainText("Next Month Event");
});
});

View file

@ -1,118 +0,0 @@
import { fromAnchor, todayStamp } from "../../fixtures/dates";
import { type CalendarHandle } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { assignPrerequisiteViaUI, ganttBarLocator } from "../../fixtures/helpers";
import { updateCalendarSettings } from "../../fixtures/seed-events";
import { sel } from "../../fixtures/testids";
const COLOR_A = "#ff0000";
const COLOR_B = "#00ff00";
const COLOR_C = "#0000ff";
const ACTIVE_LEAF = ".workspace-leaf.mod-active";
async function seedMultiColorRules(calendar: CalendarHandle): Promise<void> {
await updateCalendarSettings(calendar.page, {
colorMode: "2",
showEventColorDots: true,
colorRules: [
{ id: "rule-a", expression: "Category.includes('Alpha')", color: COLOR_A, enabled: true },
{ id: "rule-b", expression: "Category.includes('Beta')", color: COLOR_B, enabled: true },
{ id: "rule-c", expression: "Category.includes('Gamma')", color: COLOR_C, enabled: true },
],
});
}
test.describe("multi-color across views", () => {
test("calendar event tile shows gradient and overflow dots when multiple color rules match", async ({ calendar }) => {
await seedMultiColorRules(calendar);
await calendar.switchMode("month");
await calendar.seedOnDiskMany([
{
title: "Multi Category Task",
start: todayStamp(10, 0),
end: todayStamp(11, 0),
categories: ["Alpha", "Beta", "Gamma"],
},
]);
const tile = calendar.page
.locator(`${ACTIVE_LEAF} [data-testid="prisma-cal-event"][data-event-title="Multi Category Task"]`)
.first();
await expect(tile).toBeVisible();
await expect
.poll(() => tile.evaluate((el) => (el as HTMLElement).style.backgroundImage), {
message: "expected gradient on calendar tile",
})
.toContain("linear-gradient");
await expect
.poll(() => tile.locator(".prisma-event-color-dots .prisma-day-color-dot").count(), {
message: "expected 1 overflow color dot on the event tile",
})
.toBe(1);
});
test("gantt bars show gradient when event matches multiple color rules", async ({ calendar }) => {
await seedMultiColorRules(calendar);
await calendar.switchMode("month");
await calendar.goToAnchor();
await calendar.seedMany([
{
title: "Upstream Multi",
start: fromAnchor(0, 9, 0),
end: fromAnchor(0, 10, 0),
categories: ["Alpha", "Beta", "Gamma"],
},
{
title: "Downstream Multi",
start: fromAnchor(10, 14, 0),
end: fromAnchor(10, 15, 0),
categories: ["Alpha", "Beta"],
},
]);
await assignPrerequisiteViaUI(calendar.page, "Downstream Multi", "Upstream Multi");
await calendar.unlockPro();
await calendar.switchView("gantt");
const upstreamBar = ganttBarLocator(calendar.page, "Upstream Multi");
await expect(upstreamBar).toBeVisible();
await expect
.poll(() => upstreamBar.evaluate((el) => (el as HTMLElement).style.backgroundImage), {
message: "expected gradient on gantt bar",
})
.toContain("linear-gradient");
});
test("timeline items show gradient when event matches multiple color rules", async ({ calendar }) => {
await seedMultiColorRules(calendar);
await calendar.seedOnDiskMany([
{
title: "Timeline Multi",
start: todayStamp(10, 0),
end: todayStamp(11, 0),
categories: ["Alpha", "Beta", "Gamma"],
},
]);
await calendar.switchView("timeline");
const container = calendar.page.locator(sel("prisma-timeline-container")).first();
await expect(container).toBeVisible();
const item = container.locator(".vis-item.prisma-timeline-item").first();
await expect(item).toBeVisible();
await expect
.poll(() => item.evaluate((el) => (el as HTMLElement).getAttribute("style") ?? ""), {
message: "expected gradient on timeline item",
})
.toContain("linear-gradient");
});
});

View file

@ -1,22 +0,0 @@
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Heatmap / Heatmap Monthly + Stats / Gantt / Dashboard are Pro-gated. One
// spec walks them in sequence on an unlicensed vault — each should swap its
// UI for the upgrade banner. Gating is cheap logic (`isPro ? render :
// banner`) and is covered unit-level in `pro-gated-content.test.ts`; this
// spec is the last-resort E2E that proves the real user path still surfaces
// the gate.
test.describe("analytics: pro gates (unlicensed)", () => {
test("every pro-gated analytics view shows its upgrade banner", async ({ calendar }) => {
await calendar.switchView("heatmap");
await expect(calendar.page.locator(sel("prisma-pro-gate-HEATMAP")).first()).toBeVisible();
await calendar.switchView("gantt");
await expect(calendar.page.locator(sel("prisma-pro-gate-GANTT")).first()).toBeVisible();
await calendar.switchToGroupChild("dashboard", "dashboard-by-name");
await expect(calendar.page.locator(sel("prisma-pro-gate-DASHBOARD")).first()).toBeVisible();
});
});

View file

@ -1,63 +0,0 @@
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { expectUniqueVisibleEventCount } from "../../fixtures/stress-helpers";
import { LIST_MODAL_TID, sel } from "../../fixtures/testids";
// The "Show skipped events" command opens a list of every event currently
// hidden by `Skip: true`. Un-skipping from the modal must:
// 1. clear `Skip` on disk
// 2. drop the row from the modal
// 3. re-render the event on the calendar
//
// Each step crosses a different boundary (palette → React modal → command
// manager → metadataCache → calendar render). RTL can't fake the full chain.
test.describe("analytics: Show skipped events modal", () => {
test("Un-skip from modal restores the event on the calendar and clears Skip in frontmatter", async ({ calendar }) => {
// Two events pre-skipped via frontmatter at seed time (arrange state,
// not the act under test) + one visible event so the modal must
// filter down to exactly the skipped pair.
const skippedA = await calendar.seedOnDisk("Skipped Alpha", {
"Start Date": todayStamp(9, 0),
"End Date": todayStamp(10, 0),
Skip: true,
});
await calendar.seedOnDisk("Skipped Beta", {
"Start Date": todayStamp(11, 0),
"End Date": todayStamp(12, 0),
Skip: true,
});
await calendar.seedOnDisk("Visible Gamma", {
"Start Date": todayStamp(13, 0),
"End Date": todayStamp(14, 0),
});
// Sanity: calendar shows only the visible one.
await expectUniqueVisibleEventCount(calendar.page, 1);
await calendar.runCommand("Prisma Calendar: Show skipped events");
const modal = calendar.page.locator(sel(LIST_MODAL_TID)).first();
await expect(modal).toBeVisible();
const skippedRow = modal.locator(`[data-event-title="Skipped Alpha"]`);
const otherSkipped = modal.locator(`[data-event-title="Skipped Beta"]`);
const visibleRow = modal.locator(`[data-event-title="Visible Gamma"]`);
await expect(skippedRow).toHaveCount(1);
await expect(otherSkipped).toHaveCount(1);
await expect(visibleRow).toHaveCount(0);
// Un-skip Alpha — its row drops, Beta stays.
await skippedRow.getByRole("button", { name: "Un-skip" }).click();
await expect(skippedRow).toHaveCount(0);
await expect(otherSkipped).toHaveCount(1);
// Frontmatter on disk no longer carries Skip: true.
await expect.poll(() => readEventFrontmatter(calendar.vaultDir, skippedA.path)["Skip"]).toBeUndefined();
// Calendar now renders Alpha alongside the original visible event.
await expectUniqueVisibleEventCount(calendar.page, 2);
});
});

View file

@ -1,93 +0,0 @@
import { closeOpenModal, switchAggregationToCategory } from "../../fixtures/analytics-helpers";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Phase-2 stats coverage: create timed events with categories, then verify
// daily/weekly/monthly/alltime ranges each surface the events and their
// aggregated totals. Daily is a view tab; the other three are modal dialogs
// launched from toolbar buttons.
test.describe("analytics: stats (populated, numeric)", () => {
test.beforeEach(async ({ calendar }) => {
// Seed: 3× "Work" (30m each) + 2× "Personal" (45m each). Work total = 1h
// 30m, Personal total = 1h 30m — easy to eyeball.
await calendar.seedOnDiskMany([
{ title: "Work Block A", start: todayStamp(9, 0), end: todayStamp(9, 30), category: "Work" },
{ title: "Work Block B", start: todayStamp(10, 0), end: todayStamp(10, 30), category: "Work" },
{ title: "Work Block C", start: todayStamp(11, 0), end: todayStamp(11, 30), category: "Work" },
{ title: "Personal Block A", start: todayStamp(14, 0), end: todayStamp(14, 45), category: "Personal" },
{ title: "Personal Block B", start: todayStamp(15, 0), end: todayStamp(15, 45), category: "Personal" },
]);
});
test("daily-stats view surfaces all 5 today's events grouped by category", async ({ calendar }) => {
await calendar.switchView("daily-stats");
// Empty-state must be gone.
await expect(calendar.page.locator(sel("prisma-stats-empty"))).toHaveCount(0);
// Total duration label is populated (5 events, format varies by settings).
await expect(calendar.page.locator(sel("prisma-stats-total-duration")).first()).toBeVisible();
// Switch aggregation to Category to make assertions deterministic.
await switchAggregationToCategory(calendar.page);
// Category rows should show the correct event counts: Work (3), Personal (2).
await expect(calendar.page.locator(sel("prisma-stats-entry-count-Work")).first()).toHaveText("3");
await expect(calendar.page.locator(sel("prisma-stats-entry-count-Personal")).first()).toHaveText("2");
});
test("daily-stats total and per-category durations match the seeded totals exactly", async ({ calendar }) => {
// Seed math: Work = 3 × 30m = 1h 30m, Personal = 2 × 45m = 1h 30m,
// total = 3h. Locks the aggregation pipeline against accidental
// double-counting or off-by-30s rounding regressions.
await calendar.switchView("daily-stats");
await expect(calendar.page.locator(sel("prisma-stats-total-duration")).first()).toHaveText("⏱ 3h");
await switchAggregationToCategory(calendar.page);
await expect(calendar.page.locator(sel("prisma-stats-entry-duration-Work")).first()).toHaveText("1h 30m");
await expect(calendar.page.locator(sel("prisma-stats-entry-duration-Personal")).first()).toHaveText("1h 30m");
});
test("weekly-stats modal shows the seeded categories", async ({ calendar }) => {
await calendar.clickToolbar("weekly-stats");
await expect(calendar.page.locator(".modal").first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-stats-modal-period-label")).first()).toBeVisible();
// Weekly includes today's events (today falls inside this week).
// Table contains event names — assert at least the count row exists for a seeded title.
await expect(calendar.page.locator(sel("prisma-stats-table")).first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-stats-entry-Work Block A")).first()).toBeVisible();
await closeOpenModal(calendar.page);
});
test("monthly-stats modal aggregates today's work into Work category bucket", async ({ calendar }) => {
await calendar.clickToolbar("monthly-stats");
await expect(calendar.page.locator(".modal").first()).toBeVisible();
// Monthly modal's table has per-event rows by default. Confirm Work-series
// titles are present as entries.
await expect(calendar.page.locator(sel("prisma-stats-entry-Work Block A")).first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-stats-entry-Personal Block A")).first()).toBeVisible();
await closeOpenModal(calendar.page);
});
test("alltime-stats modal includes every seeded event", async ({ calendar }) => {
await calendar.clickToolbar("alltime-stats");
await expect(calendar.page.locator(".modal").first()).toBeVisible();
// All-time covers the entire vault; all 5 seeded titles must be rows.
const table = calendar.page.locator(sel("prisma-stats-table")).first();
await table.waitFor({ state: "visible" });
for (const title of ["Work Block A", "Work Block B", "Work Block C", "Personal Block A", "Personal Block B"]) {
await expect(table.locator(sel(`prisma-stats-entry-${title}`)).first()).toBeVisible();
}
await closeOpenModal(calendar.page);
});
});

View file

@ -1,35 +0,0 @@
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
test.describe("analytics: stats (populated)", () => {
test("daily-stats surfaces events created through the UI", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Work Session A", start: todayStamp(9, 0), end: todayStamp(10, 0) },
{ title: "Work Session B", start: todayStamp(14, 0), end: todayStamp(14, 30) },
]);
await calendar.switchView("daily-stats");
await expect(calendar.page.locator(sel("prisma-stats-empty"))).toHaveCount(0);
await expect(calendar.page.locator(".prisma-stats-content")).toContainText("Work Session A");
await expect(calendar.page.locator(".prisma-stats-content")).toContainText("Work Session B");
});
test("daily-stats lists both the all-day event and the timed event", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Holiday", date: todayISO(), allDay: true },
{ title: "Team Meeting", start: todayStamp(9, 0), end: todayStamp(10, 0) },
]);
await calendar.switchView("daily-stats");
await expect(calendar.page.locator(sel("prisma-stats-empty"))).toHaveCount(0);
const table = calendar.page.locator(sel("prisma-stats-table")).first();
await table.waitFor({ state: "visible" });
await expect(table.locator(sel("prisma-stats-entry-Holiday")).first()).toBeVisible();
await expect(table.locator(sel("prisma-stats-entry-Team Meeting")).first()).toBeVisible();
// The all-day event contributes 0 duration but bumps the entry count.
await expect(calendar.page.locator(sel("prisma-stats-entry-count-Holiday")).first()).toHaveText("1");
});
});

View file

@ -1,26 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// Timeline tab renders events via vis-timeline. Individual day markers are
// drawn by the library on a continuous axis — there's no per-day DOM element
// to query. Each seeded event does render as a `.prisma-timeline-item` on
// the axis, so we assert on item count once the container is ready.
test.describe("analytics: timeline (populated)", () => {
test("seeded events render as timeline items inside the container", async ({ calendar }) => {
await calendar.seedOnDiskMany([
{ title: "Morning Standup", start: todayStamp(9, 0), end: todayStamp(9, 30) },
{ title: "Design Review", start: todayStamp(13, 0), end: todayStamp(14, 0) },
{ title: "Workout", start: todayStamp(18, 0), end: todayStamp(19, 0) },
]);
await calendar.switchView("timeline");
const container = calendar.page.locator(sel("prisma-timeline-container")).first();
await expect(container).toBeVisible();
const items = container.locator(".prisma-timeline-item");
await expect(items).toHaveCount(3);
});
});

View file

@ -1,102 +0,0 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { createEventViaToolbar, saveEventModal } from "../../fixtures/helpers";
import { sel, TID } from "../../fixtures/testids";
// `TitleInputSuggest` surfaces matches from three sources: categories,
// event presets, and past event-name series. It renders inside Obsidian's
// `.suggestion-container` portal and now stamps each row with
// `prisma-title-suggest-item`. This spec drives a category-sourced
// suggestion — categories are deterministic (created on the fly by the
// first event) unlike name-series which relies on frequency history.
const SUGGEST_ITEM = sel("prisma-title-suggest-item");
test.describe("title autocomplete", () => {
test("typing a category prefix surfaces the category suggestion and fills the input on click", async ({
calendar,
}) => {
// Seed the categoryTracker by creating an event that uses the category.
await calendar.createEvent({
title: "Focus Block",
start: todayStamp(9, 0),
end: todayStamp(10, 0),
categories: ["Deep Work"],
});
await createEventViaToolbar(calendar.page);
const titleInput = calendar.page.locator(`.modal ${sel(TID.event.control("title"))}`).first();
await titleInput.focus();
await titleInput.fill("Deep");
await titleInput.dispatchEvent("input");
const suggestion = calendar.page
.locator(`${SUGGEST_ITEM}[data-suggest-source="category"][data-suggest-text="Deep Work"]`)
.first();
await expect(suggestion).toBeVisible();
await suggestion.click();
await expect(titleInput).toHaveValue("Deep Work");
await calendar.page
.locator(`.modal ${sel(TID.event.btn("cancel"))}`)
.first()
.click();
});
// Bug regression: typing "Planni" with the ghost suggesting "Planning",
// pressing Enter to accept, then Save resulted in `Planni-<zettel>.md`
// — the suggester wrote "Planning" into the DOM but never told the React
// form-state controller, so Save committed the typed prefix instead of
// the chosen text. The on-disk filename is the only truth here.
test("accepting a suggestion then saving writes the suggestion text to disk (not the typed prefix)", async ({
calendar,
}) => {
// Seed a name-series entry so typing a prefix produces a ghost match.
// The prior event becomes a frequency-1 name-series item under "Planning".
await calendar.createEvent({
title: "Planning",
start: todayStamp(9, 0),
end: todayStamp(10, 0),
});
const eventsDir = join(calendar.vaultDir, "Events");
const beforeFiles = existsSync(eventsDir) ? readdirSync(eventsDir) : [];
expect(beforeFiles.filter((f) => f.startsWith("Planning-")).length).toBe(1);
await createEventViaToolbar(calendar.page);
const titleInput = calendar.page.locator(`.modal ${sel(TID.event.control("title"))}`).first();
await titleInput.focus();
// Type the prefix that the ghost completes to the existing "Planning".
await titleInput.fill("Planni");
await titleInput.dispatchEvent("input");
const suggestion = calendar.page
.locator(`${SUGGEST_ITEM}[data-suggest-source="name-series"][data-suggest-text="Planning"]`)
.first();
await expect(suggestion).toBeVisible();
// Click is equivalent to pressing Enter on a highlighted suggestion —
// both route through AbstractInputSuggest.selectSuggestion → onAcceptTitle.
await suggestion.click();
// React picked up the hand-off and re-rendered the input to the chosen text.
await expect(titleInput).toHaveValue("Planning");
await saveEventModal(calendar.page);
// Two events with that name on disk — the seeded one plus the just-
// saved suggestion-accepted one. Critically: zero files whose basename
// is just "Planni" (the bug fingerprint).
await expect
.poll(() => readdirSync(eventsDir).filter((f) => f.startsWith("Planning-") && f.endsWith(".md")).length, {
message: "Save must persist the suggestion text (Planning), not the typed prefix (Planni)",
})
.toBe(2);
expect(readdirSync(eventsDir).filter((f) => /^Planni-\d{14}\.md$/.test(f))).toEqual([]);
});
});

View file

@ -1,28 +0,0 @@
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// The FullCalendar toolbar hosts an "⋮" untracked-events dropdown. Users
// click it to see events that have no start/end date, or to create a new
// untracked event via the inline "+ Create untracked event" button.
test.describe("analytics: untracked events dropdown", () => {
test("toggle button opens the dropdown with a search input and create button", async ({ calendar }) => {
await calendar.openUntrackedDropdown();
// Search input and create button should appear inside the dropdown.
await expect(calendar.page.locator(sel("prisma-untracked-search")).first()).toBeVisible();
await expect(calendar.page.locator(sel("prisma-untracked-create")).first()).toBeVisible();
});
test("clicking Create opens the untracked-event create modal", async ({ calendar }) => {
await calendar.openUntrackedDropdown();
await calendar.page.locator(sel("prisma-untracked-create")).first().click();
// `openCreateUntrackedEventModal` opens a schema form modal with a dedicated
// container class — confirm it surfaces on top.
await expect(calendar.page.locator(".modal.prisma-untracked-event-modal").first()).toBeVisible();
await calendar.page.keyboard.press("Escape");
});
});

View file

@ -1,29 +0,0 @@
import { test } from "../../fixtures/electron";
import type { ViewTabKey } from "../../fixtures/testids";
// Rapidly cycle every analytics tab a few times — catches teardown/re-render
// regressions (leaks, stale subs, null-deref in cleanup paths). The electron
// fixture fails the test if any renderer console.error or pageerror fires
// during the run, so the assertion is implicit.
//
// `dashboard` is a group tab (children: by-name / by-category / recurring),
// so we drill into the first child instead of clicking the parent (which
// would just leave the dropdown open).
const LEAF_TABS: ReadonlyArray<ViewTabKey> = [
"calendar",
"timeline",
"daily-stats",
"monthly-calendar-stats",
"dual-daily",
"heatmap",
"gantt",
];
test.describe("analytics: view switching smoke", () => {
test("cycling all tabs 3× raises no renderer errors", async ({ calendar }) => {
for (let i = 0; i < 3; i++) {
for (const tab of LEAF_TABS) await calendar.switchView(tab);
await calendar.switchToGroupChild("dashboard", "dashboard-by-name");
}
});
});

View file

@ -1,40 +0,0 @@
import { createPrismaApi } from "../../fixtures/api-helpers";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec for the AI surface (`aiQuery`). The action body is
// non-deterministic (LLM call), but the response *envelope* is contractual:
// `{ success: boolean, error?: string, response?: string, mode?: AIMode,
// operations?, validationErrors?, executionResult? }`.
//
// What this spec proves:
// 1. The action exists on the window surface and is callable.
// 2. The bundle-resolution failure path returns `{ success: false, error }`
// — not a thrown exception. Consumers depend on the envelope shape;
// throwing instead of returning would break every caller.
//
// What this spec does NOT prove:
// - LLM response correctness (non-deterministic, would need a live AI key).
// - End-to-end planning / manipulation flow.
//
// Those belong in an integration tier with a recorded or mocked AI backend.
test.describe("plugin api contract — AI surface via window.PrismaCalendar", () => {
test("aiQuery against an unknown calendarId returns the failure envelope, not throws", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const result = await api.aiQuery({
message: "List events for today",
calendarId: "does-not-exist",
mode: "query",
});
expect(result.success).toBe(false);
// The error message is contractual — callers branch on its presence.
expect(typeof result.error).toBe("string");
expect(result.error!.length).not.toBe(0);
});
});

View file

@ -1,130 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { createPrismaApi, waitForAllIndexed } from "../../fixtures/api-helpers";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec for the four batch actions in `window.PrismaCalendar.*`.
// Drives the same gateway entry-point a consumer plugin (or external script)
// would — collect filePaths, call `batchX({ filePaths })`, assert frontmatter
// on disk and (where it matters) confirms files are gone.
//
// We use `todayStamp` because this spec never opens or asserts on a
// FullCalendar viewport — the anchor-week robustness rule doesn't apply.
test.describe("plugin api contract — batch via window.PrismaCalendar", () => {
test("batchMarkAsDone → batchToggleSkip → batchDelete on 5 events", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Seed 5 timed events. `todayStamp(hour)` puts each event in a different
// hour slot so the frontmatter is unambiguously distinct.
const titles = ["Team Meeting", "Weekly Review", "Workout", "Project Planning", "Standup"];
const filePaths = await Promise.all(
titles.map(async (title, i) => {
const path = await api.createEvent({
title,
start: todayStamp(9 + i),
end: todayStamp(10 + i),
allDay: false,
});
expect(typeof path).toBe("string");
return path!;
})
);
await waitForAllIndexed(api, filePaths);
try {
// ── batchMarkAsDone ────────────────────────────────────────
expect(await api.batchMarkAsDone({ filePaths })).toBe(true);
// Frontmatter cross-check: every event has the "done" status property
// stamped. The actual key/value comes from `settings.statusProperty`
// and `settings.doneValue` — we read whichever the default seed uses.
for (const path of filePaths) {
const event = await api.getEventByPath({ filePath: path });
expect(event, `getEventByPath returned null for ${path} after batchMarkAsDone`).not.toBeNull();
expect(event!.status).toBeTruthy();
}
// ── batchToggleSkip ────────────────────────────────────────
expect(await api.batchToggleSkip({ filePaths })).toBe(true);
for (const path of filePaths) {
const event = await api.getEventByPath({ filePath: path });
expect(event).not.toBeNull();
expect(event!.skipped).toBe(true);
}
// Disk cross-check: frontmatter on disk reflects the skip flag.
// The skip property key is `Skip` per default settings; reading the
// raw frontmatter avoids depending on the API's serialisation layer.
// Poll because the frontmatter write is debounced relative to the
// API return — the in-memory `event.skipped` flips first, the
// on-disk `Skip:` field flushes a tick later.
for (const path of filePaths) {
await expect.poll(() => readEventFrontmatter(obsidian.vaultDir, path)["Skip"]).toBe(true);
}
} finally {
// ── batchDelete ────────────────────────────────────────────
// Lives in the finally block so a mid-spec assertion failure still
// tries to clean up. Asserting the delete result + on-disk absence
// proves the cleanup actually happened.
expect(await api.batchDelete({ filePaths })).toBe(true);
for (const path of filePaths) {
expect(existsSync(join(obsidian.vaultDir, path)), `${path} should be gone after batchDelete`).toBe(false);
}
}
});
test("batchMarkAsUndone clears the done flag set by batchMarkAsDone", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const filePaths = await Promise.all(
[1, 2, 3].map(async (n) => {
const path = await api.createEvent({
title: `Event ${n}`,
start: todayStamp(13 + n),
end: todayStamp(14 + n),
allDay: false,
});
return path!;
})
);
await waitForAllIndexed(api, filePaths);
try {
expect(await api.batchMarkAsDone({ filePaths })).toBe(true);
// Capture the post-done frontmatter Status — this is the sentinel we
// expect `batchMarkAsUndone` to flip away from. The frontmatter write
// is debounced, so poll each path until Status appears before snapshotting.
const doneStatusByPath = new Map<string, unknown>();
for (const path of filePaths) {
await expect.poll(() => readEventFrontmatter(obsidian.vaultDir, path)["Status"]).toBeTruthy();
doneStatusByPath.set(path, readEventFrontmatter(obsidian.vaultDir, path)["Status"]);
}
expect(await api.batchMarkAsUndone({ filePaths })).toBe(true);
// After undone, frontmatter Status must differ from the done sentinel.
// A regression where `batchMarkAsUndone` no-ops would leave the value
// unchanged — `.not.toBe(undefined)` couldn't catch that, but a strict
// inequality against the captured done value can. Poll-until-flipped
// covers the debounced write.
for (const path of filePaths) {
await expect
.poll(() => readEventFrontmatter(obsidian.vaultDir, path)["Status"])
.not.toBe(doneStatusByPath.get(path));
}
} finally {
expect(await api.batchDelete({ filePaths })).toBe(true);
}
});
});

View file

@ -1,141 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createPrismaApi, waitForApiIndex } from "../../fixtures/api-helpers";
import { todayStamp } from "../../fixtures/dates";
import {
MULTI_CALENDAR_PRIMARY_DIR,
MULTI_CALENDAR_PRIMARY_ID,
MULTI_CALENDAR_SECONDARY_DIR,
MULTI_CALENDAR_SECONDARY_ID,
testMultiCalendar as test,
} from "../../fixtures/electron";
import { openCalendarView, waitForWorkspaceReady } from "../events/events-helpers";
const expect = test.expect;
// Tier 1 cross-calendar contract spec. Exercises `moveEventToCalendar` —
// the highest-risk action because it crosses bundle boundaries and the
// concurrent-stores rewrite at `cross-calendar-undo.spec.ts` proved this
// path has historical flake.
//
// What this spec proves:
// 1. `moveEventToCalendar` returns a `PrismaMoveEventToCalendarResult`-shaped
// envelope (schema-validated via the generated `PrismaCalendarApi` type).
// 2. After a successful move, the file is physically gone from the source
// bundle's directory and present in the target bundle's directory.
// 3. The post-move file is reachable via `getEventByPath` under its new
// path, so the indexer caught up on both bundles.
//
// We use `todayStamp` because no FullCalendar viewport is asserted on —
// the proof is filesystem + API readback, not DOM.
test.describe("plugin api contract — cross-calendar via window.PrismaCalendar", () => {
test.beforeEach(async ({ calendar }) => {
await waitForWorkspaceReady(calendar.page);
});
test("moveEventToCalendar relocates a primary event to the secondary bundle", async ({ calendar, obsidian }) => {
// Pro is required: the gateway only exposes the full API surface in Pro.
await calendar.unlockPro();
// Activate the primary bundle so subsequent createEvent calls without
// an explicit `calendarId` fall through to it. We still pass
// `calendarId: "primary"` explicitly below to keep the spec
// independent of "last-used bundle" resolution.
await openCalendarView(calendar.page, MULTI_CALENDAR_PRIMARY_ID);
const api = createPrismaApi(obsidian.page);
// Seed a tracked event in the primary bundle.
const originalPath = (await api.createEvent({
title: "Cross Calendar Subject",
start: todayStamp(10),
end: todayStamp(11),
allDay: false,
calendarId: MULTI_CALENDAR_PRIMARY_ID,
}))!;
// Sanity: lives under the primary calendar's directory.
expect(originalPath.startsWith(MULTI_CALENDAR_PRIMARY_DIR)).toBe(true);
await waitForApiIndex(api, originalPath);
// ── moveEventToCalendar ────────────────────────────────────────
const moveResult = await api.moveEventToCalendar({
filePath: originalPath,
targetCalendarId: MULTI_CALENDAR_SECONDARY_ID,
calendarId: MULTI_CALENDAR_PRIMARY_ID,
});
// Envelope shape is type-checked by the generated PrismaCalendarApi.
expect(moveResult.success).toBe(true);
expect(moveResult.error).toBeUndefined();
expect(moveResult.movedFilePath).toBeDefined();
const newPath = moveResult.movedFilePath!;
// New path must live under the secondary calendar's directory.
expect(newPath.startsWith(MULTI_CALENDAR_SECONDARY_DIR)).toBe(true);
// ── Disk cross-check ───────────────────────────────────────────
// The old file is gone, the new file exists. Filesystem state is the
// authoritative truth for "did the move happen" — the API claim is
// only credible if disk matches.
expect(existsSync(join(obsidian.vaultDir, originalPath))).toBe(false);
expect(existsSync(join(obsidian.vaultDir, newPath))).toBe(true);
// ── Indexer cross-check ────────────────────────────────────────
// `getEventByPath(newPath)` must resolve through the secondary bundle's
// indexer. Polling proves the post-move re-index actually fired.
await waitForApiIndex(api, newPath);
try {
const event = await api.getEventByPath({ filePath: newPath });
expect(event).not.toBeNull();
expect(event!.title).toBe("Cross Calendar Subject");
expect(event!.type).toBe("timed");
} finally {
// Clean up the moved file — secondary bundle owns it now.
expect(
await api.deleteEvent({
filePath: newPath,
calendarId: MULTI_CALENDAR_SECONDARY_ID,
})
).toBe(true);
}
});
test("moveEventToCalendar with unknown targetCalendarId returns success:false envelope, not throw", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
await openCalendarView(calendar.page, MULTI_CALENDAR_PRIMARY_ID);
const api = createPrismaApi(obsidian.page);
const originalPath = (await api.createEvent({
title: "Unmoved Event",
start: todayStamp(13),
end: todayStamp(14),
allDay: false,
calendarId: MULTI_CALENDAR_PRIMARY_ID,
}))!;
await waitForApiIndex(api, originalPath);
try {
const result = await api.moveEventToCalendar({
filePath: originalPath,
targetCalendarId: "does-not-exist",
calendarId: MULTI_CALENDAR_PRIMARY_ID,
});
// Even the failure path must serialise as a valid envelope —
// callers depend on `{ success, error }` rather than catching.
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
// File should still be at its original path — failure is "no-op",
// not "partial move."
expect(existsSync(join(obsidian.vaultDir, originalPath))).toBe(true);
} finally {
await api.deleteEvent({ filePath: originalPath, calendarId: MULTI_CALENDAR_PRIMARY_ID });
}
});
});

View file

@ -1,184 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { defineCrudContractSuite, runContractSuite } from "@real1ty-obsidian-plugins/testing/api-contract";
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { createPrismaApi, pageEvaluateInvoker, waitForApiIndex } from "../../fixtures/api-helpers";
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec: exercises the real `window.PrismaCalendar.*` surface
// through a Playwright-driven CRUD loop. Lives in this folder because the
// gateway action map is the *contract surface* the rest of the monorepo
// depends on — a regression here is a load-bearing API regression, not just a
// UI bug.
//
// All assertions go through the API; on-disk frontmatter is the secondary
// proof. We never reach into stores, app.* internals, or DOM clicks — the
// invocation site is identical to what a consumer plugin or external script
// would do.
//
// Why `todayStamp` over `fromAnchor`: this spec never opens or asserts on a
// FullCalendar viewport, so the anchor-week robustness rule does not apply.
// We use plain today-relative timestamps for deterministic frontmatter
// assertions.
test.describe("plugin api contract — CRUD via window.PrismaCalendar", () => {
test("create → read → edit → markAsDone → toggleSkip → delete", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Sanity: the API surface is actually exposed under the canonical key.
await expect(
obsidian.page.evaluate(() => {
const surface = (window as unknown as Record<string, unknown>)["PrismaCalendar"];
return surface !== null && typeof surface === "object"
? Object.keys(surface as Record<string, unknown>).sort()
: [];
})
).resolves.toContain("createEvent");
const start = todayStamp(9);
const end = todayStamp(10);
const editedEnd = todayStamp(11);
// Pre-create the event so subsequent steps can rely on the indexed path.
// The created path is captured into the suite as the "create" step result
// the same way runContractSuite would have done.
const createdPath = await api.createEvent({
title: "Project Planning",
start,
end,
allDay: false,
categories: ["Work"],
});
expect(typeof createdPath).toBe("string");
expect(createdPath).toMatch(/^Events\/Project Planning.*\.md$/);
await waitForApiIndex(api, createdPath!);
// Drive the contract suite via the untyped Invoker — `runContractSuite`
// works at the wire level so it can replay against any transport
// (in-process vitest, page.evaluate). The typed `api` proxy above is
// the everyday DSL; this suite is the rare callsite where the loose
// surface is the right tool.
const suite = defineCrudContractSuite({
name: "window-api-crud",
steps: [
{
name: "create",
action: "getEventByPath",
params: { filePath: createdPath },
expect: (result) => {
expect(result).toMatchObject({
title: "Project Planning",
type: "timed",
allDay: false,
categories: ["Work"],
});
},
},
{
name: "edit",
// Edit only `end` — changing `title` would rename the file via
// frontmatter title sync and invalidate `createdPath` for the rest
// of the loop. Title-rename has its own dedicated coverage.
action: "editEvent",
params: { filePath: createdPath, end: editedEnd },
expect: (result) => expect(result).toBe(true),
},
{
name: "readAfterEdit",
action: "getEventByPath",
params: { filePath: createdPath },
expect: (result) => {
const event = result as { title: string; end?: string };
expect(event.title).toBe("Project Planning");
// Prisma normalises ISO timestamps to second precision on write
// (`ensureISOSuffix`), so `HH:mm` round-trips as `HH:mm:00`.
expect(event.end).toMatch(new RegExp(`^${editedEnd}(?::\\d{2})?$`));
},
},
{
name: "markAsDone",
action: "markAsDone",
params: { filePath: createdPath },
expect: (result) => expect(result).toBe(true),
},
{
name: "toggleSkip",
action: "toggleSkip",
params: { filePath: createdPath },
expect: (result) => expect(result).toBe(true),
},
{
name: "readAfterStatus",
action: "getEventByPath",
params: { filePath: createdPath },
expect: (result) => {
expect(result).toMatchObject({ skipped: true });
},
},
{
name: "delete",
action: "deleteEvent",
params: { filePath: createdPath },
expect: (result) => expect(result).toBe(true),
},
{
name: "readAfterDelete",
action: "getEventByPath",
params: { filePath: createdPath },
expect: (result) => expect(result).toBeNull(),
},
],
});
await runContractSuite(suite, { invoke: pageEvaluateInvoker(obsidian.page, "PrismaCalendar") });
// Frontmatter cross-check: the event file is gone after delete.
expect(existsSync(join(obsidian.vaultDir, createdPath!))).toBe(false);
});
test("list operations: getAllEvents, getEvents range query, getCategories", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const today = todayISO();
const rangeStart = `${today}T00:00`;
const rangeEnd = `${today}T23:59`;
const created = await api.createEvent({
title: "Workout",
start: todayStamp(14),
end: todayStamp(15),
allDay: false,
categories: ["Personal"],
});
expect(typeof created).toBe("string");
await waitForApiIndex(api, created!);
try {
const all = await api.getAllEvents({});
expect(all.some((e) => e.filePath === created)).toBe(true);
const ranged = await api.getEvents({ start: rangeStart, end: rangeEnd });
expect(ranged.some((e) => e.filePath === created)).toBe(true);
const categories = await api.getCategories({});
expect(categories.some((c) => c.name === "Personal")).toBe(true);
// Cross-check the created file actually exists on disk with the
// expected category frontmatter. Single-element lists collapse to a
// bare string under Prisma's `assignListToFrontmatter` contract; only
// multi-element lists serialise as YAML arrays.
expect(existsSync(join(obsidian.vaultDir, created!))).toBe(true);
const fm = readEventFrontmatter(obsidian.vaultDir, created!);
expect(fm).toMatchObject({ Category: "Personal" });
} finally {
expect(await api.deleteEvent({ filePath: created! })).toBe(true);
}
});
});

View file

@ -1,62 +0,0 @@
import { createPrismaApi, waitForApiAction } from "../../fixtures/api-helpers";
import { PLUGIN_ID } from "../../fixtures/constants";
import { expect, test } from "../../fixtures/electron";
import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types";
// Tier 1 contract spec for the license-surface actions (`activate`, `isPro`).
// Every other spec gates Pro via the `__setProForTesting` backdoor on the
// licenseManager — none exercise the real window-API contract surface. This
// spec proves:
//
// 1. `activate({ key })` returns undefined and does not throw, even with an
// empty key (the short-circuit path that bypasses the license server).
// 2. `isPro()` returns a boolean and reflects the underlying licenseManager
// state through the window-API path (not the internal accessor).
//
// We do NOT exercise activation with a real key — that would require live
// network access to the license server. Network-bound activation has its own
// coverage in the license-server integration tier; here we only prove the
// envelope contract.
test.describe("plugin api contract — license surface via window.PrismaCalendar", () => {
test("isPro reflects the licenseManager state through the window-API path", async ({ calendar, obsidian }) => {
const api = createPrismaApi(obsidian.page);
// Baseline: no license activated yet. The seed in `electron.ts` does not
// set a license key, so isPro starts false. The DSL helper `unlockPro`
// uses the same `__setProForTesting` backdoor — we drive it ourselves
// here to keep both directions of the boolean within one test.
expect(await api.isPro()).toBe(false);
await calendar.unlockPro();
expect(await api.isPro()).toBe(true);
// Flip back to false and reassert — proves isPro reads through to the
// live state, not a cached snapshot.
await obsidian.page.evaluate((pid) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
plugin?.licenseManager?.__setProForTesting?.(false);
}, PLUGIN_ID);
expect(await api.isPro()).toBe(false);
});
test("activate with an empty key short-circuits to status:none without throwing", async ({ calendar, obsidian }) => {
// `activate` is only attached to `window.PrismaCalendar` after the
// licenseManager subscription fires `apiManager.expose()`. Unlocking
// via the testing backdoor triggers that synchronously, but the
// page.evaluate may still arrive before the event loop tick that
// publishes the new window key — poll to be safe.
await calendar.unlockPro();
await waitForApiAction(obsidian.page, "PrismaCalendar", "activate");
const api = createPrismaApi(obsidian.page);
// Empty key: `refreshLicense` reads `licenseKey === ""` (falsy), short-
// circuits to `updateStatus("none")`, and never touches the network.
// Proves the activate handler doesn't throw for the empty-key path —
// callers depend on void-return semantics here.
const result = await api.activate({ key: "" });
expect(result).toBeUndefined();
});
});

View file

@ -1,264 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { readEventFrontmatter, seedMarkdownNote } from "@real1ty-obsidian-plugins/testing/e2e";
import { createPrismaApi, waitForApiIndex } from "../../fixtures/api-helpers";
import { PLUGIN_ID } from "../../fixtures/constants";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import type { PrismaPlugin, PrismaWindow } from "../../fixtures/window-types";
// Tier 1 contract spec for the event-lifecycle actions that no other window-API
// spec exercises end-to-end. Covers timestamp arithmetic (cloneEvent /
// moveEvent), the file→event bridge (convertFileToEvent), and the virtual
// round-trip (makeEventVirtual ↔ makeEventReal).
//
// We use `todayStamp` because no FullCalendar viewport is asserted on — the
// proof is filesystem + API readback.
test.describe("plugin api contract — event lifecycle via window.PrismaCalendar", () => {
test("cloneEvent offsets the clone's start by offsetMs and returns the new path", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const originalPath = (await api.createEvent({
title: "Team Meeting",
start: todayStamp(9),
end: todayStamp(10),
allDay: false,
}))!;
await waitForApiIndex(api, originalPath);
try {
const offsetMs = 60 * 60 * 1000; // +1h
const clonedPath = await api.cloneEvent({ filePath: originalPath, offsetMs });
expect(typeof clonedPath).toBe("string");
expect(clonedPath).not.toBe(originalPath);
// Disk cross-check: both files exist, clone's Start Date is +1h.
expect(existsSync(join(obsidian.vaultDir, originalPath))).toBe(true);
expect(existsSync(join(obsidian.vaultDir, clonedPath!))).toBe(true);
const originalFm = readEventFrontmatter(obsidian.vaultDir, originalPath);
const clonedFm = readEventFrontmatter(obsidian.vaultDir, clonedPath!);
const originalStartMs = new Date(String(originalFm["Start Date"])).getTime();
const clonedStartMs = new Date(String(clonedFm["Start Date"])).getTime();
// Exact ms-level equality — the offset arithmetic is integer math, no
// rounding involved. Anything else is a regression.
expect(clonedStartMs - originalStartMs).toBe(offsetMs);
// Indexer cross-check: clone is reachable via the read API.
await waitForApiIndex(api, clonedPath!);
const clonedEvent = await api.getEventByPath({ filePath: clonedPath! });
expect(clonedEvent).not.toBeNull();
expect(clonedEvent!.title).toBe("Team Meeting");
} finally {
await api.batchDelete({ filePaths: [originalPath] });
}
});
test("cloneEvent against a nonexistent path returns null", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(await api.cloneEvent({ filePath: "Events/Does Not Exist.md", offsetMs: 0 })).toBeNull();
});
test("moveEvent shifts Start Date (and End Date for timed) by offsetMs", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const originalPath = (await api.createEvent({
title: "Workout",
start: todayStamp(14),
end: todayStamp(15),
allDay: false,
}))!;
await waitForApiIndex(api, originalPath);
try {
const beforeFm = readEventFrontmatter(obsidian.vaultDir, originalPath);
const beforeStartMs = new Date(String(beforeFm["Start Date"])).getTime();
const beforeEndMs = new Date(String(beforeFm["End Date"])).getTime();
const offsetMs = 30 * 60 * 1000; // +30m
expect(await api.moveEvent({ filePath: originalPath, offsetMs })).toBe(true);
// Poll the on-disk frontmatter rather than reading once: the move
// resolves as soon as the command's write returns, but a follow-up
// writeback can still be rewriting the file, and Obsidian's
// truncate-then-write adapter is non-atomic — a single immediate read
// can catch a partial file and parse NaN. Re-read until both
// timestamps settle on the +30m shift.
await expect
.poll(() => {
const afterFm = readEventFrontmatter(obsidian.vaultDir, originalPath);
const afterStartMs = new Date(String(afterFm["Start Date"])).getTime();
const afterEndMs = new Date(String(afterFm["End Date"])).getTime();
return { start: afterStartMs - beforeStartMs, end: afterEndMs - beforeEndMs };
})
.toEqual({ start: offsetMs, end: offsetMs });
} finally {
await api.batchDelete({ filePaths: [originalPath] });
}
});
test("convertFileToEvent stamps an existing note with event frontmatter and indexes it", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const seedPath = "Events/Plain Note.md";
await seedMarkdownNote(obsidian.page, seedPath, "# Plain Note\n\nNo frontmatter yet.\n");
// `convertFileToEvent` internally calls `ensureFileHasZettelId` which
// renames a note without a zettel id to `<title>-<YYYYMMDDhhmmss>.md`.
// The handler returns a bare boolean and does NOT report the renamed
// path, so we discover it via `getAllEvents` afterwards. Capturing this
// in the spec also pins the renaming behaviour as part of the contract.
let convertedPath: string | undefined;
try {
const start = todayStamp(11);
const end = todayStamp(12);
expect(
await api.convertFileToEvent({
filePath: seedPath,
start,
end,
allDay: false,
categories: ["Work"],
})
).toBe(true);
// Discover the (possibly-renamed) path by basename prefix. The original
// stem "Plain Note" remains in the basename regardless of the zettel
// suffix Prisma appends.
await expect
.poll(async () => {
const all = await api.getAllEvents({});
return all.some((e) => e.filePath.startsWith("Events/Plain Note"));
})
.toBe(true);
const all = await api.getAllEvents({});
convertedPath = all.find((e) => e.filePath.startsWith("Events/Plain Note"))!.filePath;
// Disk cross-check: frontmatter was stamped.
const fm = readEventFrontmatter(obsidian.vaultDir, convertedPath);
expect(String(fm["Start Date"])).toMatch(new RegExp(`^${start}`));
expect(String(fm["End Date"])).toMatch(new RegExp(`^${end}`));
expect(fm["Category"]).toBe("Work");
// Indexer cross-check: the converted note is now a tracked event.
const event = await api.getEventByPath({ filePath: convertedPath });
expect(event).not.toBeNull();
expect(event!.type).toBe("timed");
} finally {
if (convertedPath) {
await api.deleteEvent({ filePath: convertedPath });
}
}
});
test("convertFileToEvent against a nonexistent file returns false (no exception)", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(
await api.convertFileToEvent({
filePath: "Events/Does Not Exist.md",
start: todayStamp(9),
end: todayStamp(10),
allDay: false,
})
).toBe(false);
});
test("makeEventVirtual → makeEventReal round-trip via virtualEventStore", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const originalPath = (await api.createEvent({
title: "Project Planning",
start: todayStamp(16),
end: todayStamp(17),
allDay: false,
}))!;
await waitForApiIndex(api, originalPath);
// `createEvent` auto-appends a zettel-id suffix to the basename, so the
// virtual event's title (sourced from `file.basename` in
// ConvertToVirtualCommand) is "Project Planning-<YYYYMMDDhhmmss>", not
// the bare "Project Planning". Derive the expected basename from the
// path so the assertion stays correct regardless of clock skew.
const expectedVirtualTitle = originalPath.replace(/^Events\//, "").replace(/\.md$/, "");
let realPath: string | undefined;
try {
// ── makeEventVirtual ───────────────────────────────────────
expect(await api.makeEventVirtual({ filePath: originalPath })).toBe(true);
// The real file is gone from disk (virtual events live in the store,
// not the vault). The convert command trashes the source file.
await expect.poll(() => existsSync(join(obsidian.vaultDir, originalPath))).toBe(false);
// Out-of-band ground truth: poll the virtualEventStore directly for the
// new entry. This is a legitimate assert-side bypass — we're proving the
// store reflects the API mutation, not driving the action under test.
const virtualEventId = await obsidian.page.evaluate(
async ({ pid, title }) => {
const w = window as unknown as PrismaWindow;
const plugin = w.app.plugins.plugins[pid] as PrismaPlugin | undefined;
const bundle = plugin?.calendarBundles?.[0];
if (!bundle) throw new Error("bundle missing");
const entry = bundle.virtualEventStore.getAll().find((e) => e.title === title);
return entry?.id ?? null;
},
{ pid: PLUGIN_ID, title: expectedVirtualTitle }
);
expect(virtualEventId, "virtual event must be discoverable in the store after makeEventVirtual").not.toBeNull();
// ── makeEventReal ──────────────────────────────────────────
expect(await api.makeEventReal({ virtualEventId: virtualEventId! })).toBe(true);
// Polling: the promoted note reappears as a real file under Events/.
// `convertToReal` calls `createEventFile` which derives the new
// filename from the stored title. The basename (and the parsed
// event title) therefore CONTAINS the original stem "Project
// Planning", regardless of any zettel-id suffix the create command
// appends. Substring match on title is the most resilient probe.
await expect
.poll(async () => {
const all = await api.getAllEvents({});
return all.some((e) => e.title.includes("Project Planning"));
})
.toBe(true);
const all = await api.getAllEvents({});
const promoted = all.find((e) => e.title.includes("Project Planning"));
expect(promoted, "promoted event must be reachable via getAllEvents").toBeDefined();
realPath = promoted!.filePath;
expect(existsSync(join(obsidian.vaultDir, realPath))).toBe(true);
} finally {
if (realPath) {
await api.deleteEvent({ filePath: realPath });
}
}
});
test("makeEventReal against an unknown virtualEventId returns false (no exception)", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(await api.makeEventReal({ virtualEventId: "does-not-exist" })).toBe(false);
});
});

View file

@ -1,149 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { openNote, readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { createPrismaApi, waitForActiveFile, waitForApiIndex } from "../../fixtures/api-helpers";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec for the active-note modal-action surface plus the
// singular `markAsUndone` action. These paths share a precondition (an
// "active note" of some sort) and were each missing direct window-API
// coverage:
//
// - openCreateEventModal (no active note required — fire-and-forget)
// - openEditActiveNoteModal (returns boolean post-Phase-1 fix)
// - addZettelIdToActiveNote
// - duplicateCurrentEvent
// - markAsUndone (singular — previously only covered transitively via batch)
test.describe("plugin api contract — active-note modal actions via window.PrismaCalendar", () => {
test("openCreateEventModal opens the create modal even when no note is active", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Fire-and-forget: the action returns undefined regardless of outcome.
// We assert the modal actually appeared as the load-bearing proof.
expect(await api.openCreateEventModal({})).toBeUndefined();
await expect(obsidian.page.locator(".modal").first()).toBeVisible();
});
test("active-note actions all return false when no markdown note is open", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// The fixture opens the calendar view, not a markdown note, so
// `workspace.getActiveFile()` returns null. Every active-note action
// must surface that as `false`, not throw.
expect(await api.openEditActiveNoteModal({})).toBe(false);
expect(await api.addZettelIdToActiveNote({})).toBe(false);
expect(await api.duplicateCurrentEvent({})).toBe(false);
});
test("openEditActiveNoteModal returns true when an event note is active and opens the edit modal", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const eventPath = (await api.createEvent({
title: "Team Meeting",
start: todayStamp(9),
end: todayStamp(10),
allDay: false,
}))!;
await waitForApiIndex(api, eventPath);
try {
// `openNote` uses workspace.openLinkText — same path the user takes
// from a link click. Strip the `.md` suffix that openLinkText expects.
await openNote(obsidian.page, eventPath.replace(/\.md$/, ""));
await waitForActiveFile(obsidian.page, eventPath);
expect(await api.openEditActiveNoteModal({})).toBe(true);
await expect(obsidian.page.locator(".modal").first()).toBeVisible();
} finally {
await api.deleteEvent({ filePath: eventPath });
}
});
test("duplicateCurrentEvent on an active event creates a sibling file", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const eventPath = (await api.createEvent({
title: "Workout",
start: todayStamp(11),
end: todayStamp(12),
allDay: false,
}))!;
await waitForApiIndex(api, eventPath);
try {
await openNote(obsidian.page, eventPath.replace(/\.md$/, ""));
await waitForActiveFile(obsidian.page, eventPath);
expect(await api.duplicateCurrentEvent({})).toBe(true);
// Disk cross-check: a second event with the same title now exists.
// CloneEventCommand adds a numeric suffix to the filename. Poll the
// indexed event list until a second entry surfaces.
await expect
.poll(async () => {
const all = await api.getAllEvents({});
return all.filter((e) => e.title === "Workout").length;
})
.toBe(2);
const all = await api.getAllEvents({});
const duplicatePath = all.find((e) => e.filePath !== eventPath && e.title === "Workout")?.filePath;
expect(duplicatePath, "duplicate file path must be discoverable").toBeDefined();
expect(existsSync(join(obsidian.vaultDir, duplicatePath!))).toBe(true);
await api.deleteEvent({ filePath: duplicatePath! });
} finally {
await api.deleteEvent({ filePath: eventPath });
}
});
test("markAsUndone (singular) flips Status frontmatter back from the done sentinel", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
const eventPath = (await api.createEvent({
title: "Project Planning",
start: todayStamp(13),
end: todayStamp(14),
allDay: false,
}))!;
await waitForApiIndex(api, eventPath);
try {
// Mark done first so undone has something to flip away from. The
// frontmatter write is debounced inside the command manager, so the
// API return value can resolve before the file on disk has been
// updated — poll the disk for the `Status` field to appear before
// capturing the sentinel.
expect(await api.markAsDone({ filePath: eventPath })).toBe(true);
await expect.poll(() => readEventFrontmatter(obsidian.vaultDir, eventPath)["Status"]).toBeTruthy();
const doneStatus = readEventFrontmatter(obsidian.vaultDir, eventPath)["Status"];
expect(await api.markAsUndone({ filePath: eventPath })).toBe(true);
// Status must differ from the done sentinel — proves markAsUndone
// actually flipped the field. `.not.toBe(undefined)` alone (the
// previous batch-spec antipattern) would have allowed a no-op
// regression to pass silently. Same poll-for-disk-flush pattern as
// above so the test isn't racing the debounced writer.
await expect.poll(() => readEventFrontmatter(obsidian.vaultDir, eventPath)["Status"]).not.toBe(doneStatus);
} finally {
await api.deleteEvent({ filePath: eventPath });
}
});
});

View file

@ -1,78 +0,0 @@
import { createPrismaApi } from "../../fixtures/api-helpers";
import { todayISO } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec for `navigateToDate`. The handler activates the
// calendar view, resolves `viewRef.calendarComponent`, and calls
// `calendarComponent.navigateToDate(date, view)`. Two failure modes are
// silently-false: missing calendar component (no open view) and invalid
// date string. Both are user-reachable through the action surface and
// untested elsewhere.
//
// ─── KNOWN WEAKNESS / FUTURE WORK ────────────────────────────────────
// This spec is intentionally the *weakest* of the window-api-* family.
// It only asserts on the boolean return value — it does NOT verify that
// FullCalendar's viewport actually moved. A regression where
// `cal.gotoDate(date)` silently no-ops would still let
// `navigateToDate(...)` return true and these tests would pass.
//
// We tried the stronger version (read `cal.getDate()` after each call
// and assert it matches the target) but `cal.getDate()` throws "Cannot
// read properties of undefined (reading 'getCurrentData')" until FC's
// `currentDataManager` is wired up. Even after `calendar.goToDate(...)`
// primes FC via `gotoDate`, `getDate()` kept returning null in the
// polling helper — the spec was slower and flakier than it earned.
//
// Right shape for a future pass:
// * Add a `waitForCalendarReady(page)` helper that polls until
// `cal.currentDataManager != null && cal.view != null` before any
// getDate read. Use it as the gate for a viewport-verifying spec.
// * Once viewport reads are reliable: collapse the four boolean
// smoke tests below into one spec that drives `navigateToDate` to a
// known target, asserts FC's `getDate()` equals it, then asserts the
// bad-input variants leave it unchanged.
// * Add coverage for the `view` param (e.g. `view: "timeGridDay"`)
// and assert FC's `.view.type` flipped accordingly.
// * Cross-check that an event tile rendered at `date` is reachable via
// `eventByTitle(...)` after navigation — proves the viewport mounted
// and didn't just shift its anchor variable.
//
// Until that helper exists, treat this spec as a smoke test for
// "navigateToDate is callable and returns a boolean of the right
// polarity" — not as a behavior test.
test.describe("plugin api contract — navigation via window.PrismaCalendar", () => {
test("navigateToDate with today's ISO returns true", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(await api.navigateToDate({ date: todayISO() })).toBe(true);
});
test("navigateToDate with no input defaults to 'now' and returns true", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// `input.date` is optional — handler falls through to `new Date()`. The
// view is open in the fixture, so the call must succeed.
expect(await api.navigateToDate({})).toBe(true);
});
test("navigateToDate with an invalid date string returns false (no exception)", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// `new Date("not-a-date")` → NaN, handler short-circuits to false.
// Proves the error envelope works — a regression that lets NaN through
// would corrupt FullCalendar's internal state.
expect(await api.navigateToDate({ date: "not-a-real-date" })).toBe(false);
});
test("navigateToDate against an unknown calendarId returns false", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(
await api.navigateToDate({
date: todayISO(),
calendarId: "does-not-exist",
})
).toBe(false);
});
});

View file

@ -1,177 +0,0 @@
import type {
PrismaCalendarGetCalendarInfoOutput,
PrismaCalendarGetCategoriesOutput,
PrismaCalendarGetEventsOutput,
} from "@real1ty-obsidian-plugins/external-apis/prisma-calendar";
import { createPrismaApi, waitForApiIndex } from "../../fixtures/api-helpers";
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// The .d.ts is generated from `api-contract.json` and is type-only — no
// runtime, no obsidian transitive import. Element types are extracted via
// `[number]` (arrays) and `NonNullable<…>` (point lookups that return null).
// The drift test in `tests/api/contract-drift.test.ts` owns JSON-Schema
// conformance against the committed artifact; this spec exercises the runtime
// shape with type-checked structural assertions.
type EventOutput = PrismaCalendarGetEventsOutput[number];
type CategoryOutput = PrismaCalendarGetCategoriesOutput[number];
type CalendarInfo = NonNullable<PrismaCalendarGetCalendarInfoOutput>;
function assertEventShape(event: EventOutput): void {
expect(typeof event.filePath).toBe("string");
expect(typeof event.title).toBe("string");
expect(["timed", "allDay", "untracked"]).toContain(event.type);
expect(typeof event.allDay).toBe("boolean");
expect(typeof event.skipped).toBe("boolean");
}
function assertCategoryShape(category: CategoryOutput): void {
expect(typeof category.name).toBe("string");
expect(typeof category.color).toBe("string");
}
function assertCalendarInfoShape(info: CalendarInfo): void {
expect(typeof info.calendarId).toBe("string");
expect(typeof info.name).toBe("string");
expect(typeof info.directory).toBe("string");
expect(typeof info.enabled).toBe("boolean");
expect(typeof info.eventCount).toBe("number");
expect(typeof info.untrackedEventCount).toBe("number");
}
// Tier 1 contract spec for the read surface of `window.PrismaCalendar.*`.
//
// Coverage matrix (5 of 5 read actions exercised, 100%):
// - getEvents (range query)
// - getEventByPath (point lookup)
// - getAllEvents (collection scan)
// - getCategories (derived collection)
// - getUntrackedEvents (filter)
// - listCalendars / getCalendarInfo (metadata reads — bonus coverage)
//
// Every payload is validated against the same Zod schema the contract artifact
// (`api-contract.json`) was emitted from. That is the wire-shape proof: a
// regression where the handler returns extra/missing fields fails here and
// the schema in source remains the canonical contract.
//
// We use `todayStamp` / `todayISO` because none of these actions open a
// FullCalendar viewport.
test.describe("plugin api contract — read surface via window.PrismaCalendar", () => {
test("read surface schema-conformance + value cross-checks on a known seed", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Two tracked events with distinct categories. The test asserts that
// both surface through every applicable read action — getEvents,
// getAllEvents, getEventByPath, getCategories. Variety in categories
// proves the category-aggregation path inside getCategories.
const tracked1 = (await api.createEvent({
title: "Team Meeting",
start: todayStamp(9),
end: todayStamp(10),
allDay: false,
categories: ["Work"],
}))!;
const tracked2 = (await api.createEvent({
title: "Workout",
start: todayStamp(18),
end: todayStamp(19),
allDay: false,
categories: ["Fitness"],
}))!;
await waitForApiIndex(api, tracked1);
await waitForApiIndex(api, tracked2);
// Untracked event lacks a Start Date — `createUntrackedEvent` is the
// canonical entry point. Surfaces through getUntrackedEvents.
const untracked = (await api.createUntrackedEvent({ title: "Project Planning" }))!;
// Untracked events still show up via getEventByPath once indexed.
await waitForApiIndex(api, untracked);
const allFiles = [tracked1, tracked2, untracked];
try {
// ── getEvents (range query) ────────────────────────────────
const today = todayISO();
const ranged = await api.getEvents({ start: `${today}T00:00`, end: `${today}T23:59` });
ranged.forEach(assertEventShape);
expect(ranged.some((e) => e.filePath === tracked1)).toBe(true);
expect(ranged.some((e) => e.filePath === tracked2)).toBe(true);
// Untracked events have no Start Date → excluded from range queries.
expect(ranged.some((e) => e.filePath === untracked)).toBe(false);
// ── getEventByPath (point lookup) ──────────────────────────
const one = await api.getEventByPath({ filePath: tracked1 });
expect(one).not.toBeNull();
assertEventShape(one!);
expect(one!.title).toBe("Team Meeting");
expect(one!.categories).toEqual(["Work"]);
expect(one!.type).toBe("timed");
expect(await api.getEventByPath({ filePath: "Events/Does Not Exist.md" })).toBeNull();
// ── getAllEvents ───────────────────────────────────────────
const all = await api.getAllEvents({});
all.forEach(assertEventShape);
const allPaths = new Set(all.map((e) => e.filePath));
expect(allPaths.has(tracked1)).toBe(true);
expect(allPaths.has(tracked2)).toBe(true);
expect(allPaths.has(untracked)).toBe(true);
// ── getCategories ──────────────────────────────────────────
const cats = await api.getCategories({});
cats.forEach(assertCategoryShape);
const catNames = new Set(cats.map((c) => c.name));
expect(catNames.has("Work")).toBe(true);
expect(catNames.has("Fitness")).toBe(true);
// Every entry has a non-empty color string — proves the category
// trackers feed the colour map correctly.
for (const c of cats) {
expect(c.color.length).toBeGreaterThan(0);
}
// ── getUntrackedEvents ─────────────────────────────────────
const untrackedAll = await api.getUntrackedEvents({});
untrackedAll.forEach(assertEventShape);
expect(untrackedAll.some((e) => e.filePath === untracked)).toBe(true);
// Tracked events must NOT appear in the untracked list — proves the
// filter at the read-operations layer.
expect(untrackedAll.some((e) => e.filePath === tracked1)).toBe(false);
expect(untrackedAll.some((e) => e.filePath === tracked2)).toBe(false);
} finally {
// Clean up via batchDelete to keep this spec contained — the deletes
// in any cross-cutting suite would otherwise pollute later state.
await api.batchDelete({ filePaths: allFiles });
}
});
test("calendar metadata reads: listCalendars + getCalendarInfo schema-validate and return the default bundle", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// ── listCalendars ──────────────────────────────────────────────
const list = await api.listCalendars();
list.forEach(assertCalendarInfoShape);
expect(list.length).toBeGreaterThanOrEqual(1);
// Default seed in `electron.ts` sets the calendar id to "default".
const def = list.find((c) => c.calendarId === "default");
expect(def).toBeDefined();
expect(def!.directory).toBe("Events");
// ── getCalendarInfo (no calendarId → resolves to the active bundle) ─
const info = await api.getCalendarInfo({});
expect(info).not.toBeNull();
assertCalendarInfoShape(info!);
expect(info!.calendarId).toBe("default");
expect(info!.directory).toBe("Events");
// ── getCalendarInfo (unknown id → null) ────────────────────────
expect(await api.getCalendarInfo({ calendarId: "does-not-exist" })).toBeNull();
});
});

View file

@ -1,100 +0,0 @@
import { readPluginData } from "@real1ty-obsidian-plugins/testing/e2e";
import { createPrismaApi } from "../../fixtures/api-helpers";
import { PLUGIN_ID } from "../../fixtures/constants";
import { expect, test } from "../../fixtures/electron";
// Tier 1 contract spec for the settings actions. `getSettings` /
// `updateSettings` are the only window-API actions explicitly annotated as
// "Window-API only — URL transport cannot represent the nested settings
// object." If they regress, no other test catches it: settings have no
// command palette entry, no DOM button, and no other E2E spec drives them
// through the public window contract.
//
// Disk cross-check via `readPluginData(vaultDir, PLUGIN_ID)` proves the
// update actually persisted to `.obsidian/plugins/prisma-calendar/data.json`.
interface CalendarEntry {
id: string;
defaultDurationMinutes?: number;
showDurationField?: boolean;
}
function readDefaultCalendar(vaultDir: string): CalendarEntry {
const data = readPluginData(vaultDir, PLUGIN_ID) as { calendars?: CalendarEntry[] };
const entry = data.calendars?.find((c) => c.id === "default") ?? data.calendars?.[0];
if (!entry) throw new Error("default calendar entry missing from data.json");
return entry;
}
test.describe("plugin api contract — settings via window.PrismaCalendar", () => {
test("getSettings returns the active calendar's settings; updateSettings persists patch to data.json", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// ── getSettings (snapshot before mutation) ─────────────────────
const before = await api.getSettings({ calendarId: "default" });
expect(before, "default calendar must be resolvable").not.toBeNull();
// Pick two fields with different types so the patch exercises both
// number and boolean coercion paths through the settings store.
const newDuration = before!.defaultDurationMinutes + 30;
const newShowDurationField = !before!.showDurationField;
try {
// ── updateSettings ─────────────────────────────────────────
const updated = await api.updateSettings({
calendarId: "default",
settings: {
defaultDurationMinutes: newDuration,
showDurationField: newShowDurationField,
},
});
expect(updated).toBe(true);
// ── getSettings (verify in-memory reflects the patch) ──────
const after = await api.getSettings({ calendarId: "default" });
expect(after!.defaultDurationMinutes).toBe(newDuration);
expect(after!.showDurationField).toBe(newShowDurationField);
// ── Disk cross-check: data.json reflects the patch ─────────
// The settings store debounces persistence. Poll until the on-disk
// copy matches the in-memory snapshot — proves the action's success
// boolean is not optimistic.
await expect.poll(() => readDefaultCalendar(obsidian.vaultDir).defaultDurationMinutes).toBe(newDuration);
await expect.poll(() => readDefaultCalendar(obsidian.vaultDir).showDurationField).toBe(newShowDurationField);
} finally {
// Restore the original values so the test is idempotent (test runners
// reuse the vault when leanVaultOnClose keeps the plugin dir).
await api.updateSettings({
calendarId: "default",
settings: {
defaultDurationMinutes: before!.defaultDurationMinutes,
showDurationField: before!.showDurationField,
},
});
}
});
test("getSettings against an unknown calendarId returns null (no exception)", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(await api.getSettings({ calendarId: "does-not-exist" })).toBeNull();
});
test("updateSettings against an unknown calendarId returns false (no exception)", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
expect(
await api.updateSettings({
calendarId: "does-not-exist",
settings: { defaultDurationMinutes: 90 },
})
).toBe(false);
});
});

View file

@ -1,142 +0,0 @@
import type { PrismaCalendarGetStatisticsOutput } from "@real1ty-obsidian-plugins/external-apis/prisma-calendar";
import { createPrismaApi, pageEvaluateInvoker, waitForAllIndexed } from "../../fixtures/api-helpers";
import { todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// `getStatistics` returns `null` when the date is invalid, hence `NonNullable`.
// The drift test in `tests/api/contract-drift.test.ts` owns JSON-Schema
// conformance; this spec asserts the runtime shape with type-checked
// structural assertions.
type StatisticsOutput = NonNullable<PrismaCalendarGetStatisticsOutput>;
function assertStatisticsShape(stats: StatisticsOutput): void {
expect(typeof stats.periodStart).toBe("string");
expect(typeof stats.periodEnd).toBe("string");
expect(["day", "week", "month"]).toContain(stats.interval);
expect(["name", "category"]).toContain(stats.mode);
expect(typeof stats.totalDuration).toBe("number");
expect(typeof stats.totalDurationFormatted).toBe("string");
expect(typeof stats.totalEvents).toBe("number");
expect(typeof stats.timedEvents).toBe("number");
expect(typeof stats.allDayEvents).toBe("number");
expect(typeof stats.skippedEvents).toBe("number");
expect(typeof stats.doneEvents).toBe("number");
expect(typeof stats.undoneEvents).toBe("number");
expect(Array.isArray(stats.entries)).toBe(true);
}
// Tier 1 contract spec for the statistics aggregation surface. Exercises the
// `getStatistics` action end-to-end:
//
// 1. Seed a known set of timed events covering the current day.
// 2. Invoke `getStatistics({ interval: "day", mode: "category" })`.
// 3. Cross-check the response against `PrismaStatisticsOutputSchema` (drift
// detection at the wire-shape level — guards the JSON Schema fragment
// committed in `api-contract.json`).
// 4. Assert the per-entry counts/durations match what we seeded.
//
// We use `todayStamp` because `getStatistics` defaults to "today" — no
// FullCalendar viewport is opened, so anchor-week handling doesn't apply.
test.describe("plugin api contract — statistics via window.PrismaCalendar", () => {
test("getStatistics({ interval: 'day', mode: 'category' }) returns aggregated counts", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Seed: 2 × Work, 1 × Personal, 1 × Fitness. Each one hour, distinct slots.
const seeds = [
{ title: "Team Meeting", category: "Work", hour: 9 },
{ title: "Project Planning", category: "Work", hour: 10 },
{ title: "Workout", category: "Fitness", hour: 12 },
{ title: "Weekly Review", category: "Personal", hour: 14 },
] as const;
const filePaths = await Promise.all(
seeds.map(async (seed) => {
const path = await api.createEvent({
title: seed.title,
start: todayStamp(seed.hour),
end: todayStamp(seed.hour + 1),
allDay: false,
categories: [seed.category],
});
return path!;
})
);
await waitForAllIndexed(api, filePaths);
try {
// Default `date` to undefined → handler picks "today" via `new Date()`.
const stats = await api.getStatistics({ interval: "day", mode: "category" });
// Structural proof: the returned payload has every field the
// contract claims it does. The drift test owns the JSON-Schema
// conformance proof — this spec confirms real Obsidian + real
// events produce a structurally valid payload.
expect(stats).not.toBeNull();
assertStatisticsShape(stats!);
expect(stats!.interval).toBe("day");
expect(stats!.mode).toBe("category");
// 4 timed events seeded. The `finally` block cleans up these files,
// so each test starts from the same baseline — exact counts are
// enforceable.
expect(stats!.totalEvents).toBe(4);
expect(stats!.timedEvents).toBe(4);
expect(stats!.allDayEvents).toBe(0);
// Entry-level proof: each seeded category appears with the exact
// seeded count. A regression where the aggregator double-counts
// (or skips) one event will surface here as a precise mismatch.
const byName = new Map(stats!.entries.map((e) => [e.name, e]));
expect(byName.get("Work")?.count).toBe(2);
expect(byName.get("Personal")?.count).toBe(1);
expect(byName.get("Fitness")?.count).toBe(1);
} finally {
await api.batchDelete({ filePaths });
}
});
test("getStatistics with invalid date returns null instead of throwing", async ({ calendar, obsidian }) => {
await calendar.unlockPro();
const api = createPrismaApi(obsidian.page);
// Handler short-circuits to `null` when `new Date(input.date)` is NaN —
// proves the error envelope works the way callers depend on. Anything
// less than `null` (an exception, e.g.) would be a contract regression.
expect(await api.getStatistics({ date: "not-a-real-date" })).toBeNull();
});
test("getStatistics rejects garbage interval values via z.enum (regression: cast was unsafe)", async ({
calendar,
obsidian,
}) => {
await calendar.unlockPro();
// Typed proxy can't pass `interval: "garbage"` — it's a `z.enum` at the
// schema layer. Drop to the loose invoker for this single negative case;
// the typed surface stays the default for everything else.
const invoke = pageEvaluateInvoker(obsidian.page, "PrismaCalendar");
// Pre-Phase-1, `interval` was cast to `"day"|"week"|"month"|undefined`
// without runtime validation — garbage strings slipped through and the
// handler silently defaulted to "week". After the z.enum migration,
// invalid values are rejected at the window-API boundary. The handler
// is called with the input object as-is by the window API path (no
// protocol coercion), so the rejection surfaces as a thrown error.
//
// Verifies the cast-removal behaviour change documented in
// `docs/specs/api-contract-prisma-full-coverage.md` is actually enforced.
const errOrResult = await invoke("getStatistics", { interval: "garbage" }).catch((e) => ({ error: String(e) }));
// The window API path doesn't run inputs through Zod (only the URL
// path does). So `interval: "garbage"` reaches the handler as-is and
// the ternary falls through to week-bounds. The handler also echoes
// the invalid `interval` back in the output verbatim — a small
// pre-existing wart, out of scope here. We just check we got an
// object-shaped response and the test doesn't throw.
expect(errOrResult).not.toBeUndefined();
});
});

View file

@ -1,41 +0,0 @@
import { expect, test } from "../../fixtures/electron";
// The full Bases integration — seeding a `.base` file, selecting Prisma
// Calendar as the view type, asserting events render and clicking a row opens
// the backing .md — is intentionally deferred to a dedicated Bases sweep.
// Bases is a core Obsidian feature whose `.base` file format + view-picker
// API is not stable enough to bind E2E flows against without risking churn
// on every Obsidian release. These two smoke checks are the substitute: they
// guarantee the registration call succeeded and the view factory is live, so
// a regression that silently drops the integration still fails the suite.
test.describe("bases calendar view", () => {
test("Prisma registers its Bases view type at load", async ({ obsidian }) => {
const { page } = obsidian;
const state = await page.evaluate(() => {
const w = window as unknown as {
app: {
plugins: { plugins: Record<string, unknown> };
// Obsidian's internal bases registry lives on `app.viewRegistry`
// but isn't typed in the public API. We probe it defensively.
viewRegistry?: {
getViewCreatorByType?: (type: string) => unknown;
isExtensionRegistered?: (ext: string) => boolean;
};
internalPlugins?: {
plugins?: Record<string, { enabled?: boolean }>;
};
};
};
return {
pluginLoaded: Boolean(w.app.plugins.plugins["prisma-calendar"]),
basesCoreEnabled: Boolean(w.app.internalPlugins?.plugins?.["bases"]?.enabled),
};
});
expect(state.pluginLoaded).toBe(true);
// Bases is a core plugin enabled by default in recent Obsidian; if it
// ever disables by default, the view registration above becomes a no-op
// and this guardrail surfaces it immediately.
expect(state.basesCoreEnabled).toBe(true);
});
});

View file

@ -1,63 +0,0 @@
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { clickBatchButton, enterBatchMode, waitForBatchSelectable } from "../../fixtures/history-helpers";
import { sel, sharedTID } from "../../fixtures/testids";
// Batch → "Categories" launches the assignment modal seeded with the union
// of the selected events' current categories. Submitting writes the chosen
// set to every selected file's `Category` frontmatter.
// Per-event right-click → assignCategories is covered at
// calendar/context-menu.spec.ts:71; this spec is the batch fanout case.
test.describe("calendar: batch Categories", () => {
test("Batch → Categories assigns the chosen category to every selected event", async ({ calendar }) => {
await calendar.switchMode("week");
await calendar.goToAnchor();
const titles = ["BatchCat A", "BatchCat B", "BatchCat C"];
const handles = await calendar.seedMany(
titles.map((title, i) => ({
title,
start: fromAnchor(0, 9 + i, 0),
end: fromAnchor(0, 10 + i, 0),
}))
);
// Sanity: no category set yet.
for (const h of handles) {
const fm = readEventFrontmatter(calendar.vaultDir, h.path);
expect(fm["Category"]).toBeFalsy();
}
await enterBatchMode(calendar.page);
await waitForBatchSelectable(calendar.page, titles);
await clickBatchButton(calendar.page, "select-all");
await clickBatchButton(calendar.page, "categories");
const assignModal = calendar.page.locator(`.modal:has(${sel(sharedTID.assignSearch())})`).first();
await assignModal.waitFor({ state: "visible" });
// No "Fitness" category exists yet — create it via the search-then-create flow.
const search = assignModal.locator(sel(sharedTID.assignSearch()));
await search.fill("Fitness");
const createNew = assignModal.locator(sel(sharedTID.assignCreateNew()));
await createNew.waitFor({ state: "visible" });
await createNew.click();
await assignModal.locator(sel(sharedTID.assignSubmit())).click();
await assignModal.waitFor({ state: "hidden" });
// Every selected event's frontmatter must include the new category.
for (const h of handles) {
await expect
.poll(() => {
const v = readEventFrontmatter(calendar.vaultDir, h.path)["Category"];
const arr = Array.isArray(v) ? v : v ? [v] : [];
return arr.map(String).includes("Fitness");
})
.toBe(true);
}
});
});

View file

@ -1,57 +0,0 @@
import { fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { startsWithStamp } from "../../fixtures/helpers";
import { clickBatchButton, enterBatchMode, waitForBatchSelectable } from "../../fixtures/history-helpers";
import { FORM_SUBMIT_TID, MOVE_BY_MODAL_TID, MOVE_BY_VALUE_TID, moveByUnit, sel } from "../../fixtures/testids";
// Batch → "Move By" opens the same offset modal as the per-event right-click
// (covered for the single-event path by events/move-by-modal.spec.ts), but
// the batch path commits the shift to every selected file. The single-event
// spec proves modal UI; this spec proves the batch fanout.
// Add N calendar days to a `YYYY-MM-DDTHH:mm` stamp, keeping the same local
// time of day. Mirrors the addHours helper from move-by-modal.spec.ts.
function addDays(stamp: string, days: number): string {
const [datePart, timePart] = stamp.split("T");
if (!datePart || !timePart) throw new Error(`addDays: invalid stamp ${stamp}`);
const [y, m, d] = datePart.split("-").map((n) => Number.parseInt(n, 10));
const date = new Date(y!, m! - 1, d!);
date.setDate(date.getDate() + days);
const pad = (n: number): string => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${timePart}`;
}
test.describe("calendar: batch Move-by", () => {
test("Batch → Move By +3 days shifts Start/End on every selected event", async ({ calendar }) => {
await calendar.switchMode("week");
await calendar.goToAnchor();
const titles = ["BatchMoveBy A", "BatchMoveBy B", "BatchMoveBy C"];
const seeds = titles.map((title, i) => ({
title,
start: fromAnchor(0, 9 + i, 0),
end: fromAnchor(0, 10 + i, 0),
}));
const handles = await calendar.seedMany(seeds);
await enterBatchMode(calendar.page);
await waitForBatchSelectable(calendar.page, titles);
await clickBatchButton(calendar.page, "select-all");
await clickBatchButton(calendar.page, "move-by");
const modal = calendar.page.locator(sel(MOVE_BY_MODAL_TID)).first();
await expect(modal).toBeVisible();
await modal.locator(sel(MOVE_BY_VALUE_TID)).fill("3");
await modal.locator(sel(moveByUnit("days"))).click();
await modal.locator(sel(FORM_SUBMIT_TID)).click();
await expect(modal).toBeHidden();
// Frontmatter is truth: every selected event's Start Date and End Date
// must have shifted forward 3 calendar days while keeping local time.
for (const [i, h] of handles.entries()) {
await h.expectFrontmatter("Start Date", startsWithStamp(addDays(seeds[i]!.start, 3)));
await h.expectFrontmatter("End Date", startsWithStamp(addDays(seeds[i]!.end, 3)));
}
});
});

View file

@ -1,173 +0,0 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import {
eventByTitle,
gotoToday,
seedEventViaVault,
todayISO,
todayTimedEvent,
waitForEvent,
} from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { clickBatchButton, confirmBatchAction, enterBatchMode } from "../../fixtures/history-helpers";
import { seedEvent } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
const TITLES = ["Batch One", "Batch Two", "Batch Three"] as const;
function seedTitles(vaultDir: string): void {
TITLES.forEach((title, i) => seedEvent(vaultDir, todayTimedEvent(title, 9 + i * 2, 10 + i * 2)));
}
async function seedTitlesViaVault(page: Page): Promise<void> {
const date = todayISO();
for (let i = 0; i < TITLES.length; i++) {
await seedEventViaVault(page, {
title: TITLES[i]!,
date,
startTime: `${String(9 + i * 2).padStart(2, "0")}:00`,
endTime: `${String(10 + i * 2).padStart(2, "0")}:00`,
});
}
}
async function selectAllInBatch(page: Page): Promise<void> {
// `Select All` is a no-op against events whose mount handler hasn't yet
// stamped `prisma-batch-selectable` (happens right after batch mode
// toggles on while the most-recent event is still mounting). Wait for
// every seeded title to carry the class so the click binds all of them.
for (const title of TITLES) {
await expect(eventByTitle(page, title)).toHaveClass(/prisma-batch-selectable/);
}
await clickBatchButton(page, "select-all");
}
test.describe("batch selection operations", () => {
test.beforeEach(async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedTitles(vaultDir);
await gotoToday(page);
for (const title of TITLES) await waitForEvent(page, title);
});
test("clicking Batch → Skip sets Skip: true on every selected event", async ({ calendar }) => {
const { page, vaultDir } = calendar;
await enterBatchMode(page);
await selectAllInBatch(page);
await clickBatchButton(page, "skip");
for (const title of TITLES) {
await expect.poll(() => readEventFrontmatter(vaultDir, `Events/${title}.md`)["Skip"]).toBe(true);
}
});
test("clicking Batch → Done sets Status: done on every selected event", async ({ calendar }) => {
const { page, vaultDir } = calendar;
await enterBatchMode(page);
await selectAllInBatch(page);
await clickBatchButton(page, "mark-done");
for (const title of TITLES) {
await expect
.poll(() => String(readEventFrontmatter(vaultDir, `Events/${title}.md`)["Status"] ?? "").toLowerCase())
.toMatch(/done|true/);
}
});
test("clicking Batch → Duplicate is wired up without throwing", async ({ calendar }) => {
// CloneEventCommand races with metadataCache in the e2e launcher build
// (same story as the context-menu duplicate), so this asserts the button
// is clickable with a selection and doesn't crash the renderer.
const { page } = calendar;
await seedTitlesViaVault(page);
await enterBatchMode(page);
await selectAllInBatch(page);
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await clickBatchButton(page, "duplicate");
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("clicking Batch → Delete removes every selected file", async ({ calendar }) => {
const { page, vaultDir } = calendar;
await enterBatchMode(page);
await selectAllInBatch(page);
await clickBatchButton(page, "delete");
await confirmBatchAction(page);
for (const title of TITLES) {
await expect.poll(() => existsSync(join(vaultDir, "Events", `${title}.md`))).toBe(false);
}
});
test("clicking Batch → Clone Next is wired up", async ({ calendar }) => {
const { page } = calendar;
await seedTitlesViaVault(page);
await enterBatchMode(page);
await selectAllInBatch(page);
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await clickBatchButton(page, "clone-next");
expect(errors, errors.join("\n")).toHaveLength(0);
});
// Move next/prev both call `executeMove(±1)` — same code path, opposite
// sign on the week delta. Parametrise instead of duplicating the assertion
// block so a regression in either direction is caught symmetrically.
for (const { direction, button, expectedDays } of [
{ direction: "Next", button: "move-next", expectedDays: 7 },
{ direction: "Prev", button: "move-prev", expectedDays: -7 },
] as const) {
test(`clicking Batch → Move ${direction} shifts Start Date by ${expectedDays} days`, async ({ calendar }) => {
const { page, vaultDir } = calendar;
const before: Record<string, string> = {};
for (const title of TITLES) {
before[title] = String(readEventFrontmatter(vaultDir, `Events/${title}.md`)["Start Date"]);
}
await enterBatchMode(page);
await selectAllInBatch(page);
await clickBatchButton(page, button);
for (const title of TITLES) {
await expect
.poll(() => String(readEventFrontmatter(vaultDir, `Events/${title}.md`)["Start Date"]) !== before[title])
.toBe(true);
const after = String(readEventFrontmatter(vaultDir, `Events/${title}.md`)["Start Date"]);
const deltaMs = Date.parse(after.replace(" ", "T")) - Date.parse(before[title].replace(" ", "T"));
expect(Math.round(deltaMs / (24 * 60 * 60 * 1000))).toBe(expectedDays);
}
});
}
test("clicking Batch → Clone Prev is wired up", async ({ calendar }) => {
// Same "command runs without throwing" shape as Clone Next — the file
// races in the e2e launcher build make count-based assertions flaky.
const { page } = calendar;
await seedTitlesViaVault(page);
await enterBatchMode(page);
await selectAllInBatch(page);
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await clickBatchButton(page, "clone-prev");
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("clicking Batch → Exit toggles batch mode off", async ({ calendar }) => {
const { page } = calendar;
await enterBatchMode(page);
await page
.locator(sel(TID.toolbar("batch-exit")))
.first()
.click();
await expect(page.locator(sel(TID.toolbar("batch-select"))).first()).toBeVisible();
});
});

View file

@ -1,140 +0,0 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { gotoToday, seedEventViaVault, todayISO, todayTimedEvent, waitForEvent } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
test.describe("event context menu", () => {
test("right-click an event reveals Edit / Skip / Delete menu items", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, todayTimedEvent("Ctx Menu A", 9, 10));
await gotoToday(page);
await waitForEvent(page, "Ctx Menu A");
const evt = await calendar.eventByTitle("Ctx Menu A");
// Opening the menu is part of `rightClick(item)`, but we want to assert
// three items are present before committing to any single action. Drive
// the right-click directly and then keyboard-escape to close without
// firing onAction on any item.
const block = page.locator(`${sel(TID.block)}[data-event-title="Ctx Menu A"]`).first();
await block.click({ button: "right" });
await expect(page.locator(sel(TID.ctxMenu("editEvent"))).first()).toBeVisible();
await expect(page.locator(sel(TID.ctxMenu("skipEvent"))).first()).toBeVisible();
await expect(page.locator(sel(TID.ctxMenu("deleteEvent"))).first()).toBeVisible();
await page.keyboard.press("Escape");
void evt; // handle is ready for follow-up tests if ever needed
});
test("clicking Skip event in the context menu writes Skip: true to frontmatter", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const file = seedEvent(vaultDir, todayTimedEvent("Ctx Skip", 9, 10));
await gotoToday(page);
await waitForEvent(page, "Ctx Skip");
const evt = await calendar.eventByTitle("Ctx Skip");
await evt.rightClick("skipEvent");
await expect.poll(() => readEventFrontmatter(vaultDir, file)["Skip"]).toBe(true);
});
test("clicking Duplicate event leaves the source present (new file is plugin-side)", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const date = todayISO();
await seedEventViaVault(page, { title: "Ctx Dup", date, startTime: "09:00", endTime: "10:00" });
await gotoToday(page);
await waitForEvent(page, "Ctx Dup");
const initialCount = readdirSync(join(vaultDir, "Events")).filter((f) => f.endsWith(".md")).length;
const evt = await calendar.eventByTitle("Ctx Dup");
await evt.rightClick("duplicateEvent");
await expect(page.locator(sel(TID.ctxMenu("duplicateEvent"))).first()).not.toBeVisible();
await expect
.poll(() => readdirSync(join(vaultDir, "Events")).filter((f) => f.endsWith(".md")).length)
.toBe(initialCount + 1);
const entries = readdirSync(join(vaultDir, "Events")).filter((f) => f.endsWith(".md"));
expect(entries.some((f) => f.startsWith("Ctx Dup"))).toBe(true);
});
test("clicking Assign categories opens the picker and writes Category frontmatter", async ({ calendar }) => {
// The assign-categories flow goes through a separate modal (category
// assignment) stacked on top of the calendar view — the context menu
// item just launches it. Drive it end-to-end: search, create a new
// category (none seeded by default), submit, then assert the event's
// `Category` frontmatter lists it.
const { page, vaultDir } = calendar;
const file = seedEvent(vaultDir, todayTimedEvent("Ctx Assign Cat", 15, 16));
await gotoToday(page);
await waitForEvent(page, "Ctx Assign Cat");
const evt = await calendar.eventByTitle("Ctx Assign Cat");
await evt.rightClick("assignCategories");
const assignModal = page.locator(`.modal:has(${sel("prisma-assign-search")})`).first();
await assignModal.waitFor({ state: "visible" });
const search = assignModal.locator(sel("prisma-assign-search"));
await search.fill("Fitness");
const createNew = assignModal.locator(sel("prisma-assign-create-new"));
await createNew.waitFor({ state: "visible" });
await createNew.click();
await assignModal.locator(sel("prisma-assign-submit")).click();
await assignModal.waitFor({ state: "hidden" });
await expect
.poll(
() => {
const v = readEventFrontmatter(vaultDir, file)["Category"];
const arr = Array.isArray(v) ? v : v ? [v] : [];
return arr.map(String).includes("Fitness");
},
{ message: `Category frontmatter did not include "Fitness" in ${file}` }
)
.toBe(true);
});
test("clicking Mark as done/undone flips Status; Delete removes the file", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const file = seedEvent(vaultDir, todayTimedEvent("Ctx Menu C", 13, 14));
await gotoToday(page);
await waitForEvent(page, "Ctx Menu C");
const evt = await calendar.eventByTitle("Ctx Menu C");
await evt.rightClick("markDone");
await expect
.poll(() => String(readEventFrontmatter(vaultDir, file)["Status"] ?? "").toLowerCase())
.toMatch(/done|true/);
// With Status: Done already on disk, the markDone row rewrites its
// label to "Mark as undone". Re-open the menu and assert the live
// override before deleting — `toContainText` auto-polls until the
// rewrite lands, no manual refresh needed.
const block = page.locator(`${sel(TID.block)}[data-event-title="Ctx Menu C"]`).first();
await block.click({ button: "right" });
await expect(page.locator(sel(TID.ctxMenu("markDone"))).first()).toContainText("undone");
await page
.locator(sel(TID.ctxMenu("deleteEvent")))
.first()
.click();
const confirm = page.locator(".modal-container button", { hasText: /delete|remove|yes/i }).first();
if (await confirm.isVisible().catch(() => false)) {
await confirm.click();
}
await expect.poll(() => existsSync(join(vaultDir, file))).toBe(false);
});
});

View file

@ -1,32 +0,0 @@
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { eventByTitle, gotoToday, todayTimedEvent, waitForEvent } from "../../fixtures/calendar-helpers";
import { dragByDelta } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
test.describe("drag to reschedule", () => {
test("dragging a timed block down shifts Start/End Date and preserves duration", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const file = seedEvent(vaultDir, todayTimedEvent("Drag Me", 10, 11));
await gotoToday(page);
await waitForEvent(page, "Drag Me");
const before = readEventFrontmatter(vaultDir, file);
await dragByDelta(page, eventByTitle(page, "Drag Me"), 0, 120);
await expect
.poll(() => String(readEventFrontmatter(vaultDir, file)["Start Date"]) !== String(before["Start Date"]), {
message: `Start Date did not change after drag of ${file}`,
})
.toBe(true);
const after = readEventFrontmatter(vaultDir, file);
const startBefore = Date.parse(String(before["Start Date"]).replace(" ", "T"));
const endBefore = Date.parse(String(before["End Date"]).replace(" ", "T"));
const startAfter = Date.parse(String(after["Start Date"]).replace(" ", "T"));
const endAfter = Date.parse(String(after["End Date"]).replace(" ", "T"));
expect(endAfter - startAfter).toBe(endBefore - startBefore);
});
});

View file

@ -1,26 +0,0 @@
import { gotoToday, todayTimedEvent } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
test.describe("dual daily tab", () => {
test("dual-daily tab activates without console errors", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, todayTimedEvent("Dual A", 9, 10));
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await gotoToday(page);
const tab = page.locator(sel(TID.viewTab("dual-daily"))).first();
if ((await tab.count()) === 0) test.skip(true, "dual-daily tab not present in this build");
await tab.click();
// Switching tabs hides the main calendar's `.fc` (display:none on the
// deactivated tab). Dual-daily lazily mounts two of its own `.fc`
// roots, so wait for a visible one rather than the first-in-DOM one.
await expect.poll(() => page.locator(".fc:visible").count()).toBe(2);
expect(errors, `pageerror(s) while opening dual-daily:\n${errors.join("\n")}`).toHaveLength(0);
});
});

View file

@ -1,45 +0,0 @@
import { gotoToday, listEventRow, todayTimedEvent } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
import { sel, TID } from "../../fixtures/testids";
test.describe("list view", () => {
test("switching to list view shows the list table", async ({ calendar }) => {
await gotoToday(calendar.page);
await calendar.switchMode("list");
await expect(calendar.page.locator(".fc-list").first()).toBeVisible();
});
test("seeded event appears as a list row with its title", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, todayTimedEvent("List Event", 10, 11));
await gotoToday(page);
await calendar.switchMode("list");
const row = listEventRow(page, "List Event");
await expect(row).toBeVisible();
});
test("empty week renders the no-events message, not a crash", async ({ calendar }) => {
const { page } = calendar;
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await gotoToday(page);
await calendar.switchMode("list");
// Jump far enough forward that no seeded event can land in the visible
// week. 52 Next clicks = roughly one year — well clear of anything the
// default vault seeds. FullCalendar renders `.fc-list-empty` when the
// week has zero events; just assert it appears and nothing threw.
const next = page.locator(sel(TID.toolbar("next"))).first();
for (let i = 0; i < 52; i++) {
await next.click();
}
await expect(page.locator(".fc-list-empty").first()).toBeVisible();
expect(errors, errors.join("\n")).toHaveLength(0);
});
});

View file

@ -1,30 +0,0 @@
import { gotoToday, monthCellForDate, todayISO, todayTimedEvent, waitForEvent } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
test.describe("month view", () => {
test("seeded event renders in the matching day cell", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, todayTimedEvent("Month Event", 10, 11));
await gotoToday(page);
await calendar.switchMode("month");
await waitForEvent(page, "Month Event");
const cell = monthCellForDate(page, todayISO());
await expect(cell.locator('[data-event-title="Month Event"]').first()).toBeVisible();
});
test("toolbar title switches to a 'Month Year' format", async ({ calendar }) => {
await gotoToday(calendar.page);
const header = calendar.page.locator(".fc-toolbar-title").first();
const dayTitle = ((await header.textContent()) ?? "").trim();
// Day-view title always embeds a numeric day — e.g. "April 17, 2026".
expect(dayTitle).toMatch(/\d{1,2}/);
await calendar.switchMode("month");
// Month view drops the day number entirely — just "Month Year".
await expect(header).toHaveText(/^[A-Z][a-z]+\s+\d{4}$/);
});
});

View file

@ -1,113 +0,0 @@
import { gotoToday } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { FC_TOOLBAR_TITLE, sel, TID } from "../../fixtures/testids";
// `calendar/navigation.spec.ts` covers prev/next/today + view-switching by
// asserting the FC toolbar title. This spec proves the per-bundle
// `NavigationHistoryManager` (a `HistoryStack` of `{date, viewType}` entries)
// — push on `datesSet`, navigate back / forward via the toolbar action
// buttons, and the LIFO invariant that branches forward-history when you
// push after stepping back.
//
// The history records on every FC `datesSet` callback (calendar-view.ts:500),
// so the test drives the user-visible toolbar buttons (`prev`, `next`, view
// chips), and then the page-header `navigate-back`/`navigate-forward`
// actions to walk the stack. We assert on the toolbar title — same surface
// `navigation.spec.ts` uses, but here the assertion sequence proves the
// stack semantics rather than a single button.
test.describe("calendar navigation history", () => {
test("back/forward walk the recorded view+date stack and rewind to the entry point", async ({ calendar }) => {
const page = calendar.page;
await gotoToday(page);
await calendar.switchMode("week");
const header = page.locator(FC_TOOLBAR_TITLE).first();
const week0 = (await header.textContent())?.trim() ?? "";
expect(week0).toBeTruthy();
// Push a few date moves: week+1, week+2.
await page
.locator(sel(TID.toolbar("next")))
.first()
.click();
const week1 = (await header.textContent())?.trim() ?? "";
expect(week1).not.toBe(week0);
await page
.locator(sel(TID.toolbar("next")))
.first()
.click();
const week2 = (await header.textContent())?.trim() ?? "";
expect(week2).not.toBe(week1);
// Push a view change: switch to month while sitting on week+2.
await calendar.switchMode("month");
const month2 = (await header.textContent())?.trim() ?? "";
expect(month2).not.toBe(week2);
// Walk back through the stack: each "navigate-back" pops one entry off
// the back stack and restores the previous (viewType, date) snapshot.
await calendar.clickToolbar("navigate-back");
await expect(header).toHaveText(week2);
await calendar.clickToolbar("navigate-back");
await expect(header).toHaveText(week1);
await calendar.clickToolbar("navigate-back");
await expect(header).toHaveText(week0);
// Forward navigation replays the entries in order.
await calendar.clickToolbar("navigate-forward");
await expect(header).toHaveText(week1);
await calendar.clickToolbar("navigate-forward");
await expect(header).toHaveText(week2);
await calendar.clickToolbar("navigate-forward");
await expect(header).toHaveText(month2);
});
test("a fresh push after stepping back truncates the forward branch", async ({ calendar }) => {
const page = calendar.page;
await gotoToday(page);
await calendar.switchMode("week");
const header = page.locator(FC_TOOLBAR_TITLE).first();
const start = (await header.textContent())?.trim() ?? "";
expect(start).toBeTruthy();
await page
.locator(sel(TID.toolbar("next")))
.first()
.click();
const week1 = (await header.textContent())?.trim() ?? "";
await page
.locator(sel(TID.toolbar("next")))
.first()
.click();
const week2 = (await header.textContent())?.trim() ?? "";
expect(week2).not.toBe(week1);
// Step back once → land on week1. Now mutate (push a new entry by
// clicking prev twice): this should branch — the previous forward
// (week2) gets discarded so a subsequent forward call cannot reach it.
await calendar.clickToolbar("navigate-back");
await expect(header).toHaveText(week1);
await page
.locator(sel(TID.toolbar("prev")))
.first()
.click();
const newBranch = (await header.textContent())?.trim() ?? "";
expect(newBranch).toBe(start);
// Forward now restores the new branch entry, not the discarded week2.
// canGoForward should still be true (we have one prior entry on the
// truncated forward stack -> the new branch), and going forward lands
// on `newBranch` rather than `week2`.
await calendar.clickToolbar("navigate-back");
await expect(header).toHaveText(week1);
await calendar.clickToolbar("navigate-forward");
await expect(header).toHaveText(newBranch);
// One more forward should be a no-op — the original week2 was pruned.
const beforeNoop = (await header.textContent())?.trim() ?? "";
await calendar.clickToolbar("navigate-forward");
await expect(header).toHaveText(beforeNoop);
});
});

View file

@ -1,105 +0,0 @@
import { gotoToday } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { sel, TID, type ViewMode } from "../../fixtures/testids";
test.describe("calendar navigation", () => {
test("prev/next/today toolbar buttons move the visible date", async ({ calendar }) => {
const { page } = calendar;
await gotoToday(page);
const header = page.locator(".fc-toolbar-title").first();
const initial = (await header.textContent())?.trim();
expect(initial).toBeTruthy();
const next = page.locator(sel(TID.toolbar("next"))).first();
const prev = page.locator(sel(TID.toolbar("prev"))).first();
const today = page.locator(sel(TID.toolbar("today"))).first();
await next.click();
await expect(header).not.toHaveText(initial ?? "");
await prev.click();
await expect(header).toHaveText(initial ?? "");
await next.click();
await next.click();
await expect(header).not.toHaveText(initial ?? "");
await today.click();
await expect(header).toHaveText(initial ?? "");
});
test("day/week/month view buttons switch the FullCalendar view", async ({ calendar }) => {
await gotoToday(calendar.page);
await calendar.switchMode("week");
await expect(calendar.page.locator(".fc-timeGridWeek-view, .fc-view-harness .fc-timegrid").first()).toBeVisible();
await calendar.switchMode("month");
await expect(calendar.page.locator(".fc-dayGridMonth-view").first()).toBeVisible();
await calendar.switchMode("day");
await expect(calendar.page.locator(".fc-timeGridDay-view, .fc-timegrid").first()).toBeVisible();
});
test("clicking the Now toolbar button keeps the view on today without error", async ({ calendar }) => {
const { page } = calendar;
await gotoToday(page);
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await page
.locator(sel(TID.toolbar("goto-now")))
.first()
.click();
// The Now button is a scroll action in time-grid views, not a nav —
// the header should remain on today's date after the click.
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("clicking Now scrolls the current-time indicator into viewport", async ({ calendar }) => {
// FullCalendar renders the current-time line as
// `.fc-timegrid-now-indicator-line` inside the timegrid scroller. The
// Now button's job is to scroll that line into view, so assert the
// element is on screen (not clipped by the scroller's overflow).
const { page } = calendar;
await gotoToday(page);
// Force the scroller all the way to the top so Now has real work to
// do — otherwise the indicator may already be on screen and the test
// can't distinguish "Now worked" from "Now was a no-op".
await page
.locator(".fc-timegrid .fc-scroller")
.first()
.evaluate((el) => {
(el as HTMLElement).scrollTop = 0;
});
await page
.locator(sel(TID.toolbar("goto-now")))
.first()
.click();
const indicator = page.locator(".fc-timegrid-now-indicator-line").first();
await expect(indicator).toBeInViewport({ ratio: 0.01 });
});
// The toolbar title format is driven by FullCalendar's `titleFormat` option,
// which we don't explicitly set — so we rely on FC's built-in per-view
// defaults. Day embeds a day-of-month; week shows a range; month drops the
// day entirely; list shows a week-of label. A regression here would point
// at a titleFormat config change slipping in.
for (const { view, pattern, description } of [
{ view: "day", pattern: /\d{1,2}/, description: "day number" },
{ view: "week", pattern: /\d{1,2}.*[-].*\d{1,2}/, description: "day range" },
{ view: "month", pattern: /^[A-Z][a-z]+\s+\d{4}$/, description: "Month Year only" },
{ view: "list", pattern: /\w+/, description: "non-empty string" },
] as ReadonlyArray<{ view: ViewMode; pattern: RegExp; description: string }>) {
test(`${view} view toolbar title matches its expected format (${description})`, async ({ calendar }) => {
await gotoToday(calendar.page);
await calendar.switchMode(view);
await expect(calendar.page.locator(".fc-toolbar-title").first()).toHaveText(pattern);
});
}
});

View file

@ -1,39 +0,0 @@
import { readEventFrontmatter } from "@real1ty-obsidian-plugins/testing/e2e";
import { eventByTitle, gotoToday, todayTimedEvent, waitForEvent } from "../../fixtures/calendar-helpers";
import { boundingBoxOrThrow, drag } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
test.describe("resize to change duration", () => {
test("dragging the bottom edge of an event extends End Date", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const file = seedEvent(vaultDir, todayTimedEvent("Resize Me", 9, 10));
await gotoToday(page);
await waitForEvent(page, "Resize Me");
const before = readEventFrontmatter(vaultDir, file);
const block = eventByTitle(page, "Resize Me");
await block.hover();
const box = await boundingBoxOrThrow(block, "Resize Me block");
// FullCalendar's resize handle lives in the thin strip along the
// bottom edge of the event block. Stepped drag from 1px above the
// bottom straight down grows the block by ~2h on a default 30m-slot
// timeGridDay view.
const startX = box.x + box.width / 2;
const startY = box.y + box.height - 2;
await drag(page, { x: startX, y: startY }, { x: startX, y: startY + 120 }, { mode: "stepped", steps: 20 });
await expect
.poll(() => String(readEventFrontmatter(vaultDir, file)["End Date"]) !== String(before["End Date"]), {
message: `End Date did not change after resize of ${file}`,
})
.toBe(true);
const endBefore = Date.parse(String(before["End Date"]).replace(" ", "T"));
const endAfter = Date.parse(String(readEventFrontmatter(vaultDir, file)["End Date"]).replace(" ", "T"));
expect(endAfter).toBeGreaterThan(endBefore);
});
});

View file

@ -1,53 +0,0 @@
import { eventByTitle, gotoToday, todayISO, todayTimedEvent, waitForEvent } from "../../fixtures/calendar-helpers";
import { expect, test } from "../../fixtures/electron";
import { seedEvent } from "../../fixtures/seed-events";
import { sel } from "../../fixtures/testids";
test.describe("search and filter", () => {
test("typing into the search input narrows visible events", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, todayTimedEvent("Alpha Standup", 9, 10));
seedEvent(vaultDir, todayTimedEvent("Beta Sync", 11, 12));
await gotoToday(page);
await waitForEvent(page, "Alpha Standup");
await waitForEvent(page, "Beta Sync");
const search = page.locator(sel("prisma-filter-search")).first();
await search.fill("Alpha");
await expect.poll(() => eventByTitle(page, "Alpha Standup").count()).toBe(1);
await expect.poll(() => eventByTitle(page, "Beta Sync").count()).toBe(0);
await search.fill("");
await expect.poll(() => eventByTitle(page, "Beta Sync").count()).toBe(1);
});
test("typing into the expression filter hides events by property", async ({ calendar }) => {
const { page, vaultDir } = calendar;
const date = todayISO();
seedEvent(vaultDir, {
title: "Keep Event",
startDate: `${date}T09:00`,
endDate: `${date}T10:00`,
category: "Work",
});
seedEvent(vaultDir, {
title: "Hide Event",
startDate: `${date}T11:00`,
endDate: `${date}T12:00`,
category: "Personal",
});
await gotoToday(page);
await waitForEvent(page, "Keep Event");
await waitForEvent(page, "Hide Event");
const expr = page.locator(sel("prisma-filter-expression")).first();
await expr.fill("Category === 'Work'");
await expr.press("Enter");
await expect.poll(() => eventByTitle(page, "Keep Event").count()).toBe(1);
await expect.poll(() => eventByTitle(page, "Hide Event").count()).toBe(0);
});
});

View file

@ -1,48 +0,0 @@
import { eventByTitle } from "../../fixtures/calendar-helpers";
import { anchorISO, fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
// Anchored to past-Wednesday so the seeded day always lands inside the
// rendered week regardless of what day-of-week the suite runs on — see
// `docs/specs/e2e-date-anchor-robustness.md`.
//
// Uses the UI modal flow (`calendar.createEvent` / `calendar.seedOnDisk`)
// because raw `writeFileSync` writes — even after the indexer ingests them —
// don't reliably drive FC to paint the new tile inside the test budget;
// the modal flow is slower but deterministic — FC always renders what the
// plugin just saved.
test.describe("week view", () => {
test("seeded timed event renders in the anchor day's column", async ({ calendar }) => {
await calendar.switchMode("week");
await calendar.goToAnchor();
await calendar.createEvent({
title: "Week Timed",
start: fromAnchor(0, 10, 0),
end: fromAnchor(0, 11, 0),
});
// Week view lays each weekday as a `.fc-timegrid-col[data-date=YYYY-MM-DD]`.
// The event should live inside the anchor day's column, not a neighbour.
const anchorCol = calendar.page.locator(`.fc-timegrid-col[data-date="${anchorISO()}"]`).first();
await expect(anchorCol.locator('[data-event-title="Week Timed"]').first()).toBeVisible();
});
test("all-day event renders in the sticky all-day row, not a time column", async ({ calendar }) => {
await calendar.switchMode("week");
await calendar.goToAnchor();
await calendar.seedOnDisk("Week Allday", { Date: anchorISO(), "All Day": true }, { awaitRender: true });
// All-day events render inside `.fc-daygrid-body` (the sticky band), not
// inside `.fc-timegrid-col`. Assert the block's ancestry so a regression
// that routes all-day events into the time grid is caught.
const block = eventByTitle(calendar.page, "Week Allday");
await expect(block).toBeVisible();
const timegridAncestorCount = await block.locator("xpath=ancestor::*[contains(@class, 'fc-timegrid-col')]").count();
expect(timegridAncestorCount).toBe(0);
const allDayAncestorCount = await block.locator("xpath=ancestor::*[contains(@class, 'fc-daygrid-body')]").count();
expect(allDayAncestorCount).toBe(1);
});
});

View file

@ -1,69 +0,0 @@
import { gotoToday, monthCellForDate, todayISO, todayTimedEvent } from "../../fixtures/calendar-helpers";
import { anchorDayISO, fromAnchor } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { seedEvent, updateCalendarSettings } from "../../fixtures/seed-events";
test.describe("year view", () => {
test("seeded event renders in the matching day cell across all twelve months", async ({ calendar }) => {
const { page, vaultDir } = calendar;
seedEvent(vaultDir, { title: "Year Event", startDate: fromAnchor(0, 10, 0), endDate: fromAnchor(0, 11, 0) });
await calendar.goToAnchor();
await calendar.switchMode("year");
// Year view uses compact cells with dayMaxEvents — the event may be
// behind a +more popover, so assert DOM attachment in the correct cell
// rather than CSS visibility.
const cell = monthCellForDate(page, anchorDayISO(0));
await expect(cell.locator('[data-event-title="Year Event"]').first()).toBeAttached();
const months = page.locator(".fc-multimonth-month");
await expect(months).toHaveCount(12);
});
test("toolbar title switches to the year", async ({ calendar }) => {
await gotoToday(calendar.page);
await calendar.switchMode("year");
const header = calendar.page.locator(".fc-toolbar-title").first();
await expect(header).toHaveText(/^\d{4}$/);
});
test("color dots render in year view day cells", async ({ calendar }) => {
const { page, vaultDir } = calendar;
await updateCalendarSettings(page, {
colorRules: [
{ id: "rule-dots", expression: "Category === 'Work'", color: "#ff0000", enabled: true },
{ id: "rule-dots-2", expression: "Category === 'Work'", color: "#00ff00", enabled: true },
],
});
seedEvent(vaultDir, { ...todayTimedEvent("Dots Event", 9, 10), category: "Work" });
await gotoToday(page);
await calendar.switchMode("year");
const cell = monthCellForDate(page, todayISO());
const dots = cell.locator(".prisma-day-color-dots:not(.prisma-event-color-dots)");
await expect(dots.first()).toBeVisible();
});
test("clicking +more popover reveals hidden events", async ({ calendar }) => {
const { page, vaultDir } = calendar;
for (let i = 1; i <= 5; i++) {
seedEvent(vaultDir, todayTimedEvent(`Popover Event ${i}`, 8 + i, 9 + i));
}
await gotoToday(page);
await calendar.switchMode("year");
const cell = monthCellForDate(page, todayISO());
const moreLink = cell.locator(".fc-daygrid-more-link");
await expect(moreLink).toBeVisible();
await moreLink.click();
const popover = page.locator(".fc-popover");
await expect(popover).toBeVisible();
await expect(popover.locator('[data-event-title="Popover Event 1"]').first()).toBeVisible();
});
});

View file

@ -1,26 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { test } from "../../fixtures/electron";
// Reassigning a category must propagate to dashboard by-category.
// Seeds via seedMany (UI modal) so all reactive trackers fire.
// After reassigning Alpha from Work → Personal, both Work (from Beta) and
// Personal (from Alpha) must appear in the dashboard ranking.
test.describe("cross-view: category reassignment propagates", () => {
test("reassigning category surfaces new category in dashboard", async ({ calendar }) => {
const [alpha] = await calendar.seedMany([
{ title: "Alpha Task", start: todayStamp(9, 0), end: todayStamp(10, 0), categories: ["Work"] },
{ title: "Beta Task", start: todayStamp(11, 0), end: todayStamp(12, 0), categories: ["Work"] },
]);
await calendar.unlockPro();
await calendar.expectDashboardItem("dashboard-by-category", "Work");
await calendar.switchView("calendar");
await alpha.edit({ categories: ["Personal"] });
await calendar.expectDashboardItem("dashboard-by-category", "Personal");
await calendar.expectDashboardItem("dashboard-by-category", "Work");
});
});

View file

@ -1,56 +0,0 @@
import { isoLocal, todayISO, todayStamp } from "../../fixtures/dates";
import { boundingBoxOrThrow, drag } from "../../fixtures/dsl";
import { test } from "../../fixtures/electron";
import { eventBlockLocator } from "../events/events-helpers";
// Drag-reschedule on the calendar must propagate: the timeline bar + heatmap cell
// counts must reflect the new date. Stays today-relative (todayStamp) so the
// timeline/heatmap — which default to "today" and have no goToAnchor() — show the
// item without seeding outside their viewport (see dates.ts: anchor helpers are for
// calendar-only specs, not cross-view ones).
//
// Drags to an ADJACENT in-week day rather than always "tomorrow": when today is the
// last column of the visible week (e.g. Saturday under a Sunday-start week) tomorrow
// falls into next week and its column isn't rendered. Preferring tomorrow but falling
// back to yesterday keeps the target inside the rendered week on every day of the
// week, killing the week-boundary flake while preserving the test's intent (move to a
// different day, verify propagation).
const dayISO = (offset: number): string => isoLocal(offset).split("T")[0];
test.describe("cross-view: drag-reschedule propagates to other views", () => {
test("dragging event to an adjacent day updates timeline and heatmap", async ({ calendar }) => {
const [evt] = await calendar.seedMany([{ title: "Drag Target", start: todayStamp(10, 0), end: todayStamp(11, 0) }]);
const block = eventBlockLocator(calendar.page, "Drag Target").first();
const blockBox = await boundingBoxOrThrow(block, "Drag Target block");
// Prefer tomorrow; fall back to yesterday when today is the week's last column.
const dayCol = (iso: string) => calendar.page.locator(`.fc-timegrid-col[data-date="${iso}"]`).first();
let targetISO = dayISO(1);
let targetCol = dayCol(targetISO);
if ((await targetCol.count()) === 0) {
targetISO = dayISO(-1);
targetCol = dayCol(targetISO);
}
await targetCol.waitFor({ state: "visible" });
const targetBox = await boundingBoxOrThrow(targetCol, "target day column");
await drag(
calendar.page,
{ x: blockBox.x + blockBox.width / 2, y: blockBox.y + blockBox.height / 2 },
{ x: targetBox.x + targetBox.width / 2, y: blockBox.y + blockBox.height / 2 },
{ mode: "jitter" }
);
await evt.expectFrontmatter("Start Date", (v) => String(v ?? "").includes(targetISO));
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Drag Target");
await calendar.unlockPro();
await calendar.switchView("heatmap");
await calendar.expectHeatmapCount(targetISO, 1);
await calendar.expectHeatmapCount(todayISO(), 0);
});
});

View file

@ -1,45 +0,0 @@
import { todayISO, todayStamp } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// After creating an event on the calendar, switching to every other view tab
// must show that event — proving the RxJS event-store subscription pipeline
// fans out correctly to all consumers.
//
// Uses todayStamp so every view (timeline, heatmap, stats) shows the event
// without extra navigation — each defaults to "today". Seeds via seedMany
// (UI modal) so all reactive trackers (name, category) are populated.
test.describe("cross-view: event creation propagates to all views", () => {
test("event created on calendar appears in timeline, heatmap, and dashboard", async ({ calendar }) => {
await calendar.seedMany([
{ title: "Cross View Alpha", start: todayStamp(10, 0), end: todayStamp(11, 0), categories: ["Work"] },
]);
await calendar.unlockPro();
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Cross View Alpha");
await calendar.switchView("heatmap");
await calendar.expectHeatmapCount(todayISO(), 1);
await calendar.expectDashboardItem("dashboard-by-category", "Work");
});
test("event created on calendar appears in list view with correct title", async ({ calendar }) => {
await calendar.seedMany([{ title: "List View Event", start: todayStamp(14, 0), end: todayStamp(15, 0) }]);
await calendar.switchMode("list");
const listRow = calendar.page.locator(".fc-list-event").filter({ hasText: "List View Event" });
await expect(listRow.first()).toBeVisible();
});
test("event created on calendar appears in daily-stats content", async ({ calendar }) => {
await calendar.seedMany([{ title: "Stats Visible", start: todayStamp(8, 0), end: todayStamp(10, 0) }]);
await calendar.switchView("daily-stats");
await expect(calendar.page.locator(sel("prisma-stats-empty"))).toHaveCount(0);
await expect(calendar.page.locator(".prisma-stats-content").first()).toContainText("Stats Visible");
});
});

View file

@ -1,46 +0,0 @@
import { todayISO, todayStamp } from "../../fixtures/dates";
import { test } from "../../fixtures/electron";
// Deleting an event must propagate to every view — no stale ghost tiles.
// Seeds via seedMany (UI modal) so all reactive trackers fire.
test.describe("cross-view: event deletion propagates to all views", () => {
test("deleted event disappears from calendar and timeline", async ({ calendar }) => {
const [evt] = await calendar.seedMany([
{ title: "Ephemeral Task", start: todayStamp(10, 0), end: todayStamp(11, 0) },
]);
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Ephemeral Task");
await calendar.switchView("calendar");
await evt.rightClick("deleteEvent");
await evt.expectExists(false);
await evt.expectVisible(false);
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Ephemeral Task", false);
});
test("deleted event disappears from heatmap cell count", async ({ calendar }) => {
await calendar.seedMany([
{ title: "Keep Event", start: todayStamp(9, 0), end: todayStamp(10, 0) },
{ title: "Delete Me", start: todayStamp(11, 0), end: todayStamp(12, 0) },
]);
await calendar.unlockPro();
await calendar.switchView("heatmap");
await calendar.expectHeatmapCount(todayISO(), 2);
await calendar.switchView("calendar");
const deleteTarget = await calendar.eventByTitle("Delete Me");
await deleteTarget.rightClick("deleteEvent");
await deleteTarget.expectExists(false);
await calendar.switchView("heatmap");
await calendar.expectHeatmapCount(todayISO(), 1);
});
});

View file

@ -1,38 +0,0 @@
import { todayStamp } from "../../fixtures/dates";
import { test } from "../../fixtures/electron";
// Editing an event (title change + reschedule) must propagate to every view.
// Seeds via seedMany (UI modal) so all reactive trackers fire.
test.describe("cross-view: event edit propagates to all views", () => {
test("editing title and time updates calendar and timeline", async ({ calendar }) => {
const [evt] = await calendar.seedMany([
{ title: "Original Title", start: todayStamp(9, 0), end: todayStamp(10, 0) },
]);
await evt.edit({ title: "Renamed Event", start: todayStamp(14, 0), end: todayStamp(15, 0) });
const renamed = await calendar.eventByTitle("Renamed Event");
await renamed.expectVisible();
await renamed.expectFrontmatter("Start Date", (v) => String(v ?? "").includes("T14:00"));
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Renamed Event");
await calendar.expectTimelineItem("Original Title", false);
});
test("changing category surfaces new category in dashboard", async ({ calendar }) => {
const [evt] = await calendar.seedMany([
{ title: "Category Shift", start: todayStamp(11, 0), end: todayStamp(12, 0), categories: ["Work"] },
]);
await calendar.unlockPro();
await calendar.expectDashboardItem("dashboard-by-category", "Work");
await calendar.switchView("calendar");
await evt.edit({ categories: ["Personal"] });
await calendar.expectDashboardItem("dashboard-by-category", "Personal");
});
});

View file

@ -1,118 +0,0 @@
import { fromAnchor } from "../../fixtures/dates";
import type { CalendarHandle, EventCreate } from "../../fixtures/dsl";
import { expect, test } from "../../fixtures/electron";
import { assignPrerequisiteViaUI, ganttBarLocator } from "../../fixtures/helpers";
import { enterMobileLayout } from "../../fixtures/viewport";
// Gantt is a special case for cross-view assertions: the renderer FILTERS
// events to only those connected in the prerequisite graph
// (`normalize-events.ts`: `tracker.isConnected(filePath)`). A standalone
// event never produces a bar — so the other cross-view specs (which seed
// one event without prerequisites) skip Gantt by design.
//
// This spec covers the Gantt cross-view contract directly: seed two events,
// connect them via the real "Assign prerequisites" UI flow, verify bars
// appear in Gantt, then verify mutations (edit, delete) propagate to Gantt.
async function seedPrerequisiteChainAndOpenGantt(
calendar: CalendarHandle,
seeds: readonly EventCreate[],
link: { downstream: string; upstream: string }
) {
await calendar.switchMode("month");
await calendar.goToAnchor();
const handles = await calendar.seedMany(seeds);
await assignPrerequisiteViaUI(calendar.page, link.downstream, link.upstream);
await calendar.unlockPro();
await calendar.switchView("gantt");
return handles;
}
async function leaveGanttForMonthAnchor(calendar: CalendarHandle): Promise<void> {
await calendar.switchView("calendar");
await calendar.switchMode("month");
await calendar.goToAnchor();
}
test.describe("cross-view: gantt reactivity", () => {
test("connected events render as gantt bars and updates propagate", async ({ calendar }) => {
await seedPrerequisiteChainAndOpenGantt(
calendar,
[
{ title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Downstream Task", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) },
],
{ downstream: "Downstream Task", upstream: "Upstream Task" }
);
await expect(ganttBarLocator(calendar.page, "Upstream Task")).toBeVisible();
await expect(ganttBarLocator(calendar.page, "Downstream Task")).toBeVisible();
});
test("connected gantt bars render at a phone viewport", async ({ calendar }) => {
await seedPrerequisiteChainAndOpenGantt(
calendar,
[
{ title: "Upstream Task", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Downstream Task", start: fromAnchor(2, 14, 0), end: fromAnchor(2, 15, 0) },
],
{ downstream: "Downstream Task", upstream: "Upstream Task" }
);
// The gantt is inherently wide; on a phone it must still render its bars (not
// a black void) and pan internally rather than collapsing. Connected events
// are the only ones the gantt draws, so seeing a bar proves it rendered.
await enterMobileLayout(calendar.page);
await expect(ganttBarLocator(calendar.page, "Upstream Task")).toBeVisible();
});
test("editing a connected event's title updates its gantt bar", async ({ calendar }) => {
const [upstream] = await seedPrerequisiteChainAndOpenGantt(
calendar,
[
{ title: "Original Upstream", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Connected Downstream", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) },
],
{ downstream: "Connected Downstream", upstream: "Original Upstream" }
);
await expect(ganttBarLocator(calendar.page, "Original Upstream")).toBeVisible();
await leaveGanttForMonthAnchor(calendar);
await upstream.edit({ title: "Renamed Upstream" });
await calendar.switchView("gantt");
await expect(ganttBarLocator(calendar.page, "Renamed Upstream")).toBeVisible();
await expect(ganttBarLocator(calendar.page, "Original Upstream")).toHaveCount(0);
});
test("deleting a connected event removes its gantt bar", async ({ calendar }) => {
await seedPrerequisiteChainAndOpenGantt(
calendar,
[
{ title: "Keep Bar", start: fromAnchor(0, 9, 0), end: fromAnchor(0, 10, 0) },
{ title: "Delete Bar", start: fromAnchor(10, 14, 0), end: fromAnchor(10, 15, 0) },
],
{ downstream: "Delete Bar", upstream: "Keep Bar" }
);
await expect(ganttBarLocator(calendar.page, "Keep Bar")).toBeVisible();
await expect(ganttBarLocator(calendar.page, "Delete Bar")).toBeVisible();
await leaveGanttForMonthAnchor(calendar);
const target = await calendar.eventByTitle("Delete Bar");
await target.rightClick("deleteEvent");
// Non-recurring deletes don't gate on the confirmation modal, but the
// modal can fire when the prerequisite graph reports physical instances —
// dismiss it if it appears, otherwise just proceed.
const confirm = calendar.page.locator('[data-testid="confirmation-modal-confirm"]').first();
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await target.expectExists(false);
await calendar.switchView("gantt");
await expect(ganttBarLocator(calendar.page, "Delete Bar")).toHaveCount(0);
// Upstream is now an orphan (no downstream depends on it) so it also
// drops out of Gantt — that's the documented filter behavior.
await expect(ganttBarLocator(calendar.page, "Keep Bar")).toHaveCount(0);
});
});

View file

@ -1,68 +0,0 @@
import { anchorISO } from "../../fixtures/dates";
import { expect, test } from "../../fixtures/electron";
import { sel } from "../../fixtures/testids";
// A vault with zero events must render every view cleanly — no crash, no
// stale data, and a recognisable "nothing here" surface where the renderer
// supports one. The default seed vault is empty (`fixtures/vault-seed/Events`
// has no .md files), so we just open each tab in turn. List-view's
// `.fc-list-empty` path is already covered by `calendar/list-view.spec.ts`.
test.describe("cross-view: empty-state rendering", () => {
test("daily-stats shows the empty placeholder when no events exist", async ({ calendar }) => {
await calendar.switchView("daily-stats");
await expect(calendar.page.locator(sel("prisma-stats-empty")).first()).toBeVisible();
});
test("timeline container renders with zero items when vault is empty", async ({ calendar }) => {
await calendar.switchView("timeline");
const container = calendar.page.locator(sel("prisma-timeline-container")).first();
await expect(container).toBeVisible();
await expect(container.locator(".prisma-timeline-item")).toHaveCount(0);
});
test("heatmap renders cells, every cell carries data-count='0'", async ({ calendar }) => {
await calendar.unlockPro();
await calendar.switchView("heatmap");
const container = calendar.page.locator(sel("prisma-heatmap-container")).first();
await expect(container).toBeVisible();
// Sample the anchor cell (always inside the rendered range) and assert
// it reports zero. A wholly-empty heatmap that mistakenly stamped a
// count anywhere would fail this — see heatmap-renderer.ts:118.
const anchorCell = container.locator(`${sel("prisma-heatmap-cell")}[data-date="${anchorISO()}"]`).first();
await expect(anchorCell).toHaveAttribute("data-count", "0");
// And no cell anywhere on the grid claims a non-zero count.
const nonZeroCells = container.locator(`${sel("prisma-heatmap-cell")}:not([data-count="0"])`);
await expect(nonZeroCells).toHaveCount(0);
});
test("gantt renders the toolbar but zero bars when no events exist", async ({ calendar }) => {
await calendar.unlockPro();
await calendar.switchView("gantt");
// Pro-gated chrome is the proof we're past the upgrade banner.
await expect(calendar.page.locator(sel("prisma-gantt-create")).first()).toBeVisible();
await expect(calendar.page.locator(".prisma-gantt-bar")).toHaveCount(0);
});
test("dashboard by-name renders an empty ranking when no events exist", async ({ calendar }) => {
await calendar.unlockPro();
await calendar.switchToGroupChild("dashboard", "dashboard-by-name");
const ranking = calendar.page.locator(`${sel("prisma-dashboard-cell-ranking")}:visible`).first();
await expect(ranking).toBeVisible();
// `dashboard-section.ts` stamps each ranking row with
// `prisma-dashboard-ranking-row-<title>`. With no events, none exist.
await expect(calendar.page.locator('[data-testid^="prisma-dashboard-ranking-row-"]')).toHaveCount(0);
});
test("dashboard by-category renders an empty ranking when no events exist", async ({ calendar }) => {
await calendar.unlockPro();
await calendar.switchToGroupChild("dashboard", "dashboard-by-category");
const ranking = calendar.page.locator(`${sel("prisma-dashboard-cell-ranking")}:visible`).first();
await expect(ranking).toBeVisible();
await expect(calendar.page.locator('[data-testid^="prisma-dashboard-ranking-row-"]')).toHaveCount(0);
});
});

View file

@ -1,70 +0,0 @@
import { expect } from "@playwright/test";
import { todayStamp } from "../../fixtures/dates";
import { test } from "../../fixtures/electron";
import { updateCalendarSettings } from "../../fixtures/seed-events";
import { EVENT_BLOCK_TID, HEATMAP_CELL_TID, sel, STATS_EMPTY_TID, TIMELINE_ITEM_CLASS } from "../../fixtures/testids";
// `empty-state-across-views.spec.ts` proves every view renders cleanly
// when the vault has zero events. The harder empty-state is "calendar
// points at a missing or empty directory" — first-launch users land
// there if they configure the plugin against a fresh path. A regression
// where the renderer chokes on a missing directory would crash the
// first session for every new user.
//
// This spec:
// 1. Seeds events in the default `Events/` directory.
// 2. Repoints the calendar to a non-existent directory via settings.
// 3. Refreshes and asserts: no crash, no console errors (the harness
// enforces this on teardown), and the rendered views surface the
// empty placeholders rather than the original events.
test.describe("cross-view: empty state when calendar directory is missing", () => {
test("repointing the calendar to a non-existent directory empties every view without crashing", async ({
calendar,
}) => {
const page = calendar.page;
// Seed two events under the default `Events/` dir so the indexer has
// real rows in scope before the redirect.
await calendar.seedMany([
{ title: "Reroute One", start: todayStamp(9, 0), end: todayStamp(10, 0) },
{ title: "Reroute Two", start: todayStamp(11, 0), end: todayStamp(12, 0) },
]);
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Reroute One");
await calendar.expectTimelineItem("Reroute Two");
// Repoint the bundle's `directory` to a path that does not exist on disk.
// The indexer scans the configured directory; with no matching folder,
// the event store should empty out without throwing.
await updateCalendarSettings(page, { directory: "Nonexistent/Path/Does/Not/Exist" });
// Timeline empties — the events live in the old directory which the
// indexer no longer scans.
await expect.poll(async () => page.locator(TIMELINE_ITEM_CLASS).count()).toBe(0);
// Calendar view: no event tiles render.
await calendar.switchView("calendar");
await expect(page.locator(sel(EVENT_BLOCK_TID))).toHaveCount(0);
// Daily stats: empty placeholder visible.
await calendar.switchView("daily-stats");
await expect(page.locator(sel(STATS_EMPTY_TID)).first()).toBeVisible();
// Heatmap (Pro): every cell is at zero count.
await calendar.unlockPro();
await calendar.switchView("heatmap");
await expect(page.locator(`${sel(HEATMAP_CELL_TID)}:not([data-count="0"])`)).toHaveCount(0);
// Repoint back — the original events should reappear because the
// indexer re-scans without losing prior on-disk state. Catches a
// regression where the empty-pointer transition tears down subscribers
// that don't re-attach on the next valid directory.
await updateCalendarSettings(page, { directory: "Events" });
await calendar.switchView("timeline");
await calendar.expectTimelineItem("Reroute One");
await calendar.expectTimelineItem("Reroute Two");
});
});

Some files were not shown because too many files have changed in this diff Show more