Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .agents/skills/implement-playwright-method/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand All @@ -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<Keyboard>(
export const Keyboard = Context.Service<Keyboard>(
"effect-playwright/keyboard/Keyboard",
);

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/upgrade-playwright/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")<Greeting, string>() {}
class Greeting extends Context.Service<Greeting, string>()("Greeting") {}

layer(Layer.succeed(Greeting, "hello"))("Greeting", (it) => {
it.effect("uses a shared service", () =>
Expand Down
24 changes: 15 additions & 9 deletions src/browser-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,19 +60,19 @@ const eventMappings = {
backgroundpage: (page: CorePage) => makePage(page),
close: (context: CoreBrowserContext) => makeBrowserContext(context),
console: identity<ConsoleMessage>,
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<WebError>,
} as const;

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 88 additions & 8 deletions src/common.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
Loading
Loading