diff --git a/CHANGELOG.md b/CHANGELOG.md index 63eb394..1d8a5a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - **Navigation responses**: `Page.goto()`, `Frame.goto()` and `Page.reload()` now return the main resource response as `Option`, like `Page.goBack()` and `Page.goForward()`, instead of `void`. Playwright does not fail on an error status such as 404, so the response is the way to tell whether the page loaded. Code that ignores the result is unaffected, and code that needs `Effect` can pipe `Effect.asVoid`. - **Playwright 1.64.1 Upgrade**: Upgraded `playwright-core`, `playwright`, and `@playwright/test` to version `1.64.1`. - **Playwright 1.64 APIs**: Added `Locator.within()`, `Page.getByRef()`, the `includeShadow` option of `Page.content()` and `Frame.content()`, and `Page.webmcp` and `Frame.webmcp`, backed by the new `Playwright.WebMCP` service (`tools()` and `callTool()`). New `fps` and action `style` video options pass through the existing option types. +- **Scoped tracing**: Added `Tracing.startScoped()` and `Tracing.startChunkScoped()`, which stop tracing when the current `Scope` closes, including on failure or interruption. Stop options such as `path` are passed as the second argument. This replaces Playwright 1.64's disposable return value from `start()` and `startChunk()`; the existing `start()` and `startChunk()` still succeed with `void`. ## 0.8.0 diff --git a/src/browser-context.test.ts b/src/browser-context.test.ts index 1857401..e9760d3 100644 --- a/src/browser-context.test.ts +++ b/src/browser-context.test.ts @@ -1,5 +1,8 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { assert, layer } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Exit, Option } from "effect"; import { PlaywrightSpawner } from "effect-playwright"; import { chromium } from "playwright-core"; import { Browser } from "./browser"; @@ -131,4 +134,59 @@ layer(PlaywrightSpawner.layer(chromium))("BrowserContext", (it) => { assert.strictEqual(afterDelete.length, 0); }).pipe(PlaywrightSpawner.withBrowser), ); + it.effect("tracing.startScoped saves the trace when the scope fails", () => + Effect.gen(function* () { + const browser = yield* Browser; + const context = yield* browser.newContext(); + const dir = yield* Effect.promise(() => + mkdtemp(join(tmpdir(), "effect-playwright-trace-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => rm(dir, { recursive: true, force: true })), + ); + const path = join(dir, "trace.zip"); + + const exit = yield* Effect.gen(function* () { + yield* context.tracing.startScoped({ snapshots: true }, { path }); + const page = yield* context.newPage; + yield* page.setContent("

traced

"); + return yield* Effect.fail("workflow failed"); + }).pipe(Effect.scoped, Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + const trace = yield* Effect.promise(() => stat(path)); + assert.isAbove(trace.size, 0); + + // Tracing was stopped, so it can be started again. + yield* context.tracing.start(); + yield* context.tracing.stop(); + }).pipe(PlaywrightSpawner.withBrowser), + ); + + it.effect( + "tracing.startChunkScoped saves the chunk when the scope closes", + () => + Effect.gen(function* () { + const browser = yield* Browser; + const context = yield* browser.newContext(); + const dir = yield* Effect.promise(() => + mkdtemp(join(tmpdir(), "effect-playwright-trace-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => rm(dir, { recursive: true, force: true })), + ); + const path = join(dir, "chunk.zip"); + + yield* context.tracing.start(); + yield* Effect.gen(function* () { + yield* context.tracing.startChunkScoped(undefined, { path }); + const page = yield* context.newPage; + yield* page.setContent("

chunk

"); + }).pipe(Effect.scoped); + + const chunk = yield* Effect.promise(() => stat(path)); + assert.isAbove(chunk.size, 0); + yield* context.tracing.stop(); + }).pipe(PlaywrightSpawner.withBrowser), + ); }); diff --git a/src/tracing.ts b/src/tracing.ts index 31e1b5c..2becd52 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -4,7 +4,7 @@ * @since 0.5.0 */ -import { Context, type Effect } from "effect"; +import { Context, Effect, type Scope } from "effect"; import type { Tracing as CoreTracing } from "playwright-core"; import type { PlaywrightError } from "./errors"; import { useHelper } from "./utils"; @@ -34,6 +34,65 @@ export interface Tracing { options?: Parameters[0], ) => Effect.Effect; + /** + * Starts tracing and stops it when the current `Scope` closes. + * + * **When to use** + * + * Use instead of pairing {@link Tracing.start} with {@link Tracing.stop}, so + * the trace is stopped even when the workflow fails or is interrupted. + * + * **Gotchas** + * + * Pass `stopOptions.path` to save the trace; without it Playwright discards + * the recording. A failure while stopping is logged instead of failing the + * scope. + * + * @example + * ```ts + * import { Effect } from "effect"; + * import { Playwright } from "effect-playwright"; + * import { chromium } from "playwright-core"; + * + * const program = Effect.gen(function* () { + * const playwright = yield* Playwright.Playwright; + * const browser = yield* playwright.launchScoped(chromium); + * const context = yield* browser.newContext(); + * yield* context.tracing.startScoped( + * { screenshots: true, snapshots: true }, + * { path: "trace.zip" }, + * ); + * const page = yield* context.newPage; + * yield* page.goto("https://example.com"); + * }).pipe(Effect.scoped, Effect.provide(Playwright.layer)); + * ``` + * + * @param options - Options passed to {@link Tracing.start}. + * @param stopOptions - Options passed to {@link Tracing.stop} when the scope closes. + * @see {@link CoreTracing.start} + * @since 0.9.0 + */ + readonly startScoped: ( + options?: Parameters[0], + stopOptions?: Parameters[0], + ) => Effect.Effect; + + /** + * Starts a new tracing chunk and stops it when the current `Scope` closes. + * + * Tracing must already be started. Pass `stopOptions.path` to save the + * chunk; a failure while stopping is logged instead of failing the scope. + * + * @param options - Options passed to {@link Tracing.startChunk}. + * @param stopOptions - Options passed to {@link Tracing.stopChunk} when the scope closes. + * @see {@link CoreTracing.startChunk} + * @since 0.9.0 + */ + readonly startChunkScoped: ( + options?: Parameters[0], + stopOptions?: Parameters[0], + ) => Effect.Effect; + /** * Stops a tracing chunk. * @@ -89,11 +148,26 @@ export const Tracing = Context.Service( */ export const makeTracing = (tracing: CoreTracing): Tracing => { const use = useHelper(tracing); + const start: Tracing["start"] = (options) => + use((t) => t.start(options).then(() => {})); + const startChunk: Tracing["startChunk"] = (options) => + use((t) => t.startChunk(options).then(() => {})); + const stopChunk: Tracing["stopChunk"] = (options) => + use((t) => t.stopChunk(options)); + const stop: Tracing["stop"] = (options) => use((t) => t.stop(options)); return Tracing.of({ - start: (options) => use((t) => t.start(options).then(() => {})), - startChunk: (options) => use((t) => t.startChunk(options).then(() => {})), - stopChunk: (options) => use((t) => t.stopChunk(options)), - stop: (options) => use((t) => t.stop(options)), + start, + startChunk, + startScoped: (options, stopOptions) => + Effect.acquireRelease(start(options), () => + stop(stopOptions).pipe(Effect.ignore({ log: true })), + ), + startChunkScoped: (options, stopOptions) => + Effect.acquireRelease(startChunk(options), () => + stopChunk(stopOptions).pipe(Effect.ignore({ log: true })), + ), + stopChunk, + stop, startHar: (options) => use((t) => t.startHar(options).then(() => {})), stopHar: use((t) => t.stopHar()), });