Repository: scrapy-plugins/scrapy-playwright (language: python)

Issue:
Supporting for Windows
I have tested it on win11, please check it, hope it helpful.

scrapy_playwright/handler.py :

```python
import asyncio
import logging
from contextlib import suppress
from dataclasses import dataclass
from ipaddress import ip_address
from tempfile import NamedTemporaryFile
from time import time
from typing import Awaitable, Callable, Dict, Optional, Tuple, Type, TypeVar, Union

from playwright.async_api import (
    BrowserContext,
    BrowserType,
    Download,
    Error as PlaywrightError,
    Page,
    PlaywrightContextManager,
    Request as PlaywrightRequest,
    Response as PlaywrightResponse,
    Route,
)
from scrapy import Spider, signals
from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
from scrapy.crawler import Crawler
from scrapy.http import Request, Response
from scrapy.http.headers import Headers
from scrapy.responsetypes import responsetypes
from scrapy.utils.defer import deferred_from_coro
from scrapy.utils.misc import load_object
from scrapy.utils.reactor import verify_installed_reactor
from twisted.internet.defer import Deferred, inlineCallbacks

from scrapy_playwright.headers import use_scrapy_headers
from scrapy_playwright.page import PageMethod
from scrapy_playwright._utils import (
    _encode_body,
    _get_header_value,
    _get_page_content,
    _is_safe_close_error,
    _maybe_await,
)

## working on Windows ######################################
import sys

if sys.platform == 'win32' and sys.version_info >= (3, 8):
    import threading

    _loop = None
    _thread = None


    def get_default_event_loop():
        global _loop, _thread
        if _thread is None:
            if _loop is None:
                _loop = asyncio.WindowsProactorEventLoopPolicy().new_event_loop()
                asyncio.set_event_loop(_loop)
            if not _loop.is_running():
                _thread = threading.Thread(
                    target=_loop.run_forever,
                    daemon=True)
                _thread.start()
        return _loop


    deferred_from_coro__old = deferred_from_coro


    def deferred_from_coro(o):
        async def get_result():
            future = asyncio.run_coroutine_threadsafe(o, get_default_event_loop())
            result = future.result()
            return result

        if isinstance(o, Deferred):
            return o
        return deferred_from_coro__old(get_result())
## working on Windows ######################################


__all__ = ["ScrapyPlaywrightDownloadHandler"]

PlaywrightHandler = TypeVar("PlaywrightHandler", bound="ScrapyPlaywrightDownloadHandler")

logger = logging.getLogger("scrapy-playwright")

DEFAULT_BROWSER_TYPE = "chromium"
DEFAULT_CONTEXT_NAME = "default"
PERSISTENT_CONTEXT_PATH_KEY = "user_data_dir"


@dataclass
class BrowserContextWrapper:
    context: BrowserContext
    semaphore: asyncio.Semaphore
    persistent: bool


class ScrapyPlaywrightDownloadHandler(HTTPDownloadHandler):
    def __init__(self, crawler: Crawler) -> None:
        settings = crawler.settings
        super().__init__(settings=settings, crawler=crawler)
        verify_installed_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
        crawler.signals.connect(self._engine_started, signals.engine_started)
        self.stats = crawler.stats

        # browser
        self.browser_cdp_url = settings.get("PLAYWRIGHT_CDP_URL")
        self.browser_cdp_kwargs = settings.get("PLAYWRIGHT_CDP_KWARGS") or {}
        self.browser_cdp_kwargs.pop("endpoint_url", None)
        self.browser_type_name = settings.get("PLAYWRIGHT_BROWSER_TYPE") or DEFAULT_BROWSER_TYPE
        self.browser_launch_lock = asyncio.Lock()
        self.launch_options: dict = settings.getdict("PLAYWRIGHT_LAUNCH_OPTIONS") or {}
        if self.browser_cdp_url and self.launch_options:
            logger.warning("PLAYWRIGHT_CDP_URL is set, ignoring PLAYWRIGHT_LAUNCH_OPTIONS")

        # contexts
        self.max_pages_per_context: int = settings.getint(
            "PLAYWRIGHT_MAX_PAGES_PER_CONTEXT"
        ) or settings.getint("CONCURRENT_REQUESTS")
        self.context_launch_lock = asyncio.Lock()
        self.context_wrappers: Dict[str, BrowserContextWrapper] = {}
        self.startup_context_kwargs: dict = settings.getdict("PLAYWRIGHT_CONTEXTS")
        if settings.getint("PLAYWRIGHT_MAX_CONTEXTS"):
            self.context_semaphore = asyncio.Semaphore(
                value=settings.getint("PLAYWRIGHT_MAX_CONTEXTS")
            )

        self.default_navigation_timeout: Optional[float] = None
        if "PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT" in settings:
            with suppress(TypeError, ValueError):
                self.default_navigation_timeout = float(
                    settings.get("PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT")
                )

        # headers
        if "PLAYWRIGHT_PROCESS_REQUEST_HEADERS" in settings:
            if settings["PLAYWRIGHT_PROCESS_REQUEST_HEADERS"] is None:
                self.process_request_headers = None
            else:
                self.process_request_headers = load_object(
                    settings["PLAYWRIGHT_PROCESS_REQUEST_HEADERS"]
                )
        else:
            self.process_request_headers = use_scrapy_headers

        self.abort_request: Optional[Callable[[PlaywrightRequest], Union[Awaitable, bool]]] = None
        if settings.get("PLAYWRIGHT_ABORT_REQUEST"):
            self.abort_request = load_object(settings["PLAYWRIGHT_ABORT_REQUEST"])

    @classmethod
    def from_crawler(cls: Type[PlaywrightHandler], crawler: Crawler) -> PlaywrightHandler:
        return cls(crawler)

    def _engine_started(self) -> Deferred:
        """Launch the browser. Use the engine_started signal as it supports returning deferreds."""
        return deferred_from_coro(self._launch())

    async def _launch(self) -> None:
        """Launch Playwright manager and configured startup context(s)."""
        logger.info("Starting download handler")
        self.playwright_context_manager = PlaywrightContextManager()
        self.playwright = await self.playwright_context_manager.start()
        self.browser_type: BrowserType = getattr(self.playwright, self.browser_type_name)
        if self.startup_context_kwargs:
            logger.info("Launching %i startup context(s)", len(self.startup_context_kwargs))
            await asyncio.gather(
                *[
                    self._create_browser_context(name=name, context_kwargs=kwargs)
                    for name, kwargs in self.startup_context_kwargs.items()
                ]
            )
            self._set_max_concurrent_context_count()
            logger.info("Startup context(s) launched")
            self.stats.set_value("playwright/page_count", self._get_total_page_count())
        del self.startup_context_kwargs

    async def _maybe_launch_browser(self) -> None:
        async with self.browser_launch_lock:
            if not hasattr(self, "browser"):
                logger.info("Launching browser %s", self.browser_type.name)
                self.browser = await self.browser_type.launch(**self.launch_options)
                logger.info("Browser %s launched", self.browser_type.name)

    async def _maybe_connect_devtools(self) -> None:
        async with self.browser_launch_lock:
            if not hasattr(self, "browser"):
                logger.info("Connecting using CDP: %s", self.browser_cdp_url)
                self.browser = await self.browser_type.connect_over_cdp(
                    self.browser_cdp_url, **self.browser_cdp_kwargs
                )
                logger.info("Connected using CDP: %s", self.browser_cdp_url)

    async def _create_browser_context(
            self,
            name: str,
            context_kwargs: Optional[dict],
            spider: Optional[Spider] = None,
    ) -> BrowserContextWrapper:
        """Create a new context, also launching a local browser or connecting
        to a remote one if necessary.
        """
        if hasattr(self, "context_semaphore"):
            await self.context_semaphore.acquire()
        context_kwargs = context_kwargs or {}
        if context_kwargs.get(PERSISTENT_CONTEXT_PATH_KEY):
            context = await self.browser_type.launch_persistent_context(**context_kwargs)
            persistent = True
            remote = False
        elif self.browser_cdp_url:
            await self._maybe_connect_devtools()
            context = await self.browser.new_context(**context_kwargs)
            persistent = False
            remote = True
        else:
            await self._maybe_launch_browser()
            context = await self.browser.new_context(**context_kwargs)
            persistent = False
            remote = False

        context.on(
            "close", self._make_close_browser_context_callback(name, persistent, remote, spider)
        )
        self.stats.inc_value("playwright/context_count")
        self.stats.inc_value(f"playwright/context_count/persistent/{persistent}")
        self.stats.inc_value(f"playwright/context_count/remote/{remote}")
        logger.debug(
            "Browser context started: '%s' (persistent=%s, remote=%s)",
            name,
            persistent,
            remote,
            extra={
                "spider": spider,
                "context_name": name,
                "persistent": persistent,
                "remote": remote,
            },
        )
        if self.default_navigation_timeout is not None:
            context.set_default_navigation_timeout(self.default_navigation_timeout)
        self.context_wrappers[name] = BrowserContextWrapper(
            context=context,
            semaphore=asyncio.Semaphore(value=self.max_pages_per_context),
            persistent=persistent,
        )
        self._set_max_concurrent_context_count()
        return self.context_wrappers[name]

    async def _create_page(self, request: Request, spider: Spider) -> Page:
        """Create a new page in a context, also creating a new context if necessary."""
        context_name = request.meta.setdefault("playwright_context", DEFAULT_CONTEXT_NAME)
        # this block needs to be locked because several attempts to launch a context
        # with the same name could happen at the same time from different requests
        async with self.context_launch_lock:
            ctx_wrapper = self.context_wrappers.get(context_name)
            if ctx_wrapper is None:
                ctx_wrapper = await self._create_browser_context(
                    name=context_name,
                    context_kwargs=request.meta.get("playwright_context_kwargs"),
                    spider=spider,
                )

        await ctx_wrapper.semaphore.acquire()
        page = await ctx_wrapper.context.new_page()
        self.stats.inc_value("playwright/page_count")
        total_page_count = self._get_total_page_count()
        logger.debug(
            "[Context=%s] New page created, page count is %i (%i for all contexts)",
            context_name,
            len(ctx_wrapper.context.pages),
            total_page_count,
            extra={
                "spider": spider,
                "context_name": context_name,
                "context_page_count": len(ctx_wrapper.context.pages),
                "total_page_count": total_page_count,
                "scrapy_request_url": request.url,
                "scrapy_request_method": request.method,
            },
        )
        self._set_max_concurrent_page_count()
        if self.default_navigation_timeout is not None:
            page.set_default_navigation_timeout(self.default_navigation_timeout)

        page.on("close", self._make_close_page_callback(context_name))
        page.on("crash", self._make_close_page_callback(context_name))
        page.on("request", _make_request_logger(context_name, spider))
        page.on("response", _make_response_logger(context_name, spider))
        page.on("request", self._increment_request_stats)
        page.on("response", self._increment_response_stats)

        return page

    def _get_total_page_count(self):
        return sum(len(ctx.context.pages) for ctx in self.context_wrappers.values())

    def _set_max_concurrent_page_count(self):
        count = self._get_total_page_count()
        current_max_count = self.stats.get_value("playwright/page_count/max_concurrent")
        if current_max_count is None or count > current_max_count:
            self.stats.set_value("playwright/page_count/max_concurrent", count)

    def _set_max_concurrent_context_count(self):
        current_max_count = self.stats.get_value("playwright/context_count/max_concurrent")
        if current_max_count is None or len(self.context_wrappers) > current_max_count:
            self.stats.set_value(
                "playwright/context_count/max_concurrent", len(self.context_wrappers)
            )

    @inlineCallbacks
    def close(self) -> Deferred:
        logger.info("Closing download handler")
        yield super().close()
        yield deferred_from_coro(self._close())

    async def _close(self) -> None:
        await asyncio.gather(*[ctx.context.close() for ctx in self.context_wrappers.values()])
        self.context_wrappers.clear()
        if hasattr(self, "browser"):
            logger.info("Closing browser")
            await self.browser.close()
        await self.playwright_context_manager.__aexit__()
        await self.playwright.stop()

    def download_request(self, request: Request, spider: Spider) -> Deferred:
        if request.meta.get("playwright"):
            return deferred_from_coro(self._download_request(request, spider))
        return super().download_request(request, spider)

    async def _download_request(self, request: Request, spider: Spider) -> Response:
        page = request.meta.get("playwright_page")
        if not isinstance(page, Page):
            page = await self._create_page(request=request, spider=spider)
        context_name = request.meta.setdefault("playwright_context", DEFAULT_CONTEXT_NAME)

        _attach_page_event_handlers(
            page=page, request=request, spider=spider, context_name=context_name
        )

        await page.unroute("**")
        await page.route(
            "**",
            self._make_request_handler(
                context_name=context_name,
                method=request.method,
                url=request.url,
                headers=request.headers,
                body=request.body,
                encoding=request.encoding,
                spider=spider,
            ),
        )

        await _maybe_execute_page_init_callback(
            page=page, request=request, context_name=context_name, spider=spider
        )

        try:
            return await self._download_request_with_page(request, page, spider)
        except Exception as ex:
            if not request.meta.get("playwright_include_page") and not page.is_closed():
                logger.warning(
                    "Closing page due to failed request: %s exc_type=%s exc_msg=%s",
                    request,
                    type(ex),
                    str(ex),
                    extra={
                        "spider": spider,
                        "context_name": context_name,
                        "scrapy_request_url": request.url,
                        "scrapy_request_method": request.method,
                        "exception": ex,
                    },
                    exc_info=True,
                )
                await page.close()
                self.stats.inc_value("playwright/page_count/closed")
            raise

    async def _download_request_with_page(
            self, request: Request, page: Page, spider: Spider
    ) -> Response:
        # set this early to make it available in errbacks even if something fails
        if request.meta.get("playwright_include_page"):
            request.meta["playwright_page"] = page

        start_time = time()
        response, download = await self._get_response_and_download(request=request, page=page)
        if isinstance(response, PlaywrightResponse):
            await _set_redirect_meta(request=request, response=response)
            headers = Headers(await response.all_headers())
            headers.pop("Content-Encoding", None)
        else:
            logger.warning(
                "Navigating to %s returned None, the response"
                " will have empty headers and status 200",
                request,
                extra={
                    "spider": spider,
                    "context_name": request.meta.get("playwright_context"),
                    "scrapy_request_url": request.url,
                    "scrapy_request_method": request.method,
                },
            )
            headers = Headers()

        await self._apply_page_methods(page, request, spider)
        body_str = await _get_page_content(
            page=page,
            spider=spider,
            context_name=request.meta.get("playwright_context"),
            scrapy_request_url=request.url,
            scrapy_request_method=request.method,
        )
        request.meta["download_latency"] = time() - start_time

        server_ip_address = None
        if response is not None:
            request.meta["playwright_security_details"] = await response.security_details()
            with suppress(KeyError, TypeError, ValueError):
                server_addr = await response.server_addr()
                server_ip_address = ip_address(server_addr["ipAddress"])

        if download.get("exception"):
            raise download["exception"]

        if not request.meta.get("playwright_include_page"):
            await page.close()
            self.stats.inc_value("playwright/page_count/closed")

        if download:
            request.meta["playwright_suggested_filename"] = download.get("suggested_filename")
            respcls = responsetypes.from_args(url=download["url"], body=download["bytes"])
            return respcls(
                url=download["url"],
                status=200,
                body=download["bytes"],
                request=request,
                flags=["playwright"],
            )

        body, encoding = _encode_body(headers=headers, text=body_str)
        respcls = responsetypes.from_args(headers=headers, url=page.url, body=body)
        return respcls(
            url=page.url,
            status=response.status if response is not None else 200,
            headers=headers,
            body=body,
            request=request,
            flags=["playwright"],
            encoding=encoding,
            ip_address=server_ip_address,
        )

    async def _get_response_and_download(
            self, request: Request, page: Page
    ) -> Tuple[Optional[PlaywrightResponse], dict]:
        response: Optional[PlaywrightResponse] = None
        download: dict = {}  # updated in-place in _handle_download
        download_ready = asyncio.Event()

        async def _handle_download(dwnld: Download) -> None:
            self.stats.inc_value("playwright/download_count")
            try:
                if failure := await dwnld.failure():
                    raise RuntimeError(f"Failed to download {dwnld.url}: {failure}")
                with NamedTemporaryFile() as temp_file:
                    await dwnld.save_as(temp_file.name)
                    temp_file.seek(0)
                    download["bytes"] = temp_file.read()
                download["url"] = dwnld.url
                download["suggested_filename"] = dwnld.suggested_filename
            except Exception as ex:
                download["exception"] = ex
            finally:
                download_ready.set()

        page_goto_kwargs = request.meta.get("playwright_page_goto_kwargs") or {}
        page_goto_kwargs.pop("url", None)
        page.on("download", _handle_download)
        try:
            response = await page.goto(url=request.url, **page_goto_kwargs)
        except PlaywrightError as err:
            if not (
                    self.browser_type_name in ("firefox", "webkit")
                    and "Download is starting" in err.message
                    or self.browser_type_name == "chromium"
                    and "net::ERR_ABORTED" in err.message
            ):
                raise
            await download_ready.wait()
        finally:
            page.remove_listener("download", _handle_download)

        return response, download

    async def _apply_page_methods(self, page: Page, request: Request, spider: Spider) -> None:
        context_name = request.meta.get("playwright_context")
        page_methods = request.meta.get("playwright_page_methods") or ()
        if isinstance(page_methods, dict):
            page_methods = page_methods.values()
        for pm in page_methods:
            if isinstance(pm, PageMethod):
                try:
                    method = getattr(page, pm.method)
                except AttributeError as ex:
                    logger.warning(
                        "Ignoring %r: could not find method",
                        pm,
                        extra={
                            "spider": spider,
                            "context_name": context_name,
                            "scrapy_request_url": request.url,
                            "scrapy_request_method": request.method,
                            "exception": ex,
                        },
                        exc_info=True,
                    )
                else:
                    pm.result = await _maybe_await(method(*pm.args, **pm.kwargs))
                    await page.wait_for_load_state(timeout=self.default_navigation_timeout)
            else:
                logger.warning(
                    "Ignoring %r: expected PageMethod, got %r",
                    pm,
                    type(pm),
                    extra={
                        "spider": spider,
                        "context_name": context_name,
                        "scrapy_request_url": request.url,
                        "scrapy_request_method": request.method,
                    },
                )

    def _increment_request_stats(self, request: PlaywrightRequest) -> None:
        stats_prefix = "playwright/request_count"
        self.stats.inc_value(stats_prefix)
        self.stats.inc_value(f"{stats_prefix}/resource_type/{request.resource_type}")
        self.stats.inc_value(f"{stats_prefix}/method/{request.method}")
        if request.is_navigation_request():
            self.stats.inc_value(f"{stats_prefix}/navigation")

    def _increment_response_stats(self, response: PlaywrightResponse) -> None:
        stats_prefix = "playwright/response_count"
        self.stats.inc_value(stats_prefix)
        self.stats.inc_value(f"{stats_prefix}/resource_type/{response.request.resource_type}")
        self.stats.inc_value(f"{stats_prefix}/method/{response.request.method}")

    def _make_close_page_callback(self, context_name: str) -> Callable:
        def close_page_callback() -> None:
            if context_name in self.context_wrappers:
                self.context_wrappers[context_name].semaphore.release()

        return close_page_callback

    def _make_close_browser_context_callback(
            self, name: str, persistent: bool, remote: bool, spider: Optional[Spider] = None
    ) -> Callable:
        def close_browser_context_callback() -> None:
            self.context_wrappers.pop(name, None)
            if hasattr(self, "context_semaphore"):
                self.context_semaphore.release()
            logger.debug(
                "Browser context closed: '%s' (persistent=%s, remote=%s)",
                name,
                persistent,
                remote,
                extra={
                    "spider": spider,
                    "context_name": name,
                    "persistent": persistent,
                    "remote": remote,
                },
            )

        return close_browser_context_callback

    def _make_request_handler(
            self,
            context_name: str,
            method: str,
            url: str,
            headers: Headers,
            body: Optional[bytes],
            encoding: str,
            spider: Spider,
    ) -> Callable:
        async def _request_handler(route: Route, playwright_request: PlaywrightRequest) -> None:
            """Override request headers, method and body."""
            if self.abort_request:
                should_abort = await _maybe_await(self.abort_request(playwright_request))
                if should_abort:
                    await route.abort()
                    logger.debug(
                        "[Context=%s] Aborted Playwright request <%s %s>",
                        context_name,
                        playwright_request.method.upper(),
                        playwright_request.url,
                        extra={
                            "spider": spider,
                            "context_name": context_name,
                            "scrapy_request_url": url,
                            "scrapy_request_method": method,
                            "playwright_request_url": playwright_request.url,
                            "playwright_request_method": playwright_request.method,
                        },
                    )
                    self.stats.inc_value("playwright/request_count/aborted")
                    return None

            overrides: dict = {}

            if self.process_request_headers is None:
                final_headers = await playwright_request.all_headers()
            else:
                overrides["headers"] = final_headers = await _maybe_await(
                    self.process_request_headers(
                        self.browser_type_name, playwright_request, headers
                    )
                )

            # if the current request corresponds to the original scrapy one
            if (
                    playwright_request.url.rstrip("/") == url.rstrip("/")
                    and playwright_request.is_navigation_request()
            ):
                if method.upper() != playwright_request.method.upper():
                    overrides["method"] = method
                if body:
                    overrides["post_data"] = body.decode(encoding)
                # the request that reaches the callback should contain the final headers
                headers.clear()
                headers.update(final_headers)

            del final_headers

            original_playwright_method: str = playwright_request.method
            try:
                await route.continue_(**overrides)
                if overrides.get("method"):
                    logger.debug(
                        "[Context=%s] Overridden method for Playwright request"
                        " to %s: original=%s new=%s",
                        context_name,
                        playwright_request.url,
                        original_playwright_method,
                        overrides["method"],
                        extra={
                            "spider": spider,
                            "context_name": context_name,
                            "scrapy_request_url": url,
                            "scrapy_request_method": method,
                            "playwright_request_url": playwright_request.url,
                            "playwright_request_method_original": original_playwright_method,
                            "playwright_request_method_new": overrides["method"],
                        },
                    )
            except PlaywrightError as ex:
                if _is_safe_close_error(ex):
                    logger.warning(
                        "Failed processing Playwright request: <%s %s> exc_type=%s exc_msg=%s",
                        playwright_request.method,
                        playwright_request.url,
                        type(ex),
                        str(ex),
                        extra={
                            "spider": spider,
                            "context_name": context_name,
                            "scrapy_request_url": url,
                            "scrapy_request_method": method,
                            "playwright_request_url": playwright_request.url,
                            "playwright_request_method": playwright_request.method,
                            "exception": ex,
                        },
                        exc_info=True,
                    )
                else:
                    raise

        return _request_handler


def _attach_page_event_handlers(
        page: Page, request: Request, spider: Spider, context_name: str
) -> None:
    event_handlers = request.meta.get("playwright_page_event_handlers") or {}
    for event, handler in event_handlers.items():
        if callable(handler):
            page.on(event, handler)
        elif isinstance(handler, str):
            try:
                page.on(event, getattr(spider, handler))
            except AttributeError as ex:
                logger.warning(
                    "Spider '%s' does not have a '%s' attribute,"
                    " ignoring handler for event '%s'",
                    spider.name,
                    handler,
                    event,
                    extra={
                        "spider": spider,
                        "context_name": context_name,
                        "scrapy_request_url": request.url,
                        "scrapy_request_method": request.method,
                        "exception": ex,
                    },
                    exc_info=True,
                )


async def _set_redirect_meta(request: Request, response: PlaywrightResponse) -> None:
    """Update a Scrapy request with metadata about redirects."""
    redirect_times: int = 0
    redirect_urls: list = []
    redirect_reasons: list = []
    redirected = response.request.redirected_from
    while redirected is not None:
        redirect_times += 1
        redirect_urls.append(redirected.url)
        redirected_response = await redirected.response()
        reason = None if redirected_response is None else redirected_response.status
        redirect_reasons.append(reason)
        redirected = redirected.redirected_from
    if redirect_times:
        request.meta["redirect_times"] = redirect_times
        request.meta["redirect_urls"] = list(reversed(redirect_urls))
        request.meta["redirect_reasons"] = list(reversed(redirect_reasons))


async def _maybe_execute_page_init_callback(
        page: Page,
        request: Request,
        context_name: str,
        spider: Spider,
) -> None:
    page_init_callback = request.meta.get("playwright_page_init_callback")
    if page_init_callback:
        try:
            page_init_callback = load_object(page_init_callback)
            await page_init_callback(page, request)
        except Exception as ex:
            logger.warning(
                "[Context=%s] Page init callback exception for %s exc_type=%s exc_msg=%s",
                context_name,
                repr(request),
                type(ex),
                str(ex),
                extra={
                    "spider": spider,
                    "context_name": context_name,
                    "scrapy_request_url": request.url,
                    "scrapy_request_method": request.method,
                    "exception": ex,
                },
                exc_info=True,
            )


def _make_request_logger(context_name: str, spider: Spider) -> Callable:
    async def _log_request(request: PlaywrightRequest) -> None:
        log_args = [context_name, request.method.upper(), request.url, request.resource_type]
        referrer = await _get_header_value(request, "referer")
        if referrer:
            log_args.append(referrer)
            log_msg = "[Context=%s] Request: <%s %s> (resource type: %s, referrer: %s)"
        else:
            log_msg = "[Context=%s] Request: <%s %s> (resource type: %s)"
        logger.debug(
            log_msg,
            *log_args,
            extra={
                "spider": spider,
                "context_name": context_name,
                "playwright_request_url": request.url,
                "playwright_request_method": request.method,
                "playwright_resource_type": request.resource_type,
            },
        )

    return _log_request


def _make_response_logger(context_name: str, spider: Spider) -> Callable:
    async def _log_response(response: PlaywrightResponse) -> None:
        log_args = [context_name, response.status, response.url]
        location = await _get_header_value(response, "location")
        if location:
            log_args.append(location)
            log_msg = "[Context=%s] Response: <%i %s> (location: %s)"
        else:
            log_msg = "[Context=%s] Response: <%i %s>"
        logger.debug(
            log_msg,
            *log_args,
            extra={
                "spider": spider,
                "context_name": context_name,
                "playwright_response_url": response.url,
                "playwright_response_status": response.status,
            },
        )

    return _log_response

```
Supporting for windows



Discussion / hints:
Thanks, I appreciate the effort. However, if the intention is to make a contribution, please open a pull request. If you do so, it would be ideal if you could also extend the CI configuration to run the test suite using Windows.


Fix patch:
diff --git a/README.md b/README.md
index 13230d2..6727a3b 100644
--- a/README.md
+++ b/README.md
@@ -56,10 +56,13 @@ See the [changelog](docs/changelog.md) document.
 
 ## Activation
 
+### Download handler
+
 Replace the default `http` and/or `https` Download Handlers through
 [`DOWNLOAD_HANDLERS`](https://docs.scrapy.org/en/latest/topics/settings.html):
 
 ```python
+# settings.py
 DOWNLOAD_HANDLERS = {
     "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
     "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
@@ -70,12 +73,19 @@ Note that the `ScrapyPlaywrightDownloadHandler` class inherits from the default
 `http/https` handler. Unless explicitly marked (see [Basic usage](#basic-usage)),
 requests will be processed by the regular Scrapy download handler.
 
-Also, be sure to [install the `asyncio`-based Twisted reactor](https://docs.scrapy.org/en/latest/topics/asyncio.html#installing-the-asyncio-reactor):
+
+### Twisted reactor
+
+When running on GNU/Linux or macOS you'll need to
+[install the `asyncio`-based Twisted reactor](https://docs.scrapy.org/en/latest/topics/asyncio.html#installing-the-asyncio-reactor):
 
 ```python
+# settings.py
 TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
 ```
 
+This is not a requirement on Windows (see [Windows support](#windows-support))
+
 
 ## Basic usage
 
@@ -112,6 +122,20 @@ does not match the running Browser. If you prefer the `User-Agent` sent by
 default by the specific browser you're using, set the Scrapy user agent to `None`.
 
 
+## Windows support
+
+Windows support is possible by running Playwright in a `ProactorEventLoop` in a separate thread.
+This is necessary because it's not possible to run Playwright in the same
+asyncio event loop as the Scrapy crawler:
+* Playwright runs the driver in a subprocess. Source:
+  [Playwright repository](https://github.com/microsoft/playwright-python/blob/v1.44.0/playwright/_impl/_transport.py#L120-L130).
+* "On Windows, the default event loop `ProactorEventLoop` supports subprocesses,
+  whereas `SelectorEventLoop` does not". Source:
+  [Python docs](https://docs.python.org/3/library/asyncio-platforms.html#asyncio-windows-subprocess).
+* Twisted's `asyncio` reactor requires the `SelectorEventLoop`. Source:
+  [Twisted repository](https://github.com/twisted/twisted/blob/twisted-24.3.0/src/twisted/internet/asyncioreactor.py#L31)
+
+
 ## Supported [settings](https://docs.scrapy.org/en/latest/topics/settings.html)
 
 ### `PLAYWRIGHT_BROWSER_TYPE`
@@ -851,6 +875,12 @@ Refer to the
 [upstream docs](https://docs.scrapy.org/en/latest/topics/extensions.html#module-scrapy.extensions.memusage)
 for more information about supported settings.
 
+### Windows support
+
+Just like the [upstream Scrapy extension](https://docs.scrapy.org/en/latest/topics/extensions.html#module-scrapy.extensions.memusage), this custom memory extension does not work
+on Windows. This is because the stdlib [`resource`](https://docs.python.org/3/library/resource.html)
+module is not available.
+
 
 ## Examples
 
@@ -912,23 +942,6 @@ See the [examples](examples) directory for more.
 
 ## Known issues
 
-### Lack of native support for Windows
-
-This package does not work natively on Windows. This is because:
-
-* Playwright runs the driver in a subprocess. Source:
-[Playwright repository](https://github.com/microsoft/playwright-python/blob/v1.28.0/playwright/_impl/_transport.py#L120-L129).
-* "On Windows, the default event loop `ProactorEventLoop` supports subprocesses,
-whereas `SelectorEventLoop` does not". Source:
-[Python docs](https://docs.python.org/3/library/asyncio-platforms.html#asyncio-windows-subprocess).
-* Twisted's `asyncio` reactor requires the `SelectorEventLoop`. Source:
-[Twisted repository](https://github.com/twisted/twisted/blob/twisted-22.4.0/src/twisted/internet/asyncioreactor.py#L31).
-
-Some users have reported having success
-[running under WSL](https://github.com/scrapy-plugins/scrapy-playwright/issues/7#issuecomment-817394494).
-See also [#78](https://github.com/scrapy-plugins/scrapy-playwright/issues/78)
-for information about working in headful mode under WSL.
-
 ### No per-request proxy support
 Specifying a proxy via the `proxy` Request meta key is not supported.
 Refer to the [Proxy support](#proxy-support) section for more information.
diff --git a/docs/changelog.md b/docs/changelog.md
index f3ba5af..1cd4e6f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,10 @@
 # scrapy-playwright changelog
 
+### [v0.0.36](https://github.com/scrapy-plugins/scrapy-playwright/releases/tag/v0.0.36) (2024-MM-DD)
+
+* Windows support (#276)
+
+
 ### [v0.0.35](https://github.com/scrapy-plugins/scrapy-playwright/releases/tag/v0.0.35) (2024-06-01)
 
 * Update exception message check
diff --git a/scrapy_playwright/_utils.py b/scrapy_playwright/_utils.py
index d6eee04..bc93f14 100644
--- a/scrapy_playwright/_utils.py
+++ b/scrapy_playwright/_utils.py
@@ -1,10 +1,15 @@
+import asyncio
+import concurrent
 import logging
+import platform
+import threading
 from typing import Awaitable, Iterator, Optional, Tuple, Union
 
+import scrapy
 from playwright.async_api import Error, Page, Request, Response
-from scrapy import Spider
 from scrapy.http.headers import Headers
 from scrapy.utils.python import to_unicode
+from twisted.internet.defer import Deferred
 from w3lib.encoding import html_body_declared_encoding, http_content_type_encoding
 
 
@@ -53,7 +58,7 @@ def _is_safe_close_error(error: Error) -> bool:
 
 async def _get_page_content(
     page: Page,
-    spider: Spider,
+    spider: scrapy.Spider,
     context_name: str,
     scrapy_request_url: str,
     scrapy_request_method: str,
@@ -89,3 +94,38 @@ async def _get_header_value(
         return await resource.header_value(header_name)
     except Exception:
         return None
+
+
+if platform.system() == "Windows":
+
+    class _WindowsAdapter:
+        """Utility class to redirect coroutines to an asyncio event loop running
+        in a different thread. This allows to use a ProactorEventLoop, which is
+        supported by Playwright on Windows.
+        """
+
+        loop = None
+        thread = None
+
+        @classmethod
+        def get_event_loop(cls) -> asyncio.AbstractEventLoop:
+            if cls.thread is None:
+                if cls.loop is None:
+                    policy = asyncio.WindowsProactorEventLoopPolicy()  # type: ignore
+                    cls.loop = policy.new_event_loop()
+                    asyncio.set_event_loop(cls.loop)
+                if not cls.loop.is_running():
+                    cls.thread = threading.Thread(target=cls.loop.run_forever, daemon=True)
+                    cls.thread.start()
+                    logger.info("Started loop on separate thread: %s", cls.loop)
+            return cls.loop
+
+        @classmethod
+        async def get_result(cls, coro) -> concurrent.futures.Future:
+            return asyncio.run_coroutine_threadsafe(coro=coro, loop=cls.get_event_loop()).result()
+
+    def _deferred_from_coro(coro) -> Deferred:
+        return scrapy.utils.defer.deferred_from_coro(_WindowsAdapter.get_result(coro))
+
+else:
+    _deferred_from_coro = scrapy.utils.defer.deferred_from_coro
diff --git a/scrapy_playwright/handler.py b/scrapy_playwright/handler.py
index a2ae3f2..9096cbb 100644
--- a/scrapy_playwright/handler.py
+++ b/scrapy_playwright/handler.py
@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import platform
 from contextlib import suppress
 from dataclasses import dataclass
 from ipaddress import ip_address
@@ -25,7 +26,6 @@
 from scrapy.http.headers import Headers
 from scrapy.responsetypes import responsetypes
 from scrapy.settings import Settings
-from scrapy.utils.defer import deferred_from_coro
 from scrapy.utils.misc import load_object
 from scrapy.utils.reactor import verify_installed_reactor
 from twisted.internet.defer import Deferred, inlineCallbacks
@@ -38,6 +38,7 @@
     _get_page_content,
     _is_safe_close_error,
     _maybe_await,
+    _deferred_from_coro,
 )
 
 
@@ -101,7 +102,8 @@ class ScrapyPlaywrightDownloadHandler(HTTPDownloadHandler):
 
     def __init__(self, crawler: Crawler) -> None:
         super().__init__(settings=crawler.settings, crawler=crawler)
-        verify_installed_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
+        if platform.system() != "Windows":
+            verify_installed_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
         crawler.signals.connect(self._engine_started, signals.engine_started)
         self.stats = crawler.stats
 
@@ -134,7 +136,7 @@ def from_crawler(cls: Type[PlaywrightHandler], crawler: Crawler) -> PlaywrightHa
 
     def _engine_started(self) -> Deferred:
         """Launch the browser. Use the engine_started signal as it supports returning deferreds."""
-        return deferred_from_coro(self._launch())
+        return _deferred_from_coro(self._launch())
 
     async def _launch(self) -> None:
         """Launch Playwright manager and configured startup context(s)."""
@@ -290,7 +292,7 @@ def _set_max_concurrent_context_count(self):
     def close(self) -> Deferred:
         logger.info("Closing download handler")
         yield super().close()
-        yield deferred_from_coro(self._close())
+        yield _deferred_from_coro(self._close())
 
     async def _close(self) -> None:
         await asyncio.gather(*[ctx.context.close() for ctx in self.context_wrappers.values()])
@@ -305,7 +307,7 @@ async def _close(self) -> None:
 
     def download_request(self, request: Request, spider: Spider) -> Deferred:
         if request.meta.get("playwright"):
-            return deferred_from_coro(self._download_request(request, spider))
+            return _deferred_from_coro(self._download_request(request, spider))
         return super().download_request(request, spider)
 
     async def _download_request(self, request: Request, spider: Spider) -> Response:


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6b1f027..e6fcca3 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,6 +13,8 @@ jobs:
         include:
           - os: macos-latest
             python-version: "3.12"
+          - os: windows-latest
+            python-version: "3.12"
 
     steps:
     - uses: actions/checkout@v4
@@ -48,3 +50,12 @@ jobs:
         curl -Os https://uploader.codecov.io/latest/macos/codecov
         chmod +x codecov
         ./codecov
+
+    - name: Upload coverage report (Windows)
+      if: runner.os == 'Windows'
+      env:
+        CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+      run: |
+        $ProgressPreference = 'SilentlyContinue'
+        Invoke-WebRequest -Uri https://uploader.codecov.io/latest/windows/codecov.exe -Outfile codecov.exe
+        .\codecov.exe
diff --git a/tests/__init__.py b/tests/__init__.py
index 641b79d..1fddfd8 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,10 +1,38 @@
+import inspect
+import logging
+import platform
 from contextlib import asynccontextmanager
+from functools import wraps
 
 from scrapy import Request
 from scrapy.http.response.html import HtmlResponse
 from scrapy.utils.test import get_crawler
 
 
+logger = logging.getLogger("scrapy-playwright-tests")
+
+
+if platform.system() == "Windows":
+    from scrapy_playwright._utils import _WindowsAdapter
+
+    def allow_windows(test_method):
+        """Wrap tests with the _WindowsAdapter class on Windows."""
+        if not inspect.iscoroutinefunction(test_method):
+            raise RuntimeError(f"{test_method} must be an async def method")
+
+        @wraps(test_method)
+        async def wrapped(self, *args, **kwargs):
+            logger.debug("Calling _WindowsAdapter.get_result for %r", self)
+            await _WindowsAdapter.get_result(test_method(self, *args, **kwargs))
+
+        return wrapped
+
+else:
+
+    def allow_windows(test_method):
+        return test_method
+
+
 @asynccontextmanager
 async def make_handler(settings_dict: dict):
     """Convenience function to obtain an initialized handler and close it gracefully"""
diff --git a/tests/conftest.py b/tests/conftest.py
index 90b5c49..0b1eb82 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,20 @@
+import platform
+
+import pytest
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_configure(config):
+    # https://twistedmatrix.com/trac/ticket/9766
+    # https://github.com/pytest-dev/pytest-twisted/issues/80
+
+    if config.getoption("reactor", "default") == "asyncio" and platform.system() == "Windows":
+        import asyncio
+
+        selector_policy = asyncio.WindowsSelectorEventLoopPolicy()
+        asyncio.set_event_loop_policy(selector_policy)
+
+
 def pytest_sessionstart(session):  # pylint: disable=unused-argument
     """
     Called after the Session object has been created and before performing
diff --git a/tests/tests_asyncio/test_browser_contexts.py b/tests/tests_asyncio/test_browser_contexts.py
index fb0484c..8c73009 100644
--- a/tests/tests_asyncio/test_browser_contexts.py
+++ b/tests/tests_asyncio/test_browser_contexts.py
@@ -10,11 +10,12 @@
 from scrapy import Spider, Request
 from scrapy_playwright.page import PageMethod
 
-from tests import make_handler
+from tests import allow_windows, make_handler
 from tests.mockserver import StaticMockServer
 
 
 class MixinTestCaseMultipleContexts:
+    @allow_windows
     async def test_context_kwargs(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -37,6 +38,7 @@ async def test_context_kwargs(self):
                 with pytest.raises(PlaywrightTimeoutError):
                     await handler._download_request(req, Spider("foo"))
 
+    @allow_windows
     async def test_contexts_max_pages(self):
         settings = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -71,6 +73,7 @@ async def test_contexts_max_pages(self):
 
             assert handler.stats.get_value("playwright/page_count/max_concurrent") == 4
 
+    @allow_windows
     async def test_max_contexts(self):
         def cb_close_context(task):
             response = task.result()
@@ -105,6 +108,7 @@ def cb_close_context(task):
 
             assert handler.stats.get_value("playwright/context_count/max_concurrent") == 4
 
+    @allow_windows
     async def test_contexts_startup(self):
         settings = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -143,6 +147,7 @@ async def test_contexts_startup(self):
             assert cookie["value"] == "bar"
             assert cookie["domain"] == "example.org"
 
+    @allow_windows
     async def test_persistent_context(self):
         temp_dir = f"{tempfile.gettempdir()}/{uuid4()}"
         settings = {
@@ -161,6 +166,7 @@ async def test_persistent_context(self):
             assert handler.context_wrappers["persistent"].persistent
             assert not hasattr(handler, "browser")
 
+    @allow_windows
     async def test_mixed_persistent_contexts(self):
         temp_dir = f"{tempfile.gettempdir()}/{uuid4()}"
         settings = {
@@ -183,6 +189,7 @@ async def test_mixed_persistent_contexts(self):
             assert not handler.context_wrappers["non-persistent"].persistent
             assert isinstance(handler.browser, Browser)
 
+    @allow_windows
     async def test_contexts_dynamic(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             assert len(handler.context_wrappers) == 0
diff --git a/tests/tests_asyncio/test_extensions.py b/tests/tests_asyncio/test_extensions.py
index c51c067..370f7c5 100644
--- a/tests/tests_asyncio/test_extensions.py
+++ b/tests/tests_asyncio/test_extensions.py
@@ -1,3 +1,4 @@
+import platform
 from asyncio.subprocess import Process as AsyncioProcess
 from unittest import IsolatedAsyncioTestCase
 from unittest.mock import MagicMock, patch
@@ -34,6 +35,10 @@ class MockMemoryInfo:
     rss = 999
 
 
+@pytest.mark.skipif(
+    platform.system() == "Windows",
+    reason="resource stdlib module is not available on Windows",
+)
 @patch("scrapy.extensions.memusage.MailSender")
 class TestMemoryUsageExtension(IsolatedAsyncioTestCase):
     async def test_process_availability(self, _MailSender):
diff --git a/tests/tests_asyncio/test_headers.py b/tests/tests_asyncio/test_headers.py
index 61dd672..9956bd7 100644
--- a/tests/tests_asyncio/test_headers.py
+++ b/tests/tests_asyncio/test_headers.py
@@ -5,11 +5,12 @@
 import pytest
 from scrapy import Spider, Request
 
-from tests import make_handler
+from tests import allow_windows, make_handler
 from tests.mockserver import MockServer
 
 
 class MixinProcessHeadersTestCase:
+    @allow_windows
     async def test_user_agent(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -39,6 +40,7 @@ async def test_user_agent(self):
                 headers = {key.lower(): value for key, value in headers.items()}
                 assert headers["user-agent"] == "foobar"
 
+    @allow_windows
     async def test_playwright_headers(self):
         """Ignore Scrapy headers"""
         settings_dict = {
@@ -63,6 +65,7 @@ async def test_playwright_headers(self):
                 assert "asdf" not in req.headers
                 assert b"asdf" not in req.headers
 
+    @allow_windows
     async def test_use_custom_headers(self):
         """Custom header processing function"""
 
diff --git a/tests/tests_asyncio/test_page_methods.py b/tests/tests_asyncio/test_page_methods.py
index 34a3f7b..6cf3b37 100644
--- a/tests/tests_asyncio/test_page_methods.py
+++ b/tests/tests_asyncio/test_page_methods.py
@@ -10,7 +10,7 @@
 
 from scrapy_playwright.page import PageMethod
 
-from tests import make_handler, assert_correct_response
+from tests import allow_windows, make_handler, assert_correct_response
 from tests.mockserver import StaticMockServer
 
 
@@ -24,6 +24,7 @@ def get_mimetype(file):
 
 
 class TestPageMethods(IsolatedAsyncioTestCase):
+    @allow_windows
     async def test_page_methods(self):
         screenshot = PageMethod("screenshot", "foo", 123, path="/tmp/file", type="png")
         assert screenshot.method == "screenshot"
@@ -39,6 +40,7 @@ def inject_fixtures(self, caplog):
         caplog.set_level(logging.DEBUG)
         self._caplog = caplog
 
+    @allow_windows
     async def test_page_non_page_method(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -63,6 +65,7 @@ async def test_page_non_page_method(self):
                 f"Ignoring {repr(obj)}: expected PageMethod, got {repr(type(obj))}",
             ) in self._caplog.record_tuples
 
+    @allow_windows
     async def test_page_mixed_page_methods(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -89,6 +92,7 @@ async def test_page_mixed_page_methods(self):
         assert not req.meta["playwright_page_methods"]["is_closed"].result
         assert req.meta["playwright_page_methods"]["title"].result == "Awesome site"
 
+    @allow_windows
     async def test_page_method_navigation(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -110,6 +114,7 @@ async def test_page_method_navigation(self):
             text = resp.css("p::text").get()
             assert text == "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
 
+    @allow_windows
     async def test_page_method_infinite_scroll(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -136,9 +141,10 @@ async def test_page_method_infinite_scroll(self):
             assert_correct_response(resp, req)
             assert len(resp.css("div.quote")) == 30
 
+    @allow_windows
     async def test_page_method_screenshot(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
-            with NamedTemporaryFile(mode="w+b") as png_file:
+            with NamedTemporaryFile(mode="w+b", delete=False) as png_file:
                 with StaticMockServer() as server:
                     req = Request(
                         url=server.urljoin("/index.html"),
@@ -153,14 +159,16 @@ async def test_page_method_screenshot(self):
 
                 png_file.file.seek(0)
                 assert png_file.file.read() == req.meta["playwright_page_methods"]["png"].result
-                assert get_mimetype(png_file) == "image/png"
+                if platform.system() != "Windows":
+                    assert get_mimetype(png_file) == "image/png"
 
+    @allow_windows
     async def test_page_method_pdf(self):
         if self.browser_type != "chromium":
             pytest.skip("PDF generation is supported only in Chromium")
 
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
-            with NamedTemporaryFile(mode="w+b") as pdf_file:
+            with NamedTemporaryFile(mode="w+b", delete=False) as pdf_file:
                 with StaticMockServer() as server:
                     req = Request(
                         url=server.urljoin("/index.html"),
@@ -175,7 +183,8 @@ async def test_page_method_pdf(self):
 
                 pdf_file.file.seek(0)
                 assert pdf_file.file.read() == req.meta["playwright_page_methods"]["pdf"].result
-                assert get_mimetype(pdf_file) == "application/pdf"
+                if platform.system() != "Windows":
+                    assert get_mimetype(pdf_file) == "application/pdf"
 
 
 class TestPageMethodChromium(IsolatedAsyncioTestCase, MixinPageMethodTestCase):
diff --git a/tests/tests_asyncio/test_playwright_requests.py b/tests/tests_asyncio/test_playwright_requests.py
index 9844128..fc97294 100644
--- a/tests/tests_asyncio/test_playwright_requests.py
+++ b/tests/tests_asyncio/test_playwright_requests.py
@@ -18,7 +18,7 @@
 from scrapy_playwright.handler import DEFAULT_CONTEXT_NAME
 from scrapy_playwright.page import PageMethod
 
-from tests import make_handler, assert_correct_response
+from tests import allow_windows, make_handler, assert_correct_response
 from tests.mockserver import MockServer, StaticMockServer
 
 
@@ -41,6 +41,7 @@ def inject_fixtures(self, caplog):
         caplog.set_level(logging.DEBUG)
         self._caplog = caplog
 
+    @allow_windows
     async def test_basic_response(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -54,6 +55,7 @@ async def test_basic_response(self):
             assert resp.meta["playwright_page"].url == resp.url
             await resp.meta["playwright_page"].close()
 
+    @allow_windows
     async def test_post_request(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with MockServer() as server:
@@ -65,6 +67,7 @@ async def test_post_request(self):
             assert_correct_response(resp, req)
             assert "Request body: foo=bar" in resp.text
 
+    @allow_windows
     async def test_timeout_error(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -82,6 +85,7 @@ async def test_timeout_error(self):
                     f" exc_type={type(excinfo.value)} exc_msg={str(excinfo.value)}",
                 ) in self._caplog.record_tuples
 
+    @allow_windows
     async def test_retry_page_content_still_navigating(self):
         if self.browser_type != "chromium":
             pytest.skip("Only Chromium seems to redirect meta tags within the same goto call")
@@ -103,6 +107,7 @@ async def test_retry_page_content_still_navigating(self):
             ) in self._caplog.record_tuples
 
     @patch("scrapy_playwright.handler.logger")
+    @allow_windows
     async def test_route_continue_exception(self, logger):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             scrapy_request = Request(url="https://example.org", method="GET")
@@ -153,6 +158,7 @@ async def test_route_continue_exception(self, logger):
             with pytest.raises(PlaywrightError):
                 await req_handler(route, playwright_request)
 
+    @allow_windows
     async def test_event_handler_dialog_callable(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -174,6 +180,7 @@ async def test_event_handler_dialog_callable(self):
 
             assert spider.dialog_message == "foobar"
 
+    @allow_windows
     async def test_event_handler_dialog_str(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -195,6 +202,7 @@ async def test_event_handler_dialog_str(self):
 
             assert spider.dialog_message == "foobar"
 
+    @allow_windows
     async def test_event_handler_dialog_missing(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with StaticMockServer() as server:
@@ -218,6 +226,7 @@ async def test_event_handler_dialog_missing(self):
         ) in self._caplog.record_tuples
         assert getattr(spider, "dialog_message", None) is None
 
+    @allow_windows
     async def test_response_attributes(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with MockServer() as server:
@@ -229,6 +238,7 @@ async def test_response_attributes(self):
 
         assert response.ip_address == ip_address(server.address)
 
+    @allow_windows
     async def test_page_goto_kwargs_referer(self):
         if self.browser_type != "chromium":
             pytest.skip("referer as goto kwarg seems to work only with chromium :shrug:")
@@ -247,6 +257,7 @@ async def test_page_goto_kwargs_referer(self):
         headers = json.loads(response.css("pre::text").get())
         assert headers["Referer"] == fake_referer
 
+    @allow_windows
     async def test_navigation_returns_none(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with MockServer():
@@ -262,6 +273,7 @@ async def test_navigation_returns_none(self):
         assert not response.headers
         assert response.status == 200
 
+    @allow_windows
     async def test_abort_requests(self):
         async def should_abort_request_async(request):
             return request.resource_type == "image"
@@ -294,6 +306,7 @@ def should_abort_request_sync(request):
                     assert handler.stats.get_value(f"{resp_prefix}/resource_type/image") is None
                     assert handler.stats.get_value(f"{req_prefix}/aborted") == 3
 
+    @allow_windows
     async def test_page_initialization_ok(self):
         async def init_page(page, _request):
             await page.set_extra_http_headers({"Extra-Header": "Qwerty"})
@@ -314,6 +327,7 @@ async def init_page(page, _request):
         headers = {key.lower(): value for key, value in headers.items()}
         assert headers["extra-header"] == "Qwerty"
 
+    @allow_windows
     async def test_page_initialization_fail(self):
         async def init_page(page, _request, _missing):
             await page.set_extra_http_headers({"Extra-Header": "Qwerty"})
@@ -340,6 +354,7 @@ async def init_page(page, _request, _missing):
                 assert f"[Context=default] Page init callback exception for {req!r}" in entry[2]
                 assert "init_page() missing 1 required positional argument: '_missing'" in entry[2]
 
+    @allow_windows
     async def test_redirect(self):
         async with make_handler({"PLAYWRIGHT_BROWSER_TYPE": self.browser_type}) as handler:
             with MockServer() as server:
@@ -357,6 +372,7 @@ async def test_redirect(self):
             server.urljoin("/redirect"),
         ]
 
+    @allow_windows
     async def test_logging_record_spider(self):
         """Make sure at least one log record has the spider as an attribute
         (records sent before opening the spider will not have it).
@@ -369,6 +385,7 @@ async def test_logging_record_spider(self):
 
         assert any(getattr(rec, "spider", None) is spider for rec in self._caplog.records)
 
+    @allow_windows
     async def test_download_file(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -384,6 +401,7 @@ async def test_download_file(self):
                 assert response.body.startswith(b"%PDF-1.5")
                 assert handler.stats.get_value("playwright/download_count") == 1
 
+    @allow_windows
     async def test_download_file_delay_ok(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -400,6 +418,7 @@ async def test_download_file_delay_ok(self):
                 assert response.body.startswith(b"%PDF-1.5")
                 assert handler.stats.get_value("playwright/download_count") == 1
 
+    @allow_windows
     async def test_download_file_delay_error(self):
         settings_dict = {
             "PLAYWRIGHT_BROWSER_TYPE": self.browser_type,
@@ -420,6 +439,7 @@ async def test_download_file_delay_error(self):
                     f" exc_type={type(excinfo.value)} exc_msg={str(excinfo.value)}",
                 ) in self._caplog.record_tuples
 
+    @allow_windows
     async def test_download_file_failure(self):
         if self.browser_type != "chromium":
             pytest.skip()
diff --git a/tests/tests_asyncio/test_remote.py b/tests/tests_asyncio/test_remote.py
index 1be4059..174c909 100644
--- a/tests/tests_asyncio/test_remote.py
+++ b/tests/tests_asyncio/test_remote.py
@@ -10,7 +10,7 @@
 from playwright.async_api import async_playwright
 from scrapy import Request, Spider
 
-from tests import make_handler, assert_correct_response
+from tests import allow_windows, make_handler, assert_correct_response
 from tests.mockserver import StaticMockServer
 
 
@@ -61,6 +61,7 @@ def inject_fixtures(self, caplog):
         caplog.set_level(logging.DEBUG)
         self._caplog = caplog
 
+    @allow_windows
     async def test_devtools(self):
         async with remote_chromium() as devtools_url:
             settings_dict = {
diff --git a/tests/tests_asyncio/test_settings.py b/tests/tests_asyncio/test_settings.py
index 42e7da5..3dbabc6 100644
--- a/tests/tests_asyncio/test_settings.py
+++ b/tests/tests_asyncio/test_settings.py
@@ -4,7 +4,7 @@
 
 from scrapy_playwright.handler import Config
 
-from tests import make_handler
+from tests import allow_windows, make_handler
 
 
 class TestSettings(IsolatedAsyncioTestCase):
@@ -31,6 +31,7 @@ async def test_max_pages_per_context(self):
         config = Config.from_settings(Settings({"CONCURRENT_REQUESTS": 9876}))
         assert config.max_pages_per_context == 9876
 
+    @allow_windows
     async def test_max_contexts(self):
         async with make_handler({"PLAYWRIGHT_MAX_CONTEXTS": None}) as handler:
             assert not hasattr(handler, "context_semaphore")
diff --git a/tests/tests_twisted/test_mixed_requests.py b/tests/tests_twisted/test_mixed_requests.py
index 0516668..d07eb48 100644
--- a/tests/tests_twisted/test_mixed_requests.py
+++ b/tests/tests_twisted/test_mixed_requests.py
@@ -27,24 +27,28 @@ def tearDown(self):
         self.server.__exit__(None, None, None)
         yield self.handler.close()
 
-    def test_regular_request(self):
-        def _test(response):
+    @defer.inlineCallbacks
+    def test_download_request(self):
+        def _test_regular(response, request):
             self.assertIsInstance(response, Response)
             self.assertEqual(response.css("a::text").getall(), ["Lorem Ipsum", "Infinite Scroll"])
             self.assertEqual(response.url, request.url)
             self.assertEqual(response.status, 200)
             self.assertNotIn("playwright", response.flags)
 
-        request = Request(self.server.urljoin("/index.html"))
-        return self.handler.download_request(request, Spider("foo")).addCallback(_test)
-
-    def test_playwright_request(self):
-        def _test(response):
+        def _test_playwright(response, request):
             self.assertIsInstance(response, Response)
             self.assertEqual(response.css("a::text").getall(), ["Lorem Ipsum", "Infinite Scroll"])
             self.assertEqual(response.url, request.url)
             self.assertEqual(response.status, 200)
             self.assertIn("playwright", response.flags)
 
-        request = Request(self.server.urljoin("/index.html"), meta={"playwright": True})
-        return self.handler.download_request(request, Spider("foo")).addCallback(_test)
+        req1 = Request(self.server.urljoin("/index.html"))
+        yield self.handler.download_request(req1, Spider("foo")).addCallback(
+            _test_regular, request=req1
+        )
+
+        req2 = Request(self.server.urljoin("/index.html"), meta={"playwright": True})
+        yield self.handler.download_request(req2, Spider("foo")).addCallback(
+            _test_playwright, request=req2
+        )