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 7d82359a45
commit e2ecdc7533
24 changed files with 1911 additions and 1 deletions
@@ -0,0 +1,67 @@
---
id: "0088"
title: "kanban: requester input vacío + navegación con teclado"
status: open
created_at: 2026-05-14
priority: medium
app: kanban
---
## Problema
Al crear una nueva card en el kanban (`Nueva tarjeta` modal), el campo "Solicitante" se pre-rellena con el `display_name` del usuario logueado. Esto fuerza al usuario a borrar el contenido si quiere otro solicitante.
Además, al escribir en el Autocomplete:
- La tecla `Enter` dispara el submit del formulario (línea 51-56 de `CardForm.tsx`) en lugar de seleccionar la sugerencia resaltada del dropdown.
- Las flechas ↑↓ no son navegables porque el Autocomplete de Mantine sí las soporta nativamente, pero el `onKeyDown` con submit-on-Enter las invalida (al pulsar Enter para confirmar la selección, se cierra el form sin haber seleccionado).
## Solución
### Frontend
1. `apps/kanban/frontend/src/App.tsx:548`
```ts
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
```
→ cambiar a:
```ts
initial={{ requester: "" }}
```
(Quitar pre-fill. Si más adelante se quiere "último solicitante usado", se hace en otro issue.)
2. `apps/kanban/frontend/src/components/CardForm.tsx`
- Cambiar el handler `enterSubmit` aplicado al `Autocomplete` de "Solicitante". Para Enter en el requester:
- Si dropdown abierto → dejar que Mantine maneje la selección (no `e.preventDefault()`, no `submit()`).
- Si dropdown cerrado → no hacer nada (no `submit()`).
- El submit se hace solo con el botón "Crear" (o Ctrl+Enter desde el textarea de descripción, que ya existe).
- Añadir `data-field="requester"` al Autocomplete para selectores estables en e2e.
### Tests
- **vitest componente**: `apps/kanban/frontend/src/components/CardForm.test.tsx`
- Render con `requesterOptions=["Alice","Anna","Bob"]`, sin `initial.requester`.
- Assert input vacío.
- Type "An" → dropdown muestra Alice + Anna (filtro).
- Press ArrowDown → highlight Alice, press ArrowDown → highlight Anna, press Enter → input vale "Anna" y `onSubmit` NO se llamó.
- Press Enter en input cuando dropdown cerrado → `onSubmit` NO se llamó.
- Click en botón "Crear" → `onSubmit` llamado con `requester: "Anna"`.
- **Playwright e2e**: `apps/kanban/frontend/e2e/requester-input.spec.ts` usando funciones del registry (`pw_launch_browser`, `pw_kanban_login`, `pw_keyboard_sequence`, `pw_assert_class`).
- Login → click "+ Nueva tarjeta" en cualquier columna.
- Assert input requester vacío.
- Type "Enma", esperar 200ms (debounce dropdown).
- ArrowDown + Enter → assert modal sigue visible y input contiene un valor.
- Click "Crear" → modal se cierra y card aparece en columna.
## Criterios de aceptación
- [ ] Input "Solicitante" entra vacío al abrir "Nueva tarjeta".
- [ ] Enter dentro del Autocomplete NO cierra el form.
- [ ] ↑↓ navegan el dropdown y Enter selecciona la entrada resaltada.
- [ ] Botón "Crear" sigue funcionando como único submit del form.
- [ ] Tests vitest + e2e Playwright pasan.
## Ramas / commits
- Rama: `issue/0088-kanban-requester-empty-nav`
- Merge `--no-ff` a master.
+1
View File
@@ -23,6 +23,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
| [cpp-windows](cpp-windows.md) | 7 | Compilar, desplegar, lanzar y verificar apps C++ en Windows desde WSL2 |
| [git](git.md) | 19 | Operaciones git y Gitea: clonar, commit, push/pull, hooks, TBD, webhooks, sync entre PCs |
| [playwright](playwright.md) | 6 | E2E browser: launch chromium, login kanban, drag dnd-kit, keyboard sequence, wait predicate, assert class |
## Como anadir grupo
+86
View File
@@ -0,0 +1,86 @@
# playwright
Helpers para tests end-to-end de frontends del registry con Playwright. Wrappers finos sobre la API de Playwright para que los tests queden declarativos y consistentes entre apps.
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| `pw_launch_browser_ts_browser` | `(opts?: PwLaunchOptions) => Promise<PwHandles>` | Lanza chromium + context + page; navega a `baseUrl`; soporta `storageStatePath` para pre-auth |
| `pw_kanban_login_ts_browser` | `(page, {username, password, baseUrl?}) => Promise<void>` | POST `/api/login`, setea cookie de sesión, navega al root y verifica que la pantalla de login desaparece |
| `pw_drag_drop_ts_browser` | `(page, {source, target, steps?, delayMs?, hoverMs?, activateOffset?}) => Promise<void>` | Drag con PointerEvents compatible con dnd-kit (umbral 8px); soporta `hoverMs` para dropzones temporales |
| `pw_keyboard_sequence_ts_browser` | `(page, KbStep[]) => Promise<void>` | Ejecuta secuencia ordenada de `focus`/`type`/`press`/`wait` |
| `pw_wait_predicate_ts_browser` | `(page, predicate, {timeoutMs?, pollMs?, arg?, message?}) => Promise<T>` | Espera a que un predicado JS en la página devuelva truthy; mensaje custom en timeout |
| `pw_assert_class_ts_browser` | `(page, {selector, className, mustHave?, timeoutMs?}) => Promise<void>` | Assert (con poll) de que un selector/Locator tiene o no una CSS class |
## Ejemplo canónico
Test e2e del kanban: login, crea card, navega autocomplete con ↑↓, valida que el modal sigue abierto.
```ts
import { test } from "@playwright/test";
import { pw_launch_browser } from "@fn_library/browser/pw_launch_browser";
import { pw_kanban_login } from "@fn_library/browser/pw_kanban_login";
import { pw_keyboard_sequence } from "@fn_library/browser/pw_keyboard_sequence";
import { pw_assert_class } from "@fn_library/browser/pw_assert_class";
test("requester input ↑↓Enter no cierra modal", async () => {
const h = await pw_launch_browser({ baseUrl: "http://localhost:5180", headless: true });
try {
await pw_kanban_login(h.page, { username: "egutierrez", password: process.env.KANBAN_PWD! });
await h.page.click("[data-add-card]");
await pw_keyboard_sequence(h.page, [
{ kind: "focus", selector: "input[data-field=requester]" },
{ kind: "type", text: "Enma" },
{ kind: "wait", ms: 200 },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
]);
// Modal sigue abierto:
await pw_assert_class(h.page, {
selector: "[role=dialog]",
className: "mantine-Modal-content",
mustHave: true,
timeoutMs: 500,
});
} finally {
await h.close();
}
});
```
Drag desde sidebar al board (Issue 1):
```ts
await pw_drag_drop(h.page, {
source: "[data-card-id='abc']",
target: "[data-column-id='xyz']",
hoverMs: 0, // 500 si quieres validar dropzone temporal
});
```
## Cuándo usarla
- App tiene UI compleja (drag-drop, autocomplete, animaciones) que un test backend no cubre.
- Test backend Go ya existe y necesitas un complemento de browser real.
- Cualquier app del registry que sirva HTTP en un puerto local y tenga login por cookie.
## Fronteras
- **NO** sustituye tests unitarios (vitest + @testing-library) — Playwright es lento y caro, úsalo para flujos críticos.
- **NO** hace mocks de red — usa `page.route` directamente si necesitas.
- **NO** incluye fixtures Playwright (`test.beforeAll`, etc.) — eso lo escribe el consumer.
- `playwright` y `vitest` son peer deps; cada app que use el grupo debe `pnpm add -D playwright @playwright/test vitest` localmente.
## Prerequisitos
- App corriendo en `baseUrl` (ej. `http://localhost:5180`) antes del test.
- Login disponible vía `POST /api/login` con `{username, password}` JSON.
- Selectores estables (`data-*` atributos recomendados) para drag y assertions.
## Gotchas
- dnd-kit requiere 8px de movimiento para activar; `pw_drag_drop` ya añade `activateOffset` por defecto.
- `pw_kanban_login` asume backend kanban; otros backends necesitan un helper análogo.
- `pw_assert_class` con Locator hace poll manual (no `waitForFunction`) porque los Locators no serializan a contexto de página.
- Chromium en WSL2 necesita `xvfb-run` o `headless: true` (default).
@@ -0,0 +1,69 @@
---
name: pw_assert_class
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async (page: Page, opts: PwAssertClassOptions) => Promise<void>"
description: "Asserts a Playwright Locator has (or lacks) a given CSS class, with polling up to timeoutMs. Use for visual-state checks like red borders, highlight pulses, active tabs."
tags: [playwright, e2e, browser, assert]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: page
desc: "Playwright Page."
- name: opts
desc: "{selector, className, mustHave?, timeoutMs?}. selector accepts string CSS or Locator. className without leading dot."
output: "void; throws with detailed message on timeout."
tested: true
tests: ["has class passes", "lacks class passes", "timeout error message", "string selector resolves", "locator polled"]
test_file_path: "frontend/functions/browser/pw_assert_class.test.ts"
file_path: "frontend/functions/browser/pw_assert_class.ts"
---
## Ejemplo
```typescript
import { pw_assert_class } from "./pw_assert_class";
// Assert card has red border class after max-time exceeded
await pw_assert_class(page, {
selector: "[data-card-id='abc']",
className: "border-red",
mustHave: true,
timeoutMs: 3000,
});
// Assert roulette animation class is active on a card Locator
const card = page.locator(".kanban-card").first();
await pw_assert_class(page, {
selector: card,
className: "highlight-pulse",
mustHave: true,
timeoutMs: 2000,
});
// Assert class is NOT present after animation ends
await pw_assert_class(page, {
selector: card,
className: "highlight-pulse",
mustHave: false,
});
```
## Cuando usarla
Cuando necesites verificar que un elemento tiene (o no tiene) una clase CSS concreta en un test e2e de Playwright — por ejemplo comprobar que una tarjeta tiene `border-red` cuando se supera su tiempo máximo, o que `highlight-pulse` está activo durante la animación de ruleta. Úsala después de disparar la acción que debería cambiar el estado visual y antes de continuar con el siguiente paso del test.
## Gotchas
- Si `selector` es un string, usa `page.waitForFunction` internamente (delegado a la página), lo que es eficiente pero requiere que el selector sea un CSS selector válido para `document.querySelector`.
- Si `selector` es un `Locator`, usa un bucle de polling con `locator.evaluate()`. Los Locators no se pueden serializar a través del contexto de `waitForFunction`.
- `timeoutMs` por defecto es 5000 ms — ajústalo si la animación o transición tarda más.
- Las clases se comprueban con `classList.contains(className)` — no incluyas el punto inicial.
- Si el elemento está desconectado del DOM durante el polling de un Locator, se trata como condición no cumplida y sigue reintentando hasta el timeout.
@@ -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"
);
});
});
@@ -0,0 +1,82 @@
import type { Locator, Page } from "playwright";
/** Options for asserting a CSS class on a Playwright Locator. */
export interface PwAssertClassOptions {
/** CSS selector string or an existing Playwright Locator. */
selector: string | Locator;
/** CSS class to check, without the leading dot (e.g. "border-red"). */
className: string;
/** If true (default), asserts the class IS present. If false, asserts it is NOT present. */
mustHave?: boolean;
/** Max milliseconds to poll for the condition. Default: 5000. */
timeoutMs?: number;
}
/**
* pw_assert_class — asserts a Playwright Locator has (or lacks) a given CSS class.
*
* Polls the element up to timeoutMs. Throws with a descriptive message if the
* condition is not met in time.
*
* When selector is a string, resolves via page.locator(selector).first() and
* delegates to Playwright's native waitForFunction for efficiency.
* When selector is a Locator, uses a manual polling loop with locator.evaluate()
* because Locator objects do not serialize across waitForFunction page boundaries.
*/
export async function pw_assert_class(page: Page, opts: PwAssertClassOptions): Promise<void> {
const { selector, className } = opts;
const mustHave = opts.mustHave ?? true;
const timeoutMs = opts.timeoutMs ?? 5000;
if (typeof selector === "string") {
// String selector: use waitForFunction for efficient native polling.
const selectorRepr = selector;
try {
await page.waitForFunction(
({ sel, cls, must }: { sel: string; cls: string; must: boolean }) => {
const el = document.querySelector(sel);
if (!el) return false;
const has = el.classList.contains(cls);
return must ? has : !has;
},
{ sel: selector, cls: className, must: mustHave },
{ timeout: timeoutMs }
);
} catch {
throw new Error(
`expected ${selectorRepr} to ${mustHave ? "have" : "NOT have"} class ${className} within ${timeoutMs}ms`
);
}
} else {
// Locator: manual polling loop — Locators don't cross waitForFunction boundary.
const locator = selector;
const selectorRepr = "<Locator>";
const deadline = Date.now() + timeoutMs;
const pollMs = 100;
while (true) {
let conditionMet = false;
try {
const has = await locator.evaluate(
(el: Element, cls: string) => el.classList.contains(cls),
className
);
conditionMet = mustHave ? has : !has;
} catch {
// Element may not be attached yet — treat as condition not met.
conditionMet = false;
}
if (conditionMet) return;
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error(
`expected ${selectorRepr} to ${mustHave ? "have" : "NOT have"} class ${className} within ${timeoutMs}ms`
);
}
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
}
}
}
@@ -0,0 +1,52 @@
---
name: pw_drag_drop
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async (page: Page, opts: PwDragDropOptions) => Promise<void>"
description: "Simulates a human pointer drag in Playwright compatible with dnd-kit (PointerEvents + 8px activation threshold). Uses stepped mousemove with configurable pacing. Supports hoverMs for time-based dropzones (e.g. sidebar auto-open after 400ms hover)."
tags: [playwright, e2e, browser, drag]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: page
desc: "Playwright Page."
- name: opts
desc: "{source, target, steps?, delayMs?, hoverMs?, activateOffset?}. source/target accept selector string or Locator."
output: "void; performs full pointer drag from source center to target center."
tested: true
tests: ["happy path", "no source bbox throws", "no target bbox throws", "hoverMs adds wait", "string selectors resolve"]
test_file_path: "frontend/functions/browser/pw_drag_drop.test.ts"
file_path: "frontend/functions/browser/pw_drag_drop.ts"
---
## Ejemplo
```typescript
import { pw_drag_drop } from "./pw_drag_drop";
await pw_drag_drop(page, {
source: "[data-card-id='abc']",
target: "[data-column-id='xyz']",
hoverMs: 500,
});
```
## Cuando usarla
Cuando necesites arrastrar un elemento en el browser durante un test e2e con Playwright y el target usa `@dnd-kit/core`. El `dragTo` nativo de Playwright emite eventos HTML5 drag que dnd-kit ignora; esta funcion usa la API de mouse raw con un movimiento de activacion inicial para cruzar el umbral de 8px de dnd-kit.
## Gotchas
- `playwright` es una peer dependency: debe estar instalada en el proyecto consumidor (`pnpm add -D playwright`). El registry no la instala.
- El umbral de activacion de dnd-kit es configurable en el `PointerSensor`; si el proyecto usa un umbral distinto al default de 8px, ajustar `activateOffset` (default: `{x: 12, y: 0}`) para superarlo.
- `steps` y `delayMs` controlan la suavidad del drag. Valores bajos (steps=5, delayMs=0) pueden hacer que dnd-kit no detecte el movimiento como drag en versiones antiguas; los defaults (20 steps, 16ms) simulan ~60fps.
- `hoverMs` es util para dropzones que se abren al mantener hover durante un tiempo (ej. sidebar de kanban que se abre tras 400ms). Pasarlo en 0 omite la espera.
- Los source/target deben ser visibles y tener bounding box; si no se encuentran (display:none, detached) la funcion lanza un error descriptivo.
- `activateOffset` es relativo al centro del source, no a su esquina.
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { pw_drag_drop } from "./pw_drag_drop";
import type { Locator, Page } from "playwright";
// --- Mock factories ---
function makeLocator(bbox: { x: number; y: number; width: number; height: number } | null): Locator {
return {
boundingBox: vi.fn().mockResolvedValue(bbox),
} as unknown as Locator;
}
function makeFirstLocator(bbox: { x: number; y: number; width: number; height: number } | null): Locator {
const loc = makeLocator(bbox);
return loc;
}
function makePage(
sourceBbox: { x: number; y: number; width: number; height: number } | null,
targetBbox: { x: number; y: number; width: number; height: number } | null,
): Page {
const moveStub = vi.fn().mockResolvedValue(undefined);
const downStub = vi.fn().mockResolvedValue(undefined);
const upStub = vi.fn().mockResolvedValue(undefined);
const waitStub = vi.fn().mockResolvedValue(undefined);
// locator() returns an object with .first() that returns the locator mock
const locatorFactory = (bbox: typeof sourceBbox) => {
const inner = makeFirstLocator(bbox);
return { first: vi.fn().mockReturnValue(inner) };
};
let callCount = 0;
const locatorMock = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) return locatorFactory(sourceBbox);
return locatorFactory(targetBbox);
});
return {
mouse: {
move: moveStub,
down: downStub,
up: upStub,
},
locator: locatorMock,
waitForTimeout: waitStub,
} as unknown as Page;
}
// --- Tests ---
describe("pw_drag_drop", () => {
describe("happy path", () => {
it("calls mouse.down once, mouse.up once, mouse.move at least steps+2 times, waitForTimeout steps times when hoverMs=0", async () => {
const steps = 5;
const sourceBbox = { x: 10, y: 20, width: 100, height: 50 };
const targetBbox = { x: 300, y: 20, width: 100, height: 50 };
const page = makePage(sourceBbox, targetBbox);
const sourceLocator = makeLocator(sourceBbox);
const targetLocator = makeLocator(targetBbox);
await pw_drag_drop(page, {
source: sourceLocator,
target: targetLocator,
steps,
delayMs: 0,
hoverMs: 0,
});
expect((page.mouse.down as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
expect((page.mouse.up as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
// move(sx,sy) + move(activate, {steps:2}) + steps moves = at least steps + 2 calls
const moveCalls = (page.mouse.move as ReturnType<typeof vi.fn>).mock.calls.length;
expect(moveCalls).toBeGreaterThanOrEqual(steps + 2);
// waitForTimeout called exactly `steps` times (no hoverMs)
const waitCalls = (page.waitForTimeout as ReturnType<typeof vi.fn>).mock.calls.length;
expect(waitCalls).toBe(steps);
});
});
describe("no source bbox throws", () => {
it("throws when source.boundingBox returns null", async () => {
const page = makePage(null, { x: 300, y: 20, width: 100, height: 50 });
const sourceLocator = makeLocator(null);
const targetLocator = makeLocator({ x: 300, y: 20, width: 100, height: 50 });
await expect(
pw_drag_drop(page, { source: sourceLocator, target: targetLocator }),
).rejects.toThrow("source element has no bounding box");
});
});
describe("no target bbox throws", () => {
it("throws when target.boundingBox returns null", async () => {
const page = makePage({ x: 10, y: 20, width: 100, height: 50 }, null);
const sourceLocator = makeLocator({ x: 10, y: 20, width: 100, height: 50 });
const targetLocator = makeLocator(null);
await expect(
pw_drag_drop(page, { source: sourceLocator, target: targetLocator }),
).rejects.toThrow("target element has no bounding box");
});
});
describe("hoverMs adds wait", () => {
it("calls waitForTimeout steps+1 times when hoverMs>0", async () => {
const steps = 4;
const sourceBbox = { x: 10, y: 20, width: 100, height: 50 };
const targetBbox = { x: 300, y: 20, width: 100, height: 50 };
const page = makePage(sourceBbox, targetBbox);
const sourceLocator = makeLocator(sourceBbox);
const targetLocator = makeLocator(targetBbox);
await pw_drag_drop(page, {
source: sourceLocator,
target: targetLocator,
steps,
delayMs: 0,
hoverMs: 400,
});
const waitCalls = (page.waitForTimeout as ReturnType<typeof vi.fn>).mock.calls.length;
// steps waits for delayMs + 1 wait for hoverMs
expect(waitCalls).toBe(steps + 1);
});
});
describe("string selectors resolve", () => {
it("calls page.locator(selector).first() when source/target are strings", async () => {
const sourceBbox = { x: 10, y: 20, width: 100, height: 50 };
const targetBbox = { x: 300, y: 20, width: 100, height: 50 };
const page = makePage(sourceBbox, targetBbox);
await pw_drag_drop(page, {
source: "[data-card-id='abc']",
target: "[data-column-id='xyz']",
steps: 2,
delayMs: 0,
});
const locatorCalls = (page.locator as ReturnType<typeof vi.fn>).mock.calls;
expect(locatorCalls.length).toBe(2);
expect(locatorCalls[0]?.[0]).toBe("[data-card-id='abc']");
expect(locatorCalls[1]?.[0]).toBe("[data-column-id='xyz']");
});
});
});
@@ -0,0 +1,81 @@
import type { Locator, Page } from "playwright";
/** Options for pw_drag_drop. */
export interface PwDragDropOptions {
/** Selector string or Playwright Locator for the element to drag from. */
source: string | Locator;
/** Selector string or Playwright Locator for the drop target. */
target: string | Locator;
/** Number of intermediate mousemove steps from source to target. Default: 20. */
steps?: number;
/** Pause between each step in ms. Default: 16. */
delayMs?: number;
/** Pause over the target before releasing in ms. Useful for time-based dropzones. Default: 0. */
hoverMs?: number;
/** Initial offset after mousedown to cross dnd-kit's activation threshold (default 8px). Default: {x: 12, y: 0}. */
activateOffset?: { x: number; y: number };
}
/**
* pw_drag_drop — simulates a human pointer drag compatible with dnd-kit.
*
* Playwright's built-in dragTo uses HTML5 drag events which dnd-kit ignores
* (it listens to PointerEvents). This helper uses raw mouse API with a
* small activation move to cross dnd-kit's default 8px threshold, then
* interpolates in configurable steps toward the target.
*/
export async function pw_drag_drop(page: Page, opts: PwDragDropOptions): Promise<void> {
const steps = opts.steps ?? 20;
const delayMs = opts.delayMs ?? 16;
const hoverMs = opts.hoverMs ?? 0;
const activateOffset = opts.activateOffset ?? { x: 12, y: 0 };
// Resolve source and target to Locators.
const sourceLocator: Locator =
typeof opts.source === "string" ? page.locator(opts.source).first() : opts.source;
const targetLocator: Locator =
typeof opts.target === "string" ? page.locator(opts.target).first() : opts.target;
// Get bounding boxes.
const sourceBbox = await sourceLocator.boundingBox();
if (sourceBbox === null) {
throw new Error("pw_drag_drop: source element has no bounding box (not visible or detached)");
}
const targetBbox = await targetLocator.boundingBox();
if (targetBbox === null) {
throw new Error("pw_drag_drop: target element has no bounding box (not visible or detached)");
}
// Compute centers.
const sx = sourceBbox.x + sourceBbox.width / 2;
const sy = sourceBbox.y + sourceBbox.height / 2;
const tx = targetBbox.x + targetBbox.width / 2;
const ty = targetBbox.y + targetBbox.height / 2;
// Move to source and press down.
await page.mouse.move(sx, sy);
await page.mouse.down();
// Small move to cross dnd-kit activation threshold (default 8px).
await page.mouse.move(sx + activateOffset.x, sy + activateOffset.y, { steps: 2 });
// Interpolate from activation position toward target.
const startX = sx + activateOffset.x;
const startY = sy + activateOffset.y;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const xi = startX + (tx - startX) * t;
const yi = startY + (ty - startY) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(delayMs);
}
// Optional hover pause before release (useful for sidebar auto-open, etc.).
if (hoverMs > 0) {
await page.waitForTimeout(hoverMs);
}
// Release.
await page.mouse.up();
}
@@ -0,0 +1,53 @@
---
name: pw_kanban_login
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async (page: Page, opts: PwLoginOptions) => Promise<void>"
description: "Authenticates a Playwright Page against the kanban backend via POST /api/login. Sets session cookie and navigates to root. Throws on failure or if login page remains visible."
tags: [playwright, e2e, browser, kanban]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: page
desc: "Playwright Page (typically from pw_launch_browser). The page context shares storageState with page.request, so the Set-Cookie response propagates automatically."
- name: opts
desc: "{username, password, baseUrl?}. baseUrl defaults to the origin derived from page.url() — so the page must already be navigated to the target host before calling this function."
output: "void; throws on failed login (HTTP >= 400) or if login page selectors remain visible after navigation. After resolve, page is on root (baseUrl + '/') and the kanban_session cookie is active."
tested: true
tests:
- "happy path"
- "rejects 401"
- "rejects when login page still visible"
test_file_path: "frontend/functions/browser/pw_kanban_login.test.ts"
file_path: "frontend/functions/browser/pw_kanban_login.ts"
---
## Ejemplo
```typescript
import { pw_launch_browser } from "@fn_library/../browser/pw_launch_browser";
import { pw_kanban_login } from "@fn_library/../browser/pw_kanban_login";
const { page, close } = await pw_launch_browser({ baseUrl: "http://localhost:5180" });
await pw_kanban_login(page, { username: "egutierrez", password: "..." });
// page is now on http://localhost:5180/ with kanban_session cookie active
await close();
```
## Cuando usarla
Usala al inicio de cualquier test Playwright o script e2e contra el kanban. Despues de esta llamada el `page` esta autenticado y puede acceder a rutas protegidas sin redireccion al login.
## Gotchas
- `page.url()` debe devolver una URL valida con origin antes de llamar a esta funcion si no se pasa `baseUrl`. Si la pagina no ha navegado todavia (`about:blank`), pasar `baseUrl` explicitamente.
- La funcion asume que `page.request` comparte el contexto de cookies con `page` (comportamiento por defecto de Playwright). Si usas un `APIRequestContext` separado, la cookie no se propagara automaticamente.
- La comprobacion post-login busca texto literal "Login" o "Iniciar sesion". Si el backend usa otro texto en el boton, la comprobacion pasara aunque la autenticacion haya fallado silenciosamente — en ese caso ampliar los selectores de sanidad.
- Espera `networkidle` tras `goto`: si la app lanza requests continuos (polling, WebSocket upgrade), `networkidle` puede tardar o no resolverse. Considerar `page.waitForURL` como alternativa en esos casos.
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { pw_kanban_login } from "./pw_kanban_login";
// --- mock helpers ---
function makeResponse(status: number) {
return { status: () => status };
}
function makePage(overrides: {
currentUrl?: string;
postStatus?: number;
loginTextVisible?: boolean;
} = {}) {
const currentUrl = overrides.currentUrl ?? "http://localhost:5180/login";
const postStatus = overrides.postStatus ?? 200;
const loginTextVisible = overrides.loginTextVisible ?? false;
const locatorMock = {
isVisible: vi.fn().mockResolvedValue(loginTextVisible),
};
return {
url: vi.fn().mockReturnValue(currentUrl),
request: {
post: vi.fn().mockResolvedValue(makeResponse(postStatus)),
},
goto: vi.fn().mockResolvedValue(undefined),
waitForLoadState: vi.fn().mockResolvedValue(undefined),
waitForSelector: vi.fn().mockResolvedValue(undefined),
locator: vi.fn().mockReturnValue(locatorMock),
_locatorMock: locatorMock,
};
}
// --- tests ---
describe("pw_kanban_login", () => {
it("happy path", async () => {
const page = makePage({ postStatus: 200, loginTextVisible: false });
await pw_kanban_login(page as any, {
username: "egutierrez",
password: "secret",
});
// Should POST to derived origin + /api/login with correct body
expect(page.request.post).toHaveBeenCalledWith(
"http://localhost:5180/api/login",
{ data: { username: "egutierrez", password: "secret" } },
);
// Should navigate to root
expect(page.goto).toHaveBeenCalledWith("http://localhost:5180/");
// Should wait for page load
expect(page.waitForLoadState).toHaveBeenCalledWith("networkidle");
expect(page.waitForSelector).toHaveBeenCalledWith("body", { state: "attached" });
});
it("rejects 401", async () => {
const page = makePage({ postStatus: 401 });
await expect(
pw_kanban_login(page as any, { username: "bad", password: "wrong" }),
).rejects.toThrow("kanban login failed: 401");
// Should NOT navigate after failed login
expect(page.goto).not.toHaveBeenCalled();
});
it("rejects 500", async () => {
const page = makePage({ postStatus: 500 });
await expect(
pw_kanban_login(page as any, { username: "u", password: "p" }),
).rejects.toThrow("kanban login failed: 500");
});
it("rejects when login page still visible", async () => {
// Simulate server returns 200 but login page is still showing (e.g. cookie not set)
const page = makePage({ postStatus: 200, loginTextVisible: true });
await expect(
pw_kanban_login(page as any, { username: "egutierrez", password: "secret" }),
).rejects.toThrow("kanban login failed: login page still visible after authentication");
});
it("uses explicit baseUrl over page origin", async () => {
const page = makePage({ currentUrl: "http://localhost:5180/login", postStatus: 200 });
await pw_kanban_login(page as any, {
username: "egutierrez",
password: "secret",
baseUrl: "http://custom-host:9000",
});
expect(page.request.post).toHaveBeenCalledWith(
"http://custom-host:9000/api/login",
expect.any(Object),
);
expect(page.goto).toHaveBeenCalledWith("http://custom-host:9000/");
});
});
@@ -0,0 +1,49 @@
import type { Page } from "playwright";
/** Options for authenticating a Playwright Page against the kanban backend. */
export interface PwLoginOptions {
username: string;
password: string;
/** Base URL of the kanban server. Defaults to the origin of page.url(). */
baseUrl?: string;
}
/**
* pw_kanban_login — authenticates a Playwright Page against the kanban backend.
*
* Posts credentials to POST /api/auth/login. The request fixture shares storageState
* with the page context, so the Set-Cookie: kanban_session cookie auto-propagates.
* After successful login, navigates to root and verifies the login page is gone.
*
* Throws if the login response returns HTTP >= 400 or if the login page remains
* visible after navigation.
*/
export async function pw_kanban_login(page: Page, opts: PwLoginOptions): Promise<void> {
const { username, password } = opts;
// Resolve baseUrl from opts or from current page origin
const baseUrl = opts.baseUrl ?? new URL(page.url()).origin;
// POST credentials — page.request shares cookies/storageState with the page context
const response = await page.request.post(`${baseUrl}/api/auth/login`, {
data: { username, password },
});
if (response.status() >= 400) {
throw new Error(`kanban login failed: ${response.status()}`);
}
// Navigate to root; cookie is already propagated via shared context
await page.goto(`${baseUrl}/`);
await page.waitForLoadState("networkidle");
await page.waitForSelector("body", { state: "attached" });
// Sanity check: ensure we are no longer on the login page
const loginTextVisible =
(await page.locator("text=Login").isVisible()) ||
(await page.locator("text=Iniciar sesión").isVisible());
if (loginTextVisible) {
throw new Error("kanban login failed: login page still visible after authentication");
}
}
@@ -0,0 +1,59 @@
---
name: pw_keyboard_sequence
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async (page: Page, sequence: KbStep[]) => Promise<void>"
description: "Executes a sequence of keyboard interactions (focus, type, key press, wait) on a Playwright Page. Use to script realistic input flows like typing then navigating autocomplete dropdowns with arrow keys."
tags: ["playwright", "e2e", "browser", "keyboard"]
params:
- name: page
desc: "Playwright Page."
- name: sequence
desc: "Array of steps: focus/type/press/wait. Executed in order."
output: "void; mutates focused element / page state."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["playwright"]
tested: true
tests:
- "focus calls focus"
- "type uses default delay"
- "press routes correctly"
- "wait calls waitForTimeout"
- "unknown kind throws"
- "order preserved"
test_file_path: "frontend/functions/browser/pw_keyboard_sequence.test.ts"
file_path: "frontend/functions/browser/pw_keyboard_sequence.ts"
---
## Ejemplo
```ts
import { pw_keyboard_sequence } from "./pw_keyboard_sequence";
await pw_keyboard_sequence(page, [
{ kind: "focus", selector: "input[name=requester]" },
{ kind: "type", text: "Enma" },
{ kind: "wait", ms: 200 },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
]);
```
## Cuando usarla
Cuando necesites encadenar foco + escritura + teclas especiales sobre un elemento (autocompletado, selects, modales de búsqueda). Úsala en tests E2E con Playwright en lugar de mezclar calls sueltas de `keyboard.type` y `keyboard.press`.
## Gotchas
- `focus` usa `.first()` — si el selector coincide con múltiples elementos, foca el primero. Afinar el selector si es necesario.
- `type` simula escritura carácter a carácter con `delay` (default 30 ms). Para input más rápido pasar `delayMs: 0`.
- `press` espera un nombre de tecla Playwright válido: `"ArrowDown"`, `"Enter"`, `"Escape"`, `"Tab"`, etc.
- Un `kind` desconocido lanza en runtime (útil para detectar typos en JS sin TypeScript).
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Page } from "playwright";
import { pw_keyboard_sequence, type KbStep } from "./pw_keyboard_sequence";
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
const mockFocus = vi.fn().mockResolvedValue(undefined);
const mockFirst = vi.fn().mockReturnValue({ focus: mockFocus });
const mockLocator = vi.fn().mockReturnValue({ first: mockFirst });
const mockType = vi.fn().mockResolvedValue(undefined);
const mockPress = vi.fn().mockResolvedValue(undefined);
const mockWaitForTimeout = vi.fn().mockResolvedValue(undefined);
const mockPage = {
locator: mockLocator,
keyboard: {
type: mockType,
press: mockPress,
},
waitForTimeout: mockWaitForTimeout,
} as unknown as Page;
beforeEach(() => {
vi.clearAllMocks();
mockFirst.mockReturnValue({ focus: mockFocus });
mockLocator.mockReturnValue({ first: mockFirst });
mockFocus.mockResolvedValue(undefined);
mockType.mockResolvedValue(undefined);
mockPress.mockResolvedValue(undefined);
mockWaitForTimeout.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
describe("pw_keyboard_sequence", () => {
it("focus calls focus", async () => {
const sequence: KbStep[] = [{ kind: "focus", selector: "input[name=requester]" }];
await pw_keyboard_sequence(mockPage, sequence);
expect(mockLocator).toHaveBeenCalledWith("input[name=requester]");
expect(mockFirst).toHaveBeenCalledOnce();
expect(mockFocus).toHaveBeenCalledOnce();
});
it("type uses default delay", async () => {
const sequence: KbStep[] = [{ kind: "type", text: "Enmanuel" }];
await pw_keyboard_sequence(mockPage, sequence);
expect(mockType).toHaveBeenCalledWith("Enmanuel", { delay: 30 });
});
it("type with explicit delayMs uses that value", async () => {
const sequence: KbStep[] = [{ kind: "type", text: "hello", delayMs: 100 }];
await pw_keyboard_sequence(mockPage, sequence);
expect(mockType).toHaveBeenCalledWith("hello", { delay: 100 });
});
it("press routes correctly", async () => {
const sequence: KbStep[] = [
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
];
await pw_keyboard_sequence(mockPage, sequence);
expect(mockPress).toHaveBeenCalledTimes(2);
expect(mockPress).toHaveBeenNthCalledWith(1, "ArrowDown");
expect(mockPress).toHaveBeenNthCalledWith(2, "Enter");
});
it("wait calls waitForTimeout", async () => {
const sequence: KbStep[] = [{ kind: "wait", ms: 200 }];
await pw_keyboard_sequence(mockPage, sequence);
expect(mockWaitForTimeout).toHaveBeenCalledWith(200);
});
it("unknown kind throws", async () => {
const sequence = [{ kind: "unknown_step" }] as unknown as KbStep[];
await expect(pw_keyboard_sequence(mockPage, sequence)).rejects.toThrow(
'pw_keyboard_sequence: unknown step kind "unknown_step"'
);
});
it("order preserved", async () => {
const callOrder: string[] = [];
mockFocus.mockImplementationOnce(async () => { callOrder.push("focus"); });
mockType.mockImplementationOnce(async () => { callOrder.push("type"); });
mockWaitForTimeout.mockImplementationOnce(async () => { callOrder.push("wait"); });
mockPress.mockImplementationOnce(async () => { callOrder.push("press"); });
const sequence: KbStep[] = [
{ kind: "focus", selector: "input" },
{ kind: "type", text: "abc" },
{ kind: "wait", ms: 50 },
{ kind: "press", key: "Enter" },
];
await pw_keyboard_sequence(mockPage, sequence);
expect(callOrder).toEqual(["focus", "type", "wait", "press"]);
});
});
@@ -0,0 +1,50 @@
import type { Page } from "playwright";
/**
* A single step in a keyboard interaction sequence.
*
* - `focus` — focuses a DOM element via CSS selector.
* - `type` — types text character by character with a humanlike delay.
* - `press` — sends a single named key (e.g. "ArrowDown", "Enter", "Escape", "Tab").
* - `wait` — pauses execution for the given number of milliseconds.
*/
export type KbStep =
| { kind: "focus"; selector: string }
| { kind: "type"; text: string; delayMs?: number }
| { kind: "press"; key: string }
| { kind: "wait"; ms: number };
/**
* pw_keyboard_sequence — executes an ordered sequence of keyboard interactions on a Playwright Page.
*
* Use to script realistic input flows such as typing into an autocomplete field and then
* navigating its dropdown with arrow keys before pressing Enter.
*/
export async function pw_keyboard_sequence(
page: Page,
sequence: KbStep[]
): Promise<void> {
for (const step of sequence) {
switch (step.kind) {
case "focus":
await page.locator(step.selector).first().focus();
break;
case "type":
await page.keyboard.type(step.text, { delay: step.delayMs ?? 30 });
break;
case "press":
await page.keyboard.press(step.key);
break;
case "wait":
await page.waitForTimeout(step.ms);
break;
default: {
// TypeScript exhaustiveness check — will also throw at runtime for JS callers.
const _exhaustive: never = step;
throw new Error(
`pw_keyboard_sequence: unknown step kind "${(step as KbStep & { kind: string }).kind}"`
);
}
}
}
}
@@ -0,0 +1,46 @@
---
name: pw_launch_browser
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async (opts?: PwLaunchOptions) => Promise<PwHandles>"
description: "Launches chromium and returns a Page navigated to baseUrl. Optionally pre-authenticates via storageState. Used as the entry point for any Playwright e2e test in the registry."
tags: [playwright, e2e, browser]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: opts
desc: "Launch options; all fields optional with sensible defaults for kanban e2e."
output: "Playwright handles {browser, context, page, close}; page is already navigated to baseUrl."
tested: true
tests: ["launch defaults", "launch with storageState", "close calls both"]
test_file_path: "frontend/functions/browser/pw_launch_browser.test.ts"
file_path: "frontend/functions/browser/pw_launch_browser.ts"
---
## Ejemplo
```typescript
import { pw_launch_browser } from "./pw_launch_browser";
const h = await pw_launch_browser({ baseUrl: "http://localhost:5180", headless: false });
await h.page.click("text=Login");
await h.close();
```
## Cuando usarla
Cuando necesites iniciar un test e2e de Playwright para el SPA de kanban (o cualquier frontend del registry). Es el primer paso de cualquier suite: abre el browser, navega al baseUrl y devuelve los handles listos para interactuar.
## Gotchas
- `playwright` es una peer dependency: debe estar instalada en el proyecto consumidor (`pnpm add -D playwright` o `npm i -D playwright`). El registry no la instala.
- `storageStatePath` se ignora silenciosamente si el archivo no existe — verifica que el archivo de auth se haya generado antes de pasarlo.
- `close()` cierra context primero y luego browser; no llames `browser.close()` manualmente antes de `close()` o el context quedara huerfano.
- La navegacion a `baseUrl` ocurre dentro de `pw_launch_browser`; si el servidor no esta levantado, `page.goto` lanzara un error de red.
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- mocks ----------------------------------------------------------------
const mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
};
const mockContext = {
newPage: vi.fn().mockResolvedValue(mockPage),
close: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
newContext: vi.fn().mockResolvedValue(mockContext),
close: vi.fn().mockResolvedValue(undefined),
};
const mockChromium = {
launch: vi.fn().mockResolvedValue(mockBrowser),
};
vi.mock("playwright", () => ({
chromium: mockChromium,
}));
// Mock fs.existsSync — return true by default; individual tests override as needed.
vi.mock("fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { existsSync } from "fs";
import { pw_launch_browser } from "./pw_launch_browser";
// --------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
// Reset page.goto and existsSync defaults after clearing
mockPage.goto.mockResolvedValue(undefined);
mockContext.newPage.mockResolvedValue(mockPage);
mockContext.close.mockResolvedValue(undefined);
mockBrowser.newContext.mockResolvedValue(mockContext);
mockBrowser.close.mockResolvedValue(undefined);
mockChromium.launch.mockResolvedValue(mockBrowser);
vi.mocked(existsSync).mockReturnValue(true);
});
// --------------------------------------------------------------------------
describe("pw_launch_browser", () => {
it("launch defaults", async () => {
const handles = await pw_launch_browser();
// chromium.launch called with defaults
expect(mockChromium.launch).toHaveBeenCalledWith({ headless: true, slowMo: 0 });
// context created with default viewport
expect(mockBrowser.newContext).toHaveBeenCalledWith(
expect.objectContaining({ viewport: { width: 1280, height: 800 } })
);
// no storageState in context options when not provided
const ctxCall = mockBrowser.newContext.mock.calls[0][0] as Record<string, unknown>;
expect(ctxCall.storageState).toBeUndefined();
// page navigated to default baseUrl
expect(mockPage.goto).toHaveBeenCalledWith("http://localhost:5180");
// handles returned
expect(handles.browser).toBe(mockBrowser);
expect(handles.context).toBe(mockContext);
expect(handles.page).toBe(mockPage);
expect(typeof handles.close).toBe("function");
});
it("launch with storageState", async () => {
vi.mocked(existsSync).mockReturnValue(true);
const handles = await pw_launch_browser({
headless: false,
baseUrl: "http://localhost:5173",
viewport: { width: 1920, height: 1080 },
storageStatePath: "/tmp/auth.json",
slowMo: 50,
});
expect(mockChromium.launch).toHaveBeenCalledWith({ headless: false, slowMo: 50 });
expect(mockBrowser.newContext).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { width: 1920, height: 1080 },
storageState: "/tmp/auth.json",
})
);
expect(mockPage.goto).toHaveBeenCalledWith("http://localhost:5173");
expect(handles.page).toBe(mockPage);
});
it("close calls both", async () => {
const handles = await pw_launch_browser();
await handles.close();
expect(mockContext.close).toHaveBeenCalledOnce();
expect(mockBrowser.close).toHaveBeenCalledOnce();
// context must close before browser (call order)
const contextCloseOrder = mockContext.close.mock.invocationCallOrder[0];
const browserCloseOrder = mockBrowser.close.mock.invocationCallOrder[0];
expect(contextCloseOrder).toBeLessThan(browserCloseOrder);
});
it("storageState skipped when file missing", async () => {
vi.mocked(existsSync).mockReturnValue(false);
await pw_launch_browser({ storageStatePath: "/tmp/nonexistent.json" });
const ctxCall = mockBrowser.newContext.mock.calls[0][0] as Record<string, unknown>;
expect(ctxCall.storageState).toBeUndefined();
});
});
@@ -0,0 +1,58 @@
import { existsSync } from "fs";
import type { Browser, BrowserContext, Page } from "playwright";
/** Options for launching a Playwright browser session. */
export interface PwLaunchOptions {
/** Use headless mode. Default: true */
headless?: boolean;
/** Base URL to navigate to after opening the page. Default: "http://localhost:5180" */
baseUrl?: string;
/** Viewport size. Default: 1280x800 */
viewport?: { width: number; height: number };
/** Optional path to a Playwright storageState JSON for pre-auth. Loaded only if the file exists. */
storageStatePath?: string;
/** Slow down operations by the given ms. Default: 0 */
slowMo?: number;
}
/** Playwright handles returned after launching the browser. */
export interface PwHandles {
browser: Browser;
context: BrowserContext;
page: Page;
/** Closes context then browser. */
close: () => Promise<void>;
}
/**
* pw_launch_browser — launches chromium and returns a Page navigated to baseUrl.
*
* Optionally pre-authenticates via storageState (loaded only if the file exists).
* Returns handles including a convenience close() that tears down context and browser.
*/
export async function pw_launch_browser(opts: PwLaunchOptions = {}): Promise<PwHandles> {
const { chromium } = await import("playwright");
const headless = opts.headless ?? true;
const slowMo = opts.slowMo ?? 0;
const baseUrl = opts.baseUrl ?? "http://localhost:5180";
const viewport = opts.viewport ?? { width: 1280, height: 800 };
const browser = await chromium.launch({ headless, slowMo });
const contextOptions: Parameters<Browser["newContext"]>[0] = { viewport };
if (opts.storageStatePath && existsSync(opts.storageStatePath)) {
contextOptions.storageState = opts.storageStatePath;
}
const context = await browser.newContext(contextOptions);
const page = await context.newPage();
await page.goto(baseUrl);
const close = async (): Promise<void> => {
await context.close();
await browser.close();
};
return { browser, context, page, close };
}
@@ -0,0 +1,61 @@
---
name: pw_wait_predicate
kind: function
lang: ts
domain: browser
version: "1.0.0"
purity: impure
signature: "async <T = unknown>(page: Page, predicate: string | ((arg?: unknown) => unknown), opts?: PwWaitOptions) => Promise<T>"
description: "Polls an arbitrary predicate inside the page context until it returns truthy or times out. Thin wrapper around page.waitForFunction with friendlier defaults and custom error messages. Use to wait for CSS class changes, DOM counts, or any computed condition."
tags: [playwright, e2e, browser, wait]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: page
desc: "Playwright Page."
- name: predicate
desc: "JS expression as string OR function evaluated in page context. Returns truthy to stop polling."
- name: opts
desc: "{timeoutMs?, pollMs?, arg?, message?}."
output: "Resolved value of the predicate (page.waitForFunction jsonValue)."
tested: true
tests: ["defaults applied", "arg passed through", "jsonValue returned", "message wraps timeout"]
test_file_path: "frontend/functions/browser/pw_wait_predicate.test.ts"
file_path: "frontend/functions/browser/pw_wait_predicate.ts"
---
## Ejemplo
```typescript
import { pw_wait_predicate } from "./pw_wait_predicate";
// Wait for at least one red-bordered card to appear
await pw_wait_predicate(
page,
() => document.querySelectorAll('[data-card-id].border-red').length > 0,
{ timeoutMs: 2000, message: "no red-bordered card appeared" },
);
// Wait for exactly 1 highlighted card using an arg
await pw_wait_predicate(
page,
(sel) => document.querySelectorAll(sel as string).length === 1,
{ arg: ".highlighted", timeoutMs: 3000, pollMs: 50 },
);
```
## Cuando usarla
Cuando `waitForSelector` no basta porque la condicion depende de una clase CSS compuesta, un conteo exacto de elementos, o cualquier expresion calculada en el contexto de la pagina. Usar antes de assertions que requieren un estado DOM especifico que tarda en estabilizarse.
## Gotchas
- `predicate` se ejecuta en el contexto de la pagina (sandbox del browser), no en Node. No puede cerrar sobre variables de Node; usa `opts.arg` para pasar datos.
- Si `predicate` es una funcion, Playwright la serializa a string para enviarla al browser. No uses referencias externas ni closures de Node dentro de ella.
- `opts.message` solo se usa cuando `waitForFunction` lanza; no modifica el valor de retorno en caso de exito.
- `playwright` debe estar instalada en el proyecto consumidor como peer dependency. El registry no la instala.
- Con `polling: number`, Playwright hace polling activo (busy loop con sleeps cortos). Un `pollMs` muy bajo (< 50) puede saturar la CPU del browser en tests largos.
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { JSHandle } from "playwright";
// --- mocks ------------------------------------------------------------------
const mockJsonValue = vi.fn();
const mockHandle: Partial<JSHandle> = {
jsonValue: mockJsonValue,
};
const mockPage = {
waitForFunction: vi.fn(),
};
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
mockPage.waitForFunction.mockResolvedValue(mockHandle);
mockJsonValue.mockResolvedValue(true);
});
import { pw_wait_predicate } from "./pw_wait_predicate";
// ---------------------------------------------------------------------------
describe("pw_wait_predicate", () => {
it("defaults applied", async () => {
const predicate = () => document.querySelector(".ready") !== null;
await pw_wait_predicate(mockPage as never, predicate);
expect(mockPage.waitForFunction).toHaveBeenCalledWith(
predicate,
undefined, // arg default
{ timeout: 5000, polling: 100 },
);
});
it("arg passed through", async () => {
const predicate = (sel: unknown) =>
document.querySelectorAll(sel as string).length > 0;
const arg = ".border-red";
await pw_wait_predicate(mockPage as never, predicate, {
timeoutMs: 2000,
pollMs: 50,
arg,
});
expect(mockPage.waitForFunction).toHaveBeenCalledWith(
predicate,
arg,
{ timeout: 2000, polling: 50 },
);
});
it("jsonValue returned", async () => {
const expected = { count: 3 };
mockJsonValue.mockResolvedValue(expected);
const result = await pw_wait_predicate(mockPage as never, "() => true");
expect(result).toEqual(expected);
expect(mockJsonValue).toHaveBeenCalledOnce();
});
it("message wraps timeout", async () => {
const originalError = new Error("Timeout 5000ms exceeded");
mockPage.waitForFunction.mockRejectedValue(originalError);
await expect(
pw_wait_predicate(mockPage as never, "() => false", {
message: "no red-bordered card appeared",
}),
).rejects.toThrow("no red-bordered card appeared: Timeout 5000ms exceeded");
});
it("error passthrough without message", async () => {
const originalError = new Error("Something went wrong");
mockPage.waitForFunction.mockRejectedValue(originalError);
await expect(
pw_wait_predicate(mockPage as never, "() => false"),
).rejects.toThrow("Something went wrong");
});
});
@@ -0,0 +1,45 @@
import type { Page } from "playwright";
/** Options for pw_wait_predicate. */
export interface PwWaitOptions {
/** Maximum time to wait in milliseconds. Default: 5000 */
timeoutMs?: number;
/** Polling interval in milliseconds. Default: 100 */
pollMs?: number;
/** Argument passed to the predicate function in the page context. */
arg?: unknown;
/** Custom error message prepended to the timeout error. */
message?: string;
}
/**
* pw_wait_predicate — polls an arbitrary predicate inside the page context
* until it returns truthy or the timeout elapses.
*
* Thin wrapper around `page.waitForFunction` with friendlier defaults and
* optional custom error messages. More flexible than `waitForSelector` for
* computed conditions such as "card has class .border-red" or "exactly 1
* element is highlighted".
*/
export async function pw_wait_predicate<T = unknown>(
page: Page,
predicate: string | ((arg?: unknown) => unknown),
opts: PwWaitOptions = {},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 5000;
const pollMs = opts.pollMs ?? 100;
try {
const handle = await page.waitForFunction(predicate, opts.arg, {
timeout: timeoutMs,
polling: pollMs,
});
return handle.jsonValue() as Promise<T>;
} catch (err) {
if (opts.message) {
const cause = err instanceof Error ? err : new Error(String(err));
throw new Error(`${opts.message}: ${cause.message}`, { cause });
}
throw err;
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "fn-registry-frontend-functions",
"type": "module",
"private": true
}
+3 -1
View File
@@ -35,11 +35,13 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.60.0",
"postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vite": "^8.0.3"
"vite": "^8.0.3",
"vitest": "^4.1.6"
}
}
+285
View File
@@ -69,6 +69,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0))
playwright:
specifier: ^1.60.0
version: 1.60.0
postcss:
specifier: ^8.5.8
version: 8.5.8
@@ -87,6 +90,9 @@ importers:
vite:
specifier: ^8.0.3
version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0)
vitest:
specifier: ^4.1.6
version: 4.1.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0))
packages:
@@ -283,6 +289,9 @@ packages:
'@fontsource-variable/geist@5.2.8':
resolution: {integrity: sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@mantine/charts@9.0.0':
resolution: {integrity: sha512-TnbjiT2tXZDAQWZrv/+Xu3JKYjPiTfO5jSIbcwnxZSVtLI+PIxA7zrSps+it/Nx3ch8GHpDizJ7UArC0UfmNkQ==}
peerDependencies:
@@ -471,6 +480,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -498,6 +510,12 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -522,6 +540,39 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vitest/expect@4.1.6':
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
'@vitest/mocker@4.1.6':
resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.6':
resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
'@vitest/runner@4.1.6':
resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==}
'@vitest/snapshot@4.1.6':
resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==}
'@vitest/spy@4.1.6':
resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==}
'@vitest/utils@4.1.6':
resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
attr-accept@2.2.5:
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
engines: {node: '>=4'}
@@ -530,10 +581,17 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -602,6 +660,9 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
@@ -610,6 +671,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -617,6 +681,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -633,6 +701,11 @@ packages:
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
engines: {node: '>= 12'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -758,6 +831,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -767,6 +843,12 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -774,6 +856,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
postcss-js@4.1.0:
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
engines: {node: ^12 || ^14 || >= 16}
@@ -919,6 +1011,9 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sigma@3.0.2:
resolution: {integrity: sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==}
@@ -926,6 +1021,12 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
sugarss@5.0.1:
resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==}
engines: {node: '>=18.0'}
@@ -942,10 +1043,21 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1037,6 +1149,52 @@ packages:
yaml:
optional: true
vitest@4.1.6:
resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.6
'@vitest/browser-preview': 4.1.6
'@vitest/browser-webdriverio': 4.1.6
'@vitest/coverage-istanbul': 4.1.6
'@vitest/coverage-v8': 4.1.6
'@vitest/ui': 4.1.6
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots:
'@babel/runtime@7.29.2': {}
@@ -1162,6 +1320,8 @@ snapshots:
'@fontsource-variable/geist@5.2.8': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@mantine/charts@9.0.0(@mantine/core@9.0.0(@mantine/hooks@9.0.0(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.0.0(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1))':
dependencies:
'@mantine/core': 9.0.0(@mantine/hooks@9.0.0(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1315,6 +1475,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -1339,6 +1504,10 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -1354,12 +1523,59 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0)
'@vitest/expect@4.1.6':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.6
'@vitest/utils': 4.1.6
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0))':
dependencies:
'@vitest/spy': 4.1.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0)
'@vitest/pretty-format@4.1.6':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.6':
dependencies:
'@vitest/utils': 4.1.6
pathe: 2.0.3
'@vitest/snapshot@4.1.6':
dependencies:
'@vitest/pretty-format': 4.1.6
'@vitest/utils': 4.1.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.6': {}
'@vitest/utils@4.1.6':
dependencies:
'@vitest/pretty-format': 4.1.6
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
assertion-error@2.0.1: {}
attr-accept@2.2.5: {}
camelcase-css@2.0.1: {}
chai@6.2.2: {}
clsx@2.1.1: {}
convert-source-map@2.0.0: {}
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -1415,6 +1631,8 @@ snapshots:
'@babel/runtime': 7.29.2
csstype: 3.2.3
es-module-lexer@2.1.0: {}
es-toolkit@1.45.1: {}
esbuild@0.27.4:
@@ -1446,10 +1664,16 @@ snapshots:
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
eventemitter3@5.0.4: {}
events@3.3.0: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fdir@6.5.0(picomatch@4.0.4):
@@ -1460,6 +1684,9 @@ snapshots:
dependencies:
tslib: 2.8.1
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -1551,14 +1778,30 @@ snapshots:
dependencies:
js-tokens: 4.0.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.11: {}
object-assign@4.1.1: {}
obug@2.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
playwright-core@1.60.0: {}
playwright@1.60.0:
dependencies:
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
postcss-js@4.1.0(postcss@8.5.8):
dependencies:
camelcase-css: 2.0.1
@@ -1728,6 +1971,8 @@ snapshots:
scheduler@0.27.0: {}
siginfo@2.0.0: {}
sigma@3.0.2(graphology-types@0.24.8):
dependencies:
events: 3.3.0
@@ -1737,6 +1982,10 @@ snapshots:
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
sugarss@5.0.1(postcss@8.5.8):
dependencies:
postcss: 8.5.8
@@ -1747,11 +1996,17 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tslib@2.8.1: {}
tsx@4.21.0:
@@ -1821,3 +2076,33 @@ snapshots:
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
vitest@4.1.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0)):
dependencies:
'@vitest/expect': 4.1.6
'@vitest/mocker': 4.1.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0))
'@vitest/pretty-format': 4.1.6
'@vitest/runner': 4.1.6
'@vitest/snapshot': 4.1.6
'@vitest/spy': 4.1.6
'@vitest/utils': 4.1.6
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(tsx@4.21.0)
why-is-node-running: 2.3.0
transitivePeerDependencies:
- msw
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2