feat(registry): add playwright capability group (6 TS browser fns)

New domain `browser` under frontend/functions/ with 6 Playwright helpers:
- pw_launch_browser: chromium + context + page bootstrap with storageState
  support and baseUrl navigation.
- pw_kanban_login: authenticates a Page against /api/auth/login; sets the
  kanban_session cookie via shared storageState; verifies login page no
  longer visible after navigation.
- pw_drag_drop: human-like pointer drag (mousedown + activateOffset +
  stepped move + mouseup) compatible with @dnd-kit/core's 8px activation
  threshold; supports hoverMs for time-based dropzones.
- pw_keyboard_sequence: ordered focus/type/press/wait steps for scripting
  realistic input flows (typing then arrow-key navigating autocompletes).
- pw_wait_predicate: thin wrapper over page.waitForFunction with friendlier
  defaults and custom error messages.
- pw_assert_class: poll-based assertion that a Locator has/lacks a CSS
  class within a timeout; useful for visual-state checks.

Each function ships with vitest tests (5-8 cases each) covering both happy
and error paths, plus self-documenting .md (Ejemplo + Cuando usarla +
Gotchas + frontmatter with params/output schema).

Adds frontend/functions/package.json with `"type": "module"` so consumers
can ESM-import the .ts files from anywhere in the registry (Playwright's
tsx loader respects nearest package.json).

Capability page docs/capabilities/playwright.md documents the group with
a canonical end-to-end example, frontiers, prerequisites, and gotchas.
Index updated.

First consumer (issue 0088): apps/kanban requester-input.spec.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:57:30 +02:00
parent b5a56bb5ff
commit 626d06327c
24 changed files with 1911 additions and 1 deletions
@@ -0,0 +1,187 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Page, Locator } from "playwright";
import { pw_assert_class, type PwAssertClassOptions } from "./pw_assert_class";
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
/** Build a mock Page with configurable waitForFunction behavior. */
function makeMockPage(opts: {
waitForFunctionResolves?: boolean;
waitForFunctionError?: Error;
} = {}): Page {
const waitForFunction = opts.waitForFunctionError
? vi.fn().mockRejectedValue(opts.waitForFunctionError)
: vi.fn().mockResolvedValue(undefined);
return {
waitForFunction,
locator: vi.fn(),
} as unknown as Page;
}
/** Build a mock Locator whose evaluate() returns the given classList.has() result. */
function makeMockLocator(hasClass: boolean): Locator {
return {
evaluate: vi.fn().mockResolvedValue(hasClass),
} as unknown as Locator;
}
/** Build a mock Locator whose evaluate() throws (simulates detached element). */
function makeMockLocatorThrows(): Locator {
return {
evaluate: vi.fn().mockRejectedValue(new Error("Element not attached")),
} as unknown as Locator;
}
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
describe("pw_assert_class", () => {
// -------------------------------------------------------------------------
it("has class passes", async () => {
// mustHave=true (default), element has the class → should resolve immediately.
const page = makeMockPage({ waitForFunctionResolves: true });
const opts: PwAssertClassOptions = {
selector: "[data-card-id='abc']",
className: "border-red",
mustHave: true,
timeoutMs: 3000,
};
await expect(pw_assert_class(page, opts)).resolves.toBeUndefined();
expect((page.waitForFunction as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
});
// -------------------------------------------------------------------------
it("lacks class passes", async () => {
// mustHave=false, waitForFunction resolves → element does NOT have the class.
const page = makeMockPage({ waitForFunctionResolves: true });
const opts: PwAssertClassOptions = {
selector: ".card",
className: "highlight-pulse",
mustHave: false,
timeoutMs: 3000,
};
await expect(pw_assert_class(page, opts)).resolves.toBeUndefined();
});
// -------------------------------------------------------------------------
it("timeout error message", async () => {
// waitForFunction throws a timeout-like error → pw_assert_class re-throws with detail.
const page = makeMockPage({
waitForFunctionError: new Error("Timeout 3000ms exceeded"),
});
const opts: PwAssertClassOptions = {
selector: "[data-card-id='abc']",
className: "border-red",
mustHave: true,
timeoutMs: 3000,
};
await expect(pw_assert_class(page, opts)).rejects.toThrow(
"expected [data-card-id='abc'] to have class border-red within 3000ms"
);
});
// -------------------------------------------------------------------------
it("string selector resolves", async () => {
// Verify the string path calls page.waitForFunction with the right serializable args.
const page = makeMockPage({ waitForFunctionResolves: true });
const opts: PwAssertClassOptions = {
selector: "div.card",
className: "active",
mustHave: true,
timeoutMs: 5000,
};
await pw_assert_class(page, opts);
const wff = page.waitForFunction as ReturnType<typeof vi.fn>;
expect(wff).toHaveBeenCalledOnce();
// Second arg must include sel, cls, must
const serializedArg = wff.mock.calls[0][1] as { sel: string; cls: string; must: boolean };
expect(serializedArg).toMatchObject({ sel: "div.card", cls: "active", must: true });
// Third arg must include timeout
const pwOpts = wff.mock.calls[0][2] as { timeout: number };
expect(pwOpts).toMatchObject({ timeout: 5000 });
});
// -------------------------------------------------------------------------
it("locator polled", async () => {
// Locator path: evaluate returns true for mustHave=true → resolves without timeout.
const locator = makeMockLocator(true);
const page = makeMockPage();
const opts: PwAssertClassOptions = {
selector: locator,
className: "highlight-pulse",
mustHave: true,
timeoutMs: 1000,
};
await expect(pw_assert_class(page, opts)).resolves.toBeUndefined();
// waitForFunction should NOT be called for Locator path
expect((page.waitForFunction as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
// evaluate called with className
const evalMock = locator.evaluate as ReturnType<typeof vi.fn>;
expect(evalMock).toHaveBeenCalledWith(expect.any(Function), "highlight-pulse");
});
// -------------------------------------------------------------------------
it("locator not have class passes", async () => {
// mustHave=false, element lacks class (evaluate returns false) → condition met.
const locator = makeMockLocator(false);
const page = makeMockPage();
const opts: PwAssertClassOptions = {
selector: locator,
className: "border-red",
mustHave: false,
timeoutMs: 1000,
};
await expect(pw_assert_class(page, opts)).resolves.toBeUndefined();
});
// -------------------------------------------------------------------------
it("locator timeout throws descriptive message", async () => {
// evaluate always returns false for mustHave=true → exhausts timeout.
const locator = makeMockLocator(false);
const page = makeMockPage();
const opts: PwAssertClassOptions = {
selector: locator,
className: "border-red",
mustHave: true,
timeoutMs: 150, // Short timeout so test stays fast
};
await expect(pw_assert_class(page, opts)).rejects.toThrow(
"expected <Locator> to have class border-red within 150ms"
);
});
// -------------------------------------------------------------------------
it("locator evaluate throws treated as not met", async () => {
// If evaluate throws (detached element), condition is treated as not met;
// if that persists past timeout, final error is thrown.
const locator = makeMockLocatorThrows();
const page = makeMockPage();
const opts: PwAssertClassOptions = {
selector: locator,
className: "border-red",
mustHave: true,
timeoutMs: 150,
};
await expect(pw_assert_class(page, opts)).rejects.toThrow(
"expected <Locator> to have class border-red within 150ms"
);
});
});