diff --git a/.agents/skills/implement-playwright-method/SKILL.md b/.agents/skills/implement-playwright-method/SKILL.md index 6049435..a94d6b6 100644 --- a/.agents/skills/implement-playwright-method/SKILL.md +++ b/.agents/skills/implement-playwright-method/SKILL.md @@ -47,7 +47,7 @@ Determine if the method can throw and what it returns. **Do not blindly follow e Some Playwright interfaces expose other classes as properties (e.g., `Page.keyboard`, `Page.mouse`, `BrowserContext.tracing`). -1. **Create a new Wrapper**: Create a same-named service interface and `Context.GenericTag` value, plus a named constructor such as `makeKeyboard`. +1. **Create a new Wrapper**: Create a same-named service interface and `Context.Service` value, plus a named constructor such as `makeKeyboard`. 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):** @@ -62,14 +62,14 @@ export interface Page { } ``` -**Example (Tag and Named Constructor):** +**Example (Service and Named Constructor):** ```typescript export interface Keyboard { // Wrapped operations } -export const Keyboard = Context.GenericTag( +export const Keyboard = Context.Service( "effect-playwright/keyboard/Keyboard", ); diff --git a/.agents/skills/upgrade-playwright/SKILL.md b/.agents/skills/upgrade-playwright/SKILL.md index 5eca80f..4aaf712 100644 --- a/.agents/skills/upgrade-playwright/SKILL.md +++ b/.agents/skills/upgrade-playwright/SKILL.md @@ -50,7 +50,7 @@ pnpm exec playwright install ### 5. Implementation -* **Create New Wrappers:** For significant new namespaces (e.g., `Screencast`), define a same-named service interface and `Context.GenericTag`, add a named constructor such as `makeScreencast`, and re-export them directly from `src/playwright-api.ts`. +* **Create New Wrappers:** For significant new namespaces (e.g., `Screencast`), define a same-named service interface and `Context.Service` value, add a named constructor such as `makeScreencast`, and re-export them directly from `src/playwright-api.ts`. * **Fix Breakages:** Address any type errors or removed APIs. * **Update Tests:** Check `src/*.test.ts` for any tests that broke due to API changes (especially property-to-method conversions). * **Add New APIs:** Systematically add wrappers for new Playwright methods. @@ -68,7 +68,7 @@ pnpm exec playwright install ## Common Gotchas - **Browser Binary Mismatch:** If tests fail with "Executable doesn't exist", you likely updated `playwright-core` but not `playwright`, or forgot to run `pnpm exec playwright install`. -- **New Namespaces:** Large additions like `Page.screencast` should be their own same-named service interface and `Context.GenericTag`, following the pattern of `Clock` or `Keyboard`. +- **New Namespaces:** Large additions like `Page.screencast` should be their own same-named service interface and `Context.Service` value, following the pattern of `Clock` or `Keyboard`. ## Example: Analyzing 1.60.0 diff --git a/AGENTS.md b/AGENTS.md index 11fdb34..ceea087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ Use `pnpm` for all package management tasks. ### General Architecture - **Effect-First:** All asynchronous operations must be wrapped in `Effect`. -- **Services:** Each service module exports a same-named interface and `Context.GenericTag` value (for example, `Browser`). Core wrapper functionality is grouped under the `Playwright` namespace, where `Playwright.Browser` and similar names work in both type and value positions. Scoped browser provisioning is grouped under `PlaywrightSpawner`. -- **Constructors:** Wrap native Playwright objects with named functions such as `makeBrowser` and `makePage`. Do not add `*Service` aliases or static `Tag.make` constructors. +- **Services:** Each service module exports a same-named interface and `Context.Service` value (for example, `Browser`). Core wrapper functionality is grouped under the `Playwright` namespace, where `Playwright.Browser` and similar names work in both type and value positions. Scoped browser provisioning is grouped under `PlaywrightSpawner`. +- **Constructors:** Wrap native Playwright objects with named functions such as `makeBrowser` and `makePage`. Do not add `*Service` aliases or static constructors on service values. - **Resource Management:** Rely on Effect's `Scope` for automatic resource cleanup (browsers, contexts). ### Imports diff --git a/CHANGELOG.md b/CHANGELOG.md index d09b2b8..e200f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## 0.8.0 (currently prerelease) + +### Breaking Changes + +- **Effect 4**: Upgraded the library to Effect 4. Consumers must migrate their applications and Effect dependencies to Effect 4 before upgrading. +- **Common wrappers are now services**: `Playwright.Request`, `Playwright.Response`, `Playwright.Worker`, `Playwright.Dialog`, `Playwright.FileChooser`, and `Playwright.Download` are now same-named `Context.Service` values and interfaces instead of `Data.TaggedClass` constructors. + - Replace static constructors such as `Playwright.Request.make(request)` with their named equivalents, such as `Playwright.makeRequest(request)`. The corresponding constructors are `makeRequest`, `makeResponse`, `makeWorker`, `makeDialog`, `makeFileChooser`, and `makeDownload`. + - Wrapped values no longer expose the `Data.TaggedClass` `_tag` field. + ## 0.7.0 ### Breaking Changes diff --git a/README.md b/README.md index fe5e35e..529a7a2 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ the block has finished. Nested layers reuse their parent services. import { Context, Effect, Layer } from "effect"; import { expect, layer } from "effect-playwright/test"; -class Greeting extends Context.Tag("Greeting")() {} +class Greeting extends Context.Service()("Greeting") {} layer(Layer.succeed(Greeting, "hello"))("Greeting", (it) => { it.effect("uses a shared service", () => diff --git a/src/browser-context.ts b/src/browser-context.ts index 758ba50..3c76ab3 100644 --- a/src/browser-context.ts +++ b/src/browser-context.ts @@ -20,7 +20,13 @@ import type { } from "playwright-core"; import { type Browser, makeBrowser } from "./browser"; import { type Clock, makeClock } from "./clock"; -import { Dialog, Download, Request, Response, Worker } from "./common"; +import { + makeDialog, + makeDownload, + makeRequest, + makeResponse, + makeWorker, +} from "./common"; import { type Credentials, makeCredentials } from "./credentials"; import type { PlaywrightError } from "./errors"; import { makeFrame } from "./frame"; @@ -54,19 +60,19 @@ const eventMappings = { backgroundpage: (page: CorePage) => makePage(page), close: (context: CoreBrowserContext) => makeBrowserContext(context), console: identity, - dialog: (dialog: CoreDialog) => Dialog.make(dialog), - download: (download: CoreDownload) => Download.make(download), + dialog: (dialog: CoreDialog) => makeDialog(dialog), + download: (download: CoreDownload) => makeDownload(download), frameattached: (frame: CoreFrame) => makeFrame(frame), framedetached: (frame: CoreFrame) => makeFrame(frame), framenavigated: (frame: CoreFrame) => makeFrame(frame), page: (page: CorePage) => makePage(page), pageclose: (page: CorePage) => makePage(page), pageload: (page: CorePage) => makePage(page), - request: (request: CoreRequest) => Request.make(request), - requestfailed: (request: CoreRequest) => Request.make(request), - requestfinished: (request: CoreRequest) => Request.make(request), - response: (response: CoreResponse) => Response.make(response), - serviceworker: (worker: CoreWorker) => Worker.make(worker), + request: (request: CoreRequest) => makeRequest(request), + requestfailed: (request: CoreRequest) => makeRequest(request), + requestfinished: (request: CoreRequest) => makeRequest(request), + response: (response: CoreResponse) => makeResponse(response), + serviceworker: (worker: CoreWorker) => makeWorker(worker), weberror: identity, } as const; @@ -297,7 +303,7 @@ export interface BrowserContext { } /** - * Service tag for the active {@link BrowserContext}. + * Service for the active {@link BrowserContext}. * * @category services * @since 0.1.0 diff --git a/src/browser.ts b/src/browser.ts index a4e61df..f8bc960 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -192,7 +192,7 @@ export interface Browser { } /** - * Service tag for the active {@link Browser}. + * Service for the active {@link Browser}. * * @category services * @since 0.1.0 diff --git a/src/common.test.ts b/src/common.test.ts index a16fd5c..1758531 100644 --- a/src/common.test.ts +++ b/src/common.test.ts @@ -1,10 +1,73 @@ import { assert, layer } from "@effect/vitest"; import { Effect, Fiber, Option, Stream } from "effect"; import { PlaywrightSpawner } from "effect-playwright"; -import { chromium } from "playwright-core"; +import { type Request as CoreRequest, chromium } from "playwright-core"; import { Browser } from "./browser"; +import { + Dialog, + Download, + FileChooser, + makeRequest, + Request, + Response, + Worker as WorkerService, +} from "./common"; layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { + it.effect("Request.postDataJSON handles synchronous results", () => + Effect.gen(function* () { + const make = (postDataJSON: CoreRequest["postDataJSON"]) => + makeRequest({ postDataJSON } as unknown as CoreRequest); + + const parsed = yield* make(() => ({ hello: "world" })).postDataJSON; + assert.deepStrictEqual(parsed, Option.some({ hello: "world" })); + + const empty = yield* make(() => null).postDataJSON; + assert(Option.isNone(empty)); + + const cause = new Error("invalid post data"); + const failure = yield* make(() => { + throw cause; + }).postDataJSON.pipe(Effect.flip); + assert.strictEqual(failure._tag, "PlaywrightError"); + assert.strictEqual(failure.cause, cause); + }), + ); + it.effect("Request nullable methods preserve the receiver", () => + Effect.sync(() => { + const body = Buffer.from("hello"); + const coreRequest = { + body: body as Buffer | null, + failureText: "failed" as string | null, + postData() { + return this.body?.toString("utf8") ?? null; + }, + postDataBuffer() { + return this.body; + }, + failure() { + return this.failureText === null + ? null + : { errorText: this.failureText }; + }, + }; + const request = makeRequest(coreRequest as unknown as CoreRequest); + + assert.deepStrictEqual(request.postData(), Option.some("hello")); + assert.deepStrictEqual(request.postDataBuffer(), Option.some(body)); + assert.deepStrictEqual( + request.failure(), + Option.some({ errorText: "failed" }), + ); + + coreRequest.body = null; + coreRequest.failureText = null; + assert(Option.isNone(request.postData())); + assert(Option.isNone(request.postDataBuffer())); + assert(Option.isNone(request.failure())); + }), + ); + it.effect("Request and Response", () => Effect.gen(function* () { const browser = yield* Browser; @@ -26,8 +89,14 @@ layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { const response = yield* Fiber.join(responseFiber).pipe( Effect.flatMap(Effect.fromOption), ); - assert.strictEqual(request._tag, "effect-playwright/common/Request"); - assert.strictEqual(response._tag, "effect-playwright/common/Response"); + assert.strictEqual( + yield* Request.pipe(Effect.provideService(Request, request)), + request, + ); + assert.strictEqual( + yield* Response.pipe(Effect.provideService(Response, response)), + response, + ); assert(request.url().includes("example.com")); assert(request.method() === "GET"); @@ -76,7 +145,10 @@ layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { const worker = yield* Fiber.join(workerFiber).pipe( Effect.flatMap(Effect.fromOption), ); - assert.strictEqual(worker._tag, "effect-playwright/common/Worker"); + assert.strictEqual( + yield* WorkerService.pipe(Effect.provideService(WorkerService, worker)), + worker, + ); assert(worker.url().startsWith("blob:")); const result = yield* worker.evaluate(() => 1 + 1); @@ -100,7 +172,10 @@ layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { const dialog = yield* Fiber.join(dialogFiber).pipe( Effect.flatMap(Effect.fromOption), ); - assert.strictEqual(dialog._tag, "effect-playwright/common/Dialog"); + assert.strictEqual( + yield* Dialog.pipe(Effect.provideService(Dialog, dialog)), + dialog, + ); assert(dialog.message() === "hello world"); assert(dialog.type() === "alert"); @@ -128,8 +203,10 @@ layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { Effect.flatMap(Effect.fromOption), ); assert.strictEqual( - fileChooser._tag, - "effect-playwright/common/FileChooser", + yield* FileChooser.pipe( + Effect.provideService(FileChooser, fileChooser), + ), + fileChooser, ); assert(fileChooser.isMultiple() === false); @@ -156,7 +233,10 @@ layer(PlaywrightSpawner.layer(chromium))("PlaywrightCommon", (it) => { const download = yield* Fiber.join(downloadFiber).pipe( Effect.flatMap(Effect.fromOption), ); - assert.strictEqual(download._tag, "effect-playwright/common/Download"); + assert.strictEqual( + yield* Download.pipe(Effect.provideService(Download, download)), + download, + ); assert(download.suggestedFilename() === "test.txt"); const url = download.url(); diff --git a/src/common.ts b/src/common.ts index 65eebde..97e5fd4 100644 --- a/src/common.ts +++ b/src/common.ts @@ -6,7 +6,7 @@ */ import { Readable } from "node:stream"; -import { Data, Effect, Option, Stream } from "effect"; +import { Context, Effect, Option, Stream } from "effect"; import type { Dialog as CoreDialog, Download as CoreDownload, @@ -26,9 +26,7 @@ import { useHelper } from "./utils"; * @category models * @since 0.1.2 */ -export class Request extends Data.TaggedClass( - "effect-playwright/common/Request", -)<{ +export interface Request { /** * An object with all the request HTTP headers associated with this request. The header names are lower-cased. * @see {@link CoreRequest.allHeaders} @@ -148,65 +146,81 @@ export class Request extends Data.TaggedClass( * @see {@link CoreRequest.url} */ url: () => string; -}> { - static make(request: CoreRequest): Request { - const use = useHelper(request); +} - return new Request({ - allHeaders: use(() => request.allHeaders()), - existingResponse: (): Option.Option => - Option.fromNullishOr(request.existingResponse()).pipe( - Option.map(Response.make), - ), - failure: Option.liftNullishOr(request.failure), - frame: Effect.try({ - try: () => makeFrame(request.frame()), - catch: wrapError, - }), - headerValue: (name) => - use(() => request.headerValue(name)).pipe( - Effect.map(Option.fromNullishOr), - ), - headers: () => request.headers(), - headersArray: use(() => request.headersArray()), - isNavigationRequest: () => request.isNavigationRequest(), - method: () => request.method(), - postData: Option.liftNullishOr(request.postData), - postDataBuffer: Option.liftNullishOr(request.postDataBuffer), - postDataJSON: use(() => request.postDataJSON()).pipe( - Effect.map(Option.fromNullishOr), +/** + * Service for a {@link Request}. + * + * @category services + * @since 0.1.2 + */ +export const Request = Context.Service( + "effect-playwright/common/Request", +); + +/** + * Creates a `Request` from a Playwright `Request` instance. + * + * @param request - The Playwright `Request` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeRequest = (request: CoreRequest): Request => { + const use = useHelper(request); + + return Request.of({ + allHeaders: use(() => request.allHeaders()), + existingResponse: (): Option.Option => + Option.fromNullishOr(request.existingResponse()).pipe( + Option.map(makeResponse), ), - redirectedFrom: (): Option.Option => - Option.fromNullishOr(request.redirectedFrom()).pipe( - Option.map(Request.make), - ), - redirectedTo: (): Option.Option => - Option.fromNullishOr(request.redirectedTo()).pipe( - Option.map(Request.make), - ), - resourceType: () => request.resourceType(), - response: use(() => request.response()).pipe( + failure: () => Option.fromNullishOr(request.failure()), + frame: Effect.try({ + try: () => makeFrame(request.frame()), + catch: wrapError, + }), + headerValue: (name) => + use(() => request.headerValue(name)).pipe( Effect.map(Option.fromNullishOr), - Effect.map(Option.map(Response.make)), ), - serviceWorker: () => - Option.fromNullishOr(request.serviceWorker()).pipe( - Option.map(Worker.make), - ), - sizes: use(() => request.sizes()), - timing: () => request.timing(), - url: () => request.url(), - }); - } -} + headers: () => request.headers(), + headersArray: use(() => request.headersArray()), + isNavigationRequest: () => request.isNavigationRequest(), + method: () => request.method(), + postData: () => Option.fromNullishOr(request.postData()), + postDataBuffer: () => Option.fromNullishOr(request.postDataBuffer()), + postDataJSON: Effect.try({ + try: () => request.postDataJSON(), + catch: wrapError, + }).pipe(Effect.map(Option.fromNullishOr)), + redirectedFrom: (): Option.Option => + Option.fromNullishOr(request.redirectedFrom()).pipe( + Option.map(makeRequest), + ), + redirectedTo: (): Option.Option => + Option.fromNullishOr(request.redirectedTo()).pipe( + Option.map(makeRequest), + ), + resourceType: () => request.resourceType(), + response: use(() => request.response()).pipe( + Effect.map(Option.fromNullishOr), + Effect.map(Option.map(makeResponse)), + ), + serviceWorker: () => + Option.fromNullishOr(request.serviceWorker()).pipe( + Option.map(makeWorker), + ), + sizes: use(() => request.sizes()), + timing: () => request.timing(), + url: () => request.url(), + }); +}; /** * @category models * @since 0.1.2 */ -export class Response extends Data.TaggedClass( - "effect-playwright/common/Response", -)<{ +export interface Response { allHeaders: Effect.Effect< Awaited>, PlaywrightError @@ -267,110 +281,154 @@ export class Response extends Data.TaggedClass( PlaywrightError >; url: () => string; -}> { - static make(response: CoreResponse) { - const use = useHelper(response); +} - return new Response({ - allHeaders: use(() => response.allHeaders()), - body: use(() => response.body()), - finished: use(() => response.finished()), - frame: Effect.try({ - try: () => makeFrame(response.frame()), - catch: wrapError, - }), - fromServiceWorker: () => response.fromServiceWorker(), - headers: () => response.headers(), - headersArray: use(() => response.headersArray()), - headerValue: (name) => - use(() => response.headerValue(name)).pipe( - Effect.map(Option.fromNullishOr), - ), - headerValues: (name) => use(() => response.headerValues(name)), - httpVersion: use(() => response.httpVersion()), - json: use(() => response.json()), - ok: () => response.ok(), - request: () => Request.make(response.request()), - securityDetails: use(() => response.securityDetails()).pipe( - Effect.map(Option.fromNullishOr), - ), - serverAddr: use(() => response.serverAddr()).pipe( +/** + * Service for a {@link Response}. + * + * @category services + * @since 0.1.2 + */ +export const Response = Context.Service( + "effect-playwright/common/Response", +); + +/** + * Creates a `Response` from a Playwright `Response` instance. + * + * @param response - The Playwright `Response` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeResponse = (response: CoreResponse): Response => { + const use = useHelper(response); + + return Response.of({ + allHeaders: use(() => response.allHeaders()), + body: use(() => response.body()), + finished: use(() => response.finished()), + frame: Effect.try({ + try: () => makeFrame(response.frame()), + catch: wrapError, + }), + fromServiceWorker: () => response.fromServiceWorker(), + headers: () => response.headers(), + headersArray: use(() => response.headersArray()), + headerValue: (name) => + use(() => response.headerValue(name)).pipe( Effect.map(Option.fromNullishOr), ), - status: () => response.status(), - statusText: () => response.statusText(), - text: use(() => response.text()), - url: () => response.url(), - }); - } -} + headerValues: (name) => use(() => response.headerValues(name)), + httpVersion: use(() => response.httpVersion()), + json: use(() => response.json()), + ok: () => response.ok(), + request: () => makeRequest(response.request()), + securityDetails: use(() => response.securityDetails()).pipe( + Effect.map(Option.fromNullishOr), + ), + serverAddr: use(() => response.serverAddr()).pipe( + Effect.map(Option.fromNullishOr), + ), + status: () => response.status(), + statusText: () => response.statusText(), + text: use(() => response.text()), + url: () => response.url(), + }); +}; /** * @category models * @since 0.1.2 */ -export class Worker extends Data.TaggedClass( - "effect-playwright/common/Worker", -)<{ +export interface Worker { evaluate: ( pageFunction: PageFunction, arg?: Arg, ) => Effect.Effect; url: () => string; -}> { - static make(worker: CoreWorker) { - const use = useHelper(worker); +} + +/** + * Service for a {@link Worker}. + * + * @category services + * @since 0.1.2 + */ +export const Worker = Context.Service( + "effect-playwright/common/Worker", +); + +/** + * Creates a `Worker` from a Playwright `Worker` instance. + * + * @param worker - The Playwright `Worker` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeWorker = (worker: CoreWorker): Worker => { + const use = useHelper(worker); - return new Worker({ - evaluate: (f: PageFunction, arg?: Arg) => - use((worker) => - worker.evaluate( - // Playwright's overload cannot preserve the wrapper's generic function type. - f as unknown as Parameters>[0], - arg as Arg, - ), + return Worker.of({ + evaluate: (f: PageFunction, arg?: Arg) => + use((worker) => + worker.evaluate( + // Playwright's overload cannot preserve the wrapper's generic function type. + f as unknown as Parameters>[0], + arg as Arg, ), - url: () => worker.url(), - }); - } -} + ), + url: () => worker.url(), + }); +}; /** * @category models * @since 0.1.2 */ -export class Dialog extends Data.TaggedClass( - "effect-playwright/common/Dialog", -)<{ +export interface Dialog { accept: (promptText?: string) => Effect.Effect; defaultValue: () => string; dismiss: Effect.Effect; message: () => string; page: () => Option.Option; type: () => string; -}> { - static make(dialog: CoreDialog) { - const use = useHelper(dialog); - - return new Dialog({ - accept: (promptText) => use(() => dialog.accept(promptText)), - defaultValue: () => dialog.defaultValue(), - dismiss: use(() => dialog.dismiss()), - message: () => dialog.message(), - page: () => - Option.fromNullishOr(dialog.page()).pipe(Option.map(makePage)), - type: () => dialog.type(), - }); - } } +/** + * Service for a {@link Dialog}. + * + * @category services + * @since 0.1.2 + */ +export const Dialog = Context.Service( + "effect-playwright/common/Dialog", +); + +/** + * Creates a `Dialog` from a Playwright `Dialog` instance. + * + * @param dialog - The Playwright `Dialog` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeDialog = (dialog: CoreDialog): Dialog => { + const use = useHelper(dialog); + + return Dialog.of({ + accept: (promptText) => use(() => dialog.accept(promptText)), + defaultValue: () => dialog.defaultValue(), + dismiss: use(() => dialog.dismiss()), + message: () => dialog.message(), + page: () => Option.fromNullishOr(dialog.page()).pipe(Option.map(makePage)), + type: () => dialog.type(), + }); +}; + /** * @category models * @since 0.1.2 */ -export class FileChooser extends Data.TaggedClass( - "effect-playwright/common/FileChooser", -)<{ +export interface FileChooser { element: () => ElementHandle; isMultiple: () => boolean; page: () => Page; @@ -378,27 +436,42 @@ export class FileChooser extends Data.TaggedClass( files: Parameters[0], options?: Parameters[1], ) => Effect.Effect; -}> { - static make(fileChooser: CoreFileChooser) { - const use = useHelper(fileChooser); - - return new FileChooser({ - element: () => fileChooser.element(), - isMultiple: () => fileChooser.isMultiple(), - page: () => makePage(fileChooser.page()), - setFiles: (files, options) => - use(() => fileChooser.setFiles(files, options)), - }); - } } +/** + * Service for a {@link FileChooser}. + * + * @category services + * @since 0.1.2 + */ +export const FileChooser = Context.Service( + "effect-playwright/common/FileChooser", +); + +/** + * Creates a `FileChooser` from a Playwright `FileChooser` instance. + * + * @param fileChooser - The Playwright `FileChooser` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeFileChooser = (fileChooser: CoreFileChooser): FileChooser => { + const use = useHelper(fileChooser); + + return FileChooser.of({ + element: () => fileChooser.element(), + isMultiple: () => fileChooser.isMultiple(), + page: () => makePage(fileChooser.page()), + setFiles: (files, options) => + use(() => fileChooser.setFiles(files, options)), + }); +}; + /** * @category models * @since 0.1.2 */ -export class Download extends Data.TaggedClass( - "effect-playwright/common/Download", -)<{ +export interface Download { cancel: Effect.Effect; /** * Creates a stream of the download data. @@ -416,33 +489,50 @@ export class Download extends Data.TaggedClass( use: ( f: (download: CoreDownload) => Promise, ) => Effect.Effect; -}> { - static make(download: CoreDownload) { - const use = useHelper(download); +} - return new Download({ - cancel: use(() => download.cancel()), - stream: use(() => - download.createReadStream().then((s) => Readable.toWeb(s)), - ).pipe( - Effect.map((s) => - Stream.fromReadableStream({ - evaluate: () => s as ReadableStream, - onError: wrapError, - }), - ), - Stream.unwrap, - ), - delete: use(() => download.delete()), - failure: use(() => download.failure()).pipe( - Effect.map(Option.fromNullishOr), +/** + * Service for a {@link Download}. + * + * @category services + * @since 0.1.2 + */ +export const Download = Context.Service( + "effect-playwright/common/Download", +); + +/** + * Creates a `Download` from a Playwright `Download` instance. + * + * @param download - The Playwright `Download` instance to wrap. + * @category constructors + * @since 0.1.2 + */ +export const makeDownload = (download: CoreDownload): Download => { + const use = useHelper(download); + + return Download.of({ + cancel: use(() => download.cancel()), + stream: use(() => + download.createReadStream().then((s) => Readable.toWeb(s)), + ).pipe( + Effect.map((s) => + Stream.fromReadableStream({ + evaluate: () => s as ReadableStream, + onError: wrapError, + }), ), - page: () => makePage(download.page()), - path: use(() => download.path()).pipe(Effect.map(Option.fromNullishOr)), - saveAs: (path) => use(() => download.saveAs(path)), - suggestedFilename: () => download.suggestedFilename(), - url: () => download.url(), - use, - }); - } -} + Stream.unwrap, + ), + delete: use(() => download.delete()), + failure: use(() => download.failure()).pipe( + Effect.map(Option.fromNullishOr), + ), + page: () => makePage(download.page()), + path: use(() => download.path()).pipe(Effect.map(Option.fromNullishOr)), + saveAs: (path) => use(() => download.saveAs(path)), + suggestedFilename: () => download.suggestedFilename(), + url: () => download.url(), + use, + }); +}; diff --git a/src/page.ts b/src/page.ts index 907cc8c..5b8008a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -30,12 +30,18 @@ import type { import { type BrowserContext, makeBrowserContext } from "./browser-context"; import { type Clock, makeClock } from "./clock"; import { - Dialog, - Download, - FileChooser, - Request, - Response, - Worker, + type Dialog, + type Download, + type FileChooser, + makeDialog, + makeDownload, + makeFileChooser, + makeRequest, + makeResponse, + makeWorker, + type Request, + type Response, + type Worker, } from "./common"; import type { PlaywrightError } from "./errors"; import { type Frame, makeFrame } from "./frame"; @@ -103,22 +109,22 @@ const eventMappings = { close: (page: CorePage) => makePage(page), console: identity, crash: (page: CorePage) => makePage(page), - dialog: (dialog: CoreDialog) => Dialog.make(dialog), + dialog: (dialog: CoreDialog) => makeDialog(dialog), domcontentloaded: (page: CorePage) => makePage(page), - download: (download: CoreDownload) => Download.make(download), - filechooser: (fileChooser: CoreFileChooser) => FileChooser.make(fileChooser), + download: (download: CoreDownload) => makeDownload(download), + filechooser: (fileChooser: CoreFileChooser) => makeFileChooser(fileChooser), frameattached: (frame: CoreFrame) => makeFrame(frame), framedetached: (frame: CoreFrame) => makeFrame(frame), framenavigated: (frame: CoreFrame) => makeFrame(frame), load: (page: CorePage) => makePage(page), pageerror: identity, popup: (page: CorePage) => makePage(page), - request: (request: CoreRequest) => Request.make(request), - requestfailed: (request: CoreRequest) => Request.make(request), - requestfinished: (request: CoreRequest) => Request.make(request), - response: (response: CoreResponse) => Response.make(response), + request: (request: CoreRequest) => makeRequest(request), + requestfailed: (request: CoreRequest) => makeRequest(request), + requestfinished: (request: CoreRequest) => makeRequest(request), + response: (response: CoreResponse) => makeResponse(response), websocket: identity, - worker: (worker: CoreWorker) => Worker.make(worker), + worker: (worker: CoreWorker) => makeWorker(worker), } as const satisfies { readonly [K in keyof CorePageEventMap]: ( value: CorePageEventMap[K], @@ -854,7 +860,7 @@ export interface Page { } /** - * Service tag for the active {@link Page}. + * Service for the active {@link Page}. * * @category services * @since 0.1.0 @@ -963,7 +969,7 @@ export const makePage = (page: CorePage): Page => { consoleMessages: (options) => use((page) => page.consoleMessages(options)), pageErrors: (options) => use((page) => page.pageErrors(options)), requests: use((page) => page.requests()).pipe( - Effect.map(Array.map(Request.make)), + Effect.map(Array.map(makeRequest)), ), pickLocator: use((page) => page.pickLocator().then(makeLocator)), cancelPickLocator: use((page) => page.cancelPickLocator()), @@ -973,7 +979,7 @@ export const makePage = (page: CorePage): Page => { Effect.map(Option.fromNullishOr), Effect.map(Option.map(makePage)), ), - workers: () => page.workers().map(Worker.make), + workers: () => page.workers().map(makeWorker), frame: (frameSelector) => Option.fromNullishOr(page.frame(frameSelector)).pipe( Option.map(makeFrame), @@ -984,12 +990,12 @@ export const makePage = (page: CorePage): Page => { goBack: (options) => use((page) => page.goBack(options)).pipe( Effect.map(Option.fromNullishOr), - Effect.map(Option.map(Response.make)), + Effect.map(Option.map(makeResponse)), ), goForward: (options) => use((page) => page.goForward(options)).pipe( Effect.map(Option.fromNullishOr), - Effect.map(Option.map(Response.make)), + Effect.map(Option.map(makeResponse)), ), requestGC: use((page) => page.requestGC()), bringToFront: use((page) => page.bringToFront()), diff --git a/src/playwright-api.ts b/src/playwright-api.ts index fb3db51..1b2b650 100644 --- a/src/playwright-api.ts +++ b/src/playwright-api.ts @@ -23,6 +23,12 @@ export { Dialog, Download, FileChooser, + makeDialog, + makeDownload, + makeFileChooser, + makeRequest, + makeResponse, + makeWorker, Request, Response, Worker, diff --git a/src/playwright-spawner.ts b/src/playwright-spawner.ts index bf7335a..290cdef 100644 --- a/src/playwright-spawner.ts +++ b/src/playwright-spawner.ts @@ -26,7 +26,7 @@ export interface PlaywrightSpawner { } /** - * Service tag for the active {@link PlaywrightSpawner}. + * Service for the active {@link PlaywrightSpawner}. * * @category services * @since 0.7.0 diff --git a/src/playwright.test.ts b/src/playwright.test.ts index 3ca1077..2757b65 100644 --- a/src/playwright.test.ts +++ b/src/playwright.test.ts @@ -57,15 +57,21 @@ layer(Playwright.layer)("Playwright", (it) => { Playwright.makeBrowserContext, Playwright.makeClock, Playwright.makeCredentials, + Playwright.makeDialog, + Playwright.makeDownload, + Playwright.makeFileChooser, Playwright.makeFrame, Playwright.makeFrameLocator, Playwright.makeKeyboard, Playwright.makeLocator, Playwright.makeMouse, + Playwright.makeRequest, + Playwright.makeResponse, Playwright.makePage, Playwright.makeScreencast, Playwright.makeTouchscreen, Playwright.makeTracing, + Playwright.makeWorker, Playwright.makeWebStorage, ]) { assert.strictEqual(typeof constructor, "function");