From 39f6d09017440be4adfdd759100a447ea03ca5ac Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 21:47:14 +0100 Subject: [PATCH 01/33] implement clock --- scripts/coverage.ts | 1 + src/browser-context.ts | 6 ++ src/clock.ts | 124 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/page.test.ts | 79 +++++++++++++++++++++++--- src/page.ts | 6 ++ 6 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/clock.ts diff --git a/scripts/coverage.ts b/scripts/coverage.ts index 406a9aa..13bbce1 100644 --- a/scripts/coverage.ts +++ b/scripts/coverage.ts @@ -20,6 +20,7 @@ const MAPPINGS = [ { pw: "Dialog", ep: "PlaywrightDialog", type: "class" as const }, { pw: "FileChooser", ep: "PlaywrightFileChooser", type: "class" as const }, { pw: "Download", ep: "PlaywrightDownload", type: "class" as const }, + { pw: "Clock", ep: "PlaywrightClockService", type: "interface" as const }, ]; const EXCLUDED_METHODS = new Set([ diff --git a/src/browser-context.ts b/src/browser-context.ts index 4030f02..dd15a99 100644 --- a/src/browser-context.ts +++ b/src/browser-context.ts @@ -17,6 +17,7 @@ import { } from "./common"; import type { PlaywrightError } from "./errors"; import { PlaywrightPage } from "./page"; +import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import type { PatchedEvents } from "./playwright-types"; import { useHelper } from "./utils"; @@ -58,6 +59,10 @@ type BrowserContextWithPatchedEvents = PatchedEvents< * @since 0.1.0 */ export interface PlaywrightBrowserContextService { + /** + * Access the clock. + */ + readonly clock: PlaywrightClockService; /** * Returns the list of all open pages in the browser context. * @@ -122,6 +127,7 @@ export class PlaywrightBrowserContext extends Context.Tag( ): PlaywrightBrowserContextService { const use = useHelper(context); return PlaywrightBrowserContext.of({ + clock: PlaywrightClock.make(context.clock), pages: Effect.sync(() => context.pages().map(PlaywrightPage.make)), newPage: use((c) => c.newPage().then(PlaywrightPage.make)), close: use((c) => c.close()), diff --git a/src/clock.ts b/src/clock.ts new file mode 100644 index 0000000..88ac267 --- /dev/null +++ b/src/clock.ts @@ -0,0 +1,124 @@ +import { Context, type Effect } from "effect"; +import type { Clock } from "playwright-core"; +import type { PlaywrightError } from "./errors"; +import { useHelper } from "./utils"; + +/** + * Interface for a Playwright clock. + * @category model + */ +export interface PlaywrightClockService { + /** + * Advance the clock by jumping forward in time. Only fires due timers at most once. This is equivalent to user + * closing the laptop lid for a while and reopening it later, after given time. + * + * @see {@link Clock.fastForward} + * @since 0.1.0 + */ + readonly fastForward: ( + ticks: number | string, + ) => Effect.Effect; + + /** + * Install fake implementations for time-related functions. + * + * @see {@link Clock.install} + * @since 0.1.0 + */ + readonly install: (options?: { + time?: number | string | Date; + }) => Effect.Effect; + + /** + * Advance the clock by jumping forward in time and pause the time. + * + * @see {@link Clock.pauseAt} + * @since 0.1.0 + */ + readonly pauseAt: ( + time: number | string | Date, + ) => Effect.Effect; + + /** + * Resumes timers. Once this method is called, time resumes flowing, timers are fired as usual. + * + * @see {@link Clock.resume} + * @since 0.1.0 + */ + readonly resume: Effect.Effect; + + /** + * Advance the clock, firing all the time-related callbacks. + * + * @see {@link Clock.runFor} + * @since 0.1.0 + */ + readonly runFor: ( + ticks: number | string, + ) => Effect.Effect; + + /** + * Makes `Date.now` and `new Date()` return fixed fake time at all times, keeps all the timers running. + * + * @see {@link Clock.setFixedTime} + * @since 0.1.0 + */ + readonly setFixedTime: ( + time: number | string | Date, + ) => Effect.Effect; + + /** + * Sets system time, but does not trigger any timers. + * + * @see {@link Clock.setSystemTime} + * @since 0.1.0 + */ + readonly setSystemTime: ( + time: number | string | Date, + ) => Effect.Effect; + + /** + * A generic utility to execute any promise-based method on the underlying Playwright `Clock`. + * Can be used to access any Clock functionality not directly exposed by this service. + * + * @param f - A function that takes the Playwright `Clock` and returns a `Promise`. + * @returns An effect that wraps the promise and returns its result. + * @see {@link Clock} + * @since 0.1.0 + */ + readonly use: ( + f: (clock: Clock) => Promise, + ) => Effect.Effect; +} + +/** + * A service that provides a `PlaywrightClock` instance. + * + * @since 0.1.0 + * @category tag + */ +export class PlaywrightClock extends Context.Tag( + "effect-playwright/PlaywrightClock", +)() { + /** + * Creates a `PlaywrightClock` from a Playwright `Clock` instance. + * + * @param clock - The Playwright `Clock` instance to wrap. + * @since 0.1.0 + * @category constructor + */ + static make(clock: Clock): typeof PlaywrightClock.Service { + const use = useHelper(clock); + + return PlaywrightClock.of({ + fastForward: (ticks) => use((c) => c.fastForward(ticks)), + install: (options) => use((c) => c.install(options)), + pauseAt: (time) => use((c) => c.pauseAt(time)), + resume: use((c) => c.resume()), + runFor: (ticks) => use((c) => c.runFor(ticks)), + setFixedTime: (time) => use((c) => c.setFixedTime(time)), + setSystemTime: (time) => use((c) => c.setSystemTime(time)), + use, + }); + } +} diff --git a/src/index.ts b/src/index.ts index af20ec5..5fd8734 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,4 @@ export * from "./frame"; export * from "./locator"; export * from "./page"; export * from "./playwright"; +export * from "./clock"; diff --git a/src/page.test.ts b/src/page.test.ts index 7bbe235..e4ab3c9 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -4,6 +4,12 @@ import { chromium } from "playwright-core"; import { PlaywrightBrowser } from "./browser"; import { PlaywrightEnvironment } from "./experimental"; +type TestWindow = Window & { + timerFired?: boolean; + clicked?: boolean; + clickCoords?: { x: number; y: number } | null; +}; + layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { it.scoped("goto should navigate to a URL", () => Effect.gen(function* () { @@ -35,7 +41,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { const page = yield* browser.newPage(); yield* page.evaluate(() => { - const win = window as Window & { clicked?: boolean }; + const win = window as TestWindow; document.body.innerHTML = ''; win.clicked = false; @@ -44,7 +50,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { yield* page.click("#mybutton"); const clicked = yield* page.evaluate( - () => (window as Window & { clicked?: boolean }).clicked, + () => (window as TestWindow).clicked, ); assert(clicked === true); }).pipe(PlaywrightEnvironment.withBrowser), @@ -92,9 +98,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { const page = yield* browser.newPage(); yield* page.evaluate(() => { - const win = window as Window & { - clickCoords?: { x: number; y: number } | null; - }; + const win = window as TestWindow; document.body.innerHTML = ''; win.clickCoords = null; @@ -107,9 +111,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { yield* page.click("#mybutton", { position: { x: 10, y: 10 } }); const coords = yield* page.evaluate( - () => - (window as Window & { clickCoords?: { x: number; y: number } | null }) - .clickCoords, + () => (window as TestWindow).clickCoords, ); assert(coords !== null); }).pipe(PlaywrightEnvironment.withBrowser), @@ -243,4 +245,65 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert.strictEqual(page.url(), url2); }).pipe(PlaywrightEnvironment.withBrowser), ); + + it.scoped("clock should allow fast forwarding time", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + // Install clock + yield* page.clock.install({ time: new Date("2024-01-01T00:00:00.000Z") }); + + yield* page.evaluate(() => { + (window as TestWindow).timerFired = false; + setTimeout(() => { + (window as TestWindow).timerFired = true; + }, 10000); + }); + + let timerFired = yield* page.evaluate( + () => (window as TestWindow).timerFired, + ); + assert.strictEqual(timerFired, false); + + yield* page.clock.fastForward(10000); + + timerFired = yield* page.evaluate( + () => (window as TestWindow).timerFired, + ); + assert.strictEqual(timerFired, true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("clock should allow fast forwarding time on context", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const context = yield* browser.newContext(); + const page = yield* context.newPage; + + // Install clock on context + yield* context.clock.install({ + time: new Date("2024-01-01T00:00:00.000Z"), + }); + + yield* page.evaluate(() => { + (window as TestWindow).timerFired = false; + setTimeout(() => { + (window as TestWindow).timerFired = true; + }, 10000); + }); + + let timerFired = yield* page.evaluate( + () => (window as TestWindow).timerFired, + ); + assert.strictEqual(timerFired, false); + + yield* context.clock.fastForward(10000); + + timerFired = yield* page.evaluate( + () => (window as TestWindow).timerFired, + ); + assert.strictEqual(timerFired, true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index 08df20c..4405d20 100644 --- a/src/page.ts +++ b/src/page.ts @@ -24,6 +24,7 @@ import { import type { PlaywrightError } from "./errors"; import { PlaywrightFrame } from "./frame"; import { PlaywrightLocator } from "./locator"; +import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import type { PageFunction, PatchedEvents } from "./playwright-types"; import { useHelper } from "./utils"; @@ -79,6 +80,10 @@ type PageWithPatchedEvents = PatchedEvents; * @since 0.1.0 */ export interface PlaywrightPageService { + /** + * Access the clock. + */ + readonly clock: PlaywrightClockService; /** * Navigates the page to the given URL. * @@ -312,6 +317,7 @@ export class PlaywrightPage extends Context.Tag( const use = useHelper(page); return PlaywrightPage.of({ + clock: PlaywrightClock.make(page.clock), goto: (url, options) => use((p) => p.goto(url, options)), waitForURL: (url, options) => use((p) => p.waitForURL(url, options)), waitForLoadState: (state, options) => From 0109e61e586837caedfa6c4145f987727447f4d4 Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 22:36:46 +0100 Subject: [PATCH 02/33] add page.addInitScript --- src/browser-context.test.ts | 39 +++++++++++++++++++++++++++++++++++++ src/browser-context.ts | 15 +++++++++++++- src/page.test.ts | 19 ++++++++++++++++++ src/page.ts | 17 +++++++++++++--- 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/browser-context.test.ts diff --git a/src/browser-context.test.ts b/src/browser-context.test.ts new file mode 100644 index 0000000..4a6f78a --- /dev/null +++ b/src/browser-context.test.ts @@ -0,0 +1,39 @@ +import { assert, layer } from "@effect/vitest"; +import { Effect } from "effect"; +import { chromium } from "playwright-core"; +import { PlaywrightBrowser } from "./browser"; +import { PlaywrightEnvironment } from "./experimental"; + +type TestWindow = Window & { + magicValue?: number; +}; + +layer(PlaywrightEnvironment.layer(chromium))( + "PlaywrightBrowserContext", + (it) => { + it.scoped("addInitScript should execute script in all new pages", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const context = yield* browser.newContext(); + + yield* context.addInitScript(() => { + (window as TestWindow).magicValue = 84; + }); + + const page1 = yield* context.newPage; + yield* page1.goto("about:blank"); + const magicValue1 = yield* page1.evaluate( + () => (window as TestWindow).magicValue, + ); + assert.strictEqual(magicValue1, 84); + + const page2 = yield* context.newPage; + yield* page2.goto("about:blank"); + const magicValue2 = yield* page2.evaluate( + () => (window as TestWindow).magicValue, + ); + assert.strictEqual(magicValue2, 84); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + }, +); diff --git a/src/browser-context.ts b/src/browser-context.ts index dd15a99..d3044ce 100644 --- a/src/browser-context.ts +++ b/src/browser-context.ts @@ -9,6 +9,7 @@ import type { WebError, Worker, } from "playwright-core"; +import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import { PlaywrightDialog, PlaywrightRequest, @@ -17,7 +18,6 @@ import { } from "./common"; import type { PlaywrightError } from "./errors"; import { PlaywrightPage } from "./page"; -import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import type { PatchedEvents } from "./playwright-types"; import { useHelper } from "./utils"; @@ -92,6 +92,18 @@ export interface PlaywrightBrowserContextService { * @since 0.1.0 */ readonly close: Effect.Effect; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever a page is created in the browser context or is navigated. + * - Whenever a child frame is attached or navigated. In this case, the script is evaluated in the context of the newly attached frame. + * + * @see {@link BrowserContext.addInitScript} + * @since 0.2.0 + */ + readonly addInitScript: ( + script: Parameters[0], + arg?: Parameters[1], + ) => Effect.Effect; /** * Creates a stream of the given event from the browser context. @@ -131,6 +143,7 @@ export class PlaywrightBrowserContext extends Context.Tag( pages: Effect.sync(() => context.pages().map(PlaywrightPage.make)), newPage: use((c) => c.newPage().then(PlaywrightPage.make)), close: use((c) => c.close()), + addInitScript: (script, arg) => use((c) => c.addInitScript(script, arg)), eventStream: (event: K) => Stream.asyncPush((emit) => Effect.acquireRelease( diff --git a/src/page.test.ts b/src/page.test.ts index e4ab3c9..d46f079 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -8,6 +8,7 @@ type TestWindow = Window & { timerFired?: boolean; clicked?: boolean; clickCoords?: { x: number; y: number } | null; + magicValue?: number; }; layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { @@ -306,4 +307,22 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert.strictEqual(timerFired, true); }).pipe(PlaywrightEnvironment.withBrowser), ); + + it.scoped("addInitScript should execute script before page load", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.addInitScript(() => { + (window as TestWindow).magicValue = 42; + }); + + yield* page.goto("about:blank"); + + const magicValue = yield* page.evaluate( + () => (window as TestWindow).magicValue, + ); + assert.strictEqual(magicValue, 42); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index 4405d20..e9a31cd 100644 --- a/src/page.ts +++ b/src/page.ts @@ -11,7 +11,7 @@ import type { WebSocket, Worker, } from "playwright-core"; - +import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import { PlaywrightDialog, PlaywrightDownload, @@ -20,11 +20,9 @@ import { PlaywrightResponse, PlaywrightWorker, } from "./common"; - import type { PlaywrightError } from "./errors"; import { PlaywrightFrame } from "./frame"; import { PlaywrightLocator } from "./locator"; -import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import type { PageFunction, PatchedEvents } from "./playwright-types"; import { useHelper } from "./utils"; @@ -149,6 +147,18 @@ export interface PlaywrightPageService { pageFunction: PageFunction, arg?: Arg, ) => Effect.Effect; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever the page is navigated. + * - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the newly attached frame. + * + * @see {@link Page.addInitScript} + * @since 0.2.0 + */ + readonly addInitScript: ( + script: Parameters[0], + arg?: Parameters[1], + ) => Effect.Effect; /** * Returns the page title. * @@ -325,6 +335,7 @@ export class PlaywrightPage extends Context.Tag( title: use((p) => p.title()), evaluate: (f: PageFunction, arg?: Arg) => use((p) => p.evaluate(f, arg as Arg)), + addInitScript: (script, arg) => use((p) => p.addInitScript(script, arg)), locator: (selector, options) => PlaywrightLocator.make(page.locator(selector, options)), getByRole: (role, options) => From 7159514f720a7652283320f3f461dbdbf2c58393 Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 22:54:02 +0100 Subject: [PATCH 03/33] implement Page.keyboard --- src/index.ts | 3 +- src/keyboard.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ src/page.test.ts | 19 +++++++++++ src/page.ts | 6 ++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/keyboard.ts diff --git a/src/index.ts b/src/index.ts index 5fd8734..457b8a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,11 +19,12 @@ export { PlaywrightBrowserContext, type PlaywrightBrowserContextService, } from "./browser-context"; +export * from "./clock"; export * from "./common"; export type { PlaywrightErrorReason } from "./errors"; export { PlaywrightError } from "./errors"; export * from "./frame"; +export * from "./keyboard"; export * from "./locator"; export * from "./page"; export * from "./playwright"; -export * from "./clock"; diff --git a/src/keyboard.ts b/src/keyboard.ts new file mode 100644 index 0000000..e00cbab --- /dev/null +++ b/src/keyboard.ts @@ -0,0 +1,83 @@ +import { Context, type Effect } from "effect"; +import type { Keyboard } from "playwright-core"; +import type { PlaywrightError } from "./errors"; +import { useHelper } from "./utils"; + +/** + * @category model + * @since 0.1.0 + */ +export interface PlaywrightKeyboardService { + /** + * Dispatches a `keydown` event. + * + * @see {@link Keyboard.down} + * @since 0.1.0 + */ + readonly down: ( + key: Parameters[0], + ) => Effect.Effect; + /** + * Dispatches only `input` event, does not emit the `keydown`, `keyup` or `keypress` events. + * + * @see {@link Keyboard.insertText} + * @since 0.1.0 + */ + readonly insertText: ( + text: Parameters[0], + ) => Effect.Effect; + /** + * Dispatches a `keydown` and `keyup` event. + * + * @see {@link Keyboard.press} + * @since 0.1.0 + */ + readonly press: ( + key: Parameters[0], + options?: Parameters[1], + ) => Effect.Effect; + /** + * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character in the text. + * + * @see {@link Keyboard.type} + * @since 0.1.0 + */ + readonly type: ( + text: Parameters[0], + options?: Parameters[1], + ) => Effect.Effect; + /** + * Dispatches a `keyup` event. + * + * @see {@link Keyboard.up} + * @since 0.1.0 + */ + readonly up: ( + key: Parameters[0], + ) => Effect.Effect; +} + +/** + * @category tag + */ +export class PlaywrightKeyboard extends Context.Tag( + "effect-playwright/PlaywrightKeyboard", +)() { + /** + * Creates a `PlaywrightKeyboard` from a Playwright `Keyboard` instance. + * + * @param keyboard - The Playwright `Keyboard` instance to wrap. + * @since 0.1.0 + */ + static make(keyboard: Keyboard): PlaywrightKeyboardService { + const use = useHelper(keyboard); + + return PlaywrightKeyboard.of({ + down: (key) => use((k) => k.down(key)), + insertText: (text) => use((k) => k.insertText(text)), + press: (key, options) => use((k) => k.press(key, options)), + type: (text, options) => use((k) => k.type(text, options)), + up: (key) => use((k) => k.up(key)), + }); + } +} diff --git a/src/page.test.ts b/src/page.test.ts index d46f079..7e56086 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -325,4 +325,23 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert.strictEqual(magicValue, 42); }).pipe(PlaywrightEnvironment.withBrowser), ); + + it.scoped("keyboard should allow typing text", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.evaluate(() => { + document.body.innerHTML = ''; + document.getElementById("input")?.focus(); + }); + + yield* page.keyboard.type("Hello Effect"); + + const value = yield* page.evaluate( + () => (document.getElementById("input") as HTMLInputElement).value, + ); + assert.strictEqual(value, "Hello Effect"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index e9a31cd..a8a1748 100644 --- a/src/page.ts +++ b/src/page.ts @@ -22,6 +22,7 @@ import { } from "./common"; import type { PlaywrightError } from "./errors"; import { PlaywrightFrame } from "./frame"; +import { PlaywrightKeyboard, type PlaywrightKeyboardService } from "./keyboard"; import { PlaywrightLocator } from "./locator"; import type { PageFunction, PatchedEvents } from "./playwright-types"; import { useHelper } from "./utils"; @@ -82,6 +83,10 @@ export interface PlaywrightPageService { * Access the clock. */ readonly clock: PlaywrightClockService; + /** + * Access the keyboard. + */ + readonly keyboard: PlaywrightKeyboardService; /** * Navigates the page to the given URL. * @@ -328,6 +333,7 @@ export class PlaywrightPage extends Context.Tag( return PlaywrightPage.of({ clock: PlaywrightClock.make(page.clock), + keyboard: PlaywrightKeyboard.make(page.keyboard), goto: (url, options) => use((p) => p.goto(url, options)), waitForURL: (url, options) => use((p) => p.waitForURL(url, options)), waitForLoadState: (state, options) => From 222cfe42017bc2af0cffd9b66cda8033fc2339e1 Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 22:54:02 +0100 Subject: [PATCH 04/33] improve implement skill --- .../implement-playwright-method/SKILL.md | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/.agents/skills/implement-playwright-method/SKILL.md b/.agents/skills/implement-playwright-method/SKILL.md index 9026aa9..8bd0e16 100644 --- a/.agents/skills/implement-playwright-method/SKILL.md +++ b/.agents/skills/implement-playwright-method/SKILL.md @@ -22,7 +22,7 @@ First, find the implementation of the method in the Playwright codebase to under ### 2. Analyze the Method -Determine if the method can throw and what it returns. **Do not blindly follow existing patterns in `effect-playwright` if they wrap safe synchronous methods in Effects.** +Determine if the method can throw and what it returns. **Do not blindly follow existing patterns in `effect-playwright`. Always analyze the original Playwright source code to determine behavior.** #### Can it throw? @@ -43,7 +43,38 @@ Determine if the method can throw and what it returns. **Do not blindly follow e - **`T | null`** -> `Option` (if sync) or `Effect, PlaywrightError>` (if async) - **Playwright Object (e.g., `Page`)** -> **Wrapped Object (e.g., `PlaywrightPage`)** -### 3. Define the Interface +### 3. Handle Sub-APIs / Nested Properties + +Some Playwright interfaces expose other classes as properties (e.g., `Page.keyboard`, `Page.mouse`, `BrowserContext.tracing`). + +1. **Create a new Wrapper**: Create a new file, Service, and Tag for the sub-API (e.g., `PlaywrightKeyboardService` wrapping `Keyboard`). +2. **Expose as a Sync Property**: Expose it as a direct, read-only property on the parent service. Do not wrap property access in an `Effect`. + +**Example (Interface in Parent):** + +```typescript +export interface PlaywrightPageService { + /** + * Access the keyboard. + * @see {@link Page.keyboard} + */ + readonly keyboard: PlaywrightKeyboardService; +} +``` + +**Example (Implementation in Parent's `make`):** + +```typescript +static make(page: Page): PlaywrightPageService { + return PlaywrightPage.of({ + // Initialize the sub-API wrapper synchronously + keyboard: PlaywrightKeyboard.make(page.keyboard), + // ... + }); +} +``` + +### 4. Define the Interface Add the method to the Service interface in the corresponding `src/X.ts` file (e.g., `PlaywrightPageService` in `src/page.ts`). @@ -93,7 +124,7 @@ readonly url: () => string; readonly textContent: Effect.Effect, PlaywrightError>; ``` -### 4. Implement the Method +### 5. Implement the Method Implement the method in the `make` function of the implementation class (e.g., `PlaywrightPage.make`). @@ -135,7 +166,7 @@ Implement the method in the `make` function of the implementation class (e.g., ` ), ``` -### 5. Verify +### 6. Verify - Ensure types match `PlaywrightXService`. -- Run `npm run typecheck` (or equivalent) to verify implementation. +- Run `pnpm run type-check` and `pnpm test` to verify implementation. From 547cc50db1cbe62f73bde5ced0464dd9c6ed997d Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 23:07:04 +0100 Subject: [PATCH 05/33] lint fixes --- src/common.test.ts | 18 ++++++------------ src/experimental/environment.ts | 3 ++- src/page.test.ts | 3 +-- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/common.test.ts b/src/common.test.ts index a01b0aa..05d02bb 100644 --- a/src/common.test.ts +++ b/src/common.test.ts @@ -12,13 +12,11 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightCommon", (it) => { const requestFiber = yield* page .eventStream("request") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); const responseFiber = yield* page .eventStream("response") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.goto("http://example.com"); @@ -52,8 +50,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightCommon", (it) => { const workerFiber = yield* page .eventStream("worker") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.evaluate(() => { const blob = new Blob(['console.log("worker")'], { @@ -77,8 +74,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightCommon", (it) => { const dialogFiber = yield* page .eventStream("dialog") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.evaluate(() => { setTimeout(() => alert("hello world"), 10); @@ -104,8 +100,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightCommon", (it) => { const fileChooserFiber = yield* page .eventStream("filechooser") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.locator("#fileinput").click(); @@ -130,8 +125,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightCommon", (it) => { const downloadFiber = yield* page .eventStream("download") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.locator("#download").click(); diff --git a/src/experimental/environment.ts b/src/experimental/environment.ts index 7443df4..ad6827c 100644 --- a/src/experimental/environment.ts +++ b/src/experimental/environment.ts @@ -63,7 +63,8 @@ export const layer = (browser: BrowserType, launchOptions?: LaunchOptions) => { browser: playwright.launchScoped(browser, launchOptions), }); }), - ).pipe(Effect.provide(Playwright.layer)), + Effect.provide(Playwright.layer), + ), ); }; diff --git a/src/page.test.ts b/src/page.test.ts index 7e56086..9f78477 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -205,8 +205,7 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { const fileChooser = yield* page .eventStream("filechooser") - .pipe(Stream.runHead) - .pipe(Effect.fork); + .pipe(Stream.runHead, Effect.fork); yield* page.locator("#fileinput").click(); From e8b8f76d4af414a970c8c8477bbab389d1f37435 Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Fri, 20 Feb 2026 23:24:13 +0100 Subject: [PATCH 06/33] implement Page.screenshot --- src/page.test.ts | 12 ++++++++++++ src/page.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/page.test.ts b/src/page.test.ts index 9f78477..d1fd751 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -343,4 +343,16 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert.strictEqual(value, "Hello Effect"); }).pipe(PlaywrightEnvironment.withBrowser), ); + it.scoped("screenshot should capture an image", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("data:text/html,

Screenshot Test

"); + const buffer = yield* page.screenshot({ type: "png" }); + + assert(Buffer.isBuffer(buffer)); + assert(buffer.length > 0); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index a8a1748..31cde0d 100644 --- a/src/page.ts +++ b/src/page.ts @@ -243,6 +243,21 @@ export interface PlaywrightPageService { testId: Parameters[0], ) => typeof PlaywrightLocator.Service; + /** + * Captures a screenshot of the page. + * + * @example + * ```ts + * const buffer = yield* page.screenshot({ path: "screenshot.png" }); + * ``` + * + * @see {@link Page.screenshot} + * @since 0.2.0 + */ + readonly screenshot: ( + options?: Parameters[0], + ) => Effect.Effect; + /** * Reloads the page. * @@ -355,6 +370,7 @@ export class PlaywrightPage extends Context.Tag( frames: use((p) => Promise.resolve(p.frames().map(PlaywrightFrame.make))), reload: use((p) => p.reload()), close: use((p) => p.close()), + screenshot: (options) => use((p) => p.screenshot(options)), click: (selector, options) => use((p) => p.click(selector, options)), eventStream: (event: K) => Stream.asyncPush((emit) => From 8990a5796c1dd8c6d64f173e4481be883365b472 Mon Sep 17 00:00:00 2001 From: Filip Weiss Date: Sat, 21 Feb 2026 10:26:21 +0100 Subject: [PATCH 07/33] implement Page.addScriptTag --- src/page.test.ts | 16 ++++++++++++++++ src/page.ts | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/page.test.ts b/src/page.test.ts index d1fd751..956a58e 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -355,4 +355,20 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert(buffer.length > 0); }).pipe(PlaywrightEnvironment.withBrowser), ); + + it.scoped("addScriptTag should add a script tag to the page", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + yield* page.addScriptTag({ content: "window.magicValue = 42;" }); + + const magicValue = yield* page.evaluate( + () => (window as TestWindow).magicValue, + ); + assert.strictEqual(magicValue, 42); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index 31cde0d..8c81856 100644 --- a/src/page.ts +++ b/src/page.ts @@ -3,6 +3,7 @@ import type { ConsoleMessage, Dialog, Download, + ElementHandle, FileChooser, Frame, Page, @@ -164,6 +165,15 @@ export interface PlaywrightPageService { script: Parameters[0], arg?: Parameters[1], ) => Effect.Effect; + /** + * Adds a `", + ); + + yield* Fiber.join(workerFiber); + + const workers = page.workers(); + assert(workers.length >= 1); + assert.strictEqual(typeof workers[0].url(), "string"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); }); diff --git a/src/page.ts b/src/page.ts index f1e65c0..69f4e5a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -689,6 +689,13 @@ export interface PlaywrightPageService { * @since 0.3.0 */ readonly pageErrors: Effect.Effect, PlaywrightError>; + /** + * Returns all workers. + * + * @see {@link Page.workers} + * @since 0.3.0 + */ + readonly workers: () => ReadonlyArray; /** * Get the browser context that the page belongs to. @@ -843,6 +850,7 @@ export class PlaywrightPage extends Context.Tag( ), consoleMessages: use((p) => p.consoleMessages()), pageErrors: use((p) => p.pageErrors()), + workers: () => page.workers().map(PlaywrightWorker.make), frame: (frameSelector) => Option.fromNullable(page.frame(frameSelector)).pipe(