diff --git a/.agents/skills/implement-playwright-method/SKILL.md b/.agents/skills/implement-playwright-method/SKILL.md index 9026aa9..fa46782 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 type-check` and `pnpm test` to verify implementation. diff --git a/AGENTS.md b/AGENTS.md index d153158..767ca11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Use `pnpm` for all package management tasks. - **Run All Tests:** `pnpm test` (uses `vitest`) - **Run Single Test File:** `pnpm test src/path/to/test.ts` - **Type Check:** `pnpm type-check` (runs `tsc --noEmit`) -- **Format:** `pnpm format-fix` (uses `biome format --fix`) +- **Format:** `pnpm format` (uses `biome format --fix`) - **Generate Docs:** `pnpm generate-docs` ## 2. Code Style & Conventions diff --git a/package.json b/package.json index b4e159a..79d943f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "type-check": "tsc --noEmit", "coverage": "tsx scripts/coverage.ts", "generate-docs": "typedoc", - "format-fix": "biome format --fix" + "format": "biome format --fix" }, "keywords": [ "effect", diff --git a/scripts/coverage.ts b/scripts/coverage.ts index 406a9aa..c4aa577 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([ @@ -61,11 +62,16 @@ function isRelevantProperty(name: string) { } function isDeprecated(node: JSDocableNode): boolean { - return node - .getJsDocs() - .some((doc) => - doc.getTags().some((tag) => tag.getTagName() === "deprecated"), - ); + return node.getJsDocs().some((doc) => { + const hasDeprecatedTag = doc + .getTags() + .some((tag) => tag.getTagName() === "deprecated"); + const docText = doc.getText(); + + // some methods are "soft-deprecated", i.e. they are still available but discouraged + const hasLocatorNote = docText.includes("**NOTE** Use locator-based"); + return hasDeprecatedTag || hasLocatorNote; + }); } const runCoverage = Effect.gen(function* () { 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 4030f02..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, @@ -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. * @@ -87,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. @@ -122,9 +139,11 @@ 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()), + addInitScript: (script, arg) => use((c) => c.addInitScript(script, arg)), eventStream: (event: K) => Stream.asyncPush((emit) => Effect.acquireRelease( 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/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/index.ts b/src/index.ts index af20ec5..0639c53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,10 +19,14 @@ 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 "./mouse"; export * from "./page"; export * from "./playwright"; +export * from "./touchscreen"; 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/mouse.ts b/src/mouse.ts new file mode 100644 index 0000000..af9843d --- /dev/null +++ b/src/mouse.ts @@ -0,0 +1,98 @@ +import { Context, type Effect } from "effect"; +import type { Mouse } from "playwright-core"; +import type { PlaywrightError } from "./errors"; +import { useHelper } from "./utils"; + +/** + * @category model + * @since 0.3.0 + */ +export interface PlaywrightMouseService { + /** + * Shortcut for mouse.move, mouse.down, mouse.up. + * + * @see {@link Mouse.click} + * @since 0.3.0 + */ + readonly click: ( + x: Parameters[0], + y: Parameters[1], + options?: Parameters[2], + ) => Effect.Effect; + /** + * Shortcut for mouse.move, mouse.down, mouse.up, mouse.down and mouse.up. + * + * @see {@link Mouse.dblclick} + * @since 0.3.0 + */ + readonly dblclick: ( + x: Parameters[0], + y: Parameters[1], + options?: Parameters[2], + ) => Effect.Effect; + /** + * Dispatches a `mousedown` event. + * + * @see {@link Mouse.down} + * @since 0.3.0 + */ + readonly down: ( + options?: Parameters[0], + ) => Effect.Effect; + /** + * Dispatches a `mousemove` event. + * + * @see {@link Mouse.move} + * @since 0.3.0 + */ + readonly move: ( + x: Parameters[0], + y: Parameters[1], + options?: Parameters[2], + ) => Effect.Effect; + /** + * Dispatches a `mouseup` event. + * + * @see {@link Mouse.up} + * @since 0.3.0 + */ + readonly up: ( + options?: Parameters[0], + ) => Effect.Effect; + /** + * Dispatches a `wheel` event. + * + * @see {@link Mouse.wheel} + * @since 0.3.0 + */ + readonly wheel: ( + deltaX: Parameters[0], + deltaY: Parameters[1], + ) => Effect.Effect; +} + +/** + * @category tag + */ +export class PlaywrightMouse extends Context.Tag( + "effect-playwright/PlaywrightMouse", +)() { + /** + * Creates a `PlaywrightMouse` from a Playwright `Mouse` instance. + * + * @param mouse - The Playwright `Mouse` instance to wrap. + * @since 0.3.0 + */ + static make(mouse: Mouse): PlaywrightMouseService { + const use = useHelper(mouse); + + return PlaywrightMouse.of({ + click: (x, y, options) => use((m) => m.click(x, y, options)), + dblclick: (x, y, options) => use((m) => m.dblclick(x, y, options)), + down: (options) => use((m) => m.down(options)), + move: (x, y, options) => use((m) => m.move(x, y, options)), + up: (options) => use((m) => m.up(options)), + wheel: (deltaX, deltaY) => use((m) => m.wheel(deltaX, deltaY)), + }); + } +} diff --git a/src/page.test.ts b/src/page.test.ts index 7bbe235..c4ea75c 100644 --- a/src/page.test.ts +++ b/src/page.test.ts @@ -1,9 +1,16 @@ import { assert, layer } from "@effect/vitest"; -import { Effect, Fiber, Stream } from "effect"; +import { Effect, Fiber, Option, Ref, Stream } from "effect"; 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; + magicValue?: number; +}; + layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { it.scoped("goto should navigate to a URL", () => Effect.gen(function* () { @@ -18,6 +25,17 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { }).pipe(PlaywrightEnvironment.withBrowser), ); + it.scoped("setContent should set the page content", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setContent("

Hello World

"); + const content = yield* page.content; + assert(content.includes("

Hello World

")); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + it.scoped("title should return the page title", () => Effect.gen(function* () { const browser = yield* PlaywrightBrowser; @@ -29,13 +47,26 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { }).pipe(PlaywrightEnvironment.withBrowser), ); + it.scoped("content should return the page content", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto( + "data:text/html,Content

Hello

", + ); + const content = yield* page.content; + assert(content.includes("

Hello

")); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + it.scoped("click should click an element", () => Effect.gen(function* () { const browser = yield* PlaywrightBrowser; 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 +75,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 +123,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 +136,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), @@ -158,6 +185,9 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => {
Test Content
+ Alt Text + +
Hover Me
`; }); @@ -172,6 +202,19 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { const byTestId = yield* page.getByTestId("test-id").textContent(); assert(byTestId === "Test Content"); + + const byAltText = yield* page + .getByAltText("Alt Text") + .getAttribute("alt"); + assert(byAltText === "Alt Text"); + + const byPlaceholder = yield* page + .getByPlaceholder("Placeholder Text") + .getAttribute("placeholder"); + assert(byPlaceholder === "Placeholder Text"); + + const byTitle = yield* page.getByTitle("Title Text").textContent(); + assert(byTitle === "Hover Me"); }).pipe(PlaywrightEnvironment.withBrowser), ); @@ -202,8 +245,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(); @@ -243,4 +285,643 @@ layer(PlaywrightEnvironment.layer(chromium))("PlaywrightPage", (it) => { assert.strictEqual(page.url(), url2); }).pipe(PlaywrightEnvironment.withBrowser), ); + + it.scoped("goBack and goForward should navigate through history", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const url1 = "data:text/html,

Page 1

"; + yield* page.goto(url1); + + const url2 = "data:text/html,

Page 2

"; + yield* page.goto(url2); + assert.strictEqual(page.url(), url2, "URL should be updated to url2"); + + yield* page.goBack(); + assert.strictEqual(page.url(), url1, "URL should be updated to url1"); + + yield* page.goForward(); + assert.strictEqual(page.url(), url2, "URL should be updated to url2"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("requestGC should execute without error", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.requestGC; + assert.ok(true); + }).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), + ); + + 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), + ); + + 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), + ); + + it.scoped("mouse should allow dispatching events", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.evaluate(() => { + document.body.innerHTML = + '
'; + const target = document.getElementById("target"); + if (target) { + target.addEventListener("click", () => { + (window as TestWindow).clicked = true; + }); + } + }); + + yield* page.mouse.click(50, 50); + + const clicked = yield* page.evaluate( + () => (window as TestWindow).clicked, + ); + assert.strictEqual(clicked, true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("touchscreen should allow dispatching events", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const context = yield* browser.newContext({ hasTouch: true }); + const page = yield* context.newPage; + + yield* page.evaluate(() => { + document.body.innerHTML = + '
'; + const target = document.getElementById("target"); + if (target) { + target.addEventListener("touchstart", (e) => { + const win = window as TestWindow; + win.clicked = true; + win.clickCoords = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + }); + } + }); + + yield* page.touchscreen.tap(50, 50); + + const clicked = yield* page.evaluate( + () => (window as TestWindow).clicked, + ); + assert.strictEqual(clicked, true); + + const coords = yield* page.evaluate( + () => (window as TestWindow).clickCoords, + ); + assert.deepStrictEqual(coords, { x: 50, y: 50 }); + }).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), + ); + + it.scoped("pdf should capture a PDF", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("data:text/html,

PDF Test

"); + const buffer = yield* page.pdf(); + + assert(Buffer.isBuffer(buffer)); + 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), + ); + + it.scoped("addStyleTag should add a style tag to the page", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + yield* page.evaluate(() => { + document.body.innerHTML = '
Hello
'; + }); + + yield* page.addStyleTag({ + content: "#test-div { color: rgb(255, 0, 0); }", + }); + + const color = yield* page.evaluate(() => { + const el = document.getElementById("test-div"); + return el ? window.getComputedStyle(el).color : null; + }); + assert.strictEqual(color, "rgb(255, 0, 0)"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + it.scoped("bringToFront should bring the page to the front", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const context = yield* browser.newContext(); + const page1 = yield* context.newPage; + const page2 = yield* context.newPage; + + yield* page1.bringToFront; + yield* page2.bringToFront; + + // Ensure no errors are thrown + assert.ok(true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("consoleMessages should return console messages", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + yield* page.evaluate(() => { + console.log("Hello from page"); + console.warn("Warning from page"); + }); + + const messages = yield* page.consoleMessages; + + assert.strictEqual(messages.length, 2); + assert.strictEqual(messages[0].text(), "Hello from page"); + assert.strictEqual(messages[1].text(), "Warning from page"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("pageerror event should work", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + const errorFiber = yield* page + .eventStream("pageerror") + .pipe(Stream.runHead, Effect.fork); + + yield* page.evaluate(() => { + setTimeout(() => { + throw new Error("Test Error"); + }, 0); + }); + + const errorOpt = yield* Fiber.join(errorFiber); + const error = Option.getOrThrow(errorOpt); + assert.strictEqual(error.message, "Test Error"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("pageErrors should return all page errors", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + const errorFiber = yield* page + .eventStream("pageerror") + .pipe(Stream.runHead, Effect.fork); + + yield* page.evaluate(() => { + setTimeout(() => { + throw new Error("Test Error"); + }, 0); + }); + + yield* Fiber.join(errorFiber); + + const errors = yield* page.pageErrors; + assert.ok(errors.length >= 1); + assert.strictEqual(errors[0].message, "Test Error"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("context should return the associated browser context", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const context = yield* browser.newContext(); + const page = yield* context.newPage; + + const pageContext = page.context(); + + // we can't do direct reference equality because they are wrapper objects, + // but we can check if it has the right methods and doesn't crash + const pages = yield* pageContext.pages; + assert.strictEqual(pages.length, 1); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("dragAndDrop should drag and drop an element", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.evaluate(() => { + document.body.innerHTML = ` +
+
+ `; + + const target = document.getElementById("target"); + if (target) { + target.addEventListener("drop", (e) => { + e.preventDefault(); + (window as TestWindow).magicValue = 42; + }); + target.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + } + }); + + yield* page.dragAndDrop("#source", "#target"); + + const magicValue = yield* page.evaluate( + () => (window as TestWindow).magicValue, + ); + assert.strictEqual(magicValue, 42); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("emulateMedia should emulate media features", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + // emulate dark mode + yield* page.emulateMedia({ colorScheme: "dark" }); + + let isDark = yield* page.evaluate( + () => window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + assert.strictEqual(isDark, true); + + // emulate light mode + yield* page.emulateMedia({ colorScheme: "light" }); + + isDark = yield* page.evaluate( + () => window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + assert.strictEqual(isDark, false); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped( + "exposeFunction should expose an function that runs an effect", + () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const ref = yield* Ref.make(0); + + yield* page.exposeFunction("myCustomEffect", () => + Ref.updateAndGet(ref, (n) => n + 1), + ); + + const result = yield* page.evaluate(async () => { + // @ts-expect-error + return await window.myCustomEffect(); + }); + + assert.strictEqual(yield* Ref.get(ref), 1, "Ref value is 1"); + assert.strictEqual(result, 1, "Return value is 1"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("exposeFunction should work with Effect.fn", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const ref = yield* Ref.make(0); + + // Effect.fn usage + yield* page.exposeFunction( + "myCustomEffectFn", + Effect.fn(function* (num: number) { + return yield* Ref.updateAndGet(ref, (n) => n + num); + }), + ); + + const result = yield* page.evaluate(async () => { + // @ts-expect-error + return await window.myCustomEffectFn(15); + }); + + assert.strictEqual(yield* Ref.get(ref), 15, "Ref value is 15"); + assert.strictEqual(result, 15, "Return value is 15"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("exposeEffect should expose an effect", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const ref = yield* Ref.make(0); + + yield* page.exposeEffect( + "myCustomEffect", + Ref.updateAndGet(ref, (n) => n + 1), + ); + + const result = yield* page.evaluate(async () => { + // @ts-expect-error + return await window.myCustomEffect(); + }); + + assert.strictEqual(yield* Ref.get(ref), 1, "Ref value is 1"); + assert.strictEqual(result, 1, "Return value is 1"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("frame should return an Option of PlaywrightFrame", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.use((p) => + p.setContent(''), + ); + + const frameOpt = page.frame("test-frame"); + assert(Option.isSome(frameOpt), "Frame should be Some"); + + const frameOptByName = page.frame({ name: "test-frame" }); + assert(Option.isSome(frameOptByName), "Frame should be Some"); + + const nonExistentFrame = page.frame("foo"); + assert(Option.isNone(nonExistentFrame), "Frame should be None"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("isClosed should return the closed state of the page", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + assert.strictEqual(page.isClosed(), false); + + yield* page.close; + + assert.strictEqual(page.isClosed(), true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("mainFrame should return the main frame", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const mainFrame = page.mainFrame(); + assert.ok(mainFrame); + + const url = mainFrame.url(); + assert.strictEqual(url, "about:blank"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("opener should return the opener page", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.goto("about:blank"); + + const popupFiber = yield* page + .eventStream("popup") + .pipe(Stream.runHead, Effect.fork); + + yield* page.evaluate(() => { + window.open("about:blank"); + }); + + const popupOpt = yield* Fiber.join(popupFiber); + const popup = Option.getOrThrow(popupOpt); + + const openerOpt = yield* popup.opener; + assert(Option.isSome(openerOpt), "Opener should be Some"); + + const opener = Option.getOrThrow(openerOpt); + const url = opener.url(); + assert.strictEqual(url, "about:blank"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("setViewportSize should update viewport dimensions", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setViewportSize({ width: 600, height: 400 }); + const size = yield* page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + + assert.strictEqual(size.width, 600); + assert.strictEqual(size.height, 400); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("viewportSize should return the current viewport size", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setViewportSize({ width: 600, height: 400 }); + const sizeOpt = page.viewportSize(); + assert(Option.isSome(sizeOpt)); + const size = Option.getOrThrow(sizeOpt); + + assert.strictEqual(size.width, 600); + assert.strictEqual(size.height, 400); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("setExtraHTTPHeaders should not crash", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setExtraHTTPHeaders({ "x-custom-header": "test-value" }); + assert.ok(true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("setDefaultNavigationTimeout should not crash", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setDefaultNavigationTimeout(1000); + assert.ok(true); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("setDefaultTimeout should influence timeouts", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + yield* page.setDefaultTimeout(1); + + const result = yield* page + .locator("#non-existent") + .click() + .pipe(Effect.flip); + + assert.strictEqual(result._tag, "PlaywrightError"); + }).pipe(PlaywrightEnvironment.withBrowser), + ); + + it.scoped("workers should return the list of workers", () => + Effect.gen(function* () { + const browser = yield* PlaywrightBrowser; + const page = yield* browser.newPage(); + + const workerFiber = yield* page + .eventStream("worker") + .pipe(Stream.runHead, Effect.fork); + + yield* page.goto( + "data:text/html,", + ); + + 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 08df20c..69f4e5a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,8 +1,9 @@ -import { Context, Effect, identity, Stream } from "effect"; +import { Context, Effect, identity, Option, Runtime, Stream } from "effect"; import type { ConsoleMessage, Dialog, Download, + ElementHandle, FileChooser, Frame, Page, @@ -11,7 +12,11 @@ import type { WebSocket, Worker, } from "playwright-core"; - +import { + PlaywrightBrowserContext, + type PlaywrightBrowserContextService, +} from "./browser-context"; +import { PlaywrightClock, type PlaywrightClockService } from "./clock"; import { PlaywrightDialog, PlaywrightDownload, @@ -20,11 +25,16 @@ import { PlaywrightResponse, PlaywrightWorker, } from "./common"; - import type { PlaywrightError } from "./errors"; import { PlaywrightFrame } from "./frame"; +import { PlaywrightKeyboard, type PlaywrightKeyboardService } from "./keyboard"; import { PlaywrightLocator } from "./locator"; +import { PlaywrightMouse, type PlaywrightMouseService } from "./mouse"; import type { PageFunction, PatchedEvents } from "./playwright-types"; +import { + PlaywrightTouchscreen, + type PlaywrightTouchscreenService, +} from "./touchscreen"; import { useHelper } from "./utils"; interface PageEvents { @@ -79,6 +89,30 @@ type PageWithPatchedEvents = PatchedEvents; * @since 0.1.0 */ export interface PlaywrightPageService { + /** + * Access the clock. + * + * @since 0.3.0 + */ + readonly clock: PlaywrightClockService; + /** + * Access the keyboard. + * + * @since 0.3.0 + */ + readonly keyboard: PlaywrightKeyboardService; + /** + * Access the mouse. + * + * @since 0.3.0 + */ + readonly mouse: PlaywrightMouseService; + /** + * Access the touchscreen. + * + * @since 0.3.0 + */ + readonly touchscreen: PlaywrightTouchscreenService; /** * Navigates the page to the given URL. * @@ -94,6 +128,66 @@ export interface PlaywrightPageService { url: string, options?: Parameters[1], ) => Effect.Effect; + /** + * This method internally calls [document.write()](https://developer.mozilla.org/en-US/docs/Web/API/Document/write), + * inheriting all its specific characteristics and behaviors. + * + * @see {@link Page.setContent} + * @since 0.3.0 + */ + readonly setContent: ( + html: string, + options?: Parameters[1], + ) => Effect.Effect; + /** + * This setting will change the default maximum navigation time for the following methods: + * - {@link PlaywrightPageService.goBack} + * - {@link PlaywrightPageService.goForward} + * - {@link PlaywrightPageService.goto} + * - {@link PlaywrightPageService.reload} + * - {@link PlaywrightPageService.setContent} + * - {@link PlaywrightPageService.waitForURL} + * + * @see {@link Page.setDefaultNavigationTimeout} + * @since 0.3.0 + */ + readonly setDefaultNavigationTimeout: ( + timeout: Parameters[0], + ) => Effect.Effect; + /** + * This setting will change the default maximum time for all the methods accepting `timeout` option. + * + * @see {@link Page.setDefaultTimeout} + * @since 0.3.0 + */ + readonly setDefaultTimeout: ( + timeout: Parameters[0], + ) => Effect.Effect; + /** + * The extra HTTP headers will be sent with every request the page initiates. + * + * @see {@link Page.setExtraHTTPHeaders} + * @since 0.3.0 + */ + readonly setExtraHTTPHeaders: ( + headers: Parameters[0], + ) => Effect.Effect; + /** + * Sets the viewport size for the page. + * + * @see {@link Page.setViewportSize} + * @since 0.3.0 + */ + readonly setViewportSize: ( + viewportSize: Parameters[0], + ) => Effect.Effect; + /** + * Returns the viewport size. + * + * @see {@link Page.viewportSize} + * @since 0.3.0 + */ + readonly viewportSize: () => Option.Option<{ width: number; height: number }>; /** * Waits for the page to navigate to the given URL. * @@ -144,6 +238,144 @@ 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.3.0 + */ + readonly addInitScript: ( + script: Parameters[0], + arg?: Parameters[1], + ) => Effect.Effect; + /** + * Adds a `