mirror of
https://github.com/real1tyy/Prisma-Calendar.git
synced 2026-07-22 06:43:58 +00:00
chore: full resync of prisma-calendar from monorepo
This commit is contained in:
parent
2640797d55
commit
60e16bba4f
300 changed files with 5 additions and 31444 deletions
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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())}`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expectEventsNotVisibleByTitle, expectEventsVisibleByTitle, expectTitleCount } from "../history-helpers";
|
||||
import type { EventHandle } from "./event";
|
||||
|
||||
// Bulk operations over a list of EventHandles. Specs that exercise batch ops
|
||||
// used to repeat `for (const e of events) await e.expectX(...)` 4–6× per test;
|
||||
// these helpers collapse that to one call. All of them are thin loops around
|
||||
// the single-event primitives — no new semantics, just less noise.
|
||||
|
||||
export async function expectAllExist(events: readonly EventHandle[], yes: boolean): Promise<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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 ?? [];
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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:00–17:00 and avoids
|
||||
// crossing midnight when spread is small relative to count.
|
||||
const hour = baseHour + (slotInDay % 8);
|
||||
const day = new Date(base);
|
||||
day.setDate(base.getDate() + startOffset + dayIndex);
|
||||
const ymd = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`;
|
||||
|
||||
const title = `${prefix} ${String(i + 1).padStart(width, "0")}`;
|
||||
const input: SeedEventInput = {
|
||||
title,
|
||||
startDate: `${ymd}T${String(hour).padStart(2, "0")}:00`,
|
||||
endDate: `${ymd}T${String(hour + 1).padStart(2, "0")}:00`,
|
||||
subdir,
|
||||
};
|
||||
paths.push(seedEvent(vaultDir, input));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unique events rendered in the active calendar leaf. Dedups FullCalendar's
|
||||
* overflow / multi-day clones by `data-event-file-path`: month view injects
|
||||
* extra DOM nodes with the same path for "+N more" popovers and multi-day
|
||||
* spans, inflating the raw `[data-testid="prisma-cal-event"]` count.
|
||||
*
|
||||
* In week / day / list views this matches the intuitive count (one DOM node
|
||||
* per event). In month view it counts distinct events, ignoring overflow
|
||||
* clone-rendering.
|
||||
*/
|
||||
export async function uniqueVisibleEventCount(page: Page): Promise<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);
|
||||
}
|
||||
|
|
@ -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}"]`;
|
||||
}
|
||||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
@ -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/`.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"appVersion": "1.12.7",
|
||||
"installerVersion": "1.8.10"
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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}/` });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { todayStamp } from "../../fixtures/dates";
|
||||
import { expect, test } from "../../fixtures/electron";
|
||||
import { sel } from "../../fixtures/testids";
|
||||
|
||||
// E2E value: prove the indicator label round-trips through the real RxJS
|
||||
// pipeline — on-disk events → metadataCache → eventStore.changes$ → the
|
||||
// debounced subscription → label format. Capacity math itself is covered by
|
||||
// tests/utils/stats/capacity*.test.ts; the indicator's render behaviour with
|
||||
// mocked observables is covered by tests/components/views/capacity-indicator.
|
||||
// What only E2E can catch: the wiring between those three layers in the
|
||||
// shipped bundle.
|
||||
|
||||
test.describe("analytics: capacity indicator (live)", () => {
|
||||
test("indicator label reflects used / inferred-capacity / percent from on-disk events", async ({ calendar }) => {
|
||||
// Seed two timed events: 09:00–10:00 and 11:00–12:00.
|
||||
// inferBoundaries → start=9, end=12 → capacity = 3h.
|
||||
// Used = 1h + 1h = 2h. Percent = (2/3)*100 = 66.67 → toFixed(0) = "67".
|
||||
// Day view ensures the indicator's range is exactly today.
|
||||
await calendar.switchMode("day");
|
||||
await calendar.seedOnDiskMany([
|
||||
{ title: "Capacity One", start: todayStamp(9, 0), end: todayStamp(10, 0) },
|
||||
{ title: "Capacity Two", start: todayStamp(11, 0), end: todayStamp(12, 0) },
|
||||
]);
|
||||
|
||||
const indicator = calendar.page.locator(sel("prisma-capacity-indicator")).first();
|
||||
await expect(indicator).toHaveText("⏱ 2h / 3h (67%)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in a new issue