626d06327c
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>
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|