Add .NET 8 / C# client (dotnet/) (#385)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# .NET build artifacts
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
*.png
|
||||
cloak-persist-test/
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{57F29479-09EB-40B8-8811-3EDAEC7E7294}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser", "src\CloakBrowser\CloakBrowser.csproj", "{2451570B-6417-4615-9070-5C45A24B0A05}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Cli", "src\CloakBrowser.Cli\CloakBrowser.Cli.csproj", "{E1A470E8-044F-4AB4-BABC-33B5179061B2}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8A508583-113A-430D-90C9-73C706332269}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Tests", "tests\CloakBrowser.Tests\CloakBrowser.Tests.csproj", "{06500720-AF0C-434D-B4C6-FACE892A2F10}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{A19AACE1-BC14-4D60-8EBD-082E971B184B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Examples", "examples\CloakBrowser.Examples\CloakBrowser.Examples.csproj", "{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Generators", "src\CloakBrowser.Generators\CloakBrowser.Generators.csproj", "{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10} = {8A508583-113A-430D-90C9-73C706332269}
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA} = {A19AACE1-BC14-4D60-8EBD-082E971B184B}
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,637 @@
|
||||
# CloakBrowser for .NET
|
||||
|
||||
A complete, faithful **.NET 8 / C#** port of the
|
||||
[CloakBrowser](https://github.com/CloakHQ/CloakBrowser) Python wrapper - stealth
|
||||
Chromium that passes bot-detection tests, built on top of
|
||||
[`Microsoft.Playwright`](https://playwright.dev/dotnet/).
|
||||
|
||||
CloakBrowser is a thin wrapper around a closed-source, source-level patched
|
||||
Chromium binary (58 C++ fingerprint patches). This port reproduces **all** of the
|
||||
wrapper functionality with identical behavior - same launch flags, same proxy /
|
||||
GeoIP / WebRTC logic, and a humanize layer whose curves, timings, and stealth
|
||||
paths match the Python and JavaScript clients exactly.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Why a decorator layer?](#why-a-decorator-layer)
|
||||
- [Feature matrix](#feature-matrix)
|
||||
- [Requirements](#requirements)
|
||||
- [Installation](#installation)
|
||||
- [Project layout](#project-layout)
|
||||
- [Quick start](#quick-start)
|
||||
- [Humanized interactions (transparent)](#humanized-interactions-transparent)
|
||||
- [What changes vs. plain Playwright](#what-changes-vs-plain-playwright)
|
||||
- [Explicit `HumanPage` engine (advanced)](#explicit-humanpage-engine-advanced)
|
||||
- [Launch API reference](#launch-api-reference)
|
||||
- [Options reference](#options-reference)
|
||||
- [`HumanConfig` reference](#humanconfig-reference)
|
||||
- [How the humanize layer works](#how-the-humanize-layer-works)
|
||||
- [Mouse motion (Bezier)](#mouse-motion-bezier)
|
||||
- [Human typing & the CDP stealth path](#human-typing--the-cdp-stealth-path)
|
||||
- [Non-ASCII input](#non-ascii-input)
|
||||
- [Scrolling](#scrolling)
|
||||
- [Actionability checks](#actionability-checks)
|
||||
- [Shared timeout budget (#307)](#shared-timeout-budget-307)
|
||||
- [Focus-aware press / clear](#focus-aware-press--clear)
|
||||
- [Proxy, GeoIP & WebRTC](#proxy-geoip--webrtc)
|
||||
- [CLI](#cli)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Building & testing](#building--testing)
|
||||
- [Test suite map](#test-suite-map)
|
||||
- [Mapping to the Python source](#mapping-to-the-python-source)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Why a decorator layer?
|
||||
|
||||
.NET's Playwright exposes **sealed interfaces** (`IPage`, `ILocator`, `IMouse`, ...)
|
||||
that **cannot** be monkey-patched the way the Python/JS clients replace methods at
|
||||
runtime. CloakBrowser bridges this gap with a **transparent decorator layer**:
|
||||
|
||||
1. You pass `Humanize = true` at launch.
|
||||
2. `NewPageAsync()` / `NewContextAsync()` return a **wrapped** object.
|
||||
3. Every standard Playwright call (`page.ClickAsync`, `page.FillAsync`,
|
||||
`page.Mouse.MoveAsync`, ...) is automatically humanized - **no API changes** in
|
||||
your code.
|
||||
4. The wrappers are generated at **compile time** by a Roslyn source generator
|
||||
(`[GenerateInterfaceDelegation]`), so they are fully statically typed with **no
|
||||
reflection on the hot path**. Non-intercepted members are delegated verbatim.
|
||||
|
||||
```
|
||||
your code ──> HumanizedPage ──┬─ intercepted member ─> humanize engine ─> raw IPage
|
||||
(IPage) (generated) └─ everything else ─────────────────────> raw IPage (verbatim)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature matrix
|
||||
|
||||
| Capability | Status | Source |
|
||||
| --- | :---: | --- |
|
||||
| Automatic binary **download / cache / auto-update** (SHA-256 verified) | ✅ | `Download.cs`, `Config.cs` |
|
||||
| **Stealth launch args** (random fingerprint seed, platform spoofing) | ✅ | `CloakLauncher.cs` |
|
||||
| **Proxy** - HTTP/HTTPS + SOCKS5, inline URL-encoded credentials | ✅ | `ProxyResolver.cs` |
|
||||
| **GeoIP** timezone/locale from proxy exit IP (MaxMind GeoLite2) | ✅ | `GeoIp.cs` |
|
||||
| **WebRTC** IP spoofing (`--fingerprint-webrtc-ip=auto`) | ✅ | `CloakLauncher.cs` |
|
||||
| **Widevine** CDM hint seeding (Linux) | ✅ | `Widevine.cs` |
|
||||
| **Persistent contexts** (profile reuse) | ✅ | `CloakLauncher.cs` |
|
||||
| **Humanize** - Bezier mouse, human typing, scrolling, actionability | ✅ | `Human/`, `Wrappers/` |
|
||||
| Transparent humanize via **source generator** | ✅ | `CloakBrowser.Generators/` |
|
||||
| **CLI** (`install` / `info` / `update` / `clear-cache`) | ✅ | `CloakBrowser.Cli/` |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Version | Why |
|
||||
| --- | --- | --- |
|
||||
| .NET SDK | **8.0** | target framework `net8.0` |
|
||||
| `Microsoft.Playwright` | **1.49.0** | underlying browser automation |
|
||||
| `MaxMind.GeoIP2` | **5.2.0** | GeoIP timezone/locale resolution |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# add to your project
|
||||
dotnet add package CloakBrowser
|
||||
|
||||
# the patched Chromium binary downloads automatically on first launch,
|
||||
# or pre-install it:
|
||||
dotnet run --project src/CloakBrowser.Cli -- install
|
||||
```
|
||||
|
||||
The binary is cached under `~/.cloakbrowser` (override with
|
||||
`CLOAKBROWSER_CACHE_DIR`).
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
dotnet/
|
||||
├── CloakBrowser.sln
|
||||
├── src/
|
||||
│ ├── CloakBrowser/ # the library
|
||||
│ │ ├── Config.cs # <- cloakbrowser/config.py
|
||||
│ │ ├── Download.cs # <- cloakbrowser/download.py (+ zip-slip guard)
|
||||
│ │ ├── GeoIp.cs # <- cloakbrowser/geoip.py
|
||||
│ │ ├── Widevine.cs # <- cloakbrowser/widevine.py
|
||||
│ │ ├── CloakLauncher.cs # <- cloakbrowser/browser.py (launch funcs, build_args)
|
||||
│ │ ├── ProxyResolver.cs # <- cloakbrowser/browser.py (proxy URL helpers)
|
||||
│ │ ├── ProxySettings.cs # <- ProxySettings TypedDict
|
||||
│ │ ├── LaunchOptions.cs # launch option records
|
||||
│ │ ├── Handles.cs # CloakBrowserHandle / CloakContextHandle
|
||||
│ │ ├── CloakLog.cs # logging facade
|
||||
│ │ ├── Human/ # <- cloakbrowser/human/*
|
||||
│ │ │ ├── HumanConfig.cs # <- human/config.py (HumanConfig + merge)
|
||||
│ │ │ ├── HumanRandom.cs # <- human/config.py (rand/sleep helpers)
|
||||
│ │ │ ├── HumanMouse.cs # <- human/mouse.py (Bezier engine)
|
||||
│ │ │ ├── HumanKeyboard.cs # <- human/keyboard.py (typing + CDP stealth)
|
||||
│ │ │ ├── HumanScroll.cs # <- human/scroll.py
|
||||
│ │ │ ├── Actionability.cs # <- human/actionability.py
|
||||
│ │ │ ├── IsolatedWorld.cs # <- _AsyncIsolatedWorld
|
||||
│ │ │ ├── PlaywrightAdapters.cs # IMouse/IKeyboard/ICDPSession -> raw protocols
|
||||
│ │ │ └── HumanPage.cs # <- patch_page flows (explicit engine)
|
||||
│ │ └── Wrappers/ # transparent humanize decorators (Humanize=true)
|
||||
│ │ ├── Humanize.cs # wrap entry points + helpers (idempotent)
|
||||
│ │ ├── HumanCursor.cs # shared per-page cursor / CDP stealth state
|
||||
│ │ ├── LocatorHumanizer.cs # locator-based humanize engine
|
||||
│ │ ├── HumanizedBrowser.cs # IBrowser decorator
|
||||
│ │ ├── HumanizedBrowserContext.cs # IBrowserContext decorator
|
||||
│ │ ├── HumanizedPage.cs # IPage decorator
|
||||
│ │ ├── HumanizedFrame.cs # IFrame decorator
|
||||
│ │ ├── HumanizedLocator.cs # ILocator decorator
|
||||
│ │ ├── HumanizedElementHandle.cs # IElementHandle decorator
|
||||
│ │ ├── HumanizedMouse.cs # IMouse decorator
|
||||
│ │ └── HumanizedKeyboard.cs # IKeyboard decorator
|
||||
│ ├── CloakBrowser.Generators/ # Roslyn source generator
|
||||
│ │ └── InterfaceDelegationGenerator.cs # emits delegating members
|
||||
│ └── CloakBrowser.Cli/ # <- cloakbrowser/__main__.py
|
||||
├── examples/CloakBrowser.Examples/ # runnable examples (see below)
|
||||
└── tests/CloakBrowser.Tests/ # xUnit tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using CloakBrowser;
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://bot.incolumitas.com/");
|
||||
Console.WriteLine(await page.TitleAsync());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Humanized interactions (transparent)
|
||||
|
||||
Pass `Humanize = true` and **write ordinary Playwright code** - mouse moves,
|
||||
clicks, typing, and scrolling are humanized automatically. Nothing else in your
|
||||
code changes:
|
||||
|
||||
```csharp
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true, // <- the only change
|
||||
HumanPreset = HumanPreset.Careful, // optional: Default | Careful
|
||||
HumanConfig = new Dictionary<string, object> { ["typing_delay"] = 90.0 }, // optional overrides
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync(); // returns a transparently-wrapped IPage
|
||||
await page.GotoAsync("https://example.com/login");
|
||||
|
||||
await page.FillAsync("#username", "alice"); // per-character human typing
|
||||
await page.FillAsync("#password", "s3cr3t!"); // (variable delays, thinking pauses, typos+fixes)
|
||||
await page.ClickAsync("button[type=submit]"); // Bezier mouse curve to a realistic aim point
|
||||
await page.Mouse.WheelAsync(0, 600); // accelerate -> cruise -> decelerate scroll
|
||||
```
|
||||
|
||||
The wrapping is **complete and transitive** - anything you reach through the page
|
||||
is wrapped too:
|
||||
|
||||
```csharp
|
||||
await page.Mouse.MoveAsync(400, 300); // humanized
|
||||
await page.Keyboard.TypeAsync("hello"); // humanized
|
||||
var btn = page.Locator("#submit"); // wrapped ILocator
|
||||
await btn.ClickAsync(); // humanized
|
||||
foreach (var frame in page.Frames) { /* wrapped IFrame */ }
|
||||
```
|
||||
|
||||
### What changes vs. plain Playwright
|
||||
|
||||
| Member | `Humanize = false` (default) | `Humanize = true` |
|
||||
| --- | --- | --- |
|
||||
| `IPage` / `ILocator` `ClickAsync`, `DblClickAsync`, `HoverAsync`, `TapAsync` | direct Playwright dispatch | Bezier-curve mouse move to a randomized in-element aim point, then click |
|
||||
| `IPage` / `ILocator` `FillAsync`, `TypeAsync` / `PressSequentiallyAsync` | instant value set / fast type | per-character typing with variable delays, thinking pauses, occasional typos that self-correct |
|
||||
| `IPage` / `ILocator` `PressAsync` | direct | human key timing; clicks to focus first only if not already focused |
|
||||
| `ILocator` `CheckAsync`, `UncheckAsync`, `SetCheckedAsync`, `DragToAsync` | direct | humanized click / curved drag |
|
||||
| `ILocator` `SelectOptionAsync` (all overloads) | instant native select | curved hover to the `<select>` + pause, then native select |
|
||||
| `ILocator` `ClearAsync` | instant value reset | focus (humanized click if needed) + select-all + Backspace |
|
||||
| `IMouse` `MoveAsync`, `ClickAsync`, `DblClickAsync`, `DownAsync`, `UpAsync` | direct | curved, eased motion with overshoot |
|
||||
| `IMouse` `WheelAsync` | single event | accelerate-cruise-decelerate microsteps |
|
||||
| `IKeyboard` `TypeAsync`, `PressAsync`, `InsertTextAsync` | direct | human-timed CDP key events |
|
||||
| `page.Mouse`, `page.Keyboard`, `page.Locator(...)`, `page.GetBy*`, `page.Frames`, `QuerySelectorAsync`, `IBrowser.NewPageAsync/NewContextAsync`, `IBrowserContext.NewPageAsync` | raw Playwright objects | the same call returns a **wrapped** object so humanization stays transitive |
|
||||
| every **other** member (navigation, waits, evaluation, screenshots, network, etc.) | - | **delegated verbatim** to Playwright (identical signatures, return types, exceptions, and `CancellationToken` support) |
|
||||
| escape hatch | n/a | `((HumanizedPage)page).Original` (also `.Inner`) returns the raw, un-wrapped object; `browser.RawBrowser` on the handle |
|
||||
|
||||
Per-call overrides are honored - e.g.
|
||||
`await page.ClickAsync("#x", new() { Force = true })` still works and is
|
||||
humanized. `ElementHandle` interactions are humanized too, but prefer `ILocator`
|
||||
(Playwright's recommendation) for the most reliable aiming.
|
||||
|
||||
### Calling the original Playwright methods (escape hatch)
|
||||
|
||||
When you want a specific call to skip humanization (for raw speed, or to bypass a
|
||||
behavior you don't want for one action) every wrapper exposes the underlying,
|
||||
un-humanized Playwright object via **`Original`** (with **`Inner`** as an alias).
|
||||
This is the .NET equivalent of the `page._original` escape hatch in the Python/JS
|
||||
clients - the object you get back is the genuine Playwright interface, so the
|
||||
whole API is available on it:
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new() { Humanize = true });
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
// Humanized (default): Bezier mouse curve, realistic aim point, etc.
|
||||
await page.ClickAsync("#submit");
|
||||
|
||||
// Raw Playwright, no humanization - instant, via the escape hatch:
|
||||
await ((HumanizedPage)page).Original.ClickAsync("#submit");
|
||||
await ((HumanizedPage)page).Original.FillAsync("#token", value);
|
||||
|
||||
// Works on every wrapped object:
|
||||
await ((HumanizedMouse)page.Mouse).Original.MoveAsync(10, 10);
|
||||
var rawLocator = ((HumanizedLocator)page.Locator("#row")).Original;
|
||||
```
|
||||
|
||||
The handle also exposes the un-wrapped browser/context directly:
|
||||
`browser.RawBrowser` (and `RawContext` on a context handle).
|
||||
|
||||
> Note: when `Humanize = false`, `NewPageAsync()` already returns the raw
|
||||
> Playwright `IPage`, so no escape hatch is needed.
|
||||
|
||||
### Explicit `HumanPage` engine (advanced)
|
||||
|
||||
If you want the explicit, non-transparent engine object instead of the
|
||||
transparent layer, `NewHumanPageAsync` returns a `HumanPage`. Its raw Playwright
|
||||
page is always available via `human.Page`.
|
||||
|
||||
```csharp
|
||||
HumanPage human = await browser.NewHumanPageAsync();
|
||||
await human.GotoAsync("https://example.com/login");
|
||||
await human.FillAsync("#username", "alice");
|
||||
await human.ClickAsync("button[type=submit]");
|
||||
```
|
||||
|
||||
`HumanPage` methods (all take an optional `HumanActionOptions { Timeout, Force }`):
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `GotoAsync(url, options?)` | navigate (delegates to the raw page) |
|
||||
| `ClickAsync(selector)` | Bezier move + human click |
|
||||
| `DblClickAsync(selector)` | move + double click |
|
||||
| `HoverAsync(selector)` | curved move, no click |
|
||||
| `TapAsync(selector)` | same motion as click |
|
||||
| `TypeAsync(selector, text)` | focus + per-character typing |
|
||||
| `FillAsync(selector, value)` | click + select-all + clear + type |
|
||||
| `PressAsync(selector, key)` | focus-aware single key press |
|
||||
| `PressSequentiallyAsync(selector, text)` | focus + per-character typing |
|
||||
| `ClearAsync(selector)` | focus + select-all + Backspace |
|
||||
| `CheckAsync` / `UncheckAsync` / `SetCheckedAsync(selector, state)` | humanized toggle via click |
|
||||
| `SelectOptionAsync(selector, values)` | curved hover + native select |
|
||||
| `FocusAsync(selector)` | curved move + **programmatic** focus (no click side-effects) |
|
||||
| `ScrollIntoViewIfNeededAsync(selector)` | humanized accelerate->cruise->decelerate->overshoot scroll |
|
||||
| `DragAndDropAsync(src, dst)` | curved press-drag-release |
|
||||
| `MouseMoveAsync(x, y)` / `MouseClickAsync(x, y)` | low-level curved motion |
|
||||
| `KeyboardTypeAsync(text)` | low-level human typing at the focused element |
|
||||
|
||||
---
|
||||
|
||||
## Launch API reference
|
||||
|
||||
All entry points live on the static `CloakLauncher` class and return an
|
||||
`await using`-friendly handle.
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `LaunchAsync(LaunchOptions)` | `CloakBrowserHandle` | launches a browser; `NewPageAsync`, `NewContextAsync`, `NewHumanPageAsync`, `RawBrowser` |
|
||||
| `LaunchContextAsync(LaunchContextOptions)` | `CloakContextHandle` | browser-owned context with emulation |
|
||||
| `LaunchPersistentContextAsync(userDataDir, LaunchContextOptions)` | `CloakContextHandle` | reuse a profile directory (cookies/localStorage persist) |
|
||||
|
||||
```csharp
|
||||
// emulated context
|
||||
await using var ctx = await CloakLauncher.LaunchContextAsync(new LaunchContextOptions
|
||||
{
|
||||
Locale = "en-US",
|
||||
Timezone = "America/New_York",
|
||||
Viewport = (1280, 800),
|
||||
ColorScheme = "dark",
|
||||
});
|
||||
var page = await ctx.NewPageAsync();
|
||||
```
|
||||
|
||||
> Locale and timezone are applied via **binary flags** (`--lang`,
|
||||
> `--fingerprint-locale`, `--fingerprint-timezone`) - *not* detectable CDP
|
||||
> emulation - matching the Python wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Options reference
|
||||
|
||||
`LaunchOptions` / `LaunchContextOptions` (commonly used fields):
|
||||
|
||||
| Option | Type | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `Headless` | `bool` | `true` | run headless (detectors often flag this) |
|
||||
| `Humanize` | `bool` | `false` | enable the transparent humanize layer |
|
||||
| `HumanPreset` | `HumanPreset` | `Default` | `Default` or `Careful` (slower, more cautious) |
|
||||
| `HumanConfig` | `Dictionary<string,object>` | `null` | per-field overrides (snake_case **or** PascalCase keys) |
|
||||
| `Proxy` | `string` / `ProxySettings` | `null` | HTTP/HTTPS or SOCKS5 proxy |
|
||||
| `GeoIp` | `bool` | `false` | resolve timezone/locale from the proxy exit IP |
|
||||
| `Args` | `List<string>` | `[]` | extra Chromium flags (e.g. `--fingerprint-webrtc-ip=auto`) |
|
||||
| `Locale` | `string` | `null` | BCP 47 locale -> `--lang`, `--fingerprint-locale` |
|
||||
| `Timezone` | `string` | `null` | IANA timezone -> `--fingerprint-timezone` |
|
||||
| `Viewport` | `(int,int)` | - | window size |
|
||||
| `NoViewport` | `bool` | `false` | disable viewport emulation (track the real window) |
|
||||
| `ColorScheme` | `string` | - | `light` / `dark` |
|
||||
| `LicenseKey` | `string` | `null` | CloakBrowser Pro key (or `CLOAKBROWSER_LICENSE_KEY` env / `~/.cloakbrowser/license.key`) |
|
||||
|
||||
### CloakBrowser Pro
|
||||
|
||||
Pass a `LicenseKey` (or set `CLOAKBROWSER_LICENSE_KEY`, or write the key to
|
||||
`~/.cloakbrowser/license.key`) to download the latest **Pro** binary from
|
||||
cloakbrowser.dev. Without a key, the free binary downloads from GitHub Releases
|
||||
exactly as before. License validation is cached locally for 24h, and the Pro
|
||||
binary is authenticated with the **same pinned Ed25519 signature** as the free
|
||||
binary. A valid key whose Pro download or signature check fails surfaces a clear
|
||||
error rather than silently downgrading to the free binary. `Download.BinaryInfo()`
|
||||
exposes a `Tier` field (`"pro"` / `"free"`) and `License.ValidateLicense` /
|
||||
`License.LicenseInfo` mirror the Python `validate_license` / `LicenseInfo` exports.
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
LicenseKey = "ck_live_...", // or via CLOAKBROWSER_LICENSE_KEY / ~/.cloakbrowser/license.key
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `HumanConfig` reference
|
||||
|
||||
Every behavior is tunable. Names match the Python `HumanConfig` dataclass; the
|
||||
override dictionary accepts both `snake_case` (Python parity) and `PascalCase`
|
||||
keys. Ranges are `(min, max)` and may be passed as a `(double,double)` tuple or a
|
||||
2-element array.
|
||||
|
||||
| Group | Field (PascalCase) | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| Keyboard | `TypingDelay` | `70` | base inter-key delay (ms) |
|
||||
| | `TypingDelaySpread` | `40` | ± jitter around `TypingDelay` |
|
||||
| | `TypingPauseChance` | `0.1` | chance of a longer "thinking" pause |
|
||||
| | `TypingPauseRange` | `(400, 1000)` | thinking-pause duration (ms) |
|
||||
| | `ShiftDownDelay` / `ShiftUpDelay` | `(30,70)` / `(20,50)` | shift key timing |
|
||||
| | `KeyHold` | `(15, 35)` | key-down hold time (ms) |
|
||||
| Mistype | `MistypeChance` | `0.02` | chance of a fat-finger typo (ASCII alnum only) |
|
||||
| | `MistypeDelayNotice` | `(100, 300)` | pause before noticing the typo |
|
||||
| | `MistypeDelayCorrect` | `(50, 150)` | pause after correcting |
|
||||
| | `FieldSwitchDelay` | `(800, 1500)` | delay when moving between fields |
|
||||
| Mouse - move | `MouseStepsDivisor` | `8` | distance ÷ divisor = step count |
|
||||
| | `MouseMinSteps` / `MouseMaxSteps` | `25` / `80` | clamp on step count |
|
||||
| | `MouseWobbleMax` | `1.5` | perpendicular wobble amplitude (px) |
|
||||
| | `MouseOvershootChance` | `0.15` | chance of overshoot + correction |
|
||||
| | `MouseOvershootPx` | `(3, 6)` | overshoot distance |
|
||||
| | `MouseBurstSize` / `MouseBurstPause` | `(3,5)` / `(8,18)` | move-burst grouping + pause |
|
||||
| Mouse - click | `ClickAimDelayInput` / `...Button` | `(60,140)` / `(80,200)` | aim delay before pressing |
|
||||
| | `ClickHoldInput` / `...Button` | `(40,100)` / `(60,150)` | mouse-down hold time |
|
||||
| | `ClickInputXRange` | `(0.05, 0.30)` | left-biased X target inside inputs |
|
||||
| Mouse - idle | `IdleDriftPx` | `3` | idle drift amplitude |
|
||||
| | `IdlePauseRange` | `(300, 1000)` | idle pause between drifts |
|
||||
| Scroll | `ScrollDeltaBase` | `(80, 130)` | wheel delta per microstep |
|
||||
| | `ScrollOvershootChance` | `0.1` | chance to overshoot then settle |
|
||||
| | `ScrollSettleDelay` | `(300, 600)` | pause after reaching the target |
|
||||
| | `ScrollTargetZone` | `(0.20, 0.80)` | viewport band the element lands in |
|
||||
| Cursor | `InitialCursorX` / `...Y` | `(400,700)` / `(45,60)` | starting cursor position (address-bar area) |
|
||||
| Idle between actions | `IdleBetweenActions` | `false` | opt-in micro-movements between actions (adds latency) |
|
||||
|
||||
Resolve / merge helpers:
|
||||
|
||||
```csharp
|
||||
// preset + overrides
|
||||
var cfg = HumanConfigFactory.Resolve(
|
||||
HumanPreset.Careful,
|
||||
new Dictionary<string, object> { ["typing_delay"] = 120.0, ["key_hold"] = (40.0, 90.0) });
|
||||
|
||||
// merge onto an existing config (never mutates the base; returns a new instance)
|
||||
var faster = cfg.With(new Dictionary<string, object> { ["TypingDelay"] = 30.0 });
|
||||
```
|
||||
|
||||
`With(...)` is **forgiving**: unknown keys are ignored silently, `null`/empty
|
||||
overrides return a clone, and non-overridden fields are preserved.
|
||||
|
||||
---
|
||||
|
||||
## How the humanize layer works
|
||||
|
||||
### Mouse motion (Bezier)
|
||||
|
||||
`HumanMouse.HumanMoveAsync` traces a **cubic Bezier curve** between the current
|
||||
cursor position and a randomized aim point inside the target's bounding box:
|
||||
|
||||
1. Step count scales with distance (`dist / MouseStepsDivisor`, clamped to
|
||||
`[MouseMinSteps, MouseMaxSteps]`).
|
||||
2. Two control points are biased **perpendicular** to the straight path, so the
|
||||
curve is never a straight line.
|
||||
3. Each step adds **sinusoidal wobble** (max amplitude `MouseWobbleMax`).
|
||||
4. Motion is **eased** (cubic ease-in-out) and grouped into bursts with short
|
||||
pauses (`MouseBurstSize` / `MouseBurstPause`).
|
||||
5. With probability `MouseOvershootChance`, the cursor overshoots the target and
|
||||
corrects back - exactly like a real hand.
|
||||
|
||||
Click points are computed by `HumanMouse.ClickTarget`: inputs get a left-biased X
|
||||
and wide Y band; buttons get a centered cluster.
|
||||
|
||||
### Human typing & the CDP stealth path
|
||||
|
||||
`HumanKeyboard.HumanTypeAsync` types one character at a time with variable delays
|
||||
and occasional "thinking" pauses. ASCII alphanumeric characters can trigger a
|
||||
**fat-finger typo** (a nearby QWERTY key) that is then noticed and corrected with
|
||||
Backspace.
|
||||
|
||||
Shift symbols (`@ # ! $ ...`) are the tricky case for stealth. When a **CDP
|
||||
session** is available, they are dispatched through
|
||||
`Input.dispatchKeyEvent` - producing `isTrusted = true` events with **no
|
||||
`evaluate` stack trace** for detectors to find. Without CDP, it falls back to a
|
||||
(detectable) `page.evaluate` path.
|
||||
|
||||
### Non-ASCII input
|
||||
|
||||
Cyrillic, CJK, emoji and other non-ASCII characters cannot be produced with
|
||||
physical key codes, so they are inserted via **`InsertText`**, one character at a
|
||||
time, while ASCII characters keep going through `Down`/`Up` key presses. For a
|
||||
mixed string like `"Hi Мир"`:
|
||||
|
||||
| Char | Path |
|
||||
| --- | --- |
|
||||
| `H`, `i`, space | `DownAsync` / `UpAsync` (with Shift for `H`) |
|
||||
| `М`, `и`, `р` | `InsertTextAsync` (per character) |
|
||||
|
||||
### Scrolling
|
||||
|
||||
`HumanScroll` performs an **accelerate -> cruise -> decelerate** wheel sequence in
|
||||
microsteps, with an optional overshoot-and-settle, landing the element in a
|
||||
natural viewport band (`ScrollTargetZone`).
|
||||
|
||||
### Actionability checks
|
||||
|
||||
Before interacting, the humanize layer runs Playwright-style **actionability
|
||||
checks** (`Actionability.cs`), with a retry/backoff loop `[100, 250, 500, 1000]`
|
||||
ms:
|
||||
|
||||
| Check | Meaning | Error on failure |
|
||||
| --- | --- | --- |
|
||||
| `attached` | element exists in the DOM | `ElementNotAttachedError` |
|
||||
| `visible` | element is visible | `ElementNotVisibleError` |
|
||||
| `stable` | bounding box stopped moving (post-scroll) | `ElementNotStableError` |
|
||||
| `enabled` | element is enabled | `ElementNotEnabledError` |
|
||||
| `editable` | element is editable | `ElementNotEditableError` |
|
||||
| `pointer_events` | the click point actually hits the element | `ElementNotReceivingEventsError` |
|
||||
|
||||
Action presets: `ChecksClick`, `ChecksHover`, `ChecksInput`, `ChecksFocus`,
|
||||
`ChecksCheck`.
|
||||
|
||||
**Fail-open pointer check.** The `pointer_events` probe uses
|
||||
`document.elementFromPoint`. If it *cannot run* (stale handle, execution context
|
||||
destroyed - `EvaluateAsync` throws), the check **fails open** and returns
|
||||
promptly rather than blocking until timeout - failing closed would wrongly block
|
||||
legitimate clicks. But an explicit `{ hit: false }` (the element is genuinely
|
||||
*covered*) still raises `ElementNotReceivingEventsError`.
|
||||
|
||||
### Shared timeout budget (#307)
|
||||
|
||||
All sequential steps of one action share a **single deadline** rather than each
|
||||
restarting the full timeout. The helper
|
||||
`Actionability.RemainingMs(deadline)` returns the milliseconds left (clamped at
|
||||
zero, never negative). Because every step subtracts from the *same* budget, the
|
||||
total wall-clock time can never multiply across steps - the bug fixed in
|
||||
upstream issue **#307**.
|
||||
|
||||
### Focus-aware press / clear
|
||||
|
||||
`PressAsync` and `ClearAsync` first probe focus with
|
||||
`EvaluateAsync<bool>("el => el === document.activeElement")`. If the element is
|
||||
**already focused**, they **skip the humanized click** entirely (the cursor does
|
||||
not move) and go straight to the keystrokes; otherwise they perform a humanized
|
||||
focus-click first. This avoids pointless mouse motion on an already-focused
|
||||
field.
|
||||
|
||||
---
|
||||
|
||||
## Proxy, GeoIP & WebRTC
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Proxy = "http://user:pass@proxy.example.com:8080", // or a ProxySettings
|
||||
GeoIp = true, // tz/locale from exit IP
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
```
|
||||
|
||||
- **SOCKS5** and **credentialed HTTP** proxies are routed through Chrome's
|
||||
`--proxy-server` with inline, URL-encoded credentials (matching the Python
|
||||
logic, including the `linux-x64` / `windows-x64` + binary-version gate for HTTP
|
||||
inline auth).
|
||||
- **GeoIP** looks up the proxy exit IP against MaxMind GeoLite2 and applies the
|
||||
resolved timezone/locale via binary flags.
|
||||
- **WebRTC** spoofing reuses that exit IP so `RTCPeerConnection` cannot leak the
|
||||
real address.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
dotnet run --project src/CloakBrowser.Cli -- install # download the binary
|
||||
dotnet run --project src/CloakBrowser.Cli -- info # version / path / platform
|
||||
dotnet run --project src/CloakBrowser.Cli -- update # check + download newer
|
||||
dotnet run --project src/CloakBrowser.Cli -- clear-cache # remove cached binaries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Same set as the Python wrapper:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `CLOAKBROWSER_BINARY_PATH` | Use a local binary, skip download |
|
||||
| `CLOAKBROWSER_CACHE_DIR` | Override the cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | Override the download URL |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | Enable/disable background auto-update |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | Skip SHA-256 verification |
|
||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | GeoIP HTTP timeout |
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` / `CLOAKBROWSER_WIDEVINE` | Widevine seeding control |
|
||||
|
||||
---
|
||||
|
||||
## Building & testing
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet build CloakBrowser.sln # 0 warnings, 0 errors
|
||||
dotnet test CloakBrowser.sln # all green
|
||||
```
|
||||
|
||||
The runnable examples accept a scenario name:
|
||||
|
||||
```bash
|
||||
dotnet run --project examples/CloakBrowser.Examples -- basic
|
||||
dotnet run --project examples/CloakBrowser.Examples -- humanize
|
||||
dotnet run --project examples/CloakBrowser.Examples -- visual # on-screen cursor trail
|
||||
dotnet run --project examples/CloakBrowser.Examples -- behavioral
|
||||
dotnet run --project examples/CloakBrowser.Examples -- proxy-geoip
|
||||
```
|
||||
|
||||
### Test suite map
|
||||
|
||||
All tests are **browser-free**: production wrappers are exercised through
|
||||
`DispatchProxy`-backed Playwright fakes (`Wrappers/FakeProxy.cs`,
|
||||
`Fake.Of<T>()`), and `InternalsVisibleTo` lets the tests reach internal types.
|
||||
A handful of genuinely browser-dependent timing tests are marked
|
||||
`[Fact(Skip = "requires browser")]` rather than faked.
|
||||
|
||||
| Area | File(s) | What it proves |
|
||||
| --- | --- | --- |
|
||||
| Version / download | `ConfigTests.cs`, `DownloadConfigTests.cs` | version compare, archive names, checksum parsing, **zip-slip** path guard |
|
||||
| Launch args | `BuildArgsTests.cs`, `MiscTests.cs` | stealth args, `build_args` dedup |
|
||||
| Proxy | `ProxyResolverTests.cs` | URL resolution / encoding |
|
||||
| Bezier math | `BezierMathTests.cs` | curve produces many points, ends near target, no big jumps, deviates from a straight line |
|
||||
| Humanize config | `HumanConfigTests.cs` | presets, snake/Pascal overrides, range coercion, **merge never mutates base**, null/empty/unknown-key handling |
|
||||
| Transparent layer | `Wrappers/*.cs` | non-intercepted members delegate verbatim; intercepted members humanize; nested objects come back wrapped; exceptions & `CancellationToken`s propagate |
|
||||
| Non-ASCII keyboard | `Human/NonAsciiKeyboardTests.cs` | Cyrillic/CJK go via `InsertText`; ASCII via key presses; mixed strings route per-character |
|
||||
| Pointer-events fail-open | `Human/PointerEventsFailOpenTests.cs` | throwing probe returns fast (`< 500ms`); explicit "covered" raises `ElementNotReceivingEventsError` |
|
||||
| Timeout budget (#307) | `Human/TimeoutBudgetTests.cs` | `RemainingMs` never negative, decreases over time, shared (not multiplied) |
|
||||
| Focus check | `Wrappers/FocusCheckTests.cs` | focused element -> no humanized click (cursor doesn't move); non-focused -> click happens |
|
||||
|
||||
---
|
||||
|
||||
## Mapping to the Python source
|
||||
|
||||
| .NET file | Python source |
|
||||
| --- | --- |
|
||||
| `Config.cs` | `cloakbrowser/config.py` |
|
||||
| `Download.cs` | `cloakbrowser/download.py` |
|
||||
| `GeoIp.cs` | `cloakbrowser/geoip.py` |
|
||||
| `Widevine.cs` | `cloakbrowser/widevine.py` |
|
||||
| `CloakLauncher.cs` / `ProxyResolver.cs` | `cloakbrowser/browser.py` |
|
||||
| `Human/HumanConfig.cs` | `cloakbrowser/human/config.py` |
|
||||
| `Human/HumanMouse.cs` | `cloakbrowser/human/mouse.py` |
|
||||
| `Human/HumanKeyboard.cs` | `cloakbrowser/human/keyboard.py` |
|
||||
| `Human/HumanScroll.cs` | `cloakbrowser/human/scroll.py` |
|
||||
| `Human/Actionability.cs` | `cloakbrowser/human/actionability.py` |
|
||||
| `Human/HumanPage.cs` | `cloakbrowser/human/__init__.py` (`patch_page`) |
|
||||
| `CloakBrowser.Cli/` | `cloakbrowser/__main__.py` |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT - same as the upstream CloakBrowser project.
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>cloakbrowser-examples</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Examples</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,570 @@
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
// CloakBrowser .NET examples. Run a specific example by name:
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- basic
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- humanize
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- context
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- persistent
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- proxy-geoip
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- visual
|
||||
|
||||
string which = args.Length > 0 ? args[0] : "basic";
|
||||
|
||||
switch (which)
|
||||
{
|
||||
case "basic": await Basic(); break;
|
||||
case "humanize": await Humanize(); break;
|
||||
case "context": await Context(); break;
|
||||
case "persistent": await Persistent(); break;
|
||||
case "proxy-geoip": await ProxyGeoip(); break;
|
||||
case "bottest": await BotTest(); break;
|
||||
case "behavioral": await Behavioral(); break;
|
||||
case "visual": await Visual(); break;
|
||||
case "proxytest": await ProxyTest(); break;
|
||||
case "persisttest": await PersistTest(); break;
|
||||
case "webrtctest": await WebRtcTest(); break;
|
||||
case "trusted": await TrustedTest(); break;
|
||||
case "timeout": await TimeoutTest(); break;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown example: {which}");
|
||||
Console.Error.WriteLine("Available: basic, humanize, context, persistent, proxy-geoip, bottest, behavioral, visual");
|
||||
Environment.Exit(2);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic launch - open a page and print the title.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Basic()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://bot.incolumitas.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Humanized interaction - Bezier mouse, human typing, scroll, actionability.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Humanize()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
HumanPreset = HumanPreset.Careful,
|
||||
HumanConfig = new Dictionary<string, object>
|
||||
{
|
||||
["typing_delay"] = 90.0,
|
||||
["mouse_overshoot_chance"] = 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
// NewHumanPageAsync returns a HumanPage wrapper with the configured behavior.
|
||||
HumanPage human = await browser.NewHumanPageAsync();
|
||||
await human.GotoAsync("https://example.com/");
|
||||
|
||||
// Humanized actions go through actionability checks + Bezier movement.
|
||||
await human.ClickAsync("a");
|
||||
// await human.FillAsync("#search", "hello world");
|
||||
// await human.PressAsync("#search", "Enter");
|
||||
|
||||
Console.WriteLine($"Title: {await human.Page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context with viewport, user agent, locale, timezone.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Context()
|
||||
{
|
||||
await using var ctx = await CloakLauncher.LaunchContextAsync(new LaunchContextOptions
|
||||
{
|
||||
Headless = true,
|
||||
Locale = "en-US",
|
||||
Timezone = "America/New_York",
|
||||
Viewport = (1280, 800),
|
||||
ColorScheme = "dark",
|
||||
});
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistent profile - cookies/localStorage survive across runs.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Persistent()
|
||||
{
|
||||
await using var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
"./cloak-profile",
|
||||
new LaunchContextOptions { Headless = true });
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()} (profile saved to ./cloak-profile)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy + GeoIP - timezone/locale auto-detected, WebRTC IP spoofed to exit IP.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task ProxyGeoip()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = "http://user:pass@proxy.example.com:8080",
|
||||
GeoIp = true, // resolves timezone/locale + WebRTC exit IP from the proxy
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://ipinfo.io/json");
|
||||
Console.WriteLine(await page.InnerTextAsync("body"));
|
||||
}
|
||||
|
||||
static async Task BotTest()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false, // detectors often flag headless
|
||||
Humanize = true, // transparent decorator - the KEY feature
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync(); // should return a wrapped IPage
|
||||
|
||||
// verify runtime types: is the wrapper actually in place?
|
||||
Console.WriteLine($"Page type: {page.GetType().Name}");
|
||||
Console.WriteLine($"Mouse type: {page.Mouse.GetType().Name}");
|
||||
Console.WriteLine($"Keyboard type: {page.Keyboard.GetType().Name}");
|
||||
|
||||
await page.GotoAsync("https://deviceandbrowserinfo.com/are_you_a_bot");
|
||||
await page.WaitForTimeoutAsync(2000);
|
||||
|
||||
// real humanized actions
|
||||
await page.Mouse.MoveAsync(300, 300);
|
||||
await page.Mouse.WheelAsync(0, 500);
|
||||
await page.WaitForTimeoutAsync(1500);
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "bot-result.png", FullPage = true });
|
||||
Console.WriteLine("Saved bot-result.png");
|
||||
await page.WaitForTimeoutAsync(4000); // leave time to inspect it visually
|
||||
}
|
||||
|
||||
static async Task Behavioral()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
await page.GotoAsync("https://deviceandbrowserinfo.com/are_you_a_bot_interactions",
|
||||
new() { WaitUntil = WaitUntilState.DOMContentLoaded });
|
||||
await page.WaitForTimeoutAsync(3000);
|
||||
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
await page.Locator("#email").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Locator("#email").FillAsync("test@example.com");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
await page.Locator("#password").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Locator("#password").FillAsync("SecurePass!123");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
await page.Locator("#loginForm button[type=\"submit\"]").ClickAsync();
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - t0).TotalMilliseconds;
|
||||
await page.WaitForTimeoutAsync(5000);
|
||||
|
||||
var body = await page.Locator("body").TextContentAsync() ?? "";
|
||||
|
||||
bool superHuman = body.Contains("\"superHumanSpeed\": true");
|
||||
bool suspicious = body.Contains("\"suspiciousClientSideBehavior\": true");
|
||||
|
||||
Console.WriteLine($"--- BEHAVIORAL RESULT ---");
|
||||
Console.WriteLine($"Form fill elapsed: {elapsedMs:F0} ms (Python requires > 3000)");
|
||||
Console.WriteLine($"superHumanSpeed: {superHuman} (expected False)");
|
||||
Console.WriteLine($"suspiciousClientSideBehavior: {suspicious} (expected False)");
|
||||
Console.WriteLine(superHuman || suspicious || elapsedMs < 3000
|
||||
? ">>> FAIL - the port behaves non-humanly"
|
||||
: ">>> PASS - humanization works just like Python");
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "behavioral-result.png", FullPage = true });
|
||||
Console.WriteLine("Screenshot: behavioral-result.png");
|
||||
}
|
||||
|
||||
static async Task Visual()
|
||||
{
|
||||
const string cursorJs = @"
|
||||
() => {
|
||||
if (document.getElementById('__hc')) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = '__hc';
|
||||
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||
document.body.appendChild(el);
|
||||
const trail = document.createElement('div');
|
||||
trail.id = '__hcTrail';
|
||||
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||
document.body.appendChild(trail);
|
||||
let dotCount = 0; const maxDots = 500;
|
||||
function updatePos(x, y) {
|
||||
el.style.display = 'block';
|
||||
el.style.left = (x - 9) + 'px'; el.style.top = (y - 9) + 'px';
|
||||
if (dotCount < maxDots) {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||
trail.appendChild(dot); dotCount++;
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||
}";
|
||||
|
||||
var results = new List<(string Name, bool Passed, string Detail)>();
|
||||
void Check(string name, bool ok, string detail = "")
|
||||
{
|
||||
results.Add((name, ok, detail));
|
||||
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {name}{(detail != "" ? " - " + detail : "")}");
|
||||
}
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
async Task Inject()
|
||||
{
|
||||
try { await page.EvaluateAsync(cursorJs); } catch { }
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
}
|
||||
|
||||
static long Ms(DateTime t0) => (long)(DateTime.UtcNow - t0).TotalMilliseconds;
|
||||
|
||||
Console.WriteLine(new string('=', 70));
|
||||
Console.WriteLine(" HUMAN-LIKE BEHAVIOR VISUAL TEST");
|
||||
Console.WriteLine(" Red dot = cursor, yellow = button held, trail = path");
|
||||
Console.WriteLine(new string('=', 70));
|
||||
|
||||
// SCENARIO 1: Wikipedia search
|
||||
Console.WriteLine("\n=== Wikipedia - navigation and search ===");
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
var t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
Check("click search input", Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").FillAsync("Python programming language");
|
||||
var v = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("fill search box", v == "Python programming language" && Ms(t) > 2000, $"{Ms(t)} ms, '{v}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").DblClickAsync();
|
||||
var sel = (await page.EvaluateAsync<string>("() => window.getSelection().toString().trim()")) ?? "";
|
||||
Check("dblclick selects word", sel.Length > 0 && Ms(t) > 200, $"{Ms(t)} ms, '{sel}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").FillAsync("Artificial intelligence");
|
||||
var v2 = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("fill replaces text", v2 == "Artificial intelligence" && Ms(t) > 1500, $"{Ms(t)} ms, '{v2}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("button[type=\"submit\"]").HoverAsync();
|
||||
Check("hover submit", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 2: Checkboxes
|
||||
Console.WriteLine("\n=== Checkboxes - check/uncheck ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/checkboxes");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
var cb1 = page.Locator("input[type=\"checkbox\"]").Nth(0);
|
||||
var cb2 = page.Locator("input[type=\"checkbox\"]").Nth(1);
|
||||
if (await cb1.IsCheckedAsync()) { await cb1.UncheckAsync(); await page.WaitForTimeoutAsync(500); }
|
||||
t = DateTime.UtcNow;
|
||||
await cb1.CheckAsync();
|
||||
Check("check checkbox 1", await cb1.IsCheckedAsync() && Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
if (!await cb2.IsCheckedAsync()) { await cb2.CheckAsync(); await page.WaitForTimeoutAsync(500); }
|
||||
t = DateTime.UtcNow;
|
||||
await cb2.UncheckAsync();
|
||||
Check("uncheck checkbox 2", !await cb2.IsCheckedAsync() && Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 3: Dropdown
|
||||
Console.WriteLine("\n=== Dropdown - select ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/dropdown");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#dropdown").SelectOptionAsync("1");
|
||||
Check("select option 1", await page.Locator("#dropdown").InputValueAsync() == "1" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#dropdown").SelectOptionAsync("2");
|
||||
Check("select option 2", await page.Locator("#dropdown").InputValueAsync() == "2" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 4: Drag and drop
|
||||
Console.WriteLine("\n=== Drag and Drop - A -> B ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/drag_and_drop");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
var beforeA = (await page.Locator("#column-a header").TextContentAsync())?.Trim();
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#column-a").DragToAsync(page.Locator("#column-b"));
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
var afterA = (await page.Locator("#column-a header").TextContentAsync())?.Trim();
|
||||
Check("drag A to B", beforeA != afterA && Ms(t) > 300, $"{Ms(t)} ms, swapped={beforeA != afterA}");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 5: Text editing
|
||||
Console.WriteLine("\n=== Text editing - type/press/clear/sequential ===");
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressSequentiallyAsync("Hello World");
|
||||
var hv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("type 'Hello World'", hv == "Hello World" && Ms(t) > 1000, $"{Ms(t)} ms, '{hv}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressAsync("End");
|
||||
await page.Locator("#searchInput").PressAsync("!");
|
||||
var pv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("press '!' at end", (pv ?? "").Contains("!") && Ms(t) > 100, $"{Ms(t)} ms, '{pv}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").ClearAsync();
|
||||
var cv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("clear field", cv == "" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressSequentiallyAsync("Sequential");
|
||||
var sv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("press_sequentially", sv == "Sequential" && Ms(t) > 500, $"{Ms(t)} ms, '{sv}'");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 6: Mouse precision + raw keyboard
|
||||
Console.WriteLine("\n=== Mouse precision + keyboard ===");
|
||||
t = DateTime.UtcNow;
|
||||
await page.Mouse.MoveAsync(600, 400);
|
||||
Check("mouse.move (600,400)", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Mouse.ClickAsync(200, 200);
|
||||
Check("mouse.click (200,200)", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Keyboard.TypeAsync("Direct keyboard");
|
||||
Check("keyboard.type", Ms(t) > 500, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SUMMARY
|
||||
Console.WriteLine("\n" + new string('=', 70));
|
||||
int passed = results.Count(r => r.Passed);
|
||||
foreach (var r in results)
|
||||
Console.WriteLine($" [{(r.Passed ? "OK" : "XX")}] {r.Name}");
|
||||
Console.WriteLine($"\n {passed}/{results.Count} passed, {results.Count - passed} failed");
|
||||
if (passed == results.Count) Console.WriteLine(" *** ALL VISUAL TESTS PASSED ***");
|
||||
Console.WriteLine(new string('=', 70));
|
||||
|
||||
Console.WriteLine("\nPress Enter to close the browser...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
static async Task ProxyTest()
|
||||
{
|
||||
var proxy = Environment.GetEnvironmentVariable("CLOAK_TEST_PROXY_SOCKS5");
|
||||
if (string.IsNullOrEmpty(proxy))
|
||||
{
|
||||
Console.WriteLine("Set CLOAK_TEST_PROXY_SOCKS5=socks5://user:pass@host:port to run this example");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Console.WriteLine($"Testing proxy: {proxy}");
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = proxy,
|
||||
GeoIp = true, // timezone/locale + WebRTC from the exit IP
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
// 1. is the traffic actually going through the proxy
|
||||
await page.GotoAsync("https://ipinfo.io/json");
|
||||
var body = await page.InnerTextAsync("body");
|
||||
Console.WriteLine(body);
|
||||
|
||||
// 2. which timezone/locale actually got applied in the browser
|
||||
var tz = await page.EvaluateAsync<string>(
|
||||
"() => Intl.DateTimeFormat().resolvedOptions().timeZone");
|
||||
var locale = await page.EvaluateAsync<string>("() => navigator.language");
|
||||
Console.WriteLine($"\nBrowser timezone: {tz}");
|
||||
Console.WriteLine($"Browser locale: {locale}");
|
||||
}
|
||||
static async Task PersistTest()
|
||||
{
|
||||
const string profile = "./cloak-persist-test";
|
||||
const string url = "https://example.com/";
|
||||
|
||||
Console.WriteLine("=== Run 1: write data into the profile ===");
|
||||
await using (var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
profile, new LaunchContextOptions { Headless = true }))
|
||||
{
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync(url);
|
||||
await page.EvaluateAsync(@"() => {
|
||||
localStorage.setItem('cloakTest', 'survived-42');
|
||||
document.cookie = 'cloak_cookie=persist-ok; path=/; max-age=86400';
|
||||
}");
|
||||
Console.WriteLine("Wrote: localStorage cloakTest=survived-42, cookie cloak_cookie=persist-ok");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n=== Run 2: read back from the same profile ===");
|
||||
await using (var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
profile, new LaunchContextOptions { Headless = true }))
|
||||
{
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync(url);
|
||||
var ls = await page.EvaluateAsync<string?>("() => localStorage.getItem('cloakTest')");
|
||||
var ck = await page.EvaluateAsync<string?>(
|
||||
"() => (document.cookie.match(/cloak_cookie=([^;]+)/) || [null,null])[1]");
|
||||
|
||||
Console.WriteLine($"localStorage cloakTest: {ls ?? "(null)"} (expected 'survived-42')");
|
||||
Console.WriteLine($"cookie cloak_cookie: {ck ?? "(null)"} (expected 'persist-ok')");
|
||||
Console.WriteLine(ls == "survived-42" && ck == "persist-ok"
|
||||
? ">>> PASS - the profile survives a restart"
|
||||
: ">>> FAIL - data was not persisted");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WebRtcTest()
|
||||
{
|
||||
var proxy = Environment.GetEnvironmentVariable("CLOAK_TEST_PROXY");
|
||||
if (string.IsNullOrEmpty(proxy))
|
||||
{
|
||||
Console.WriteLine("Set CLOAK_TEST_PROXY=http://user:pass@host:port to run this example");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = proxy,
|
||||
GeoIp = true,
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
await page.GotoAsync("https://browserleaks.com/webrtc");
|
||||
await page.WaitForTimeoutAsync(6000); // give WebRTC time to run
|
||||
|
||||
var text = await page.InnerTextAsync("body");
|
||||
Console.WriteLine("--- browserleaks WebRTC (looking for IPs) ---");
|
||||
// pull out lines that look like an IP
|
||||
foreach (var line in text.Split('\n'))
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(line, @"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"))
|
||||
Console.WriteLine(" " + line.Trim());
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "webrtc-result.png", FullPage = true });
|
||||
Console.WriteLine("\nScreenshot: webrtc-result.png");
|
||||
}
|
||||
static async Task TrustedTest()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false, Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(1500);
|
||||
|
||||
// detector: catch untrusted keydown + querySelector from the evaluate context
|
||||
await page.EvaluateAsync(@"() => {
|
||||
window.__untrusted = [];
|
||||
window.__evalLeaks = [];
|
||||
const input = document.querySelector('#searchInput');
|
||||
input.addEventListener('keydown', e => {
|
||||
if (!e.isTrusted) window.__untrusted.push(e.key);
|
||||
}, true);
|
||||
const origQS = document.querySelector.bind(document);
|
||||
document.querySelector = function(sel){
|
||||
try { throw new Error(); } catch(e){
|
||||
if (e.stack && /:\d+:\d+/.test(e.stack) && e.stack.includes('eval')) {
|
||||
window.__evalLeaks.push(sel);
|
||||
}
|
||||
}
|
||||
return origQS(sel);
|
||||
};
|
||||
}");
|
||||
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Keyboard.TypeAsync("Hello!@#$%^&*()");
|
||||
await page.WaitForTimeoutAsync(800);
|
||||
|
||||
var untrusted = await page.EvaluateAsync<string[]>("() => window.__untrusted");
|
||||
var leaks = await page.EvaluateAsync<string[]>("() => window.__evalLeaks");
|
||||
|
||||
Console.WriteLine($"Untrusted keydown events: {untrusted.Length} -> {string.Join(",", untrusted)}");
|
||||
Console.WriteLine($"evaluate querySelector leaks: {leaks.Length}");
|
||||
Console.WriteLine(untrusted.Length == 0
|
||||
? ">>> PASS - all shift symbols are isTrusted=true, no evaluate leaks"
|
||||
: ">>> FAIL - untrusted events found (a detector would catch them)");
|
||||
}
|
||||
static async Task TimeoutTest()
|
||||
{
|
||||
const double timeoutMs = 2000;
|
||||
const double budgetMultiplier = 1.8; // matches Python TestTimeoutBudget307
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
|
||||
Console.WriteLine($"Clicking a non-existent selector with timeout={timeoutMs}ms...");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await page.ClickAsync("#this-element-does-not-exist",
|
||||
new() { Timeout = (float)timeoutMs });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Expected exception: {ex.GetType().Name}");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
double elapsed = sw.Elapsed.TotalMilliseconds;
|
||||
double limit = timeoutMs * budgetMultiplier;
|
||||
|
||||
Console.WriteLine("--- TIMEOUT BUDGET (issue #307) ---");
|
||||
Console.WriteLine($"Elapsed: {elapsed:F0} ms");
|
||||
Console.WriteLine($"Limit (1.8x): {limit:F0} ms");
|
||||
Console.WriteLine(elapsed < limit
|
||||
? ">>> PASS - timeout budget is shared, not multiplied"
|
||||
: ">>> FAIL - timeout multiplied (each step took the full budget)");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>cloakbrowser-cli</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Cli</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
using CloakBrowser;
|
||||
|
||||
// CLI for cloakbrowser - download and manage the stealth Chromium binary.
|
||||
// Direct port of Python cloakbrowser/__main__.py.
|
||||
//
|
||||
// Usage:
|
||||
// cloakbrowser install # Download binary (with progress)
|
||||
// cloakbrowser info # Show binary version, path, platform
|
||||
// cloakbrowser update # Check for and download newer binary
|
||||
// cloakbrowser clear-cache # Remove cached binaries
|
||||
|
||||
// Route CloakBrowser logs to stderr at Info level (clean output).
|
||||
CloakLog.MinLevel = CloakLogLevel.Info;
|
||||
|
||||
string? command = args.Length > 0 ? args[0] : null;
|
||||
|
||||
if (string.IsNullOrEmpty(command) || command is "-h" or "--help" or "help")
|
||||
{
|
||||
PrintHelp();
|
||||
return string.IsNullOrEmpty(command) ? 2 : 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "install":
|
||||
await CmdInstall();
|
||||
break;
|
||||
case "info":
|
||||
CmdInfo();
|
||||
break;
|
||||
case "update":
|
||||
await CmdUpdate();
|
||||
break;
|
||||
case "clear-cache":
|
||||
CmdClearCache();
|
||||
break;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintHelp();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return 130;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static async Task CmdInstall()
|
||||
{
|
||||
string path = await Download.EnsureBinaryAsync().ConfigureAwait(false);
|
||||
Console.WriteLine(path);
|
||||
}
|
||||
|
||||
static void CmdInfo()
|
||||
{
|
||||
var info = Download.BinaryInfo();
|
||||
string? over = Config.GetLocalBinaryOverride();
|
||||
|
||||
Console.WriteLine($"Version: {info.Version}");
|
||||
Console.WriteLine($"Platform: {info.Platform}");
|
||||
Console.WriteLine($"Binary: {info.BinaryPath}");
|
||||
Console.WriteLine($"Installed: {info.Installed}");
|
||||
Console.WriteLine($"Cache: {info.CacheDir}");
|
||||
if (!string.IsNullOrEmpty(over))
|
||||
Console.WriteLine($"Override: {over} (CLOAKBROWSER_BINARY_PATH)");
|
||||
}
|
||||
|
||||
static async Task CmdUpdate()
|
||||
{
|
||||
CloakLog.Info("Checking for updates...");
|
||||
string? newVersion = await Download.CheckForUpdateAsync().ConfigureAwait(false);
|
||||
Console.WriteLine(newVersion != null
|
||||
? $"Updated to Chromium {newVersion}"
|
||||
: "Already up to date.");
|
||||
}
|
||||
|
||||
static void CmdClearCache()
|
||||
{
|
||||
if (!Directory.Exists(Config.GetCacheDir()))
|
||||
{
|
||||
Console.WriteLine("No cache to clear.");
|
||||
return;
|
||||
}
|
||||
Download.ClearCache();
|
||||
Console.WriteLine("Cache cleared.");
|
||||
}
|
||||
|
||||
static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("usage: cloakbrowser <command>");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Manage the CloakBrowser stealth Chromium binary.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("commands:");
|
||||
Console.WriteLine(" install Download the Chromium binary");
|
||||
Console.WriteLine(" info Show binary version, path, and platform");
|
||||
Console.WriteLine(" update Check for and download a newer binary");
|
||||
Console.WriteLine(" clear-cache Remove all cached binaries");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Source generators must target netstandard2.0 to load in the Roslyn host. -->
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||
<AssemblyName>CloakBrowser.Generators</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Generators</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,424 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace CloakBrowser.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Roslyn source generator that auto-implements the boilerplate "delegate everything
|
||||
/// to the wrapped object" members for a decorator class.
|
||||
///
|
||||
/// Usage: annotate a <c>partial class</c> with
|
||||
/// <c>[GenerateInterfaceDelegation(typeof(IPage))]</c> and provide a field/property
|
||||
/// named <c>_inner</c> (or the name passed to the attribute) of the interface type.
|
||||
/// The generator emits delegating implementations for every interface member the
|
||||
/// class does NOT already declare itself. Members you declare manually (the
|
||||
/// "intercepted" / humanized ones) are left untouched.
|
||||
///
|
||||
/// This lets us hand-write only the handful of methods we want to humanize while the
|
||||
/// remaining ~hundreds of interface members are forwarded verbatim with full static
|
||||
/// typing, correct async signatures, optional parameters, and no reflection.
|
||||
/// </summary>
|
||||
[Generator(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
||||
public sealed class InterfaceDelegationGenerator : IIncrementalGenerator
|
||||
{
|
||||
private const string AttributeNamespace = "CloakBrowser.Wrappers";
|
||||
private const string AttributeName = "GenerateInterfaceDelegationAttribute";
|
||||
private const string AttributeFullName = AttributeNamespace + "." + AttributeName;
|
||||
|
||||
private const string AttributeSource = @"// <auto-generated/>
|
||||
#nullable enable
|
||||
namespace CloakBrowser.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a partial decorator class for automatic generation of delegating members
|
||||
/// for the given interface. The class must expose a backing member (default name
|
||||
/// <c>_inner</c>) of the interface type that calls are forwarded to.
|
||||
/// </summary>
|
||||
[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
internal sealed class GenerateInterfaceDelegationAttribute : System.Attribute
|
||||
{
|
||||
public GenerateInterfaceDelegationAttribute(System.Type interfaceType, string innerMemberName = ""_inner"")
|
||||
{
|
||||
InterfaceType = interfaceType;
|
||||
InnerMemberName = innerMemberName;
|
||||
}
|
||||
|
||||
public System.Type InterfaceType { get; }
|
||||
public string InnerMemberName { get; }
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
// Emit the marker attribute into the consuming compilation.
|
||||
context.RegisterPostInitializationOutput(ctx =>
|
||||
ctx.AddSource("GenerateInterfaceDelegationAttribute.g.cs",
|
||||
SourceText.From(AttributeSource, Encoding.UTF8)));
|
||||
|
||||
var candidates = context.SyntaxProvider
|
||||
.CreateSyntaxProvider(
|
||||
predicate: static (node, _) =>
|
||||
node is ClassDeclarationSyntax c &&
|
||||
c.AttributeLists.Count > 0 &&
|
||||
c.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)),
|
||||
transform: static (ctx, _) => (INamedTypeSymbol?)ctx.SemanticModel.GetDeclaredSymbol(ctx.Node))
|
||||
.Where(static s => s is not null)
|
||||
.Select(static (s, _) => s!);
|
||||
|
||||
var compilationAndClasses = context.CompilationProvider.Combine(candidates.Collect());
|
||||
|
||||
context.RegisterSourceOutput(compilationAndClasses, static (spc, source) =>
|
||||
Execute(spc, source.Left, source.Right));
|
||||
}
|
||||
|
||||
private static void Execute(SourceProductionContext spc, Compilation compilation,
|
||||
System.Collections.Immutable.ImmutableArray<INamedTypeSymbol> classes)
|
||||
{
|
||||
var attrSymbol = compilation.GetTypeByMetadataName(AttributeFullName);
|
||||
if (attrSymbol is null)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var cls in classes.Distinct(SymbolEqualityComparer.Default).Cast<INamedTypeSymbol>())
|
||||
{
|
||||
var attrs = cls.GetAttributes()
|
||||
.Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attrSymbol))
|
||||
.ToList();
|
||||
if (attrs.Count == 0)
|
||||
continue;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine("#pragma warning disable CS0108 // member hides inherited member");
|
||||
sb.AppendLine("#pragma warning disable CS0612 // member is obsolete (delegation is intentional)");
|
||||
sb.AppendLine("#pragma warning disable CS0618 // member is obsolete (delegation is intentional)");
|
||||
sb.AppendLine();
|
||||
|
||||
string? ns = cls.ContainingNamespace.IsGlobalNamespace
|
||||
? null
|
||||
: cls.ContainingNamespace.ToDisplayString();
|
||||
if (ns is not null)
|
||||
{
|
||||
sb.Append("namespace ").Append(ns).AppendLine();
|
||||
sb.AppendLine("{");
|
||||
}
|
||||
|
||||
sb.Append(" partial class ").Append(cls.Name).AppendLine();
|
||||
sb.AppendLine(" {");
|
||||
|
||||
// Collect members the class already declares (by signature) so we don't
|
||||
// re-emit (and conflict with) hand-written intercepted methods.
|
||||
var declared = BuildDeclaredSignatureSet(cls);
|
||||
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
if (attr.ConstructorArguments.Length < 1)
|
||||
continue;
|
||||
if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol iface)
|
||||
continue;
|
||||
string innerName = attr.ConstructorArguments.Length >= 2 &&
|
||||
attr.ConstructorArguments[1].Value is string s
|
||||
? s
|
||||
: "_inner";
|
||||
|
||||
EmitForInterface(sb, iface, innerName, declared);
|
||||
}
|
||||
|
||||
sb.AppendLine(" }");
|
||||
if (ns is not null)
|
||||
sb.AppendLine("}");
|
||||
|
||||
string hint = (ns is null ? "" : ns + ".") + cls.Name + ".Delegation.g.cs";
|
||||
// De-dup hint names across partials.
|
||||
if (!seen.Add(hint))
|
||||
hint = hint + "." + Guid.NewGuid().ToString("N").Substring(0, 6);
|
||||
spc.AddSource(hint, SourceText.From(sb.ToString(), Encoding.UTF8));
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<string> BuildDeclaredSignatureSet(INamedTypeSymbol cls)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in cls.GetMembers())
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
||||
set.Add(MethodSignature(method));
|
||||
break;
|
||||
case IPropertySymbol prop when prop.IsIndexer:
|
||||
set.Add(IndexerSignature(prop));
|
||||
break;
|
||||
case IPropertySymbol prop:
|
||||
set.Add("prop:" + prop.Name);
|
||||
break;
|
||||
case IEventSymbol evt:
|
||||
set.Add("event:" + evt.Name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static void EmitForInterface(StringBuilder sb, INamedTypeSymbol iface,
|
||||
string innerName, HashSet<string> declared)
|
||||
{
|
||||
// Walk the interface and every interface it inherits.
|
||||
var allIfaces = new List<INamedTypeSymbol> { iface };
|
||||
allIfaces.AddRange(iface.AllInterfaces);
|
||||
|
||||
var emitted = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var i in allIfaces)
|
||||
{
|
||||
foreach (var member in i.GetMembers())
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
||||
{
|
||||
string sig = MethodSignature(method);
|
||||
if (declared.Contains(sig) || !emitted.Add(sig))
|
||||
continue;
|
||||
EmitMethod(sb, method, innerName);
|
||||
break;
|
||||
}
|
||||
case IPropertySymbol prop when prop.IsIndexer:
|
||||
{
|
||||
string sig = IndexerSignature(prop);
|
||||
if (declared.Contains(sig) || !emitted.Add(sig))
|
||||
continue;
|
||||
EmitIndexer(sb, prop, innerName);
|
||||
break;
|
||||
}
|
||||
case IPropertySymbol prop:
|
||||
{
|
||||
string key = "prop:" + prop.Name;
|
||||
if (declared.Contains(key) || !emitted.Add(key))
|
||||
continue;
|
||||
EmitProperty(sb, prop, innerName);
|
||||
break;
|
||||
}
|
||||
case IEventSymbol evt:
|
||||
{
|
||||
string key = "event:" + evt.Name;
|
||||
if (declared.Contains(key) || !emitted.Add(key))
|
||||
continue;
|
||||
EmitEvent(sb, evt, innerName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Emitters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Marker on every generated (delegating) member, so consumers/tests can tell a
|
||||
// generator-delegated member apart from a hand-written (intercepted) one.
|
||||
private const string GenAttr =
|
||||
" [global::System.CodeDom.Compiler.GeneratedCode(\"CloakBrowser.Generators\", \"1.0\")]";
|
||||
|
||||
private static void EmitMethod(StringBuilder sb, IMethodSymbol method, string innerName)
|
||||
{
|
||||
string ret = method.ReturnType.ToDisplayString(FullyQualified);
|
||||
string retKeyword = method.ReturnsVoid ? "void" : ret;
|
||||
|
||||
var pars = new List<string>();
|
||||
var args = new List<string>();
|
||||
foreach (var p in method.Parameters)
|
||||
BuildParam(p, pars, args);
|
||||
|
||||
string typeParams = method.IsGenericMethod
|
||||
? "<" + string.Join(", ", method.TypeParameters.Select(t => t.Name)) + ">"
|
||||
: "";
|
||||
string constraints = BuildConstraints(method);
|
||||
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ")
|
||||
.Append(retKeyword).Append(' ')
|
||||
.Append(method.Name).Append(typeParams).Append('(')
|
||||
.Append(string.Join(", ", pars)).Append(')')
|
||||
.Append(constraints)
|
||||
.Append(" => ")
|
||||
.Append(innerName).Append('.').Append(method.Name).Append(typeParams)
|
||||
.Append('(').Append(string.Join(", ", args)).Append(");")
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
private static void EmitProperty(StringBuilder sb, IPropertySymbol prop, string innerName)
|
||||
{
|
||||
string type = prop.Type.ToDisplayString(FullyQualified);
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ").Append(type).Append(' ').Append(prop.Name).Append(" { ");
|
||||
if (prop.GetMethod is not null)
|
||||
sb.Append("get => ").Append(innerName).Append('.').Append(prop.Name).Append("; ");
|
||||
if (prop.SetMethod is not null)
|
||||
sb.Append("set => ").Append(innerName).Append('.').Append(prop.Name).Append(" = value; ");
|
||||
sb.AppendLine("}");
|
||||
}
|
||||
|
||||
private static void EmitIndexer(StringBuilder sb, IPropertySymbol prop, string innerName)
|
||||
{
|
||||
string type = prop.Type.ToDisplayString(FullyQualified);
|
||||
var pars = new List<string>();
|
||||
var args = new List<string>();
|
||||
foreach (var p in prop.Parameters)
|
||||
BuildParam(p, pars, args);
|
||||
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ").Append(type).Append(" this[")
|
||||
.Append(string.Join(", ", pars)).Append("] { ");
|
||||
if (prop.GetMethod is not null)
|
||||
sb.Append("get => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("]; ");
|
||||
if (prop.SetMethod is not null)
|
||||
sb.Append("set => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("] = value; ");
|
||||
sb.AppendLine("}");
|
||||
}
|
||||
|
||||
private static void EmitEvent(StringBuilder sb, IEventSymbol evt, string innerName)
|
||||
{
|
||||
string type = evt.Type.ToDisplayString(FullyQualified);
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public event ").Append(type).Append(' ').Append(evt.Name).AppendLine();
|
||||
sb.AppendLine(" {");
|
||||
sb.Append(" add => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" += value;");
|
||||
sb.Append(" remove => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" -= value;");
|
||||
sb.AppendLine(" }");
|
||||
}
|
||||
|
||||
private static void BuildParam(IParameterSymbol p, List<string> pars, List<string> args)
|
||||
{
|
||||
string refKind = p.RefKind switch
|
||||
{
|
||||
RefKind.Ref => "ref ",
|
||||
RefKind.Out => "out ",
|
||||
RefKind.In => "in ",
|
||||
_ => "",
|
||||
};
|
||||
string ptype = p.Type.ToDisplayString(FullyQualified);
|
||||
// Special types (object, string, ...) sometimes drop their nullable annotation
|
||||
// in the display string; re-attach it so `object? arg = null` overrides match
|
||||
// and we don't trip CS8625.
|
||||
if (p.NullableAnnotation == NullableAnnotation.Annotated &&
|
||||
!p.Type.IsValueType && !ptype.EndsWith("?"))
|
||||
{
|
||||
ptype += "?";
|
||||
}
|
||||
string pname = SafeName(p.Name);
|
||||
|
||||
string paramsKw = p.IsParams ? "params " : "";
|
||||
string decl = paramsKw + refKind + ptype + " " + pname;
|
||||
|
||||
if (p.HasExplicitDefaultValue)
|
||||
decl += " = " + FormatDefault(p);
|
||||
|
||||
pars.Add(decl);
|
||||
args.Add(refKind + pname);
|
||||
}
|
||||
|
||||
private static string FormatDefault(IParameterSymbol p)
|
||||
{
|
||||
var v = p.ExplicitDefaultValue;
|
||||
if (v is null)
|
||||
{
|
||||
// For a null default, value types use the literal `default`; reference
|
||||
// types use `default!` so we never trip CS8625 regardless of whether the
|
||||
// interface was compiled in a nullable-aware context.
|
||||
return p.Type.IsValueType ? "default" : "default!";
|
||||
}
|
||||
return v switch
|
||||
{
|
||||
bool b => b ? "true" : "false",
|
||||
string s => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
|
||||
char c => "'" + c + "'",
|
||||
float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture) + "f",
|
||||
double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ when p.Type.TypeKind == TypeKind.Enum =>
|
||||
"(" + p.Type.ToDisplayString(FullyQualified) + ")" + v,
|
||||
_ => System.Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildConstraints(IMethodSymbol method)
|
||||
{
|
||||
if (!method.IsGenericMethod)
|
||||
return "";
|
||||
var sb = new StringBuilder();
|
||||
foreach (var tp in method.TypeParameters)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (tp.HasReferenceTypeConstraint) parts.Add("class");
|
||||
if (tp.HasValueTypeConstraint) parts.Add("struct");
|
||||
if (tp.HasNotNullConstraint) parts.Add("notnull");
|
||||
if (tp.HasUnmanagedTypeConstraint) parts.Add("unmanaged");
|
||||
foreach (var ct in tp.ConstraintTypes)
|
||||
parts.Add(ct.ToDisplayString(FullyQualified));
|
||||
if (tp.HasConstructorConstraint) parts.Add("new()");
|
||||
if (parts.Count > 0)
|
||||
sb.Append(" where ").Append(tp.Name).Append(" : ").Append(string.Join(", ", parts));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Signature helpers (must match how we detect already-declared members)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string MethodSignature(IMethodSymbol m)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(m.Name);
|
||||
sb.Append('`').Append(m.TypeParameters.Length);
|
||||
sb.Append('(');
|
||||
sb.Append(string.Join(",", m.Parameters.Select(p =>
|
||||
(p.RefKind == RefKind.None ? "" : p.RefKind.ToString().ToLowerInvariant() + " ")
|
||||
+ p.Type.ToDisplayString(SignatureFormat))));
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string IndexerSignature(IPropertySymbol p)
|
||||
{
|
||||
return "this[" + string.Join(",", p.Parameters.Select(x =>
|
||||
x.Type.ToDisplayString(SignatureFormat))) + "]";
|
||||
}
|
||||
|
||||
private static string SafeName(string name)
|
||||
{
|
||||
return SyntaxFactsKeywords.Contains(name) ? "@" + name : name;
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SyntaxFactsKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","as","base","bool","break","byte","case","catch","char","checked","class","const",
|
||||
"continue","decimal","default","delegate","do","double","else","enum","event","explicit","extern",
|
||||
"false","finally","fixed","float","for","foreach","goto","if","implicit","in","int","interface",
|
||||
"internal","is","lock","long","namespace","new","null","object","operator","out","override","params",
|
||||
"private","protected","public","readonly","ref","return","sbyte","sealed","short","sizeof","stackalloc",
|
||||
"static","string","struct","switch","this","throw","true","try","typeof","uint","ulong","unchecked",
|
||||
"unsafe","ushort","using","virtual","void","volatile","while",
|
||||
};
|
||||
|
||||
private static readonly SymbolDisplayFormat FullyQualified =
|
||||
SymbolDisplayFormat.FullyQualifiedFormat
|
||||
.WithMiscellaneousOptions(
|
||||
SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
|
||||
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier |
|
||||
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
|
||||
|
||||
// For signature comparison we want stable type names without nullable annotations,
|
||||
// so the manual override matches the interface member regardless of NRT modifiers.
|
||||
private static readonly SymbolDisplayFormat SignatureFormat =
|
||||
SymbolDisplayFormat.FullyQualifiedFormat
|
||||
.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>CloakBrowser</AssemblyName>
|
||||
<RootNamespace>CloakBrowser</RootNamespace>
|
||||
|
||||
<!-- NuGet packaging metadata -->
|
||||
<PackageId>CloakBrowser</PackageId>
|
||||
<Version>0.4.2</Version>
|
||||
<Authors>CloakHQ</Authors>
|
||||
<Description>Stealth Chromium that passes every bot detection test. Drop-in Playwright (.NET) replacement with source-level fingerprint patches.</Description>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/CloakHQ/CloakBrowser</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/CloakHQ/CloakBrowser</RepositoryUrl>
|
||||
<PackageTags>stealth;browser;chromium;playwright;scraping;web-scraping;anti-detect;antidetect;undetected;bot-detection;fingerprint;recaptcha;cloudflare;turnstile;datadome;captcha;headless;automation</PackageTags>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
|
||||
<!-- Ed25519 signature verification for downloaded binaries (.NET 8 has no
|
||||
built-in Ed25519). Mirrors Python's `cryptography` / JS's node:crypto. -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Roslyn source generator that emits the delegating wrapper members. -->
|
||||
<ProjectReference Include="..\CloakBrowser.Generators\CloakBrowser.Generators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="CloakBrowser.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Core browser launch functions for CloakBrowser - thin wrappers around Playwright
|
||||
/// that use the patched stealth Chromium binary instead of stock Chromium.
|
||||
///
|
||||
/// Direct port of Python <c>cloakbrowser/browser.py</c>. Because .NET Playwright is
|
||||
/// async-only, only the async launch surface is provided.
|
||||
/// </summary>
|
||||
public static class CloakLauncher
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// launch - returns a Browser handle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth Chromium browser. Returns a <see cref="CloakBrowserHandle"/>.</summary>
|
||||
public static async Task<CloakBrowserHandle> LaunchAsync(LaunchOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchOptions();
|
||||
|
||||
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey).ConfigureAwait(false);
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var proxyResolution = ProxyResolver.Resolve(options.Proxy);
|
||||
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var combined = new List<string>(args ?? new List<string>());
|
||||
combined.AddRange(proxyResolution.ExtraArgs);
|
||||
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths);
|
||||
|
||||
CloakLog.Debug($"Launching stealth Chromium (headless={options.Headless}, args={chromeArgs.Count})");
|
||||
|
||||
var playwright = await Playwright.CreateAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
ExecutablePath = binaryPath,
|
||||
Headless = options.Headless,
|
||||
Args = chromeArgs,
|
||||
IgnoreDefaultArgs = Config.IgnoreDefaultArgs,
|
||||
Proxy = proxyResolution.PlaywrightProxy,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
// Pass headless so headed handles default new pages/contexts to NoViewport
|
||||
// (track the real window - see CloakBrowserHandle.ApplyDefaultNoViewport).
|
||||
return new CloakBrowserHandle(playwright, browser, options.Humanize, humanCfg, options.Headless);
|
||||
}
|
||||
catch
|
||||
{
|
||||
playwright.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// launch_context - returns a Context handle (browser owned)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth browser and return a <see cref="CloakContextHandle"/> with common options pre-set.</summary>
|
||||
public static async Task<CloakContextHandle> LaunchContextAsync(LaunchContextOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchContextOptions();
|
||||
|
||||
// Resolve geoip before launch so resolved values flow to binary flags.
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var args = options.Args;
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var browserHandle = await LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = options.Headless,
|
||||
Proxy = options.Proxy,
|
||||
Args = args,
|
||||
StealthArgs = options.StealthArgs,
|
||||
Timezone = timezone,
|
||||
Locale = locale,
|
||||
ExtensionPaths = options.ExtensionPaths,
|
||||
LicenseKey = options.LicenseKey,
|
||||
// geoip already resolved above; don't re-resolve.
|
||||
GeoIp = false,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var ctxOptions = BuildContextOptions(options);
|
||||
var context = await browserHandle.Browser.NewContextAsync(ctxOptions).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
|
||||
// The context handle owns the browser; reuse the same Playwright instance.
|
||||
return new CloakContextHandle(
|
||||
GetPlaywright(browserHandle), browserHandle.Browser, context, options.Humanize, humanCfg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await browserHandle.CloseAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// launch_persistent_context - returns a Context handle (no separate browser)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth browser with a persistent profile; returns a <see cref="CloakContextHandle"/>.</summary>
|
||||
public static async Task<CloakContextHandle> LaunchPersistentContextAsync(
|
||||
string userDataDir, LaunchContextOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchContextOptions();
|
||||
|
||||
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey).ConfigureAwait(false);
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var proxyResolution = ProxyResolver.Resolve(options.Proxy);
|
||||
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var combined = new List<string>(args ?? new List<string>());
|
||||
combined.AddRange(proxyResolution.ExtraArgs);
|
||||
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths);
|
||||
|
||||
CloakLog.Debug($"Launching persistent stealth Chromium (headless={options.Headless}, user_data_dir={userDataDir})");
|
||||
|
||||
// Seed the Widevine CDM hint (Linux-only; no-op elsewhere).
|
||||
Widevine.SeedWidevineHint(userDataDir, binaryPath);
|
||||
|
||||
var playwright = await Playwright.CreateAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var ctxLaunchOptions = new BrowserTypeLaunchPersistentContextOptions
|
||||
{
|
||||
ExecutablePath = binaryPath,
|
||||
Headless = options.Headless,
|
||||
Args = chromeArgs,
|
||||
IgnoreDefaultArgs = Config.IgnoreDefaultArgs,
|
||||
Proxy = proxyResolution.PlaywrightProxy,
|
||||
};
|
||||
ApplyContextEmulation(ctxLaunchOptions, options);
|
||||
|
||||
var context = await playwright.Chromium.LaunchPersistentContextAsync(
|
||||
userDataDir, ctxLaunchOptions).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
return new CloakContextHandle(playwright, null, context, options.Humanize, humanCfg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
playwright.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GeoIP resolution
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Auto-fill timezone/locale from the proxy IP when geoip is enabled. Returns
|
||||
/// (timezone, locale, exitIp). The exit IP is a free bonus used for WebRTC spoofing.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> MaybeResolveGeoIpAsync(
|
||||
bool geoip, object? proxy, string? timezone, string? locale)
|
||||
{
|
||||
if (!geoip || proxy == null)
|
||||
return (timezone, locale, null);
|
||||
|
||||
string? proxyUrl = ProxyResolver.ExtractProxyUrl(proxy);
|
||||
if (string.IsNullOrEmpty(proxyUrl))
|
||||
return (timezone, locale, null);
|
||||
|
||||
// When both tz/locale are explicit, still resolve the exit IP for WebRTC.
|
||||
if (timezone != null && locale != null)
|
||||
{
|
||||
string? exitIpOnly = await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false);
|
||||
return (timezone, locale, exitIpOnly);
|
||||
}
|
||||
|
||||
var (geoTz, geoLocale, exitIp) = await GeoIp.ResolveProxyGeoWithIpAsync(proxyUrl).ConfigureAwait(false);
|
||||
return (timezone ?? geoTz, locale ?? geoLocale, exitIp);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebRTC args
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Replace <c>--fingerprint-webrtc-ip=auto</c> with the resolved proxy exit IP.</summary>
|
||||
public static async Task<List<string>?> ResolveWebRtcArgsAsync(List<string>? args, object? proxy)
|
||||
{
|
||||
if (args == null || args.Count == 0)
|
||||
return args;
|
||||
int idx = args.FindIndex(a => a == "--fingerprint-webrtc-ip=auto");
|
||||
if (idx < 0)
|
||||
return args;
|
||||
|
||||
string? proxyUrl = ProxyResolver.ExtractProxyUrl(proxy);
|
||||
var result = new List<string>(args);
|
||||
if (string.IsNullOrEmpty(proxyUrl))
|
||||
{
|
||||
CloakLog.Warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
result.RemoveAt(idx);
|
||||
return result;
|
||||
}
|
||||
|
||||
string? exitIp;
|
||||
try { exitIp = await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false); }
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.RemoveAt(idx);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exitIp))
|
||||
result[idx] = $"--fingerprint-webrtc-ip={exitIp}";
|
||||
else
|
||||
{
|
||||
CloakLog.Warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.RemoveAt(idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string>? MaybeAppendWebRtcExitIp(List<string>? args, string? exitIp)
|
||||
{
|
||||
if (string.IsNullOrEmpty(exitIp))
|
||||
return args;
|
||||
bool alreadySet = args != null && args.Any(a => a.StartsWith("--fingerprint-webrtc-ip"));
|
||||
if (alreadySet)
|
||||
return args;
|
||||
var result = new List<string>(args ?? new List<string>())
|
||||
{
|
||||
$"--fingerprint-webrtc-ip={exitIp}",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// build_args
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Combine stealth args with user-provided args and locale/timezone flags.
|
||||
/// Deduplicates by flag key (everything before <c>=</c>).
|
||||
/// Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
/// </summary>
|
||||
public static List<string> BuildArgs(
|
||||
bool stealthArgs,
|
||||
List<string>? extraArgs,
|
||||
string? timezone = null,
|
||||
string? locale = null,
|
||||
bool headless = true,
|
||||
List<string>? extensionPaths = null)
|
||||
{
|
||||
// Preserve insertion order while deduping by key.
|
||||
var seen = new Dictionary<string, string>();
|
||||
var order = new List<string>();
|
||||
|
||||
void Set(string key, string value)
|
||||
{
|
||||
if (seen.ContainsKey(key))
|
||||
CloakLog.Debug($"Arg override: {seen[key]} -> {value}");
|
||||
else
|
||||
order.Add(key);
|
||||
seen[key] = value;
|
||||
}
|
||||
|
||||
if (stealthArgs)
|
||||
{
|
||||
foreach (var arg in Config.GetDefaultStealthArgs())
|
||||
Set(arg.Split('=', 2)[0], arg);
|
||||
}
|
||||
|
||||
// GPU blocklist bypass in headed mode (all platforms) or on Windows (all modes).
|
||||
if (!headless || RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
Set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
|
||||
|
||||
if (extraArgs != null)
|
||||
{
|
||||
foreach (var arg in extraArgs)
|
||||
Set(arg.Split('=', 2)[0], arg);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(timezone))
|
||||
Set("--fingerprint-timezone", $"--fingerprint-timezone={timezone}");
|
||||
|
||||
if (!string.IsNullOrEmpty(locale))
|
||||
{
|
||||
Set("--lang", $"--lang={locale}");
|
||||
Set("--fingerprint-locale", $"--fingerprint-locale={locale}");
|
||||
}
|
||||
|
||||
if (extensionPaths != null && extensionPaths.Count > 0)
|
||||
{
|
||||
var absPaths = extensionPaths.Select(Path.GetFullPath);
|
||||
string extVal = string.Join(",", absPaths);
|
||||
Set("--load-extension", $"--load-extension={extVal}");
|
||||
Set("--disable-extensions-except", $"--disable-extensions-except={extVal}");
|
||||
}
|
||||
|
||||
return order.Select(k => seen[k]).ToList();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Context option helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the viewport for a context. Headed: no emulated viewport so the page
|
||||
/// tracks the real window (CDP viewport emulation forces outerWidth < innerWidth =
|
||||
/// a physically impossible window = bot tell). Headless: a fixed DEFAULT_VIEWPORT
|
||||
/// stays coherent (outer == inner) and keeps dimensions deterministic. An explicit
|
||||
/// <see cref="LaunchContextOptions.NoViewport"/> or <see cref="LaunchContextOptions.Viewport"/>
|
||||
/// is always honored. Port of Python <c>_resolve_context_viewport</c>.
|
||||
/// </summary>
|
||||
internal static ViewportSize? ResolveContextViewport(LaunchContextOptions options)
|
||||
{
|
||||
if (options.NoViewport)
|
||||
return ViewportSize.NoViewport;
|
||||
if (options.Viewport != null)
|
||||
return new ViewportSize { Width = options.Viewport.Value.Width, Height = options.Viewport.Value.Height };
|
||||
// Viewport unset: headed tracks the real window; headless gets the fixed default.
|
||||
return options.Headless
|
||||
? new ViewportSize { Width = Config.DefaultViewportWidth, Height = Config.DefaultViewportHeight }
|
||||
: ViewportSize.NoViewport;
|
||||
}
|
||||
|
||||
private static BrowserNewContextOptions BuildContextOptions(LaunchContextOptions options)
|
||||
{
|
||||
var ctx = new BrowserNewContextOptions();
|
||||
if (!string.IsNullOrEmpty(options.UserAgent))
|
||||
ctx.UserAgent = options.UserAgent;
|
||||
|
||||
ctx.ViewportSize = ResolveContextViewport(options);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ColorScheme))
|
||||
ctx.ColorScheme = ParseColorScheme(options.ColorScheme);
|
||||
if (!string.IsNullOrEmpty(options.StorageStatePath))
|
||||
ctx.StorageStatePath = options.StorageStatePath;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static void ApplyContextEmulation(
|
||||
BrowserTypeLaunchPersistentContextOptions ctx, LaunchContextOptions options)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(options.UserAgent))
|
||||
ctx.UserAgent = options.UserAgent;
|
||||
|
||||
ctx.ViewportSize = ResolveContextViewport(options);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ColorScheme))
|
||||
ctx.ColorScheme = ParseColorScheme(options.ColorScheme);
|
||||
}
|
||||
|
||||
private static ColorScheme ParseColorScheme(string s) => s.ToLowerInvariant() switch
|
||||
{
|
||||
"light" => ColorScheme.Light,
|
||||
"dark" => ColorScheme.Dark,
|
||||
"no-preference" => ColorScheme.NoPreference,
|
||||
_ => ColorScheme.Light,
|
||||
};
|
||||
|
||||
// Access the private Playwright instance of a browser handle via reflection-free shim.
|
||||
// CloakBrowserHandle exposes the browser; we need the same IPlaywright for the context
|
||||
// handle. Stored when we created it - expose through an internal accessor.
|
||||
private static IPlaywright GetPlaywright(CloakBrowserHandle handle) => handle.PlaywrightInstance;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Log severity levels for <see cref="CloakLog"/>. By default only Info/Warning/Error are
|
||||
/// written to stderr; set <c>CloakLog.MinLevel</c> to <see cref="Debug"/> for verbose output,
|
||||
/// or <see cref="None"/> to silence everything.
|
||||
/// </summary>
|
||||
public enum CloakLogLevel
|
||||
{
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warning = 2,
|
||||
Error = 3,
|
||||
None = 4,
|
||||
}
|
||||
|
||||
/// <summary>Logging facade used across the library.</summary>
|
||||
public static class CloakLog
|
||||
{
|
||||
/// <summary>Minimum level that will be emitted. Defaults to <see cref="CloakLogLevel.Info"/>.</summary>
|
||||
public static CloakLogLevel MinLevel { get; set; } = CloakLogLevel.Info;
|
||||
|
||||
/// <summary>Optional custom sink. Receives (level, message). Defaults to writing to stderr.</summary>
|
||||
public static Action<CloakLogLevel, string>? Sink { get; set; }
|
||||
|
||||
public static void Debug(string message) => Emit(CloakLogLevel.Debug, message);
|
||||
public static void Info(string message) => Emit(CloakLogLevel.Info, message);
|
||||
public static void Warning(string message) => Emit(CloakLogLevel.Warning, message);
|
||||
public static void Error(string message) => Emit(CloakLogLevel.Error, message);
|
||||
|
||||
public static void Debug(string format, params object?[] args) => Emit(CloakLogLevel.Debug, Fmt(format, args));
|
||||
public static void Info(string format, params object?[] args) => Emit(CloakLogLevel.Info, Fmt(format, args));
|
||||
public static void Warning(string format, params object?[] args) => Emit(CloakLogLevel.Warning, Fmt(format, args));
|
||||
|
||||
private static string Fmt(string format, object?[] args)
|
||||
{
|
||||
try { return args is { Length: > 0 } ? string.Format(format, args) : format; }
|
||||
catch (FormatException) { return format; }
|
||||
}
|
||||
|
||||
private static void Emit(CloakLogLevel level, string message)
|
||||
{
|
||||
if (level < MinLevel) return;
|
||||
if (Sink != null) { Sink(level, message); return; }
|
||||
Console.Error.WriteLine($"[cloakbrowser:{level.ToString().ToLowerInvariant()}] {message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>Wrapper version (mirrors Python <c>cloakbrowser/_version.py</c>).</summary>
|
||||
public static class CloakVersion
|
||||
{
|
||||
/// <summary>The CloakBrowser .NET wrapper version.</summary>
|
||||
public const string Version = "0.4.2";
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Stealth configuration and platform detection for CloakBrowser.
|
||||
/// Direct port of Python <c>cloakbrowser/config.py</c>.
|
||||
/// </summary>
|
||||
public static class Config
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// ChromiumVersion is the latest across all platforms (for display/reference).
|
||||
// Use GetChromiumVersion() for the current platform's actual version.
|
||||
// -----------------------------------------------------------------------
|
||||
public const string ChromiumVersion = "146.0.7680.177.5";
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, string> PlatformChromiumVersions =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["linux-x64"] = "146.0.7680.177.5",
|
||||
["linux-arm64"] = "146.0.7680.177.3",
|
||||
["darwin-arm64"] = "145.0.7632.109.2",
|
||||
["darwin-x64"] = "145.0.7632.109.2",
|
||||
["windows-x64"] = "146.0.7680.177.5",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ed25519 public keys for verifying downloaded binaries.
|
||||
//
|
||||
// Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
||||
// The wrapper verifies that signature against the keys below before trusting
|
||||
// any hash in the manifest, so the download origin alone cannot certify a
|
||||
// tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
||||
// entries are accepted to allow key rotation.
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly IReadOnlyList<string> BinarySigningPubkeys = new[]
|
||||
{
|
||||
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Playwright default args to suppress - these leak automation signals.
|
||||
// --enable-automation: exposes navigator.webdriver = true
|
||||
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
||||
// producing a distinctive renderer string that no real user browser has.
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly string[] IgnoreDefaultArgs =
|
||||
{ "--enable-automation", "--enable-unsafe-swiftshader" };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Default viewport - used for HEADLESS only (headed launches use no_viewport
|
||||
// so the page tracks the real window). Headless has no window chrome, so a
|
||||
// fixed viewport stays coherent (outer == inner) and gives deterministic
|
||||
// dimensions. Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
// -----------------------------------------------------------------------
|
||||
public const int DefaultViewportWidth = 1920;
|
||||
public const int DefaultViewportHeight = 947;
|
||||
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
/// <summary>
|
||||
/// Build stealth args with a random fingerprint seed per launch.
|
||||
/// On macOS, skips platform/GPU spoofing - runs as a native Mac browser.
|
||||
/// Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
|
||||
/// </summary>
|
||||
public static List<string> GetDefaultStealthArgs()
|
||||
{
|
||||
int seed;
|
||||
lock (_rng) { seed = _rng.Next(10000, 100000); }
|
||||
|
||||
var baseArgs = new List<string>
|
||||
{
|
||||
"--no-sandbox",
|
||||
$"--fingerprint={seed}",
|
||||
};
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
// Tell the fingerprint patches we're on macOS so GPU/UA match natively.
|
||||
baseArgs.Add("--fingerprint-platform=macos");
|
||||
return baseArgs;
|
||||
}
|
||||
|
||||
// Linux/Windows: Windows fingerprint profile.
|
||||
// Screen and window size come from the real display, not this flag (verified:
|
||||
// identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
// headed mode - that would break outerWidth >= innerWidth coherence.
|
||||
baseArgs.Add("--fingerprint-platform=windows");
|
||||
return baseArgs;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Platform detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Platforms with pre-built binaries available for download.</summary>
|
||||
public static IReadOnlySet<string> AvailablePlatforms =>
|
||||
new HashSet<string>(PlatformChromiumVersions.Keys);
|
||||
|
||||
/// <summary>Return the Chromium version for the current platform.</summary>
|
||||
public static string GetChromiumVersion()
|
||||
{
|
||||
var tag = GetPlatformTag();
|
||||
return PlatformChromiumVersions.TryGetValue(tag, out var v) ? v : ChromiumVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the platform tag for binary download (e.g. <c>linux-x64</c>, <c>darwin-arm64</c>).
|
||||
/// </summary>
|
||||
public static string GetPlatformTag()
|
||||
{
|
||||
var arch = RuntimeInformation.OSArchitecture;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.X64 => "linux-x64",
|
||||
Architecture.Arm64 => "linux-arm64",
|
||||
_ => throw Unsupported("Linux", arch),
|
||||
};
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.Arm64 => "darwin-arm64",
|
||||
Architecture.X64 => "darwin-x64",
|
||||
_ => throw Unsupported("Darwin", arch),
|
||||
};
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.X64 => "windows-x64",
|
||||
_ => throw Unsupported("Windows", arch),
|
||||
};
|
||||
}
|
||||
|
||||
throw new PlatformNotSupportedException(
|
||||
$"Unsupported platform: {RuntimeInformation.OSDescription} {arch}");
|
||||
}
|
||||
|
||||
private static PlatformNotSupportedException Unsupported(string system, Architecture arch) =>
|
||||
new($"Unsupported platform: {system} {arch}. " +
|
||||
"Supported: linux-x64, linux-arm64, darwin-arm64, darwin-x64, windows-x64");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Binary cache paths
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Return the cache directory for downloaded binaries.
|
||||
/// Override with the <c>CLOAKBROWSER_CACHE_DIR</c> env var. Default: <c>~/.cloakbrowser/</c>.
|
||||
/// </summary>
|
||||
public static string GetCacheDir()
|
||||
{
|
||||
var custom = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
return custom;
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".cloakbrowser");
|
||||
}
|
||||
|
||||
/// <summary>Return the directory for a Chromium version binary.</summary>
|
||||
/// <param name="version">Version string, or null for the platform default.</param>
|
||||
/// <param name="pro">When true, use the Pro-specific cache dir (<c>chromium-{v}-pro</c>).</param>
|
||||
public static string GetBinaryDir(string? version = null, bool pro = false)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
var suffix = pro ? "-pro" : "";
|
||||
return Path.Combine(GetCacheDir(), $"chromium-{v}{suffix}");
|
||||
}
|
||||
|
||||
/// <summary>Return the expected path to the chrome executable.</summary>
|
||||
public static string GetBinaryPath(string? version = null, bool pro = false)
|
||||
{
|
||||
var binaryDir = GetBinaryDir(version, pro);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return Path.Combine(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return Path.Combine(binaryDir, "chrome.exe");
|
||||
return Path.Combine(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raise a clear error if no pre-built binary exists for this platform.
|
||||
/// Skipped when <c>CLOAKBROWSER_BINARY_PATH</c> is set (user has their own build).
|
||||
/// </summary>
|
||||
public static void CheckPlatformAvailable()
|
||||
{
|
||||
if (GetLocalBinaryOverride() != null)
|
||||
return;
|
||||
|
||||
var tag = GetPlatformTag(); // throws if platform unsupported entirely
|
||||
if (!AvailablePlatforms.Contains(tag))
|
||||
{
|
||||
var available = string.Join(", ", AvailablePlatforms.OrderBy(x => x));
|
||||
throw new PlatformNotSupportedException(
|
||||
$"\nCloakBrowser - Pre-built binaries are currently only available for: {available}.\n\n" +
|
||||
"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the best available version: auto-updated if available, else platform default.
|
||||
/// Reads a platform-scoped marker file from the cache directory.
|
||||
/// When <paramref name="pro"/> is true, reads from the Pro-specific marker files.
|
||||
/// </summary>
|
||||
public static string GetEffectiveVersion(bool pro = false)
|
||||
{
|
||||
var baseVersion = GetChromiumVersion();
|
||||
var cache = GetCacheDir();
|
||||
|
||||
if (pro)
|
||||
{
|
||||
// Pro marker is authoritative for the Pro tier - no VersionNewer guard
|
||||
// (Pro versions are independent of the bundled free version, e.g. 148 vs 146).
|
||||
var proMarker = Path.Combine(cache, $"latest_pro_version_{GetPlatformTag()}");
|
||||
if (File.Exists(proMarker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = File.ReadAllText(proMarker).Trim();
|
||||
if (!string.IsNullOrEmpty(version) && File.Exists(GetBinaryPath(version, pro: true)))
|
||||
return version;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or IOException) { }
|
||||
}
|
||||
return baseVersion;
|
||||
}
|
||||
|
||||
foreach (var name in new[] { $"latest_version_{GetPlatformTag()}", "latest_version" })
|
||||
{
|
||||
var marker = Path.Combine(cache, name);
|
||||
if (File.Exists(marker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = File.ReadAllText(marker).Trim();
|
||||
if (!string.IsNullOrEmpty(version) && VersionNewer(version, baseVersion))
|
||||
{
|
||||
var binary = GetBinaryPath(version);
|
||||
if (File.Exists(binary))
|
||||
return version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or IOException) { }
|
||||
}
|
||||
}
|
||||
return baseVersion;
|
||||
}
|
||||
|
||||
/// <summary>Parse "145.0.7718.0" into (145, 0, 7718, 0) for comparison.</summary>
|
||||
public static int[] VersionTuple(string v) =>
|
||||
v.Split('.').Select(int.Parse).ToArray();
|
||||
|
||||
/// <summary>Return true if version a is strictly newer than version b.</summary>
|
||||
public static bool VersionNewer(string a, string b)
|
||||
{
|
||||
var ta = VersionTuple(a);
|
||||
var tb = VersionTuple(b);
|
||||
int len = Math.Max(ta.Length, tb.Length);
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
int va = i < ta.Length ? ta[i] : 0;
|
||||
int vb = i < tb.Length ? tb[i] : 0;
|
||||
if (va != vb) return va > vb;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Download URL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static string DownloadBaseUrl =>
|
||||
Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL") ?? "https://cloakbrowser.dev";
|
||||
|
||||
public const string GitHubApiUrl = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
|
||||
|
||||
public const string GitHubDownloadBaseUrl =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
/// <summary>Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise).</summary>
|
||||
public static string GetArchiveExt() =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".zip" : ".tar.gz";
|
||||
|
||||
/// <summary>Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz').</summary>
|
||||
public static string GetArchiveName(string? tag = null)
|
||||
{
|
||||
var t = tag ?? GetPlatformTag();
|
||||
return $"cloakbrowser-{t}{GetArchiveExt()}";
|
||||
}
|
||||
|
||||
/// <summary>Return the full download URL for the current platform's binary archive.</summary>
|
||||
public static string GetDownloadUrl(string? version = null)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
return $"{DownloadBaseUrl}/chromium-v{v}/{GetArchiveName()}";
|
||||
}
|
||||
|
||||
/// <summary>Return the GitHub Releases fallback URL for the binary archive.</summary>
|
||||
public static string GetFallbackDownloadUrl(string? version = null)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
return $"{GitHubDownloadBaseUrl}/chromium-v{v}/{GetArchiveName()}";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CloakBrowser Pro download URLs (cloakbrowser.dev, license-key authed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Return the Pro binary download URL for an explicit version. The version is
|
||||
/// requested explicitly so the served archive matches the signed Pro manifest.
|
||||
/// </summary>
|
||||
public static string GetProDownloadUrl(string version) =>
|
||||
$"{DownloadBaseUrl}/api/download/{version}";
|
||||
|
||||
/// <summary>Return the base URL for the Pro signed manifest (SHA256SUMS + .sig) of a version.</summary>
|
||||
public static string GetProManifestBaseUrl(string version) =>
|
||||
$"{DownloadBaseUrl}/releases/pro/chromium-v{version}";
|
||||
|
||||
/// <summary>Return the "latest Pro" display download URL (shown by binary_info for Pro installs).</summary>
|
||||
public static string GetProLatestDownloadUrl() =>
|
||||
$"{DownloadBaseUrl}/api/download/latest";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local binary override (skip download, use your own build)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Check if the user has set a local binary path via the <c>CLOAKBROWSER_BINARY_PATH</c> env var.
|
||||
/// </summary>
|
||||
public static string? GetLocalBinaryOverride() =>
|
||||
Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// GeoIP-based timezone and locale detection from a proxy IP.
|
||||
/// Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
|
||||
/// <c>~/.cloakbrowser/geoip/</c>. Background re-download after 30 days.
|
||||
/// Direct port of Python <c>cloakbrowser/geoip.py</c>.
|
||||
/// </summary>
|
||||
public static class GeoIp
|
||||
{
|
||||
// P3TERX mirror of MaxMind GeoLite2-City - no license key needed.
|
||||
private const string GeoIpDbUrl =
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||
private const string GeoIpDbFilename = "GeoLite2-City.mmdb";
|
||||
private const long GeoIpUpdateInterval = 30L * 86_400; // 30 days (seconds)
|
||||
private const double DefaultGeoIpTimeoutSeconds = 5.0;
|
||||
private const string GeoIpTimeoutEnv = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS";
|
||||
|
||||
// IP echo services - fast, no auth, return just the IP.
|
||||
private static readonly string[] IpEchoUrls =
|
||||
{
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ifconfig.me/ip",
|
||||
};
|
||||
|
||||
/// <summary>Country ISO code -> BCP 47 locale (covers ~90% of proxy traffic).</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> CountryLocaleMap =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["US"] = "en-US", ["GB"] = "en-GB", ["AU"] = "en-AU", ["CA"] = "en-CA", ["NZ"] = "en-NZ",
|
||||
["IE"] = "en-IE", ["ZA"] = "en-ZA", ["SG"] = "en-SG",
|
||||
["DE"] = "de-DE", ["AT"] = "de-AT", ["CH"] = "de-CH",
|
||||
["FR"] = "fr-FR", ["BE"] = "fr-BE",
|
||||
["ES"] = "es-ES", ["MX"] = "es-MX", ["AR"] = "es-AR", ["CO"] = "es-CO", ["CL"] = "es-CL",
|
||||
["BR"] = "pt-BR", ["PT"] = "pt-PT",
|
||||
["IT"] = "it-IT", ["NL"] = "nl-NL",
|
||||
["JP"] = "ja-JP", ["KR"] = "ko-KR", ["CN"] = "zh-CN", ["TW"] = "zh-TW", ["HK"] = "zh-HK",
|
||||
["RU"] = "ru-RU", ["UA"] = "uk-UA", ["PL"] = "pl-PL", ["CZ"] = "cs-CZ", ["RO"] = "ro-RO",
|
||||
["IL"] = "he-IL", ["TR"] = "tr-TR", ["SA"] = "ar-SA", ["AE"] = "ar-AE", ["EG"] = "ar-EG",
|
||||
["IN"] = "hi-IN", ["ID"] = "id-ID", ["PH"] = "en-PH",
|
||||
["TH"] = "th-TH", ["VN"] = "vi-VN", ["MY"] = "ms-MY",
|
||||
["SE"] = "sv-SE", ["NO"] = "nb-NO", ["DK"] = "da-DK", ["FI"] = "fi-FI",
|
||||
["GR"] = "el-GR", ["HU"] = "hu-HU", ["BG"] = "bg-BG",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolve timezone and locale from a proxy's IP address.
|
||||
/// Returns (timezone, locale) - either or both may be null on failure. Never throws.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale)> ResolveProxyGeoAsync(
|
||||
string proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
var (tz, locale, _) = await ResolveProxyGeoWithIpAsync(proxyUrl, ct).ConfigureAwait(false);
|
||||
return (tz, locale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve timezone, locale, and exit IP from a proxy.
|
||||
/// The exit IP is a free bonus from the lookup - reused for WebRTC spoofing
|
||||
/// without an extra HTTP call.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> ResolveProxyGeoWithIpAsync(
|
||||
string proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
var dbPath = await EnsureGeoIpDbAsync(ct).ConfigureAwait(false);
|
||||
if (dbPath == null)
|
||||
return (null, null, null);
|
||||
|
||||
var timeout = GetGeoIpTimeoutSeconds();
|
||||
var deadline = DeadlineFromTimeout(timeout);
|
||||
|
||||
// Exit IP (through proxy) is most accurate - gateway DNS may differ from exit.
|
||||
var ip = await ResolveExitIpAsync(proxyUrl, RemainingSeconds(deadline), ct).ConfigureAwait(false);
|
||||
if (ip == null && !DeadlineExpired(deadline))
|
||||
ip = ResolveProxyIp(proxyUrl);
|
||||
if (ip == null || DeadlineExpired(deadline))
|
||||
{
|
||||
if (deadline != null && DeadlineExpired(deadline))
|
||||
CloakLog.Warning("GeoIP resolution timed out after {0:0.0}s; continuing without GeoIP", timeout);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new DatabaseReader(dbPath);
|
||||
var resp = reader.City(ip);
|
||||
var timezone = resp.Location?.TimeZone;
|
||||
var country = resp.Country?.IsoCode;
|
||||
string? locale = country != null && CountryLocaleMap.TryGetValue(country, out var l) ? l : null;
|
||||
CloakLog.Debug("GeoIP: {0} -> tz={1}, country={2}, locale={3}", ip, timezone, country, locale);
|
||||
return (timezone, locale, ip);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("GeoIP lookup failed for {0}: {1}", ip, exc.Message);
|
||||
return (null, null, ip);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Proxy IP resolution
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string? ResolveProxyIp(string proxyUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
var hostname = uri.Host;
|
||||
if (string.IsNullOrEmpty(hostname))
|
||||
return null;
|
||||
|
||||
// Already a literal IP?
|
||||
if (IPAddress.TryParse(hostname, out var literal))
|
||||
return literal.ToString();
|
||||
|
||||
// DNS resolve (returns first result, handles both v4/v6).
|
||||
var results = Dns.GetHostAddresses(hostname);
|
||||
if (results.Length > 0)
|
||||
{
|
||||
var ip = results[0].ToString();
|
||||
CloakLog.Debug("Resolved proxy {0} -> {1}", hostname, ip);
|
||||
return ip;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("Failed to resolve proxy hostname: {0}", exc.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Check if an IP address is private/internal (not routable on the internet).</summary>
|
||||
public static bool IsPrivateIp(string ip)
|
||||
{
|
||||
if (!IPAddress.TryParse(ip, out var addr)) return false;
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var b = addr.GetAddressBytes();
|
||||
return b[0] == 10
|
||||
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| b[0] == 127
|
||||
|| (b[0] == 169 && b[1] == 254);
|
||||
}
|
||||
return IPAddress.IsLoopback(addr) || addr.IsIPv6LinkLocal || addr.IsIPv6SiteLocal;
|
||||
}
|
||||
|
||||
private static double GetGeoIpTimeoutSeconds()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(GeoIpTimeoutEnv);
|
||||
if (string.IsNullOrEmpty(raw)) return DefaultGeoIpTimeoutSeconds;
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var timeout)
|
||||
|| double.IsNaN(timeout) || double.IsInfinity(timeout))
|
||||
{
|
||||
CloakLog.Warning("Invalid {0}={1}; using {2:0.0}s", GeoIpTimeoutEnv, raw, DefaultGeoIpTimeoutSeconds);
|
||||
return DefaultGeoIpTimeoutSeconds;
|
||||
}
|
||||
return Math.Max(timeout, 0.0);
|
||||
}
|
||||
|
||||
private static double? DeadlineFromTimeout(double timeout) =>
|
||||
timeout <= 0 ? null : Now() + timeout;
|
||||
|
||||
private static double? RemainingSeconds(double? deadline) =>
|
||||
deadline == null ? null : Math.Max(deadline.Value - Now(), 0.0);
|
||||
|
||||
private static bool DeadlineExpired(double? deadline) =>
|
||||
deadline != null && Now() >= deadline.Value;
|
||||
|
||||
private static double Now() =>
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() / (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
/// <summary>Resolve only the proxy exit IP, bounded by the GeoIP timeout.</summary>
|
||||
public static async Task<string?> ResolveProxyExitIpAsync(string proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
var timeout = GetGeoIpTimeoutSeconds();
|
||||
var deadline = DeadlineFromTimeout(timeout);
|
||||
var ip = await ResolveExitIpAsync(proxyUrl, timeout, ct).ConfigureAwait(false);
|
||||
if (ip == null && DeadlineExpired(deadline))
|
||||
CloakLog.Warning("GeoIP resolution timed out after {0:0.0}s; continuing without GeoIP", timeout);
|
||||
return ip;
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveExitIpAsync(string proxyUrl, double? timeout, CancellationToken ct)
|
||||
{
|
||||
var deadline = DeadlineFromTimeout(timeout ?? 0);
|
||||
|
||||
HttpClient client;
|
||||
try
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
Proxy = new WebProxy(NormalizeProxyForWebProxy(proxyUrl)),
|
||||
UseProxy = true,
|
||||
};
|
||||
var creds = ExtractProxyCredentials(proxyUrl);
|
||||
if (creds != null)
|
||||
handler.Proxy.Credentials = creds;
|
||||
client = new HttpClient(handler);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("SOCKS5 proxy requires a SOCKS-capable transport; cannot resolve exit IP");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var url in IpEchoUrls)
|
||||
{
|
||||
try
|
||||
{
|
||||
var remaining = RemainingSeconds(deadline);
|
||||
if (remaining != null && remaining <= 0)
|
||||
return null;
|
||||
var requestTimeout = remaining != null
|
||||
? TimeSpan.FromSeconds(Math.Min(10.0, remaining.Value))
|
||||
: TimeSpan.FromSeconds(10.0);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(requestTimeout);
|
||||
var resp = await client.GetAsync(url, cts.Token).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var ip = (await resp.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false)).Trim();
|
||||
if (IPAddress.TryParse(ip, out _))
|
||||
{
|
||||
CloakLog.Debug("Exit IP via {0}: {1}", url, ip);
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* try next */ }
|
||||
}
|
||||
CloakLog.Warning("Failed to discover exit IP through proxy");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeProxyForWebProxy(string proxyUrl)
|
||||
{
|
||||
// WebProxy wants scheme://host:port without credentials.
|
||||
if (!Uri.TryCreate(proxyUrl.Contains("://") ? proxyUrl : "http://" + proxyUrl,
|
||||
UriKind.Absolute, out var uri))
|
||||
return proxyUrl;
|
||||
var builder = new UriBuilder(uri.Scheme, uri.Host, uri.Port) { Path = uri.AbsolutePath };
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
private static NetworkCredential? ExtractProxyCredentials(string proxyUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(proxyUrl.Contains("://") ? proxyUrl : "http://" + proxyUrl,
|
||||
UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
if (string.IsNullOrEmpty(uri.UserInfo))
|
||||
return null;
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
var user = Uri.UnescapeDataString(parts[0]);
|
||||
var pass = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : "";
|
||||
return new NetworkCredential(user, pass);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GeoIP database management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string GetGeoIpDir() => Path.Combine(Config.GetCacheDir(), "geoip");
|
||||
|
||||
private static async Task<string?> EnsureGeoIpDbAsync(CancellationToken ct)
|
||||
{
|
||||
var dbPath = Path.Combine(GetGeoIpDir(), GeoIpDbFilename);
|
||||
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
MaybeTriggerUpdate(dbPath);
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DownloadGeoIpDbAsync(dbPath, ct).ConfigureAwait(false);
|
||||
return dbPath;
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("Failed to download GeoIP database: {0}", exc.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DownloadGeoIpDbAsync(string dest, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
CloakLog.Info("Downloading GeoIP database (~70 MB) ...");
|
||||
|
||||
var tmpPath = dest + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
|
||||
try
|
||||
{
|
||||
using var resp = await client.GetAsync(GeoIpDbUrl, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
long total = resp.Content.Headers.ContentLength ?? 0;
|
||||
long downloaded = 0;
|
||||
int lastPct = -1;
|
||||
|
||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
|
||||
await using (var fs = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
var buffer = new byte[65_536];
|
||||
int read;
|
||||
while ((read = await src.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await fs.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
|
||||
downloaded += read;
|
||||
if (total > 0)
|
||||
{
|
||||
int pct = (int)(downloaded * 100 / total);
|
||||
if (pct >= lastPct + 10)
|
||||
{
|
||||
lastPct = pct;
|
||||
CloakLog.Info("GeoIP download: {0} %", pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(dest)) File.Delete(dest);
|
||||
File.Move(tmpPath, dest);
|
||||
CloakLog.Info("GeoIP database ready: {0}", dest);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MaybeTriggerUpdate(string dbPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var age = (DateTime.UtcNow - File.GetLastWriteTimeUtc(dbPath)).TotalSeconds;
|
||||
if (age < GeoIpUpdateInterval)
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await DownloadGeoIpDbAsync(dbPath, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception) { CloakLog.Debug("Background GeoIP update failed"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// A launched stealth browser. Owns the underlying Playwright instance and disposes
|
||||
/// it on <see cref="CloseAsync"/>. Use <see cref="Browser"/> for the raw Playwright API.
|
||||
/// </summary>
|
||||
public sealed class CloakBrowserHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright _playwright;
|
||||
private readonly bool _humanize;
|
||||
private readonly HumanConfig? _humanCfg;
|
||||
private readonly IBrowser _rawBrowser;
|
||||
private readonly bool _headless;
|
||||
|
||||
/// <summary>
|
||||
/// The Playwright browser. When humanize is enabled this is a transparent
|
||||
/// humanizing wrapper: every page/context it produces uses human-like mouse,
|
||||
/// keyboard, and scrolling automatically while exposing the full standard
|
||||
/// <see cref="IBrowser"/> API. Use <see cref="RawBrowser"/> for the un-wrapped
|
||||
/// browser.
|
||||
/// </summary>
|
||||
public IBrowser Browser { get; }
|
||||
|
||||
/// <summary>The original, un-humanized Playwright browser (escape hatch).</summary>
|
||||
public IBrowser RawBrowser => _rawBrowser;
|
||||
|
||||
/// <summary>The owning Playwright instance (used internally to transfer ownership to a context handle).</summary>
|
||||
internal IPlaywright PlaywrightInstance => _playwright;
|
||||
|
||||
internal CloakBrowserHandle(IPlaywright playwright, IBrowser browser, bool humanize, HumanConfig? humanCfg,
|
||||
bool headless = true)
|
||||
{
|
||||
_playwright = playwright;
|
||||
_rawBrowser = browser;
|
||||
_humanize = humanize;
|
||||
_humanCfg = humanCfg;
|
||||
_headless = headless;
|
||||
// Wrap the whole browser so the entire object graph (contexts, pages, mice,
|
||||
// keyboards, locators, frames) is humanized transparently. The wrapper is
|
||||
// headless-aware so the headed no-viewport default also applies when pages are
|
||||
// created through the humanized browser (parity with Python's _default_no_viewport,
|
||||
// which patches the raw browser so the default holds on every path).
|
||||
Browser = humanize
|
||||
? Wrappers.Humanize.Browser(browser, humanCfg ?? new HumanConfig(), headless)
|
||||
: browser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On headed launches, default a page/context that the caller didn't give an explicit
|
||||
/// viewport to <c>NoViewport</c> so it tracks the real OS window. Delegates to the
|
||||
/// shared <see cref="ViewportDefaults"/> (the single source of truth shared with the
|
||||
/// humanize wrapper). Port of Python <c>_default_no_viewport</c>.
|
||||
/// </summary>
|
||||
private BrowserNewPageOptions ApplyDefaultNoViewport(BrowserNewPageOptions? options) =>
|
||||
ViewportDefaults.ApplyHeadedNoViewport(options, _headless);
|
||||
|
||||
private BrowserNewContextOptions ApplyDefaultNoViewport(BrowserNewContextOptions? options) =>
|
||||
ViewportDefaults.ApplyHeadedNoViewport(options, _headless);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new browser context. On headed launches without an explicit viewport,
|
||||
/// defaults to <c>NoViewport</c> so the page tracks the real window (see
|
||||
/// <see cref="NewPageAsync"/>).
|
||||
/// </summary>
|
||||
public Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
|
||||
Browser.NewContextAsync(ApplyDefaultNoViewport(options));
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page. When humanize is enabled the returned <see cref="IPage"/> is a
|
||||
/// transparent humanizing wrapper - your standard Playwright calls
|
||||
/// (<c>page.ClickAsync</c>, <c>page.FillAsync</c>, <c>page.Mouse.MoveAsync</c>, ...)
|
||||
/// are automatically humanized.
|
||||
/// </summary>
|
||||
public Task<IPage> NewPageAsync(BrowserNewPageOptions? options = null) =>
|
||||
Browser.NewPageAsync(ApplyDefaultNoViewport(options));
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page wrapped in an explicit <see cref="HumanPage"/> with this
|
||||
/// browser's humanize config. Prefer <see cref="NewPageAsync"/> for transparent
|
||||
/// humanization; this is the explicit-wrapper API kept for advanced control.
|
||||
/// </summary>
|
||||
public async Task<HumanPage> NewHumanPageAsync(BrowserNewPageOptions? options = null)
|
||||
{
|
||||
// Use the raw browser so we don't build a HumanPage over an already-wrapped page.
|
||||
var page = await _rawBrowser.NewPageAsync(ApplyDefaultNoViewport(options)).ConfigureAwait(false);
|
||||
return await HumanPage.CreateAsync(page, _humanCfg ?? new HumanConfig()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Whether the humanize layer is enabled for this browser.</summary>
|
||||
public bool HumanizeEnabled => _humanize;
|
||||
|
||||
/// <summary>Close the browser and stop the underlying Playwright instance.</summary>
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
try { await _rawBrowser.CloseAsync().ConfigureAwait(false); }
|
||||
finally { _playwright.Dispose(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync() => await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A launched stealth browser context. Owns the underlying Playwright instance (and,
|
||||
/// for non-persistent contexts, the browser) and cleans them up on <see cref="CloseAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class CloakContextHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright _playwright;
|
||||
private readonly IBrowser? _browser; // null for persistent contexts
|
||||
private readonly bool _humanize;
|
||||
private readonly HumanConfig? _humanCfg;
|
||||
private readonly IBrowserContext _rawContext;
|
||||
|
||||
/// <summary>
|
||||
/// The Playwright browser context. When humanize is enabled this is a transparent
|
||||
/// humanizing wrapper: every page it produces uses human-like input automatically
|
||||
/// while exposing the full standard <see cref="IBrowserContext"/> API. Use
|
||||
/// <see cref="RawContext"/> for the un-wrapped context.
|
||||
/// </summary>
|
||||
public IBrowserContext Context { get; }
|
||||
|
||||
/// <summary>The original, un-humanized Playwright context (escape hatch).</summary>
|
||||
public IBrowserContext RawContext => _rawContext;
|
||||
|
||||
internal CloakContextHandle(IPlaywright playwright, IBrowser? browser, IBrowserContext context,
|
||||
bool humanize, HumanConfig? humanCfg)
|
||||
{
|
||||
_playwright = playwright;
|
||||
_browser = browser;
|
||||
_rawContext = context;
|
||||
_humanize = humanize;
|
||||
_humanCfg = humanCfg;
|
||||
Context = humanize ? Wrappers.Humanize.Context(context, humanCfg ?? new HumanConfig()) : context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page. When humanize is enabled the returned <see cref="IPage"/> is a
|
||||
/// transparent humanizing wrapper - standard Playwright calls are auto-humanized.
|
||||
/// </summary>
|
||||
public Task<IPage> NewPageAsync() => Context.NewPageAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page wrapped in an explicit <see cref="HumanPage"/> with this
|
||||
/// context's humanize config. Prefer <see cref="NewPageAsync"/> for transparent
|
||||
/// humanization.
|
||||
/// </summary>
|
||||
public async Task<HumanPage> NewHumanPageAsync()
|
||||
{
|
||||
var page = await _rawContext.NewPageAsync().ConfigureAwait(false);
|
||||
return await HumanPage.CreateAsync(page, _humanCfg ?? new HumanConfig()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Whether the humanize layer is enabled for this context.</summary>
|
||||
public bool HumanizeEnabled => _humanize;
|
||||
|
||||
/// <summary>Close the context (and browser, if owned) and stop Playwright.</summary>
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _rawContext.CloseAsync().ConfigureAwait(false);
|
||||
if (_browser != null)
|
||||
await _browser.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playwright.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync() => await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Base for all actionability failures. Mirrors Python <c>ActionabilityError</c>.</summary>
|
||||
public class ActionabilityError : Exception
|
||||
{
|
||||
/// <summary>The selector or label of the element that failed.</summary>
|
||||
public string Selector { get; }
|
||||
|
||||
/// <summary>The name of the check that failed (attached/visible/stable/...).</summary>
|
||||
public string Check { get; }
|
||||
|
||||
public ActionabilityError(string selector, string check, string message)
|
||||
: base($"Element '{selector}' failed {check} check: {message}")
|
||||
{
|
||||
Selector = selector;
|
||||
Check = check;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The element was never attached to the DOM.</summary>
|
||||
public sealed class ElementNotAttachedError : ActionabilityError
|
||||
{
|
||||
public ElementNotAttachedError(string selector)
|
||||
: base(selector, "attached", "element not found in DOM") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is present but not visible.</summary>
|
||||
public sealed class ElementNotVisibleError : ActionabilityError
|
||||
{
|
||||
public ElementNotVisibleError(string selector)
|
||||
: base(selector, "visible", "element is not visible") { }
|
||||
}
|
||||
|
||||
/// <summary>The element's bounding box keeps moving.</summary>
|
||||
public sealed class ElementNotStableError : ActionabilityError
|
||||
{
|
||||
public ElementNotStableError(string selector)
|
||||
: base(selector, "stable", "element position is still changing") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is disabled.</summary>
|
||||
public sealed class ElementNotEnabledError : ActionabilityError
|
||||
{
|
||||
public ElementNotEnabledError(string selector)
|
||||
: base(selector, "enabled", "element is disabled") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is not editable.</summary>
|
||||
public sealed class ElementNotEditableError : ActionabilityError
|
||||
{
|
||||
public ElementNotEditableError(string selector)
|
||||
: base(selector, "editable", "element is not editable") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is covered by another element at the click point.</summary>
|
||||
public sealed class ElementNotReceivingEventsError : ActionabilityError
|
||||
{
|
||||
public ElementNotReceivingEventsError(string selector, string coveringTag = "unknown")
|
||||
: base(selector, "pointer_events", $"element is covered by <{coveringTag}>") { }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Playwright-style actionability checks for the humanize layer.
|
||||
/// Direct port of Python <c>cloakbrowser/human/actionability.py</c>.
|
||||
/// Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
/// Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
/// </summary>
|
||||
public static class Actionability
|
||||
{
|
||||
/// <summary>Checks for a click action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksClick =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a hover action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksHover =
|
||||
new HashSet<string> { "attached", "visible", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a text-input action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksInput =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "editable", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a focus action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksFocus =
|
||||
new HashSet<string> { "attached", "visible", "enabled" };
|
||||
|
||||
/// <summary>Checks for a check/uncheck action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksCheck =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "pointer_events" };
|
||||
|
||||
private static readonly int[] BackoffMs = { 100, 250, 500, 1000 };
|
||||
|
||||
private static Task BackoffSleepAsync(int attempt)
|
||||
{
|
||||
int idx = Math.Min(attempt, BackoffMs.Length - 1);
|
||||
return Task.Delay(BackoffMs[idx]);
|
||||
}
|
||||
|
||||
private static double NowMs() => Environment.TickCount64;
|
||||
|
||||
/// <summary>
|
||||
/// Milliseconds left until <paramref name="deadline"/> (an <see cref="Environment.TickCount64"/>
|
||||
/// timestamp), clamped at zero. Sequential operations share one deadline so the total
|
||||
/// timeout budget is never multiplied (issue #307). Never returns a negative value.
|
||||
/// </summary>
|
||||
internal static double RemainingMs(double deadline) => Math.Max(0, deadline - NowMs());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pre-scroll actionability: attached, visible, enabled, editable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the element to pass actionability checks (pre-scroll). Retries
|
||||
/// with backoff until <paramref name="timeoutMs"/> elapsed. Throws a specific
|
||||
/// <see cref="ActionabilityError"/> subclass on failure. Returns immediately
|
||||
/// when <paramref name="force"/> is true.
|
||||
/// </summary>
|
||||
public static async Task EnsureActionableAsync(
|
||||
IPage page,
|
||||
string selector,
|
||||
IReadOnlySet<string> checks,
|
||||
double timeoutMs = 30000,
|
||||
bool force = false)
|
||||
{
|
||||
if (force)
|
||||
return;
|
||||
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
ActionabilityError? lastError = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
{
|
||||
if (lastError != null)
|
||||
throw lastError;
|
||||
throw new ActionabilityError(selector, "timeout", "timeout expired before first check");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loc = page.Locator(selector).First;
|
||||
|
||||
if (checks.Contains("attached"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await loc.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Attached,
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotAttachedError(selector); }
|
||||
}
|
||||
|
||||
if (checks.Contains("visible") && !await loc.IsVisibleAsync().ConfigureAwait(false))
|
||||
throw new ElementNotVisibleError(selector);
|
||||
|
||||
if (checks.Contains("enabled") && !await loc.IsEnabledAsync().ConfigureAwait(false))
|
||||
throw new ElementNotEnabledError(selector);
|
||||
|
||||
if (checks.Contains("editable") && !await loc.IsEditableAsync().ConfigureAwait(false))
|
||||
throw new ElementNotEditableError(selector);
|
||||
|
||||
return;
|
||||
}
|
||||
catch (ActionabilityError e)
|
||||
{
|
||||
lastError = e;
|
||||
if (NowMs() >= deadline)
|
||||
throw;
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-scroll stability check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static bool BoxesDiffer(LocatorBoundingBoxResult a, LocatorBoundingBoxResult b) =>
|
||||
Math.Abs(a.X - b.X) > 1
|
||||
|| Math.Abs(a.Y - b.Y) > 1
|
||||
|| Math.Abs(a.Width - b.Width) > 1
|
||||
|| Math.Abs(a.Height - b.Height) > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the element's position to stabilize (two samples 100ms apart).
|
||||
/// Only call after a scroll - skip if the element was already in the viewport.
|
||||
/// </summary>
|
||||
public static async Task EnsureStableAsync(IPage page, string selector, double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
throw new ElementNotStableError(selector);
|
||||
|
||||
var loc = page.Locator(selector).First;
|
||||
var box1 = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
if (box1 == null)
|
||||
throw new ElementNotAttachedError(selector);
|
||||
|
||||
await Task.Delay(100).ConfigureAwait(false);
|
||||
|
||||
var box2 = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
if (box2 == null)
|
||||
throw new ElementNotAttachedError(selector);
|
||||
|
||||
if (!BoxesDiffer(box1, box2))
|
||||
return;
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotStableError(selector);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pointer-events check (post-scroll, at actual click coordinates)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// data.box is page-space (from boundingBox); rect is frame-local. Their delta
|
||||
// is the iframe offset, needed to map page-space click coords into the frame's
|
||||
// own viewport before elementFromPoint. For main-frame elements the offset is 0.
|
||||
internal const string PointerEventsJs = @"(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}";
|
||||
|
||||
/// <summary>
|
||||
/// Result of the <c>elementFromPoint</c> pointer-events probe. Internal (not private)
|
||||
/// so unit tests can construct a "covered" result without a live browser.
|
||||
/// </summary>
|
||||
internal sealed class PointerResult
|
||||
{
|
||||
public bool Hit { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? Covering { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check that <c>elementFromPoint(x, y)</c> hits the expected element. Uses
|
||||
/// <c>locator.evaluate()</c> so all Playwright selector types work. Retries
|
||||
/// with backoff for transient overlays. Fails open when the result can't be
|
||||
/// determined.
|
||||
/// </summary>
|
||||
public static async Task CheckPointerEventsAsync(
|
||||
IPage page,
|
||||
string selector,
|
||||
double x,
|
||||
double y,
|
||||
double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
PointerResult? result = null;
|
||||
try
|
||||
{
|
||||
var loc = page.Locator(selector).First;
|
||||
var box = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(deadline - NowMs(), 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
var data = new
|
||||
{
|
||||
x,
|
||||
y,
|
||||
box = box == null ? null : new { x = box.X, y = box.Y, width = box.Width, height = box.Height },
|
||||
};
|
||||
result = await loc.EvaluateAsync<PointerResult?>(PointerEventsJs, data).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Debug($"pointer_events check failed for '{selector}': {exc.Message}");
|
||||
result = null;
|
||||
}
|
||||
|
||||
// Proceed if the check confirms a hit, or if it could not be determined
|
||||
// (null) - failing closed would block legitimate clicks.
|
||||
if (result == null || result.Hit)
|
||||
return;
|
||||
|
||||
string covering = result.Covering ?? "unknown";
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotReceivingEventsError(selector, covering);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ElementHandle variants
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Actionability checks for an <see cref="IElementHandle"/> (no selector needed).</summary>
|
||||
public static async Task EnsureActionableHandleAsync(
|
||||
IElementHandle el,
|
||||
IReadOnlySet<string> checks,
|
||||
double timeoutMs = 30000,
|
||||
bool force = false)
|
||||
{
|
||||
if (force)
|
||||
return;
|
||||
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
ActionabilityError? lastError = null;
|
||||
const string label = "<ElementHandle>";
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
{
|
||||
if (lastError != null)
|
||||
throw lastError;
|
||||
throw new ActionabilityError(label, "timeout", "timeout expired before first check");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (checks.Contains("visible"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Visible, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotVisibleError(label); }
|
||||
}
|
||||
|
||||
if (checks.Contains("enabled"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Enabled, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotEnabledError(label); }
|
||||
}
|
||||
|
||||
if (checks.Contains("editable"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Editable, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotEditableError(label); }
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (ActionabilityError e)
|
||||
{
|
||||
lastError = e;
|
||||
if (NowMs() >= deadline)
|
||||
throw;
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pointer-events check for an <see cref="IElementHandle"/>.</summary>
|
||||
public static async Task CheckPointerEventsHandleAsync(
|
||||
IElementHandle el,
|
||||
double x,
|
||||
double y,
|
||||
double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
PointerResult? result = null;
|
||||
try
|
||||
{
|
||||
var box = await el.BoundingBoxAsync().ConfigureAwait(false);
|
||||
var data = new
|
||||
{
|
||||
x,
|
||||
y,
|
||||
box = box == null ? null : new { x = box.X, y = box.Y, width = box.Width, height = box.Height },
|
||||
};
|
||||
result = await el.EvaluateAsync<PointerResult?>(PointerEventsJs, data).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result == null || result.Hit)
|
||||
return;
|
||||
|
||||
string covering = result.Covering ?? "unknown";
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotReceivingEventsError("<ElementHandle>", covering);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>A (min, max) inclusive numeric range, mirroring Python's <c>Range = Tuple[float, float]</c>.</summary>
|
||||
public readonly record struct Range(double Min, double Max)
|
||||
{
|
||||
public static implicit operator Range((double Min, double Max) t) => new(t.Min, t.Max);
|
||||
}
|
||||
|
||||
/// <summary>Humanize behavior preset names.</summary>
|
||||
public enum HumanPreset
|
||||
{
|
||||
Default,
|
||||
Careful,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All tunable parameters for human-like behavior.
|
||||
/// Direct port of Python <c>cloakbrowser/human/config.py</c> (the <c>HumanConfig</c> dataclass).
|
||||
/// Property names match the Python field names (snake_case keys are accepted by
|
||||
/// <see cref="HumanConfigExtensions.With(HumanConfig, IReadOnlyDictionary{string, object})"/>).
|
||||
/// </summary>
|
||||
public sealed class HumanConfig
|
||||
{
|
||||
// Keyboard
|
||||
public double TypingDelay { get; set; } = 70;
|
||||
public double TypingDelaySpread { get; set; } = 40;
|
||||
public double TypingPauseChance { get; set; } = 0.1;
|
||||
public Range TypingPauseRange { get; set; } = (400, 1000);
|
||||
public Range ShiftDownDelay { get; set; } = (30, 70);
|
||||
public Range ShiftUpDelay { get; set; } = (20, 50);
|
||||
public Range KeyHold { get; set; } = (15, 35);
|
||||
|
||||
// Mistype (typo simulation)
|
||||
public double MistypeChance { get; set; } = 0.02;
|
||||
public Range MistypeDelayNotice { get; set; } = (100, 300);
|
||||
public Range MistypeDelayCorrect { get; set; } = (50, 150);
|
||||
|
||||
public Range FieldSwitchDelay { get; set; } = (800, 1500);
|
||||
|
||||
// Mouse - movement
|
||||
public double MouseStepsDivisor { get; set; } = 8;
|
||||
public int MouseMinSteps { get; set; } = 25;
|
||||
public int MouseMaxSteps { get; set; } = 80;
|
||||
public double MouseWobbleMax { get; set; } = 1.5;
|
||||
public double MouseOvershootChance { get; set; } = 0.15;
|
||||
public Range MouseOvershootPx { get; set; } = (3, 6);
|
||||
public Range MouseBurstSize { get; set; } = (3, 5);
|
||||
public Range MouseBurstPause { get; set; } = (8, 18);
|
||||
|
||||
// Mouse - clicks
|
||||
public Range ClickAimDelayInput { get; set; } = (60, 140);
|
||||
public Range ClickAimDelayButton { get; set; } = (80, 200);
|
||||
public Range ClickHoldInput { get; set; } = (40, 100);
|
||||
public Range ClickHoldButton { get; set; } = (60, 150);
|
||||
public Range ClickInputXRange { get; set; } = (0.05, 0.30);
|
||||
|
||||
// Mouse - idle
|
||||
public double IdleDriftPx { get; set; } = 3;
|
||||
public Range IdlePauseRange { get; set; } = (300, 1000);
|
||||
|
||||
// Scroll
|
||||
public Range ScrollDeltaBase { get; set; } = (80, 130);
|
||||
public double ScrollDeltaVariance { get; set; } = 0.2;
|
||||
public Range ScrollPauseFast { get; set; } = (30, 80);
|
||||
public Range ScrollPauseSlow { get; set; } = (80, 200);
|
||||
public Range ScrollAccelSteps { get; set; } = (2, 3);
|
||||
public Range ScrollDecelSteps { get; set; } = (2, 3);
|
||||
public double ScrollOvershootChance { get; set; } = 0.1;
|
||||
public Range ScrollOvershootPx { get; set; } = (50, 150);
|
||||
public Range ScrollSettleDelay { get; set; } = (300, 600);
|
||||
public Range ScrollTargetZone { get; set; } = (0.20, 0.80);
|
||||
public Range ScrollPreMoveDelay { get; set; } = (100, 300);
|
||||
|
||||
// Initial cursor position (as if coming from the address bar area)
|
||||
public Range InitialCursorX { get; set; } = (400, 700);
|
||||
public Range InitialCursorY { get; set; } = (45, 60);
|
||||
|
||||
// Idle micro-movements between actions (opt-in, adds latency)
|
||||
public bool IdleBetweenActions { get; set; } = false;
|
||||
public Range IdleBetweenDuration { get; set; } = (0.3, 0.8);
|
||||
|
||||
/// <summary>Create a shallow copy of this config.</summary>
|
||||
public HumanConfig Clone() => (HumanConfig)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>Resolution and merging helpers for <see cref="HumanConfig"/>.</summary>
|
||||
public static class HumanConfigFactory
|
||||
{
|
||||
private static HumanConfig CarefulConfig() => new()
|
||||
{
|
||||
// Keyboard - slower typing
|
||||
TypingDelay = 100,
|
||||
TypingDelaySpread = 50,
|
||||
TypingPauseChance = 0.15,
|
||||
TypingPauseRange = (500, 1200),
|
||||
ShiftDownDelay = (40, 90),
|
||||
ShiftUpDelay = (30, 70),
|
||||
KeyHold = (20, 45),
|
||||
FieldSwitchDelay = (1000, 2000),
|
||||
// Mouse - slower, more precise
|
||||
MouseOvershootChance = 0.10,
|
||||
MouseBurstPause = (12, 25),
|
||||
// Mouse - clicks (longer aiming and holding)
|
||||
ClickAimDelayInput = (80, 180),
|
||||
ClickAimDelayButton = (120, 280),
|
||||
ClickHoldInput = (60, 140),
|
||||
ClickHoldButton = (80, 200),
|
||||
// Scroll - slower
|
||||
ScrollPauseFast = (100, 200),
|
||||
ScrollPauseSlow = (250, 600),
|
||||
ScrollSettleDelay = (400, 800),
|
||||
ScrollPreMoveDelay = (150, 400),
|
||||
// Idle between actions enabled for careful preset
|
||||
IdleBetweenActions = true,
|
||||
IdleBetweenDuration = (0.4, 1.0),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a preset name + optional overrides into a full <see cref="HumanConfig"/>.
|
||||
/// </summary>
|
||||
public static HumanConfig Resolve(
|
||||
HumanPreset preset = HumanPreset.Default,
|
||||
IReadOnlyDictionary<string, object>? overrides = null)
|
||||
{
|
||||
var baseCfg = preset switch
|
||||
{
|
||||
HumanPreset.Default => new HumanConfig(),
|
||||
HumanPreset.Careful => CarefulConfig(),
|
||||
_ => throw new ArgumentException($"Unknown humanize preset {preset}."),
|
||||
};
|
||||
return overrides == null || overrides.Count == 0
|
||||
? baseCfg
|
||||
: baseCfg.With(overrides);
|
||||
}
|
||||
|
||||
/// <summary>Parse a preset name string ('default'/'careful'), case-insensitive.</summary>
|
||||
public static HumanPreset ParsePreset(string? preset) => (preset ?? "default").ToLowerInvariant() switch
|
||||
{
|
||||
"default" => HumanPreset.Default,
|
||||
"careful" => HumanPreset.Careful,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown humanize preset '{preset}'. Valid presets: careful, default"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers for merging override dictionaries onto a <see cref="HumanConfig"/>.</summary>
|
||||
public static class HumanConfigExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Merge a dictionary of overrides (keys may be snake_case like the Python API,
|
||||
/// or PascalCase property names) on top of <paramref name="baseCfg"/>.
|
||||
/// Returns a new config - the base is never mutated. Unknown keys are ignored.
|
||||
/// </summary>
|
||||
public static HumanConfig With(this HumanConfig baseCfg, IReadOnlyDictionary<string, object>? overrides)
|
||||
{
|
||||
if (overrides == null || overrides.Count == 0)
|
||||
return baseCfg.Clone();
|
||||
|
||||
var result = baseCfg.Clone();
|
||||
foreach (var (key, value) in overrides)
|
||||
{
|
||||
var prop = ResolveProperty(key);
|
||||
if (prop == null) continue; // unknown keys ignored silently
|
||||
try
|
||||
{
|
||||
prop.SetValue(result, Coerce(value, prop.PropertyType));
|
||||
}
|
||||
catch (Exception) { /* ignore bad coercions, matching Python's forgiving merge */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static System.Reflection.PropertyInfo? ResolveProperty(string key)
|
||||
{
|
||||
var pascal = SnakeToPascal(key);
|
||||
return typeof(HumanConfig).GetProperty(pascal,
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||
}
|
||||
|
||||
private static string SnakeToPascal(string key)
|
||||
{
|
||||
if (!key.Contains('_'))
|
||||
// Already PascalCase (or single word) - normalise first char to upper.
|
||||
return key.Length == 0 ? key : char.ToUpperInvariant(key[0]) + key[1..];
|
||||
var parts = key.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Concat(parts.Select(p => char.ToUpperInvariant(p[0]) + p[1..]));
|
||||
}
|
||||
|
||||
private static object Coerce(object value, Type target)
|
||||
{
|
||||
if (target == typeof(Range))
|
||||
{
|
||||
// Accept (double,double) tuple, double[]/object[] of length 2, List, etc.
|
||||
switch (value)
|
||||
{
|
||||
case Range r:
|
||||
return r;
|
||||
case ValueTuple<double, double> vt:
|
||||
return new Range(vt.Item1, vt.Item2);
|
||||
case IEnumerable<object> seq:
|
||||
{
|
||||
var arr = seq.ToArray();
|
||||
if (arr.Length >= 2)
|
||||
return new Range(Convert.ToDouble(arr[0]), Convert.ToDouble(arr[1]));
|
||||
break;
|
||||
}
|
||||
case System.Collections.IEnumerable en and not string:
|
||||
{
|
||||
var list = en.Cast<object>().ToArray();
|
||||
if (list.Length >= 2)
|
||||
return new Range(Convert.ToDouble(list[0]), Convert.ToDouble(list[1]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new InvalidCastException("Cannot convert value to Range");
|
||||
}
|
||||
if (target == typeof(bool)) return Convert.ToBoolean(value);
|
||||
if (target == typeof(int)) return Convert.ToInt32(value);
|
||||
if (target == typeof(double)) return Convert.ToDouble(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal keyboard abstraction the humanize layer drives. Mirrors the Python
|
||||
/// <c>RawKeyboard</c> Protocol. Implemented over Playwright's <c>IKeyboard</c>.
|
||||
/// </summary>
|
||||
public interface IRawKeyboard
|
||||
{
|
||||
Task DownAsync(string key);
|
||||
Task UpAsync(string key);
|
||||
Task TypeAsync(string text);
|
||||
Task InsertTextAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tiny abstraction over a CDP session so the keyboard can dispatch trusted
|
||||
/// key events. Implemented over Playwright's <c>ICDPSession</c>.
|
||||
/// </summary>
|
||||
public interface IRawCdpSession
|
||||
{
|
||||
Task SendAsync(string method, JsonObject? args = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tiny abstraction over <c>page.evaluate</c> used by the fallback shift-symbol path.
|
||||
/// </summary>
|
||||
public interface IRawEvaluator
|
||||
{
|
||||
Task EvaluateAsync(string expression, object? arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-like keyboard input. Direct port of Python
|
||||
/// <c>cloakbrowser/human/keyboard.py</c>.
|
||||
///
|
||||
/// Stealth-aware: when a CDP session is provided, shift symbols are typed via
|
||||
/// CDP <c>Input.dispatchKeyEvent</c> (isTrusted=true, no evaluate stack trace).
|
||||
/// Falls back to <c>page.evaluate</c> when no CDP session is available.
|
||||
/// </summary>
|
||||
public static class HumanKeyboard
|
||||
{
|
||||
/// <summary>Characters that require holding Shift to produce.</summary>
|
||||
public static readonly IReadOnlySet<char> ShiftSymbols =
|
||||
new HashSet<char>("@#!$%^&*()_+{}|:\"<>?~");
|
||||
|
||||
/// <summary>QWERTY-adjacent keys used to simulate fat-finger mistypes.</summary>
|
||||
public static readonly IReadOnlyDictionary<char, string> NearbyKeys = new Dictionary<char, string>
|
||||
{
|
||||
['a'] = "sqwz", ['b'] = "vghn", ['c'] = "xdfv", ['d'] = "sfecx", ['e'] = "wrsdf",
|
||||
['f'] = "dgrtcv", ['g'] = "fhtyb", ['h'] = "gjybn", ['i'] = "ujko", ['j'] = "hkunm",
|
||||
['k'] = "jloi", ['l'] = "kop", ['m'] = "njk", ['n'] = "bhjm", ['o'] = "iklp",
|
||||
['p'] = "ol", ['q'] = "wa", ['r'] = "edft", ['s'] = "awedxz", ['t'] = "rfgy",
|
||||
['u'] = "yhji", ['v'] = "cfgb", ['w'] = "qase", ['x'] = "zsdc", ['y'] = "tghu",
|
||||
['z'] = "asx",
|
||||
['1'] = "2q", ['2'] = "13qw", ['3'] = "24we", ['4'] = "35er", ['5'] = "46rt",
|
||||
['6'] = "57ty", ['7'] = "68yu", ['8'] = "79ui", ['9'] = "80io", ['0'] = "9p",
|
||||
};
|
||||
|
||||
/// <summary>CDP key <c>code</c> for each shift symbol's physical key.</summary>
|
||||
private static readonly IReadOnlyDictionary<char, string> ShiftSymbolCodes = new Dictionary<char, string>
|
||||
{
|
||||
['!'] = "Digit1", ['@'] = "Digit2", ['#'] = "Digit3", ['$'] = "Digit4",
|
||||
['%'] = "Digit5", ['^'] = "Digit6", ['&'] = "Digit7", ['*'] = "Digit8",
|
||||
['('] = "Digit9", [')'] = "Digit0", ['_'] = "Minus", ['+'] = "Equal",
|
||||
['{'] = "BracketLeft", ['}'] = "BracketRight", ['|'] = "Backslash",
|
||||
[':'] = "Semicolon", ['"'] = "Quote", ['<'] = "Comma", ['>'] = "Period",
|
||||
['?'] = "Slash", ['~'] = "Backquote",
|
||||
};
|
||||
|
||||
/// <summary>Windows virtual key codes for <c>Input.dispatchKeyEvent</c>.</summary>
|
||||
private static readonly IReadOnlyDictionary<char, int> ShiftSymbolKeyCodes = new Dictionary<char, int>
|
||||
{
|
||||
['!'] = 49, ['@'] = 50, ['#'] = 51, ['$'] = 52, ['%'] = 53,
|
||||
['^'] = 54, ['&'] = 55, ['*'] = 56, ['('] = 57, [')'] = 48,
|
||||
['_'] = 189, ['+'] = 187, ['{'] = 219, ['}'] = 221, ['|'] = 220,
|
||||
[':'] = 186, ['"'] = 222, ['<'] = 188, ['>'] = 190, ['?'] = 191,
|
||||
['~'] = 192,
|
||||
};
|
||||
|
||||
private static bool IsAscii(char c) => c <= 0x7F;
|
||||
private static bool IsAlnum(char c) => char.IsLetterOrDigit(c) && IsAscii(c);
|
||||
|
||||
/// <summary>Return a random adjacent key for the given character.</summary>
|
||||
private static char GetNearbyKey(char ch)
|
||||
{
|
||||
char lower = char.ToLowerInvariant(ch);
|
||||
if (NearbyKeys.TryGetValue(lower, out var neighbors) && neighbors.Length > 0)
|
||||
{
|
||||
char wrong = HumanRandom.Choice(neighbors);
|
||||
return char.IsUpper(ch) ? char.ToUpperInvariant(wrong) : wrong;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type <paramref name="text"/> with human-like per-character timing.
|
||||
/// </summary>
|
||||
/// <param name="evaluator">Used by the fallback shift-symbol path (page.evaluate).</param>
|
||||
/// <param name="raw">The raw keyboard to drive.</param>
|
||||
/// <param name="text">The text to type.</param>
|
||||
/// <param name="cfg">Behavior configuration.</param>
|
||||
/// <param name="cdpSession">
|
||||
/// If provided, shift symbols use CDP <c>Input.dispatchKeyEvent</c> producing
|
||||
/// isTrusted=true events with no evaluate stack trace. If null, falls back to
|
||||
/// <paramref name="evaluator"/> (detectable).
|
||||
/// </param>
|
||||
public static async Task HumanTypeAsync(
|
||||
IRawEvaluator? evaluator,
|
||||
IRawKeyboard raw,
|
||||
string text,
|
||||
HumanConfig cfg,
|
||||
IRawCdpSession? cdpSession = null)
|
||||
{
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
char ch = text[i];
|
||||
|
||||
// Non-ASCII characters (Cyrillic, CJK, emoji) - use insertText.
|
||||
if (!IsAscii(ch))
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (i < text.Length - 1)
|
||||
await InterCharDelayAsync(cfg).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mistype chance - only for ASCII alphanumeric.
|
||||
if (HumanRandom.NextDouble() < cfg.MistypeChance && IsAlnum(ch))
|
||||
{
|
||||
char wrong = GetNearbyKey(ch);
|
||||
await TypeNormalCharAsync(raw, wrong, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayNotice)).ConfigureAwait(false);
|
||||
await raw.DownAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayCorrect)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (char.IsUpper(ch) && char.IsLetter(ch))
|
||||
await TypeShiftedCharAsync(raw, ch, cfg).ConfigureAwait(false);
|
||||
else if (ShiftSymbols.Contains(ch))
|
||||
await TypeShiftSymbolAsync(evaluator, raw, ch, cfg, cdpSession).ConfigureAwait(false);
|
||||
else
|
||||
await TypeNormalCharAsync(raw, ch, cfg).ConfigureAwait(false);
|
||||
|
||||
if (i < text.Length - 1)
|
||||
await InterCharDelayAsync(cfg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TypeNormalCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task TypeShiftedCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task TypeShiftSymbolAsync(
|
||||
IRawEvaluator? evaluator,
|
||||
IRawKeyboard raw,
|
||||
char ch,
|
||||
HumanConfig cfg,
|
||||
IRawCdpSession? cdpSession)
|
||||
{
|
||||
if (cdpSession != null)
|
||||
{
|
||||
// --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
string code = ShiftSymbolCodes.TryGetValue(ch, out var c) ? c : "";
|
||||
int keyCode = ShiftSymbolKeyCodes.TryGetValue(ch, out var kc) ? kc : 0;
|
||||
string s = ch.ToString();
|
||||
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
["type"] = "keyDown",
|
||||
["modifiers"] = 8, // Shift modifier flag
|
||||
["key"] = s,
|
||||
["code"] = code,
|
||||
["windowsVirtualKeyCode"] = keyCode,
|
||||
["text"] = s,
|
||||
["unmodifiedText"] = s,
|
||||
}).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
["type"] = "keyUp",
|
||||
["modifiers"] = 8,
|
||||
["key"] = s,
|
||||
["code"] = code,
|
||||
["windowsVirtualKeyCode"] = keyCode,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (evaluator != null)
|
||||
{
|
||||
await evaluator.EvaluateAsync(
|
||||
@"(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}",
|
||||
ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InterCharDelayAsync(HumanConfig cfg)
|
||||
{
|
||||
if (HumanRandom.NextDouble() < cfg.TypingPauseChance)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.TypingPauseRange)).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
double delay = cfg.TypingDelay + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.TypingDelaySpread;
|
||||
await HumanRandom.SleepMsAsync(Math.Max(10, delay)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>A 2D point used by the mouse-movement curve math.</summary>
|
||||
public readonly record struct Point(double X, double Y);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal mouse abstraction the humanize layer drives. Mirrors the Python
|
||||
/// <c>RawMouse</c> Protocol. Implemented over Playwright's <c>IMouse</c>.
|
||||
/// All methods are async to match .NET Playwright.
|
||||
/// </summary>
|
||||
public interface IRawMouse
|
||||
{
|
||||
Task MoveAsync(double x, double y);
|
||||
Task DownAsync();
|
||||
Task UpAsync();
|
||||
Task WheelAsync(double deltaX, double deltaY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-like mouse movement and clicking.
|
||||
/// Direct port of Python <c>cloakbrowser/human/mouse.py</c>.
|
||||
/// Movement follows a cubic Bezier curve with perpendicular wobble, burst
|
||||
/// pauses, and an optional overshoot+correction at the end.
|
||||
/// </summary>
|
||||
public static class HumanMouse
|
||||
{
|
||||
/// <summary>Cubic ease-in-out, matching the Python implementation.</summary>
|
||||
private static double EaseInOut(double t)
|
||||
{
|
||||
if (t < 0.5)
|
||||
return 4 * t * t * t;
|
||||
return 1 - Math.Pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/// <summary>Cubic Bezier interpolation between four control points.</summary>
|
||||
private static Point Bezier(Point p0, Point p1, Point p2, Point p3, double t)
|
||||
{
|
||||
double u = 1 - t;
|
||||
double uu = u * u;
|
||||
double uuu = uu * u;
|
||||
double tt = t * t;
|
||||
double ttt = tt * t;
|
||||
return new Point(
|
||||
uuu * p0.X + 3 * uu * t * p1.X + 3 * u * tt * p2.X + ttt * p3.X,
|
||||
uuu * p0.Y + 3 * uu * t * p1.Y + 3 * u * tt * p2.Y + ttt * p3.Y);
|
||||
}
|
||||
|
||||
/// <summary>Generate two random control points biased perpendicular to the path.</summary>
|
||||
private static (Point, Point) RandomControlPoints(Point start, Point end)
|
||||
{
|
||||
double dx = end.X - start.X;
|
||||
double dy = end.Y - start.Y;
|
||||
double dist = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (dist == 0) dist = 1;
|
||||
double px = -dy / dist;
|
||||
double py = dx / dist;
|
||||
double bias1 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
double bias2 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
return (
|
||||
new Point(start.X + dx * 0.25 + px * bias1, start.Y + dy * 0.25 + py * bias1),
|
||||
new Point(start.X + dx * 0.75 + px * bias2, start.Y + dy * 0.75 + py * bias2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move the cursor from (startX, startY) to (endX, endY) along a human-like
|
||||
/// Bezier curve with wobble, burst pauses, and an optional overshoot.
|
||||
/// </summary>
|
||||
public static async Task HumanMoveAsync(
|
||||
IRawMouse raw,
|
||||
double startX, double startY,
|
||||
double endX, double endY,
|
||||
HumanConfig cfg)
|
||||
{
|
||||
double dist = Math.Sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY));
|
||||
if (dist < 1)
|
||||
return;
|
||||
|
||||
int steps = (int)Math.Max(cfg.MouseMinSteps,
|
||||
Math.Min(cfg.MouseMaxSteps, Math.Round(dist / cfg.MouseStepsDivisor)));
|
||||
var start = new Point(startX, startY);
|
||||
var end = new Point(endX, endY);
|
||||
var (cp1, cp2) = RandomControlPoints(start, end);
|
||||
|
||||
int burstCounter = 0;
|
||||
int burstSize = HumanRandom.RandIntRange(cfg.MouseBurstSize);
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
double progress = (double)i / steps;
|
||||
double easedT = EaseInOut(progress);
|
||||
var pt = Bezier(start, cp1, cp2, end, easedT);
|
||||
|
||||
double wobbleAmp = Math.Sin(Math.PI * progress) * cfg.MouseWobbleMax;
|
||||
double wx = pt.X + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
double wy = pt.Y + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
|
||||
await raw.MoveAsync(Math.Round(wx), Math.Round(wy)).ConfigureAwait(false);
|
||||
|
||||
burstCounter++;
|
||||
if (burstCounter >= burstSize && i < steps)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MouseBurstPause)).ConfigureAwait(false);
|
||||
burstCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (HumanRandom.NextDouble() < cfg.MouseOvershootChance)
|
||||
{
|
||||
double overshootDist = HumanRandom.RandRange(cfg.MouseOvershootPx);
|
||||
double angle = Math.Atan2(endY - startY, endX - startX);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + Math.Cos(angle) * overshootDist),
|
||||
Math.Round(endY + Math.Sin(angle) * overshootDist)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 70)).ConfigureAwait(false);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + (HumanRandom.NextDouble() - 0.5) * 4),
|
||||
Math.Round(endY + (HumanRandom.NextDouble() - 0.5) * 4)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a randomized click point inside a bounding box. Inputs get a
|
||||
/// left-biased X and a wider Y band; buttons get a centered cluster.
|
||||
/// </summary>
|
||||
public static Point ClickTarget(BoundingBox box, bool isInput, HumanConfig cfg)
|
||||
{
|
||||
double xFrac, yFrac;
|
||||
if (isInput)
|
||||
{
|
||||
xFrac = HumanRandom.RandRange(cfg.ClickInputXRange);
|
||||
yFrac = HumanRandom.Rand(0.30, 0.70);
|
||||
}
|
||||
else
|
||||
{
|
||||
xFrac = HumanRandom.Rand(0.35, 0.65);
|
||||
yFrac = HumanRandom.Rand(0.35, 0.65);
|
||||
}
|
||||
return new Point(
|
||||
Math.Round(box.X + box.Width * xFrac),
|
||||
Math.Round(box.Y + box.Height * yFrac));
|
||||
}
|
||||
|
||||
/// <summary>Perform a human-like press: aim delay, mouse down, hold, mouse up.</summary>
|
||||
public static async Task HumanClickAsync(IRawMouse raw, bool isInput, HumanConfig cfg)
|
||||
{
|
||||
double aimDelay = isInput
|
||||
? HumanRandom.RandRange(cfg.ClickAimDelayInput)
|
||||
: HumanRandom.RandRange(cfg.ClickAimDelayButton);
|
||||
await HumanRandom.SleepMsAsync(aimDelay).ConfigureAwait(false);
|
||||
double holdTime = isInput
|
||||
? HumanRandom.RandRange(cfg.ClickHoldInput)
|
||||
: HumanRandom.RandRange(cfg.ClickHoldButton);
|
||||
await raw.DownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(holdTime).ConfigureAwait(false);
|
||||
await raw.UpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Drift the cursor with tiny random movements for ~<paramref name="seconds"/> seconds.</summary>
|
||||
public static async Task HumanIdleAsync(IRawMouse raw, double seconds, double cx, double cy, HumanConfig cfg)
|
||||
{
|
||||
var endTime = DateTime.UtcNow.AddSeconds(seconds);
|
||||
double x = cx, y = cy;
|
||||
while (DateTime.UtcNow < endTime)
|
||||
{
|
||||
double dx = (HumanRandom.NextDouble() - 0.5) * 2 * cfg.IdleDriftPx;
|
||||
double dy = (HumanRandom.NextDouble() - 0.5) * 2 * cfg.IdleDriftPx;
|
||||
x += dx;
|
||||
y += dy;
|
||||
await raw.MoveAsync(Math.Round(x), Math.Round(y)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.IdlePauseRange)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bounding box (x, y, width, height) in CSS pixels - mirrors Playwright's
|
||||
/// <c>BoundingBox</c> but kept independent so the math helpers don't depend on
|
||||
/// the Playwright type directly.
|
||||
/// </summary>
|
||||
public readonly record struct BoundingBox(double X, double Y, double Width, double Height);
|
||||
@@ -0,0 +1,667 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>Options accepted by the humanized action methods on <see cref="HumanPage"/>.</summary>
|
||||
public sealed class HumanActionOptions
|
||||
{
|
||||
/// <summary>Overall timeout in milliseconds (default 30000).</summary>
|
||||
public double Timeout { get; set; } = 30000;
|
||||
|
||||
/// <summary>Skip all actionability checks and motion guarantees when true.</summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>Per-call config overrides (snake_case or PascalCase keys), merged on top of the page config.</summary>
|
||||
public IReadOnlyDictionary<string, object>? HumanConfig { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A human-like wrapper around a Playwright <see cref="IPage"/>.
|
||||
///
|
||||
/// .NET's Playwright exposes sealed interfaces (<see cref="IPage"/>, <see cref="ILocator"/>,
|
||||
/// etc.) that cannot be monkey-patched the way the Python/JS implementations replace
|
||||
/// methods at runtime. Instead, this wrapper exposes the same humanized behaviors as
|
||||
/// explicit methods. The underlying real page is always available via <see cref="Page"/>
|
||||
/// for anything not covered here.
|
||||
///
|
||||
/// Direct behavioral port of <c>patch_page</c> in <c>cloakbrowser/human/__init__.py</c>:
|
||||
/// every action runs Playwright-style actionability checks, a Bezier-curve mouse
|
||||
/// approach with optional scroll-into-view, pointer-events verification, and human
|
||||
/// typing through the CDP isolated world / dispatchKeyEvent stealth path.
|
||||
/// </summary>
|
||||
public sealed class HumanPage
|
||||
{
|
||||
private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
private static readonly string SelectAll = IsMac ? "Meta+a" : "Control+a";
|
||||
|
||||
private readonly IPage _page;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly CursorState _cursor = new();
|
||||
|
||||
private readonly IRawMouse _rawMouse;
|
||||
private readonly IRawKeyboard _rawKeyboard;
|
||||
private readonly IRawScrollPage _scrollPage;
|
||||
private readonly IRawEvaluator _evaluator;
|
||||
|
||||
private IsolatedWorld? _stealth;
|
||||
private IRawCdpSession? _cdpSession;
|
||||
private bool _stealthInitialized;
|
||||
|
||||
/// <summary>The underlying real Playwright page.</summary>
|
||||
public IPage Page => _page;
|
||||
|
||||
/// <summary>The resolved behavior configuration for this page.</summary>
|
||||
public HumanConfig Config => _cfg;
|
||||
|
||||
/// <summary>The current virtual cursor position (x, y).</summary>
|
||||
public (double X, double Y) Cursor => (_cursor.X, _cursor.Y);
|
||||
|
||||
/// <summary>Create a humanized wrapper. Use <see cref="CreateAsync(IPage, HumanConfig?)"/> for the
|
||||
/// stealth-enabled variant (recommended).</summary>
|
||||
public HumanPage(IPage page, HumanConfig? cfg = null)
|
||||
{
|
||||
_page = page;
|
||||
_cfg = cfg ?? new HumanConfig();
|
||||
_rawMouse = new PlaywrightRawMouse(page.Mouse);
|
||||
_rawKeyboard = new PlaywrightRawKeyboard(page.Keyboard);
|
||||
_scrollPage = new PlaywrightScrollPage(page);
|
||||
_evaluator = new PlaywrightEvaluator(page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a humanized page and initialize the CDP isolated world + dispatchKeyEvent
|
||||
/// stealth path. Falls back gracefully (stealth disabled) when CDP is unavailable.
|
||||
/// </summary>
|
||||
public static async Task<HumanPage> CreateAsync(IPage page, HumanConfig? cfg = null)
|
||||
{
|
||||
var hp = new HumanPage(page, cfg);
|
||||
await hp.InitStealthAsync().ConfigureAwait(false);
|
||||
await hp.InitCursorAsync().ConfigureAwait(false);
|
||||
return hp;
|
||||
}
|
||||
|
||||
private async Task InitStealthAsync()
|
||||
{
|
||||
if (_stealthInitialized) return;
|
||||
_stealthInitialized = true;
|
||||
try
|
||||
{
|
||||
_stealth = new IsolatedWorld(_page);
|
||||
var session = await _stealth.GetCdpSessionAsync().ConfigureAwait(false);
|
||||
_cdpSession = new PlaywrightCdpSession(session);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_stealth = null;
|
||||
_cdpSession = null;
|
||||
CloakLog.Debug("Could not create CDP session - stealth features disabled");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitCursorAsync()
|
||||
{
|
||||
// Initialize cursor immediately so it doesn't visibly jump from (0,0).
|
||||
_cursor.X = HumanRandom.Rand(_cfg.InitialCursorX.Min, _cfg.InitialCursorX.Max);
|
||||
_cursor.Y = HumanRandom.Rand(_cfg.InitialCursorY.Min, _cfg.InitialCursorY.Max);
|
||||
try
|
||||
{
|
||||
await _rawMouse.MoveAsync(_cursor.X, _cursor.Y).ConfigureAwait(false);
|
||||
_cursor.Initialized = true;
|
||||
}
|
||||
catch (Exception) { /* viewport may not be ready yet */ }
|
||||
}
|
||||
|
||||
private async Task EnsureCursorInitAsync()
|
||||
{
|
||||
if (!_cursor.Initialized)
|
||||
{
|
||||
_cursor.X = HumanRandom.Rand(_cfg.InitialCursorX.Min, _cfg.InitialCursorX.Max);
|
||||
_cursor.Y = HumanRandom.Rand(_cfg.InitialCursorY.Min, _cfg.InitialCursorY.Max);
|
||||
await _rawMouse.MoveAsync(_cursor.X, _cursor.Y).ConfigureAwait(false);
|
||||
_cursor.Initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private HumanConfig MergeCfg(HumanActionOptions? opts) =>
|
||||
opts?.HumanConfig == null ? _cfg : _cfg.With(opts.HumanConfig);
|
||||
|
||||
private static double RemainingMs(double deadline) => Actionability.RemainingMs(deadline);
|
||||
|
||||
private async Task<bool> IsInputElementAsync(string selector)
|
||||
{
|
||||
if (_stealth != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string escaped = IsolatedWorld.JsonEncode(selector);
|
||||
return await _stealth.EvaluateBoolAsync(
|
||||
$"(() => {{" +
|
||||
$" const el = document.querySelector({escaped});" +
|
||||
$" if (!el) return false;" +
|
||||
$" const tag = el.tagName.toLowerCase();" +
|
||||
$" return tag === 'input' || tag === 'textarea'" +
|
||||
$" || el.getAttribute('contenteditable') === 'true';" +
|
||||
$"}})()").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { /* fall through */ }
|
||||
}
|
||||
try
|
||||
{
|
||||
return await _page.EvaluateAsync<bool>(
|
||||
@"(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}", selector).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private async Task<bool> IsSelectorFocusedAsync(string selector)
|
||||
{
|
||||
if (_stealth != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string escaped = IsolatedWorld.JsonEncode(selector);
|
||||
return await _stealth.EvaluateBoolAsync(
|
||||
$"(() => {{" +
|
||||
$" const el = document.querySelector({escaped});" +
|
||||
$" return el === document.activeElement;" +
|
||||
$"}})()").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { /* fall through */ }
|
||||
}
|
||||
try
|
||||
{
|
||||
return await _page.EvaluateAsync<bool>(
|
||||
@"(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el === document.activeElement;
|
||||
}", selector).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private async Task<BoundingBox?> GetBoxAsync(string selector, double timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var box = await _page.Locator(selector).First.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, timeoutMs),
|
||||
}).ConfigureAwait(false);
|
||||
return box == null ? null : new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Navigate to a URL and invalidate the isolated world afterward.</summary>
|
||||
public async Task<IResponse?> GotoAsync(string url, PageGotoOptions? options = null)
|
||||
{
|
||||
var response = await _page.GotoAsync(url, options).ConfigureAwait(false);
|
||||
_stealth?.Invalidate();
|
||||
return response;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Click
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like click on <paramref name="selector"/>.</summary>
|
||||
public Task ClickAsync(string selector, HumanActionOptions? options = null) =>
|
||||
ClickInternalAsync(selector, options, skipChecks: false);
|
||||
|
||||
private async Task ClickInternalAsync(string selector, HumanActionOptions? options, bool skipChecks)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force && !skipChecks)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksClick, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
bool isInput = await IsInputElementAsync(selector).ConfigureAwait(false);
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
await HumanMouse.HumanClickAsync(_rawMouse, isInput, callCfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Double-click
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like double-click on <paramref name="selector"/>.</summary>
|
||||
public async Task DblClickAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksClick, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
bool isInput = await IsInputElementAsync(selector).ConfigureAwait(false);
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
// Two presses for a double-click via Playwright IMouse click count.
|
||||
await _page.Mouse.DownAsync(new MouseDownOptions { ClickCount = 2 }).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _page.Mouse.UpAsync(new MouseUpOptions { ClickCount = 2 }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hover
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like hover over <paramref name="selector"/>.</summary>
|
||||
public Task HoverAsync(string selector, HumanActionOptions? options = null) =>
|
||||
HoverInternalAsync(selector, options, skipChecks: false);
|
||||
|
||||
private async Task HoverInternalAsync(string selector, HumanActionOptions? options, bool skipChecks)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force && !skipChecks)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksHover, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, false, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Type (append) and Fill (clear + type)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like typing into <paramref name="selector"/> (appends to existing value).</summary>
|
||||
public async Task TypeAsync(string selector, string text, HumanActionOptions? options = null)
|
||||
{
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(callCfg.FieldSwitchDelay)).ConfigureAwait(false);
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, callCfg, _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Human-like fill of <paramref name="selector"/> (selects all, deletes, then types).</summary>
|
||||
public async Task FillAsync(string selector, string value, HumanActionOptions? options = null)
|
||||
{
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(callCfg.FieldSwitchDelay)).ConfigureAwait(false);
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(SelectAll).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, value, callCfg, _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Check / Uncheck
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like check of a checkbox/radio (no-op if already checked).</summary>
|
||||
public async Task CheckAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool checked_;
|
||||
try { checked_ = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { checked_ = false; }
|
||||
if (!checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Human-like uncheck of a checkbox (no-op if already unchecked).</summary>
|
||||
public async Task UncheckAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool checked_;
|
||||
try { checked_ = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { checked_ = true; }
|
||||
if (checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Select option
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like select of a dropdown option (hovers, then uses Playwright's selectOption).</summary>
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string[] values, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HoverInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
return await _page.SelectOptionAsync(selector, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Press a key while a selector is focused
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like press of <paramref name="key"/> after focusing <paramref name="selector"/>.</summary>
|
||||
public async Task PressAsync(string selector, string key, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Set-checked (drives state to the requested value)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like set-checked: clicks only when the current state differs from
|
||||
/// the requested <paramref name="checked_"/> value. Port of <c>_humanized_set_checked</c>.</summary>
|
||||
public async Task SetCheckedAsync(string selector, bool checked_, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool current;
|
||||
try { current = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { current = !checked_; }
|
||||
if (current != checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tap (humanized click; mobile gesture maps to the same motion)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like tap. Port of <c>_humanized_tap</c> - same motion as a click.</summary>
|
||||
public Task TapAsync(string selector, HumanActionOptions? options = null) =>
|
||||
ClickAsync(selector, options);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Press sequentially (focus then human-type, no clear)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like press-sequentially: focuses (via click if needed) then types the
|
||||
/// text with human timing, without clearing. Port of <c>_humanized_press_sequentially</c>.</summary>
|
||||
public async Task PressSequentiallyAsync(string selector, string text, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, MergeCfg(options), _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clear (focus, select-all, backspace)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like clear: focuses (via click if needed), selects all, deletes.
|
||||
/// Port of <c>_humanized_clear</c>.</summary>
|
||||
public async Task ClearAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 100)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(SelectAll).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync("Backspace").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Focus (human cursor move, then programmatic focus - NO click side-effects)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like focus: moves the cursor over the element with a Bezier curve,
|
||||
/// then focuses it programmatically (no click, so no onclick/submit/navigation).
|
||||
/// Port of <c>_human_el_focus</c> applied to a selector.</summary>
|
||||
public async Task FocusAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, false, callCfg);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
// Programmatic focus - never clicks (mirrors stock Playwright el.focus()).
|
||||
await _page.FocusAsync(selector).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scroll into view (humanized accelerate->cruise->decelerate->overshoot)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like scroll-into-view. Port of <c>_humanized_scroll_into_view_if_needed</c>.
|
||||
/// Returns true if a scroll was performed (false when already in viewport).</summary>
|
||||
public async Task<bool> ScrollIntoViewIfNeededAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
try
|
||||
{
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, timeout),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
return scroll.DidScroll;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall back to native scroll, mirroring the Python except branch.
|
||||
await _page.Locator(selector).First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)timeout }).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-and-drop (humanized: move to source center, down, move to target, up)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like drag from <paramref name="sourceSelector"/> to
|
||||
/// <paramref name="targetSelector"/>. Port of <c>_frame_drag_and_drop</c> / <c>_humanized_drag_to</c>:
|
||||
/// moves to the source center, presses, moves to the target center, releases.</summary>
|
||||
public async Task DragAndDropAsync(string sourceSelector, string targetSelector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
|
||||
var srcBox = await GetBoxAsync(sourceSelector, timeout).ConfigureAwait(false);
|
||||
var tgtBox = await GetBoxAsync(targetSelector, timeout).ConfigureAwait(false);
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
// Fall back to native drag-and-drop.
|
||||
await _page.DragAndDropAsync(sourceSelector, targetSelector).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
BoundingBox src = srcBox.Value;
|
||||
BoundingBox tgt = tgtBox.Value;
|
||||
double sx = src.X + src.Width / 2;
|
||||
double sy = src.Y + src.Height / 2;
|
||||
double tx = tgt.X + tgt.Width / 2;
|
||||
double ty = tgt.Y + tgt.Height / 2;
|
||||
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = sx; _cursor.Y = sy;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _rawMouse.DownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = tx; _cursor.Y = ty;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _rawMouse.UpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Low-level mouse / keyboard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Move the virtual cursor to absolute (x, y) with a human-like curve.</summary>
|
||||
public async Task MouseMoveAsync(double x, double y)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = x;
|
||||
_cursor.Y = y;
|
||||
}
|
||||
|
||||
/// <summary>Move to absolute (x, y) and perform a human-like click there.</summary>
|
||||
public async Task MouseClickAsync(double x, double y)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = x;
|
||||
_cursor.Y = y;
|
||||
await HumanMouse.HumanClickAsync(_rawMouse, false, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Type text into whatever element currently has focus, with human timing.</summary>
|
||||
public Task KeyboardTypeAsync(string text) =>
|
||||
HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, _cfg, _cdpSession);
|
||||
|
||||
/// <summary>Mutable cursor position used across actions.</summary>
|
||||
private sealed class CursorState
|
||||
{
|
||||
public double X;
|
||||
public double Y;
|
||||
public bool Initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Random and timing utilities for the humanize layer.
|
||||
/// Mirrors the helpers at the bottom of Python <c>cloakbrowser/human/config.py</c>
|
||||
/// (<c>rand</c>, <c>rand_int</c>, <c>rand_range</c>, <c>rand_int_range</c>,
|
||||
/// <c>sleep_ms</c>, <c>async_sleep_ms</c>), plus a <c>Choice</c> helper used by
|
||||
/// the keyboard mistype simulation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses a thread-safe shared <see cref="System.Random"/>. .NET's
|
||||
/// <c>Random.Shared</c> (introduced in .NET 6) is already thread-safe, so we
|
||||
/// delegate to it directly rather than locking a private instance.
|
||||
/// </remarks>
|
||||
public static class HumanRandom
|
||||
{
|
||||
private static System.Random Rng => System.Random.Shared;
|
||||
|
||||
/// <summary>Random double in [0.0, 1.0).</summary>
|
||||
public static double NextDouble() => Rng.NextDouble();
|
||||
|
||||
/// <summary>Random float in [lo, hi] (inclusive), like Python's <c>random.uniform</c>.</summary>
|
||||
public static double Rand(double lo, double hi)
|
||||
{
|
||||
if (hi < lo)
|
||||
(lo, hi) = (hi, lo);
|
||||
return lo + Rng.NextDouble() * (hi - lo);
|
||||
}
|
||||
|
||||
/// <summary>Random integer in [lo, hi] inclusive, like Python's <c>random.randint</c>.</summary>
|
||||
public static int RandInt(int lo, int hi)
|
||||
{
|
||||
if (hi < lo)
|
||||
(lo, hi) = (hi, lo);
|
||||
// Random.Next upper bound is exclusive - add 1 for inclusive range.
|
||||
return Rng.Next(lo, hi + 1);
|
||||
}
|
||||
|
||||
/// <summary>Random float from a <see cref="Range"/> (min, max), inclusive.</summary>
|
||||
public static double RandRange(Range r) => Rand(r.Min, r.Max);
|
||||
|
||||
/// <summary>Random integer from a <see cref="Range"/> (min, max), inclusive.</summary>
|
||||
public static int RandIntRange(Range r) => RandInt((int)r.Min, (int)r.Max);
|
||||
|
||||
/// <summary>Return <c>true</c> with the given probability in [0, 1].</summary>
|
||||
public static bool Chance(double probability) => Rng.NextDouble() < probability;
|
||||
|
||||
/// <summary>Pick a random character from a non-empty string, like Python's <c>random.choice</c>.</summary>
|
||||
public static char Choice(string options)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options))
|
||||
throw new ArgumentException("Cannot choose from an empty string.", nameof(options));
|
||||
return options[Rng.Next(options.Length)];
|
||||
}
|
||||
|
||||
/// <summary>Pick a random element from a non-empty list, like Python's <c>random.choice</c>.</summary>
|
||||
public static T Choice<T>(IReadOnlyList<T> options)
|
||||
{
|
||||
if (options == null || options.Count == 0)
|
||||
throw new ArgumentException("Cannot choose from an empty collection.", nameof(options));
|
||||
return options[Rng.Next(options.Count)];
|
||||
}
|
||||
|
||||
/// <summary>Block the current thread for <paramref name="ms"/> milliseconds (no-op if <= 0).</summary>
|
||||
public static void SleepMs(double ms)
|
||||
{
|
||||
if (ms > 0)
|
||||
Thread.Sleep((int)Math.Round(ms));
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously wait for <paramref name="ms"/> milliseconds (no-op if <= 0).</summary>
|
||||
public static Task SleepMsAsync(double ms)
|
||||
{
|
||||
if (ms <= 0)
|
||||
return Task.CompletedTask;
|
||||
return Task.Delay((int)Math.Round(ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Page-level operations the scroller needs: the viewport size and a way to
|
||||
/// fetch a bounding box on demand. Implemented over Playwright's <c>IPage</c>.
|
||||
/// </summary>
|
||||
public interface IRawScrollPage
|
||||
{
|
||||
/// <summary>Current viewport size, or null if unavailable.</summary>
|
||||
(int Width, int Height)? ViewportSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Live window dimensions (<c>window.innerWidth</c>/<c>innerHeight</c>), used as a
|
||||
/// fallback when <see cref="ViewportSize"/> is null. Headed launches default to
|
||||
/// no-viewport so the page tracks the real OS window and <c>ViewportSize</c> is
|
||||
/// null there - this keeps humanized scroll working in headed (stealth) mode.
|
||||
/// Returns null if the dimensions can't be read.
|
||||
/// </summary>
|
||||
Task<(int Width, int Height)?> GetLiveWindowSizeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Result of a humanized scroll-into-view operation.</summary>
|
||||
/// <param name="Box">The element's bounding box after scrolling.</param>
|
||||
/// <param name="CursorX">The cursor X position after scrolling.</param>
|
||||
/// <param name="CursorY">The cursor Y position after scrolling.</param>
|
||||
/// <param name="DidScroll">False when the element was already in the viewport.</param>
|
||||
public readonly record struct ScrollResult(BoundingBox Box, double CursorX, double CursorY, bool DidScroll);
|
||||
|
||||
/// <summary>
|
||||
/// Human-like scrolling via mouse wheel events.
|
||||
/// Direct port of Python <c>cloakbrowser/human/scroll.py</c>.
|
||||
/// </summary>
|
||||
public static class HumanScroll
|
||||
{
|
||||
private static bool IsInViewport(BoundingBox bounds, int viewportHeight, HumanConfig cfg)
|
||||
{
|
||||
double topEdge = bounds.Y;
|
||||
double bottomEdge = bounds.Y + bounds.Height;
|
||||
double zoneTop = viewportHeight * cfg.ScrollTargetZone.Min;
|
||||
double zoneBottom = viewportHeight * cfg.ScrollTargetZone.Max;
|
||||
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||
}
|
||||
|
||||
/// <summary>Send one logical scroll as a burst of small wheel events (like real inertia).</summary>
|
||||
private static async Task SmoothWheelAsync(IRawMouse raw, int delta, HumanConfig cfg)
|
||||
{
|
||||
double absD = Math.Abs(delta);
|
||||
int sign = delta > 0 ? 1 : -1;
|
||||
double sent = 0;
|
||||
while (sent < absD)
|
||||
{
|
||||
double stepSize = HumanRandom.Rand(20, 40);
|
||||
double chunk = Math.Min(stepSize, absD - sent);
|
||||
await raw.WheelAsync(0, Math.Round(chunk) * sign).ConfigureAwait(false);
|
||||
sent += chunk;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(8, 20)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public smooth-wheel helper for the transparent <c>IMouse.WheelAsync</c> override:
|
||||
/// breaks a (deltaX, deltaY) wheel request into the same small-burst-with-inertia
|
||||
/// pattern used for scroll-into-view, so direct wheel calls look human too.
|
||||
/// </summary>
|
||||
public static async Task SmoothWheelAsync(IRawMouse raw, double deltaX, double deltaY, HumanConfig cfg)
|
||||
{
|
||||
// X axis is sent in one go (horizontal scroll is rarely incremental), Y is
|
||||
// chunked for inertia. When both are zero, send a single no-op wheel event so
|
||||
// semantics match Playwright's IMouse.WheelAsync(0, 0).
|
||||
if (deltaY != 0)
|
||||
await SmoothWheelAsync(raw, (int)Math.Round(deltaY), cfg).ConfigureAwait(false);
|
||||
if (deltaX != 0)
|
||||
await raw.WheelAsync(Math.Round(deltaX), 0).ConfigureAwait(false);
|
||||
if (deltaX == 0 && deltaY == 0)
|
||||
await raw.WheelAsync(0, 0).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Humanized scrolling that uses an arbitrary <paramref name="getBox"/> callable
|
||||
/// instead of a CSS selector. Runs the accelerate -> cruise -> decelerate ->
|
||||
/// overshoot behavior.
|
||||
/// </summary>
|
||||
/// <returns>(box, cursorX, cursorY, didScroll) - didScroll is false when the
|
||||
/// element was already in the viewport.</returns>
|
||||
public static async Task<ScrollResult> HumanScrollIntoViewAsync(
|
||||
IRawScrollPage page,
|
||||
IRawMouse raw,
|
||||
Func<Task<BoundingBox?>> getBox,
|
||||
double cursorX, double cursorY,
|
||||
HumanConfig cfg)
|
||||
{
|
||||
var viewport = page.ViewportSize;
|
||||
if (viewport == null)
|
||||
// Headed launches default to no_viewport so the page tracks the real OS
|
||||
// window; ViewportSize is then null. Fall back to the live window
|
||||
// dimensions so humanize works headed (the stealth-relevant mode).
|
||||
viewport = await page.GetLiveWindowSizeAsync().ConfigureAwait(false);
|
||||
if (viewport == null || viewport.Value.Height == 0)
|
||||
throw new InvalidOperationException("Viewport size not available");
|
||||
|
||||
int viewportHeight = viewport.Value.Height;
|
||||
int viewportWidth = viewport.Value.Width;
|
||||
|
||||
var box = await getBox().ConfigureAwait(false);
|
||||
if (box == null)
|
||||
throw new InvalidOperationException("Element not found while scrolling into view");
|
||||
|
||||
if (IsInViewport(box.Value, viewportHeight, cfg))
|
||||
return new ScrollResult(box.Value, cursorX, cursorY, false);
|
||||
|
||||
// Move cursor into scroll area.
|
||||
double scrollAreaX = Math.Round(viewportWidth * HumanRandom.Rand(0.3, 0.7));
|
||||
double scrollAreaY = Math.Round(viewportHeight * HumanRandom.Rand(0.3, 0.7));
|
||||
await HumanMouse.HumanMoveAsync(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg).ConfigureAwait(false);
|
||||
cursorX = scrollAreaX;
|
||||
cursorY = scrollAreaY;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPreMoveDelay)).ConfigureAwait(false);
|
||||
|
||||
// Calculate scroll distance.
|
||||
double targetY = viewportHeight * HumanRandom.Rand(cfg.ScrollTargetZone.Min, cfg.ScrollTargetZone.Max);
|
||||
double elementCenter = box.Value.Y + box.Value.Height / 2;
|
||||
double distanceToScroll = elementCenter - targetY;
|
||||
|
||||
int direction = distanceToScroll > 0 ? 1 : -1;
|
||||
double absDistance = Math.Abs(distanceToScroll);
|
||||
double avgDelta = (cfg.ScrollDeltaBase.Min + cfg.ScrollDeltaBase.Max) / 2;
|
||||
int totalClicks = Math.Max(3, (int)Math.Ceiling(absDistance / avgDelta));
|
||||
int accelSteps = HumanRandom.RandIntRange(cfg.ScrollAccelSteps);
|
||||
int decelSteps = HumanRandom.RandIntRange(cfg.ScrollDecelSteps);
|
||||
|
||||
// Scroll loop: accelerate -> cruise -> decelerate.
|
||||
double scrolled = 0;
|
||||
for (int i = 0; i < totalClicks; i++)
|
||||
{
|
||||
double delta, pause;
|
||||
if (i < accelSteps)
|
||||
{
|
||||
delta = HumanRandom.Rand(80, 100);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else if (i >= totalClicks - decelSteps)
|
||||
{
|
||||
delta = HumanRandom.Rand(60, 90);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else
|
||||
{
|
||||
delta = HumanRandom.RandRange(cfg.ScrollDeltaBase);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseFast);
|
||||
}
|
||||
|
||||
delta *= 1 + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.ScrollDeltaVariance;
|
||||
int deltaInt = (int)(Math.Round(delta) * direction);
|
||||
|
||||
await SmoothWheelAsync(raw, deltaInt, cfg).ConfigureAwait(false);
|
||||
scrolled += Math.Abs(deltaInt);
|
||||
await HumanRandom.SleepMsAsync(pause).ConfigureAwait(false);
|
||||
|
||||
// Check visibility every 3 steps.
|
||||
if (i % 3 == 2 || i == totalClicks - 1)
|
||||
{
|
||||
box = await getBox().ConfigureAwait(false);
|
||||
if (box != null && IsInViewport(box.Value, viewportHeight, cfg))
|
||||
break;
|
||||
}
|
||||
if (scrolled >= absDistance * 1.1)
|
||||
break;
|
||||
}
|
||||
|
||||
// Optional overshoot + correction.
|
||||
if (HumanRandom.NextDouble() < cfg.ScrollOvershootChance)
|
||||
{
|
||||
int overshootPx = (int)(Math.Round(HumanRandom.RandRange(cfg.ScrollOvershootPx)) * direction);
|
||||
await SmoothWheelAsync(raw, overshootPx, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
int corrections = HumanRandom.RandIntRange((1, 2));
|
||||
for (int c = 0; c < corrections; c++)
|
||||
{
|
||||
int corrDelta = (int)(Math.Round(HumanRandom.Rand(40, 80)) * -direction);
|
||||
await SmoothWheelAsync(raw, corrDelta, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Settle.
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
|
||||
box = await getBox().ConfigureAwait(false);
|
||||
if (box == null)
|
||||
throw new InvalidOperationException("Element lost after scrolling into view");
|
||||
|
||||
return new ScrollResult(box.Value, cursorX, cursorY, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a CDP isolated execution context for DOM reads.
|
||||
/// Direct port of Python <c>_AsyncIsolatedWorld</c> (cloakbrowser/human/__init__.py).
|
||||
///
|
||||
/// Produces clean <c>Error.stack</c> traces (no <c>eval at evaluate</c> frames)
|
||||
/// and is invisible to <c>querySelector</c> monkey-patches in the main world.
|
||||
/// The context ID is invalidated on navigation and auto-recreated on next call.
|
||||
/// </summary>
|
||||
public sealed class IsolatedWorld
|
||||
{
|
||||
private readonly IPage _page;
|
||||
private ICDPSession? _cdp;
|
||||
private int? _contextId;
|
||||
|
||||
public IsolatedWorld(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
private async Task<ICDPSession> EnsureCdpAsync()
|
||||
{
|
||||
_cdp ??= await _page.Context.NewCDPSessionAsync(_page).ConfigureAwait(false);
|
||||
return _cdp;
|
||||
}
|
||||
|
||||
private async Task<int> CreateWorldAsync()
|
||||
{
|
||||
var cdp = await EnsureCdpAsync().ConfigureAwait(false);
|
||||
var tree = await cdp.SendAsync("Page.getFrameTree").ConfigureAwait(false);
|
||||
string frameId = tree.Value
|
||||
.GetProperty("frameTree")
|
||||
.GetProperty("frame")
|
||||
.GetProperty("id")
|
||||
.GetString()!;
|
||||
var result = await cdp.SendAsync("Page.createIsolatedWorld", new Dictionary<string, object>
|
||||
{
|
||||
["frameId"] = frameId,
|
||||
["worldName"] = "",
|
||||
["grantUniveralAccess"] = true, // (intentional typo preserved from CDP/source)
|
||||
}).ConfigureAwait(false);
|
||||
_contextId = result.Value.GetProperty("executionContextId").GetInt32();
|
||||
return _contextId.Value;
|
||||
}
|
||||
|
||||
/// <summary>Evaluate JS in the isolated world. Auto-recreates on a stale context. Returns null on failure.</summary>
|
||||
public async Task<JsonElement?> EvaluateAsync(string expression)
|
||||
{
|
||||
if (_contextId == null)
|
||||
await CreateWorldAsync().ConfigureAwait(false);
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _cdp!.SendAsync("Runtime.evaluate", new Dictionary<string, object>
|
||||
{
|
||||
["expression"] = expression,
|
||||
["contextId"] = _contextId!.Value,
|
||||
["returnByValue"] = true,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (result.Value.TryGetProperty("exceptionDetails", out _))
|
||||
{
|
||||
if (attempt == 0)
|
||||
{
|
||||
await CreateWorldAsync().ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.Value.TryGetProperty("result", out var r) &&
|
||||
r.TryGetProperty("value", out var v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (attempt == 0)
|
||||
{
|
||||
_contextId = null;
|
||||
try { await CreateWorldAsync().ConfigureAwait(false); }
|
||||
catch (Exception) { return null; }
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Evaluate and coerce the result to a bool (false on null/failure).</summary>
|
||||
public async Task<bool> EvaluateBoolAsync(string expression)
|
||||
{
|
||||
var v = await EvaluateAsync(expression).ConfigureAwait(false);
|
||||
if (v == null) return false;
|
||||
return v.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number => v.Value.GetDouble() != 0,
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(v.Value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Mark the context as stale - call after navigation.</summary>
|
||||
public void Invalidate() => _contextId = null;
|
||||
|
||||
/// <summary>Get the underlying CDP session (reused for <c>Input.dispatchKeyEvent</c>).</summary>
|
||||
public Task<ICDPSession> GetCdpSessionAsync() => EnsureCdpAsync();
|
||||
|
||||
/// <summary>JSON-encode a string for safe embedding in a JS expression (like Python's json.dumps).</summary>
|
||||
public static string JsonEncode(string s) => JsonSerializer.Serialize(s);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>Adapts Playwright's <see cref="IMouse"/> to <see cref="IRawMouse"/>.</summary>
|
||||
internal sealed class PlaywrightRawMouse : IRawMouse
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
public PlaywrightRawMouse(IMouse mouse) => _mouse = mouse;
|
||||
|
||||
public Task MoveAsync(double x, double y) => _mouse.MoveAsync((float)x, (float)y);
|
||||
public Task DownAsync() => _mouse.DownAsync();
|
||||
public Task UpAsync() => _mouse.UpAsync();
|
||||
public Task WheelAsync(double deltaX, double deltaY) => _mouse.WheelAsync((float)deltaX, (float)deltaY);
|
||||
}
|
||||
|
||||
/// <summary>Adapts Playwright's <see cref="IKeyboard"/> to <see cref="IRawKeyboard"/>.</summary>
|
||||
internal sealed class PlaywrightRawKeyboard : IRawKeyboard
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
public PlaywrightRawKeyboard(IKeyboard keyboard) => _keyboard = keyboard;
|
||||
|
||||
public Task DownAsync(string key) => _keyboard.DownAsync(key);
|
||||
public Task UpAsync(string key) => _keyboard.UpAsync(key);
|
||||
public Task TypeAsync(string text) => _keyboard.TypeAsync(text);
|
||||
public Task InsertTextAsync(string text) => _keyboard.InsertTextAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="ICDPSession"/> to <see cref="IRawCdpSession"/>.</summary>
|
||||
internal sealed class PlaywrightCdpSession : IRawCdpSession
|
||||
{
|
||||
private readonly ICDPSession _session;
|
||||
public PlaywrightCdpSession(ICDPSession session) => _session = session;
|
||||
|
||||
public async Task SendAsync(string method, JsonObject? args = null)
|
||||
{
|
||||
if (args == null)
|
||||
{
|
||||
await _session.SendAsync(method).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
var dict = new Dictionary<string, object>();
|
||||
foreach (var kv in args)
|
||||
{
|
||||
if (kv.Value is JsonValue jv)
|
||||
{
|
||||
if (jv.TryGetValue<int>(out var i)) dict[kv.Key] = i;
|
||||
else if (jv.TryGetValue<double>(out var d)) dict[kv.Key] = d;
|
||||
else if (jv.TryGetValue<bool>(out var b)) dict[kv.Key] = b;
|
||||
else dict[kv.Key] = jv.ToString();
|
||||
}
|
||||
else if (kv.Value != null)
|
||||
{
|
||||
dict[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
await _session.SendAsync(method, dict).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="IPage"/> to <see cref="IRawEvaluator"/> (fallback shift-symbol path).</summary>
|
||||
internal sealed class PlaywrightEvaluator : IRawEvaluator
|
||||
{
|
||||
private readonly IPage _page;
|
||||
public PlaywrightEvaluator(IPage page) => _page = page;
|
||||
|
||||
public async Task EvaluateAsync(string expression, object? arg)
|
||||
{
|
||||
await _page.EvaluateAsync(expression, arg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="IPage"/> to <see cref="IRawScrollPage"/>.</summary>
|
||||
internal sealed class PlaywrightScrollPage : IRawScrollPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
public PlaywrightScrollPage(IPage page) => _page = page;
|
||||
|
||||
public (int Width, int Height)? ViewportSize
|
||||
{
|
||||
get
|
||||
{
|
||||
var vs = _page.ViewportSize;
|
||||
return vs == null ? null : (vs.Width, vs.Height);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(int Width, int Height)?> GetLiveWindowSizeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dims = await _page.EvaluateAsync<WindowDims>(
|
||||
"() => ({ width: window.innerWidth, height: window.innerHeight })")
|
||||
.ConfigureAwait(false);
|
||||
if (dims.Width <= 0 || dims.Height <= 0)
|
||||
return null;
|
||||
return (dims.Width, dims.Height);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowDims
|
||||
{
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CloakBrowser.Human;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Options for <see cref="CloakLauncher.LaunchAsync(LaunchOptions)"/> and friends.
|
||||
/// Mirrors the keyword arguments of the Python <c>launch()</c> family.
|
||||
/// </summary>
|
||||
public class LaunchOptions
|
||||
{
|
||||
/// <summary>Run in headless mode (default true).</summary>
|
||||
public bool Headless { get; set; } = true;
|
||||
|
||||
/// <summary>Proxy: a URL string (<c>http://user:pass@host:port</c>) or a <see cref="ProxySettings"/>.</summary>
|
||||
public object? Proxy { get; set; }
|
||||
|
||||
/// <summary>Additional Chromium CLI arguments.</summary>
|
||||
public List<string>? Args { get; set; }
|
||||
|
||||
/// <summary>Include the default stealth fingerprint args (default true).</summary>
|
||||
public bool StealthArgs { get; set; } = true;
|
||||
|
||||
/// <summary>IANA timezone, e.g. <c>America/New_York</c> - sets <c>--fingerprint-timezone</c>.</summary>
|
||||
public string? Timezone { get; set; }
|
||||
|
||||
/// <summary>BCP 47 locale, e.g. <c>en-US</c> - sets <c>--lang</c> and <c>--fingerprint-locale</c>.</summary>
|
||||
public string? Locale { get; set; }
|
||||
|
||||
/// <summary>Auto-detect timezone/locale (and WebRTC exit IP) from the proxy IP (default false).</summary>
|
||||
public bool GeoIp { get; set; }
|
||||
|
||||
/// <summary>Enable the human-like behavior layer when creating pages via <see cref="CloakBrowserHandle"/>.</summary>
|
||||
public bool Humanize { get; set; }
|
||||
|
||||
/// <summary>Humanize preset (default <see cref="HumanPreset.Default"/>).</summary>
|
||||
public HumanPreset HumanPreset { get; set; } = HumanPreset.Default;
|
||||
|
||||
/// <summary>Custom humanize config overrides (snake_case or PascalCase keys).</summary>
|
||||
public IReadOnlyDictionary<string, object>? HumanConfig { get; set; }
|
||||
|
||||
/// <summary>Chrome extension paths to load.</summary>
|
||||
public List<string>? ExtensionPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CloakBrowser Pro license key. Also read from the <c>CLOAKBROWSER_LICENSE_KEY</c>
|
||||
/// env var or <c>~/.cloakbrowser/license.key</c>. With a valid key the latest Pro
|
||||
/// binary is downloaded from cloakbrowser.dev; without one, the free binary is used.
|
||||
/// </summary>
|
||||
public string? LicenseKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Options for context-producing launchers (adds context-level emulation settings).</summary>
|
||||
public class LaunchContextOptions : LaunchOptions
|
||||
{
|
||||
/// <summary>Custom user agent string.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>Viewport size. Null means "use default 1920x947". Set <see cref="NoViewport"/> to disable.</summary>
|
||||
public (int Width, int Height)? Viewport { get; set; }
|
||||
|
||||
/// <summary>Disable viewport emulation (use the OS window size).</summary>
|
||||
public bool NoViewport { get; set; }
|
||||
|
||||
/// <summary>Color scheme preference: <c>light</c>, <c>dark</c>, or <c>no-preference</c>.</summary>
|
||||
public string? ColorScheme { get; set; }
|
||||
|
||||
/// <summary>Path to a Playwright storage-state JSON to restore cookies/localStorage.</summary>
|
||||
public string? StorageStatePath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a CloakBrowser Pro license validation.
|
||||
/// Mirrors the Python <c>LicenseInfo</c> dataclass / JS <c>LicenseInfo</c> interface.
|
||||
/// </summary>
|
||||
public sealed record LicenseInfo(bool Valid, string Plan, string? Expires);
|
||||
|
||||
/// <summary>
|
||||
/// License validation and caching for CloakBrowser Pro.
|
||||
///
|
||||
/// Handles license-key resolution (param -> env -> file), server validation with a
|
||||
/// local 24h cache, and Pro version lookups. Direct port of Python
|
||||
/// <c>cloakbrowser/license.py</c> and JS <c>js/src/license.ts</c>.
|
||||
/// </summary>
|
||||
public static class License
|
||||
{
|
||||
public const string ValidateUrl = "https://cloakbrowser.dev/api/license/validate";
|
||||
public const string ProVersionUrl = "https://cloakbrowser.dev/api/download/version";
|
||||
|
||||
// 24 hours / 1 hour, in seconds (matches Python's LICENSE_CACHE_TTL / PRO_VERSION_CHECK_INTERVAL).
|
||||
private const double LicenseCacheTtl = 86400;
|
||||
private const double ProVersionCheckInterval = 3600;
|
||||
|
||||
private static readonly HttpClient Http = CreateHttpClient();
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"cloakbrowser-dotnet/{CloakVersion.Version}");
|
||||
return client;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Testing seams - mirror the monkey-patching the Python/JS tests rely on.
|
||||
// Null means "use real behavior" (HTTP). Tests inject deterministic results
|
||||
// without touching the network.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Overrides the server license-validation call for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string, LicenseInfo?>? ValidateLicenseOverride;
|
||||
|
||||
/// <summary>Overrides the Pro latest-version lookup for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string?>? ProLatestVersionOverride;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Resolve the license key: explicit param > env var > file > null.</summary>
|
||||
public static string? ResolveLicenseKey(string? licenseKey = null)
|
||||
{
|
||||
var trimmed = licenseKey?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
return trimmed;
|
||||
|
||||
var envKey = (Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY") ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(envKey))
|
||||
return envKey;
|
||||
|
||||
try
|
||||
{
|
||||
var keyFile = Path.Combine(Config.GetCacheDir(), "license.key");
|
||||
var content = File.ReadAllText(keyFile).Trim();
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
return content;
|
||||
}
|
||||
catch (IOException) { /* file missing/unreadable */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate a license key with the CloakBrowser server.
|
||||
///
|
||||
/// Checks a local file cache first (24h TTL). Falls back to a stale cache if the
|
||||
/// server is unreachable. Returns the <see cref="LicenseInfo"/> on success, or
|
||||
/// null on total failure (server unreachable and no cache).
|
||||
/// </summary>
|
||||
public static LicenseInfo? ValidateLicense(string licenseKey)
|
||||
{
|
||||
if (ValidateLicenseOverride != null)
|
||||
return ValidateLicenseOverride(licenseKey);
|
||||
|
||||
var cachePath = Path.Combine(Config.GetCacheDir(), ".license_cache");
|
||||
var keySha = Sha256Hex(licenseKey);
|
||||
|
||||
var cached = ReadCache(cachePath, keySha);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var body = new StringContent(
|
||||
JsonSerializer.Serialize(new Dictionary<string, string> { ["license_key"] = licenseKey }),
|
||||
Encoding.UTF8, "application/json");
|
||||
using var resp = Http.PostAsync(ValidateUrl, body).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var info = new LicenseInfo(
|
||||
Valid: root.TryGetProperty("valid", out var v) && v.ValueKind == JsonValueKind.True,
|
||||
Plan: root.TryGetProperty("plan", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString() ?? "solo" : "solo",
|
||||
Expires: root.TryGetProperty("expires", out var e) && e.ValueKind == JsonValueKind.String
|
||||
? e.GetString() : null);
|
||||
|
||||
if (info.Valid)
|
||||
WriteCache(cachePath, keySha, info);
|
||||
return info;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CloakLog.Warning("License validation request failed: {0}", ex.Message);
|
||||
|
||||
var stale = ReadCache(cachePath, keySha, ignoreTtl: true);
|
||||
if (stale != null)
|
||||
{
|
||||
CloakLog.Warning("Using cached license validation (server unreachable)");
|
||||
return stale;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the latest Pro binary version from the server.
|
||||
/// Rate-limited to 1 call per hour via a marker file.
|
||||
/// </summary>
|
||||
public static string? GetProLatestVersion()
|
||||
{
|
||||
if (ProLatestVersionOverride != null)
|
||||
return ProLatestVersionOverride();
|
||||
|
||||
var marker = Path.Combine(Config.GetCacheDir(), ".last_pro_version_check");
|
||||
|
||||
if (File.Exists(marker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var age = (DateTime.UtcNow - File.GetLastWriteTimeUtc(marker)).TotalSeconds;
|
||||
if (age < ProVersionCheckInterval)
|
||||
{
|
||||
var content = File.ReadAllText(marker).Trim();
|
||||
return string.IsNullOrEmpty(content) ? null : content;
|
||||
}
|
||||
}
|
||||
catch (IOException) { /* unreadable - proceed with fetch */ }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = Http.GetAsync(ProVersionUrl).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var version = doc.RootElement.TryGetProperty("version", out var ve) && ve.ValueKind == JsonValueKind.String
|
||||
? ve.GetString() : null;
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
|
||||
var tmp = marker + ".tmp";
|
||||
File.WriteAllText(tmp, version);
|
||||
if (File.Exists(marker)) File.Delete(marker);
|
||||
File.Move(tmp, marker);
|
||||
}
|
||||
catch (IOException) { /* non-fatal */ }
|
||||
|
||||
return version;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CloakLog.Debug("Pro version check failed: {0}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cache helpers (atomic write via tmp+rename, like Python/JS).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private sealed record CacheData(
|
||||
string? key_sha256, bool valid, string? plan, string? expires, double validated_at);
|
||||
|
||||
private static LicenseInfo? ReadCache(string cachePath, string keySha, bool ignoreTtl = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(cachePath))
|
||||
return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(cachePath));
|
||||
var root = doc.RootElement;
|
||||
|
||||
var cachedSha = root.TryGetProperty("key_sha256", out var ks) && ks.ValueKind == JsonValueKind.String
|
||||
? ks.GetString() : null;
|
||||
if (cachedSha != keySha)
|
||||
return null;
|
||||
|
||||
if (!ignoreTtl)
|
||||
{
|
||||
// A non-numeric validated_at (corrupted cache) is treated as absent
|
||||
// rather than silently trusting the entry.
|
||||
if (!root.TryGetProperty("validated_at", out var va) || va.ValueKind != JsonValueKind.Number)
|
||||
return null;
|
||||
var validatedAt = va.GetDouble();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
if (now - validatedAt > LicenseCacheTtl)
|
||||
return null;
|
||||
}
|
||||
|
||||
var plan = root.TryGetProperty("plan", out var pe) && pe.ValueKind == JsonValueKind.String
|
||||
? pe.GetString() ?? "solo" : "solo";
|
||||
var expires = root.TryGetProperty("expires", out var ee) && ee.ValueKind == JsonValueKind.String
|
||||
? ee.GetString() : null;
|
||||
var valid = root.TryGetProperty("valid", out var ve) && ve.ValueKind == JsonValueKind.True;
|
||||
|
||||
// An expired license is reported invalid even if it was cached as valid.
|
||||
if (!string.IsNullOrEmpty(expires))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(expires, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var expDt))
|
||||
{
|
||||
if (expDt < DateTimeOffset.UtcNow)
|
||||
return new LicenseInfo(false, plan, expires);
|
||||
}
|
||||
}
|
||||
|
||||
return new LicenseInfo(valid, plan, expires);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Any unreadable/corrupt cache is treated as absent rather than crashing.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCache(string cachePath, string keySha, LicenseInfo info)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
|
||||
var tmpPath = cachePath + ".tmp";
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
var payload = JsonSerializer.Serialize(new CacheData(
|
||||
key_sha256: keySha, valid: info.Valid, plan: info.Plan,
|
||||
expires: info.Expires, validated_at: now));
|
||||
File.WriteAllText(tmpPath, payload);
|
||||
if (File.Exists(cachePath)) File.Delete(cachePath);
|
||||
File.Move(tmpPath, cachePath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
CloakLog.Debug("Failed to write license cache: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string s)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(s));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy resolution: maps a proxy (string URL or <see cref="ProxySettings"/>) into
|
||||
/// Playwright proxy options and/or Chrome <c>--proxy-server</c> args.
|
||||
/// Direct port of the proxy helpers in Python <c>cloakbrowser/browser.py</c>.
|
||||
/// </summary>
|
||||
internal static class ProxyResolver
|
||||
{
|
||||
private const string HttpProxyInlineAuthMinVersion = "146.0.7680.177.5";
|
||||
private static readonly IReadOnlySet<string> HttpProxyInlineAuthPlatforms =
|
||||
new HashSet<string> { "linux-x64", "windows-x64" };
|
||||
|
||||
/// <summary>Result of resolving a proxy: Playwright proxy (or null) plus extra Chrome args.</summary>
|
||||
public sealed record ProxyResolution(Microsoft.Playwright.Proxy? PlaywrightProxy, List<string> ExtraArgs);
|
||||
|
||||
// -- small URL parsing model -------------------------------------------------
|
||||
|
||||
/// <summary>A minimal parsed proxy URL (mirrors the pieces Python's urlparse exposes).</summary>
|
||||
private sealed class ParsedUrl
|
||||
{
|
||||
public string Scheme = "";
|
||||
public string? Username; // null = absent, "" = present-but-empty
|
||||
public string? Password;
|
||||
public string Host = "";
|
||||
public int? Port;
|
||||
public string Path = "";
|
||||
public string Query = "";
|
||||
public string Fragment = "";
|
||||
}
|
||||
|
||||
/// <summary>Prepend <c>http://</c> to schemeless proxy URLs so parsers can extract the hostname.</summary>
|
||||
public static string EnsureProxyScheme(string proxyUrl) =>
|
||||
proxyUrl.Contains("://") ? proxyUrl : $"http://{proxyUrl}";
|
||||
|
||||
private static ParsedUrl ParseUrl(string url)
|
||||
{
|
||||
var p = new ParsedUrl();
|
||||
string rest = url;
|
||||
|
||||
int schemeIdx = rest.IndexOf("://", StringComparison.Ordinal);
|
||||
if (schemeIdx >= 0)
|
||||
{
|
||||
p.Scheme = rest[..schemeIdx].ToLowerInvariant();
|
||||
rest = rest[(schemeIdx + 3)..];
|
||||
}
|
||||
|
||||
// Split off fragment.
|
||||
int hashIdx = rest.IndexOf('#');
|
||||
if (hashIdx >= 0) { p.Fragment = rest[(hashIdx + 1)..]; rest = rest[..hashIdx]; }
|
||||
|
||||
// Split off query.
|
||||
int qIdx = rest.IndexOf('?');
|
||||
if (qIdx >= 0) { p.Query = rest[(qIdx + 1)..]; rest = rest[..qIdx]; }
|
||||
|
||||
// Split off path.
|
||||
int slashIdx = rest.IndexOf('/');
|
||||
string netloc;
|
||||
if (slashIdx >= 0) { p.Path = rest[slashIdx..]; netloc = rest[..slashIdx]; }
|
||||
else netloc = rest;
|
||||
|
||||
// Userinfo.
|
||||
int atIdx = netloc.LastIndexOf('@');
|
||||
string hostport;
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
string userinfo = netloc[..atIdx];
|
||||
hostport = netloc[(atIdx + 1)..];
|
||||
int colon = userinfo.IndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
p.Username = userinfo[..colon];
|
||||
p.Password = userinfo[(colon + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Username = userinfo;
|
||||
p.Password = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hostport = netloc;
|
||||
}
|
||||
|
||||
// Host / port (handle IPv6 literal in brackets).
|
||||
if (hostport.StartsWith('['))
|
||||
{
|
||||
int close = hostport.IndexOf(']');
|
||||
p.Host = hostport[1..close];
|
||||
string after = hostport[(close + 1)..];
|
||||
if (after.StartsWith(':'))
|
||||
p.Port = ParsePort(after[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
int colon = hostport.LastIndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
p.Host = hostport[..colon];
|
||||
p.Port = ParsePort(hostport[(colon + 1)..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Host = hostport;
|
||||
}
|
||||
}
|
||||
// Python's urlparse().hostname cosmetically lowercases the host; match it so
|
||||
// the assembled proxy URL/server string is byte-for-byte identical.
|
||||
p.Host = p.Host.ToLowerInvariant();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static int? ParsePort(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
if (!int.TryParse(s, out var port) || port < 0 || port > 65535)
|
||||
throw new FormatException($"Invalid port: {s}");
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Percent-encode like Python's <c>quote(safe="")</c>.</summary>
|
||||
private static string Quote(string s) => Uri.EscapeDataString(s);
|
||||
|
||||
/// <summary>Percent-decode like Python's <c>unquote</c>.</summary>
|
||||
private static string Unquote(string s) => Uri.UnescapeDataString(s);
|
||||
|
||||
private static string AssembleProxyUrl(
|
||||
string scheme, string host, int? port,
|
||||
string encUser, string? encPass,
|
||||
string path = "", string query = "", string fragment = "")
|
||||
{
|
||||
if (host.Contains(':')) // IPv6 literal - re-add brackets
|
||||
host = $"[{host}]";
|
||||
string userinfo;
|
||||
if (encPass != null)
|
||||
userinfo = $"{encUser}:{encPass}@";
|
||||
else if (!string.IsNullOrEmpty(encUser))
|
||||
userinfo = $"{encUser}@";
|
||||
else
|
||||
userinfo = "";
|
||||
string netloc = $"{userinfo}{host}";
|
||||
if (port != null)
|
||||
netloc += $":{port}";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(scheme)) sb.Append(scheme).Append("://");
|
||||
sb.Append(netloc).Append(path);
|
||||
if (!string.IsNullOrEmpty(query)) sb.Append('?').Append(query);
|
||||
if (!string.IsNullOrEmpty(fragment)) sb.Append('#').Append(fragment);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// -- SOCKS handling ----------------------------------------------------------
|
||||
|
||||
public static bool IsSocksProxy(string? url) =>
|
||||
url != null && (url.StartsWith("socks5://", StringComparison.OrdinalIgnoreCase)
|
||||
|| url.StartsWith("socks5h://", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static bool IsSocksProxy(ProxySettings proxy) => IsSocksProxy(proxy.Server);
|
||||
|
||||
private static string ReconstructSocksUrl(ProxySettings proxy)
|
||||
{
|
||||
string server = proxy.Server;
|
||||
string username = proxy.Username ?? "";
|
||||
string password = proxy.Password ?? "";
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return server;
|
||||
var parsed = ParseUrl(server);
|
||||
string encUser = Quote(username);
|
||||
string? encPass = string.IsNullOrEmpty(password) ? null : Quote(password);
|
||||
return AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass, parsed.Path);
|
||||
}
|
||||
|
||||
private static string NormalizeSocksStringUrl(string url)
|
||||
{
|
||||
ParsedUrl parsed;
|
||||
try { parsed = ParseUrl(url); }
|
||||
catch (FormatException e)
|
||||
{
|
||||
CloakLog.Warning($"Malformed SOCKS5 proxy URL, passing through unchanged: {e.Message}");
|
||||
return url;
|
||||
}
|
||||
if (parsed.Username == null && parsed.Password == null)
|
||||
return url;
|
||||
string rawUser = parsed.Username ?? "";
|
||||
string encUser = string.IsNullOrEmpty(rawUser) ? "" : Quote(Unquote(rawUser));
|
||||
string? rawPass = parsed.Password;
|
||||
string? encPass = parsed.Password != null
|
||||
? (string.IsNullOrEmpty(parsed.Password) ? "" : Quote(Unquote(parsed.Password)))
|
||||
: null;
|
||||
string normalized = AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass,
|
||||
parsed.Path, parsed.Query, parsed.Fragment);
|
||||
if (encUser != rawUser || encPass != rawPass)
|
||||
CloakLog.Info("Auto URL-encoded SOCKS5 proxy credentials (special characters detected). " +
|
||||
"Pre-encode the URL to suppress this notice.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// -- HTTP handling -----------------------------------------------------------
|
||||
|
||||
private static bool HasCredentials(ProxySettings proxy) => !string.IsNullOrEmpty(proxy.Username);
|
||||
private static bool HasCredentials(string proxy) => proxy.Contains('@');
|
||||
|
||||
private static string ReconstructHttpUrl(ProxySettings proxy)
|
||||
{
|
||||
string server = proxy.Server;
|
||||
string username = proxy.Username ?? "";
|
||||
string password = proxy.Password ?? "";
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return server;
|
||||
var parsed = ParseUrl(EnsureProxyScheme(server));
|
||||
string encUser = Quote(username);
|
||||
string? encPass = string.IsNullOrEmpty(password) ? null : Quote(password);
|
||||
return AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass, parsed.Path);
|
||||
}
|
||||
|
||||
private static string NormalizeHttpStringUrl(string url)
|
||||
{
|
||||
string normalized = url.Contains("://") ? url : $"http://{url}";
|
||||
ParsedUrl parsed;
|
||||
try { parsed = ParseUrl(normalized); }
|
||||
catch (FormatException e)
|
||||
{
|
||||
CloakLog.Warning($"Malformed HTTP proxy URL, passing through unchanged: {e.Message}");
|
||||
return normalized;
|
||||
}
|
||||
if (parsed.Username == null && parsed.Password == null)
|
||||
return normalized;
|
||||
string rawUser = parsed.Username ?? "";
|
||||
string encUser = string.IsNullOrEmpty(rawUser) ? "" : Quote(Unquote(rawUser));
|
||||
string? rawPass = parsed.Password;
|
||||
string? encPass = parsed.Password != null
|
||||
? (string.IsNullOrEmpty(parsed.Password) ? "" : Quote(Unquote(parsed.Password)))
|
||||
: null;
|
||||
string result = AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass,
|
||||
parsed.Path, parsed.Query, parsed.Fragment);
|
||||
if (encUser != rawUser || encPass != rawPass)
|
||||
CloakLog.Info("Auto URL-encoded HTTP proxy credentials (special characters detected). " +
|
||||
"Pre-encode the URL to suppress this notice.");
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Parse an HTTP(S) proxy URL into a Playwright <see cref="Microsoft.Playwright.Proxy"/>.</summary>
|
||||
private static Microsoft.Playwright.Proxy ParseProxyUrl(string proxy)
|
||||
{
|
||||
string normalized = proxy;
|
||||
if (proxy.Contains('@') && !proxy.Contains("://"))
|
||||
normalized = $"http://{proxy}";
|
||||
|
||||
var parsed = ParseUrl(normalized);
|
||||
if (string.IsNullOrEmpty(parsed.Username))
|
||||
return new Microsoft.Playwright.Proxy { Server = proxy };
|
||||
|
||||
string netloc = parsed.Host;
|
||||
if (parsed.Port != null) netloc += $":{parsed.Port}";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(parsed.Scheme)) sb.Append(parsed.Scheme).Append("://");
|
||||
sb.Append(netloc).Append(parsed.Path);
|
||||
|
||||
var result = new Microsoft.Playwright.Proxy
|
||||
{
|
||||
Server = sb.ToString(),
|
||||
Username = Unquote(parsed.Username!),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(parsed.Password))
|
||||
result.Password = Unquote(parsed.Password!);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool SupportsHttpProxyInlineAuth()
|
||||
{
|
||||
string tag = Config.GetPlatformTag();
|
||||
if (!HttpProxyInlineAuthPlatforms.Contains(tag))
|
||||
return false;
|
||||
return Compare(Config.GetChromiumVersion(), HttpProxyInlineAuthMinVersion) >= 0;
|
||||
}
|
||||
|
||||
private static int Compare(string a, string b)
|
||||
{
|
||||
var ta = Config.VersionTuple(a);
|
||||
var tb = Config.VersionTuple(b);
|
||||
int n = Math.Max(ta.Length, tb.Length);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int va = i < ta.Length ? ta[i] : 0;
|
||||
int vb = i < tb.Length ? tb[i] : 0;
|
||||
if (va != vb) return va.CompareTo(vb);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Extract a normalized proxy URL string from a string or dict proxy (for geoip / webrtc).</summary>
|
||||
public static string? ExtractProxyUrl(object? proxy)
|
||||
{
|
||||
switch (proxy)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case ProxySettings ps:
|
||||
if (string.IsNullOrEmpty(ps.Server)) return null;
|
||||
return IsSocksProxy(ps) ? ReconstructSocksUrl(ps) : EnsureProxyScheme(ps.Server);
|
||||
case string s:
|
||||
return EnsureProxyScheme(s);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolve a proxy into Playwright options + extra Chrome args (one or both empty).</summary>
|
||||
public static ProxyResolution Resolve(object? proxy)
|
||||
{
|
||||
if (proxy == null)
|
||||
return new ProxyResolution(null, new List<string>());
|
||||
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
bool socks = proxy switch
|
||||
{
|
||||
ProxySettings ps => IsSocksProxy(ps),
|
||||
string s => IsSocksProxy(s),
|
||||
_ => false,
|
||||
};
|
||||
if (socks)
|
||||
{
|
||||
if (proxy is ProxySettings psd)
|
||||
{
|
||||
string url = ReconstructSocksUrl(psd);
|
||||
var extra = new List<string> { $"--proxy-server={url}" };
|
||||
if (!string.IsNullOrEmpty(psd.Bypass))
|
||||
extra.Add($"--proxy-bypass-list={psd.Bypass}");
|
||||
return new ProxyResolution(null, extra);
|
||||
}
|
||||
string sUrl = (string)proxy;
|
||||
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeSocksStringUrl(sUrl)}" });
|
||||
}
|
||||
|
||||
// HTTP/HTTPS with credentials on supported platforms: inline creds via --proxy-server.
|
||||
bool hasCreds = proxy switch
|
||||
{
|
||||
ProxySettings ps => HasCredentials(ps),
|
||||
string s => HasCredentials(s),
|
||||
_ => false,
|
||||
};
|
||||
if (hasCreds && SupportsHttpProxyInlineAuth())
|
||||
{
|
||||
if (proxy is ProxySettings psd)
|
||||
{
|
||||
string url = ReconstructHttpUrl(psd);
|
||||
var extra = new List<string> { $"--proxy-server={url}" };
|
||||
if (!string.IsNullOrEmpty(psd.Bypass))
|
||||
extra.Add($"--proxy-bypass-list={psd.Bypass}");
|
||||
return new ProxyResolution(null, extra);
|
||||
}
|
||||
string sUrl = (string)proxy;
|
||||
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeHttpStringUrl(sUrl)}" });
|
||||
}
|
||||
|
||||
// HTTP/HTTPS without credentials: use Playwright's proxy.
|
||||
if (proxy is ProxySettings dict)
|
||||
{
|
||||
return new ProxyResolution(new Microsoft.Playwright.Proxy
|
||||
{
|
||||
Server = dict.Server,
|
||||
Bypass = dict.Bypass,
|
||||
Username = dict.Username,
|
||||
Password = dict.Password,
|
||||
}, new List<string>());
|
||||
}
|
||||
return new ProxyResolution(ParseProxyUrl((string)proxy), new List<string>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Playwright-compatible proxy configuration. Mirrors the Python
|
||||
/// <c>ProxySettings</c> TypedDict. <see cref="Server"/> is required; the rest are optional.
|
||||
/// </summary>
|
||||
public sealed class ProxySettings
|
||||
{
|
||||
/// <summary>Proxy server URL, e.g. <c>http://proxy:8080</c> or <c>socks5://proxy:1080</c>.</summary>
|
||||
public string Server { get; set; } = "";
|
||||
|
||||
/// <summary>Comma-separated bypass list, e.g. <c>.google.com</c>.</summary>
|
||||
public string? Bypass { get; set; }
|
||||
|
||||
/// <summary>Proxy username (for authenticated proxies).</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>Proxy password (for authenticated proxies).</summary>
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Shared headed-launch viewport defaulting - the single source of truth behind both
|
||||
/// <see cref="CloakBrowserHandle"/> and the humanize <c>HumanizedBrowser</c> wrapper, so
|
||||
/// the default applies no matter which path creates the page/context (mirrors Python's
|
||||
/// single <c>_default_no_viewport</c> that monkey-patches the raw browser).
|
||||
///
|
||||
/// On a headed launch, a page/context the caller gave no explicit viewport defaults to
|
||||
/// <see cref="ViewportSize.NoViewport"/> so it tracks the real OS window - a bare emulated
|
||||
/// viewport on a headed window yields <c>outerWidth < innerWidth</c> (a physically
|
||||
/// impossible window / bot tell). Headless keeps Playwright's default (coherent there).
|
||||
/// An explicit viewport (including <see cref="ViewportSize.NoViewport"/>) is always honored.
|
||||
/// </summary>
|
||||
internal static class ViewportDefaults
|
||||
{
|
||||
public static BrowserNewPageOptions ApplyHeadedNoViewport(BrowserNewPageOptions? options, bool headless)
|
||||
{
|
||||
if (headless) return options ?? new BrowserNewPageOptions();
|
||||
var o = options ?? new BrowserNewPageOptions();
|
||||
o.ViewportSize ??= ViewportSize.NoViewport;
|
||||
return o;
|
||||
}
|
||||
|
||||
public static BrowserNewContextOptions ApplyHeadedNoViewport(BrowserNewContextOptions? options, bool headless)
|
||||
{
|
||||
if (headless) return options ?? new BrowserNewContextOptions();
|
||||
var o = options ?? new BrowserNewContextOptions();
|
||||
o.ViewportSize ??= ViewportSize.NoViewport;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Widevine CDM hint-file seeding for persistent contexts.
|
||||
/// CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
|
||||
/// is a proprietary Google binary we can't redistribute). Users sideload it by
|
||||
/// copying a <c>WidevineCdm/</c> directory from a real Chrome install next to the binary.
|
||||
///
|
||||
/// This module pre-seeds the hint file before launch so a sideloaded CDM works on
|
||||
/// the very first launch. It never bundles, downloads, or copies the CDM itself -
|
||||
/// it only writes the hint when a CDM the user provided is already present.
|
||||
///
|
||||
/// Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
|
||||
/// Direct port of Python <c>cloakbrowser/widevine.py</c>.
|
||||
/// </summary>
|
||||
public static class Widevine
|
||||
{
|
||||
// Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
|
||||
private const string HintFilename = "latest-component-updated-widevine-cdm";
|
||||
|
||||
private static bool SeedingDisabled()
|
||||
{
|
||||
var val = (Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE") ?? "").Trim().ToLowerInvariant();
|
||||
return val is "0" or "false" or "off" or "no";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate a sideloaded Widevine CDM directory, or null if absent.
|
||||
/// If <c>CLOAKBROWSER_WIDEVINE_CDM</c> is set, it is used exclusively (overrides
|
||||
/// auto-detection). Otherwise <c><dir of the chrome binary>/WidevineCdm</c>.
|
||||
/// A directory counts only if it contains <c>manifest.json</c>.
|
||||
/// </summary>
|
||||
public static string? ResolveWidevineCdmDir(string binaryPath)
|
||||
{
|
||||
var custom = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM");
|
||||
// "is not null" (not truthiness): a present-but-empty env var is "set" and
|
||||
// used exclusively - it resolves to an invalid path and skips seeding.
|
||||
string cdmDir;
|
||||
if (custom != null)
|
||||
{
|
||||
// A present-but-empty/whitespace value is "set" and used exclusively,
|
||||
// but is not a usable path: treat it as invalid so we skip seeding
|
||||
// (Path.Combine("", "manifest.json") would otherwise probe the CWD,
|
||||
// diverging from the Python/JS wrappers' bogus-path => null behaviour).
|
||||
if (string.IsNullOrWhiteSpace(custom))
|
||||
return null;
|
||||
cdmDir = custom;
|
||||
}
|
||||
else
|
||||
{
|
||||
cdmDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(binaryPath)) ?? ".", "WidevineCdm");
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(cdmDir, "manifest.json")))
|
||||
return Path.GetFullPath(cdmDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the Widevine CDM hint file into a persistent profile before launch.
|
||||
/// No-op on non-Linux platforms, when seeding is disabled via CLOAKBROWSER_WIDEVINE,
|
||||
/// or when no sideloaded CDM is present. Never throws.
|
||||
/// </summary>
|
||||
public static void SeedWidevineHint(string? userDataDir, string binaryPath)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return;
|
||||
if (SeedingDisabled())
|
||||
{
|
||||
CloakLog.Debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE");
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(userDataDir))
|
||||
{
|
||||
// Empty user_data_dir = Playwright's ephemeral profile (its own temp dir).
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cdmDir = ResolveWidevineCdmDir(binaryPath);
|
||||
if (cdmDir == null)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM") != null)
|
||||
CloakLog.Warning(
|
||||
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
|
||||
"skipping Widevine hint seeding");
|
||||
else
|
||||
CloakLog.Debug("No sideloaded Widevine CDM found; skipping hint seeding");
|
||||
return;
|
||||
}
|
||||
|
||||
var hintDir = Path.Combine(userDataDir, "WidevineCdm");
|
||||
Directory.CreateDirectory(hintDir);
|
||||
var hintFile = Path.Combine(hintDir, HintFilename);
|
||||
|
||||
// Compact separators byte-match the JS wrapper's JSON.stringify (UTF-8) output.
|
||||
var content = JsonSerializer.Serialize(
|
||||
new Dictionary<string, string> { ["Path"] = cdmDir },
|
||||
new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(hintFile) && File.ReadAllText(hintFile, Encoding.UTF8) == content)
|
||||
return; // already seeded correctly
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("Existing Widevine hint unreadable; rewriting");
|
||||
}
|
||||
|
||||
File.WriteAllText(hintFile, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
CloakLog.Info("Seeded Widevine CDM hint -> {0}", cdmDir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CloakLog.Warning("Failed to seed Widevine CDM hint file: {0}", e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Per-page shared humanize state: the virtual cursor position, the raw mouse /
|
||||
/// keyboard adapters, and the CDP typing stealth path. A single instance is shared
|
||||
/// by a <see cref="HumanizedPage"/>, its <see cref="HumanizedMouse"/>,
|
||||
/// <see cref="HumanizedKeyboard"/>, and every <see cref="HumanizedLocator"/> it
|
||||
/// produces, so cursor motion is continuous across them (no jumps).
|
||||
///
|
||||
/// This mirrors the cursor/stealth bookkeeping inside <see cref="HumanPage"/>, exposed
|
||||
/// in a form the wrappers can share.
|
||||
/// </summary>
|
||||
internal sealed class HumanCursor
|
||||
{
|
||||
private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
private static readonly string SelectAll = IsMac ? "Meta+a" : "Control+a";
|
||||
|
||||
private readonly IPage _page;
|
||||
private readonly IRawMouse _rawMouse;
|
||||
private readonly IRawKeyboard _rawKeyboard;
|
||||
private readonly IRawEvaluator _evaluator;
|
||||
|
||||
private IsolatedWorld? _stealth;
|
||||
private IRawCdpSession? _cdpSession;
|
||||
private bool _stealthInitialized;
|
||||
private bool _initialized;
|
||||
|
||||
public double X { get; private set; }
|
||||
public double Y { get; private set; }
|
||||
public IRawMouse RawMouse => _rawMouse;
|
||||
|
||||
public HumanCursor(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
_rawMouse = new PlaywrightRawMouse(page.Mouse);
|
||||
_rawKeyboard = new PlaywrightRawKeyboard(page.Keyboard);
|
||||
_evaluator = new PlaywrightEvaluator(page);
|
||||
}
|
||||
|
||||
public void Set(double x, double y) { X = x; Y = y; }
|
||||
|
||||
public async Task InitStealthAsync()
|
||||
{
|
||||
if (_stealthInitialized) return;
|
||||
_stealthInitialized = true;
|
||||
try
|
||||
{
|
||||
_stealth = new IsolatedWorld(_page);
|
||||
var session = await _stealth.GetCdpSessionAsync().ConfigureAwait(false);
|
||||
_cdpSession = new PlaywrightCdpSession(session);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
_stealth = null;
|
||||
_cdpSession = null;
|
||||
CloakLog.Debug("Could not create CDP session - stealth features disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public void InvalidateStealth() => _stealth?.Invalidate();
|
||||
|
||||
public async Task EnsureInitializedAsync(HumanConfig cfg)
|
||||
{
|
||||
if (_initialized) return;
|
||||
X = HumanRandom.Rand(cfg.InitialCursorX.Min, cfg.InitialCursorX.Max);
|
||||
Y = HumanRandom.Rand(cfg.InitialCursorY.Min, cfg.InitialCursorY.Max);
|
||||
try
|
||||
{
|
||||
await _rawMouse.MoveAsync(X, Y).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
catch (System.Exception) { /* viewport may not be ready yet */ }
|
||||
}
|
||||
|
||||
public Task RawMouseDownAsync(int clickCount = 1) =>
|
||||
_page.Mouse.DownAsync(new MouseDownOptions { ClickCount = clickCount });
|
||||
|
||||
public Task RawMouseUpAsync(int clickCount = 1) =>
|
||||
_page.Mouse.UpAsync(new MouseUpOptions { ClickCount = clickCount });
|
||||
|
||||
public Task SelectAllAsync() => _page.Keyboard.PressAsync(SelectAll);
|
||||
|
||||
public Task PressAsync(string key) => _page.Keyboard.PressAsync(key);
|
||||
|
||||
public Task HumanTypeAsync(string text, HumanConfig cfg) =>
|
||||
HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, cfg, _cdpSession);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Entry points and helpers for the transparent humanize layer.
|
||||
///
|
||||
/// The whole point of these wrappers is that user code keeps using the standard
|
||||
/// Playwright API - <c>page.ClickAsync(...)</c>, <c>page.Locator(...).FillAsync(...)</c>,
|
||||
/// <c>page.Mouse.MoveAsync(...)</c> - and the interaction methods are automatically
|
||||
/// routed through the human-like engine. Non-interaction members are forwarded
|
||||
/// verbatim by the Roslyn source generator, and every object that can perform an
|
||||
/// interaction (mouse, keyboard, locators, frames, child pages) is returned already
|
||||
/// wrapped so there are no "raw" leaks.
|
||||
/// </summary>
|
||||
public static class Humanize
|
||||
{
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IPage"/> so that all interaction methods are
|
||||
/// humanized. Returns a fully-wrapping <see cref="IPage"/> - assign it to an
|
||||
/// <c>IPage</c> variable and use the standard API.
|
||||
/// </summary>
|
||||
public static async Task<IPage> PageAsync(IPage page, HumanConfig? config = null)
|
||||
{
|
||||
if (page is HumanizedPage) return page; // already wrapped
|
||||
var cfg = config ?? new HumanConfig();
|
||||
var cursor = new HumanCursor(page);
|
||||
await cursor.InitStealthAsync().ConfigureAwait(false);
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
return new HumanizedPage(page, cursor, cfg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IBrowserContext"/> so every page it produces
|
||||
/// (and already contains) is humanized.
|
||||
/// </summary>
|
||||
public static IBrowserContext Context(IBrowserContext context, HumanConfig? config = null)
|
||||
{
|
||||
if (context is HumanizedBrowserContext) return context;
|
||||
return new HumanizedBrowserContext(context, config ?? new HumanConfig());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IBrowser"/> so every context/page it produces
|
||||
/// is humanized.
|
||||
/// </summary>
|
||||
public static IBrowser Browser(IBrowser browser, HumanConfig? config = null, bool headless = true)
|
||||
{
|
||||
if (browser is HumanizedBrowser) return browser;
|
||||
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal re-wrap helpers (shared by the wrappers).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
internal static ILocator WrapLocator(ILocator locator, HumanCursor cursor, HumanConfig cfg) =>
|
||||
locator is HumanizedLocator ? locator : new HumanizedLocator(locator, cursor, cfg);
|
||||
|
||||
internal static IFrame WrapFrame(IFrame frame, HumanCursor cursor, HumanConfig cfg) =>
|
||||
frame is HumanizedFrame ? frame : new HumanizedFrame(frame, cursor, cfg);
|
||||
|
||||
internal static IElementHandle WrapElementHandle(IElementHandle handle, HumanCursor cursor, HumanConfig cfg) =>
|
||||
handle is HumanizedElementHandle ? handle : new HumanizedElementHandle(handle, cursor, cfg);
|
||||
|
||||
internal static IReadOnlyList<IFrame> WrapFrames(IReadOnlyList<IFrame> frames, HumanCursor cursor, HumanConfig cfg) =>
|
||||
frames.Select(f => WrapFrame(f, cursor, cfg)).ToList();
|
||||
|
||||
internal static IReadOnlyList<IElementHandle> WrapHandles(IReadOnlyList<IElementHandle> handles, HumanCursor cursor, HumanConfig cfg) =>
|
||||
handles.Select(h => WrapElementHandle(h, cursor, cfg)).ToList();
|
||||
|
||||
/// <summary>Per-page cursor cache so pages from a context/browser share state across re-wraps.</summary>
|
||||
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IPage, HumanCursor> CursorCache = new();
|
||||
|
||||
internal static async Task<IPage> WrapPageAsync(IPage page, HumanConfig cfg)
|
||||
{
|
||||
if (page is HumanizedPage) return page;
|
||||
if (CursorCache.TryGetValue(page, out var existing))
|
||||
return new HumanizedPage(page, existing, cfg);
|
||||
|
||||
var cursor = new HumanCursor(page);
|
||||
await cursor.InitStealthAsync().ConfigureAwait(false);
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
CursorCache.Add(page, cursor);
|
||||
return new HumanizedPage(page, cursor, cfg);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<IPage> WrapPages(IReadOnlyList<IPage> pages, HumanConfig cfg) =>
|
||||
pages.Select(p =>
|
||||
{
|
||||
if (p is HumanizedPage) return p;
|
||||
var cursor = CursorCache.GetValue(p, key => new HumanCursor(key));
|
||||
return (IPage)new HumanizedPage(p, cursor, cfg);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for reading Force/Timeout out of the per-action Playwright option
|
||||
/// objects, which all expose <c>Force</c> and <c>Timeout</c> but have no common base.
|
||||
/// </summary>
|
||||
internal static class OptionReader
|
||||
{
|
||||
public static bool Force(object? options) =>
|
||||
options?.GetType().GetProperty("Force")?.GetValue(options) is bool b && b;
|
||||
|
||||
public static double Timeout(object? options)
|
||||
{
|
||||
var v = options?.GetType().GetProperty("Timeout")?.GetValue(options);
|
||||
return v is float f ? f : v is double d ? d : 30000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IBrowser"/>.
|
||||
///
|
||||
/// Intercepted: <c>NewPageAsync</c> / <c>NewContextAsync</c> / <c>Contexts</c> return
|
||||
/// humanized pages and contexts so the entire object graph reachable from the browser
|
||||
/// is humanized. Everything else is delegated to the inner browser by the generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IBrowser))]
|
||||
public sealed partial class HumanizedBrowser : IBrowser
|
||||
{
|
||||
private readonly IBrowser _inner;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly bool _headless;
|
||||
|
||||
internal HumanizedBrowser(IBrowser inner, HumanConfig cfg, bool headless = true)
|
||||
{
|
||||
_inner = inner;
|
||||
_cfg = cfg;
|
||||
_headless = headless;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright browser (escape hatch).</summary>
|
||||
public IBrowser Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IBrowser Inner => _inner;
|
||||
|
||||
public async Task<IPage> NewPageAsync(BrowserNewPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(
|
||||
await _inner.NewPageAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless)).ConfigureAwait(false),
|
||||
_cfg).ConfigureAwait(false);
|
||||
|
||||
public async Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
|
||||
Humanize.Context(
|
||||
await _inner.NewContextAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless)).ConfigureAwait(false),
|
||||
_cfg);
|
||||
|
||||
public IReadOnlyList<IBrowserContext> Contexts =>
|
||||
_inner.Contexts.Select(c => Humanize.Context(c, _cfg)).ToList();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IBrowserContext"/>.
|
||||
///
|
||||
/// Intercepted: page-producing members (<c>NewPageAsync</c>, <c>Pages</c>,
|
||||
/// <c>WaitForPageAsync</c>, <c>RunAndWaitForPageAsync</c>) return humanized pages.
|
||||
/// Everything else is delegated to the inner context by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IBrowserContext))]
|
||||
public sealed partial class HumanizedBrowserContext : IBrowserContext
|
||||
{
|
||||
private readonly IBrowserContext _inner;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedBrowserContext(IBrowserContext inner, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright context (escape hatch).</summary>
|
||||
public IBrowserContext Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IBrowserContext Inner => _inner;
|
||||
|
||||
public async Task<IPage> NewPageAsync() =>
|
||||
await Humanize.WrapPageAsync(await _inner.NewPageAsync().ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
|
||||
public IReadOnlyList<IPage> Pages => Humanize.WrapPages(_inner.Pages, _cfg);
|
||||
|
||||
public async Task<IPage> WaitForPageAsync(BrowserContextWaitForPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(await _inner.WaitForPageAsync(options).ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
|
||||
public async Task<IPage> RunAndWaitForPageAsync(System.Func<Task> action, BrowserContextRunAndWaitForPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(await _inner.RunAndWaitForPageAsync(action, options).ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IElementHandle"/>.
|
||||
///
|
||||
/// ElementHandle is a legacy, lower-level surface (Playwright itself recommends
|
||||
/// <see cref="ILocator"/>). For completeness this wrapper humanizes the common
|
||||
/// interaction methods (click/dblclick/hover/tap/fill/type/press/check/uncheck) by
|
||||
/// driving the shared cursor to the handle's bounding box, so the ElementHandle path
|
||||
/// does NOT silently bypass humanization. Handle-returning queries are re-wrapped;
|
||||
/// everything else is delegated by the generator.
|
||||
///
|
||||
/// Recommendation: prefer Locator / selector-based methods - they get the full
|
||||
/// actionability + isolated-world stealth path.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IElementHandle))]
|
||||
public sealed partial class HumanizedElementHandle : IElementHandle
|
||||
{
|
||||
private readonly IElementHandle _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedElementHandle(IElementHandle inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright element handle (escape hatch).</summary>
|
||||
public IElementHandle Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IElementHandle Inner => _inner;
|
||||
|
||||
private async Task<(double X, double Y, bool IsInput)> MoveToAsync(double timeout, bool force)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
if (!force)
|
||||
{
|
||||
try { await _inner.ScrollIntoViewIfNeededAsync(new ElementHandleScrollIntoViewIfNeededOptions { Timeout = (float)timeout }).ConfigureAwait(false); }
|
||||
catch (System.Exception) { /* best effort */ }
|
||||
}
|
||||
var box = await _inner.BoundingBoxAsync().ConfigureAwait(false);
|
||||
bool isInput;
|
||||
try
|
||||
{
|
||||
isInput = await _inner.EvaluateAsync<bool>(
|
||||
@"el => { const t = el.tagName.toLowerCase();
|
||||
return t==='input'||t==='textarea'||el.getAttribute('contenteditable')==='true'; }")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { isInput = false; }
|
||||
|
||||
var bb = box == null ? new BoundingBox(_cursor.X, _cursor.Y, 1, 1)
|
||||
: new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
var target = HumanMouse.ClickTarget(bb, isInput, _cfg);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, target.X, target.Y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(target.X, target.Y);
|
||||
return (target.X, target.Y, isInput);
|
||||
}
|
||||
|
||||
public async Task ClickAsync(ElementHandleClickOptions? options = null)
|
||||
{
|
||||
var t = await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, t.IsInput, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DblClickAsync(ElementHandleDblClickOptions? options = null)
|
||||
{
|
||||
await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task HoverAsync(ElementHandleHoverOptions? options = null) =>
|
||||
MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(ElementHandleTapOptions? options = null) =>
|
||||
ClickAsync(new ElementHandleClickOptions
|
||||
{
|
||||
Force = OptionReader.Force(options),
|
||||
Timeout = (float)OptionReader.Timeout(options),
|
||||
});
|
||||
|
||||
public async Task FillAsync(string value, ElementHandleFillOptions? options = null)
|
||||
{
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await _cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.HumanTypeAsync(value, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TypeAsync(string text, ElementHandleTypeOptions? options = null)
|
||||
{
|
||||
await _inner.FocusAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.HumanTypeAsync(text, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task PressAsync(string key, ElementHandlePressOptions? options = null)
|
||||
{
|
||||
await _inner.FocusAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task CheckAsync(ElementHandleCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(ElementHandleUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(bool checkedState, ElementHandleSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync().ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectOptionAsync (all 6 IElementHandle overloads) - humanized pre-roll.
|
||||
// Mirrors Python _human_el_select_option: move the cursor to the <select>
|
||||
// (curved), click, pause, then delegate the real select (native popups can't
|
||||
// be mouse-driven). Unwrap any HumanizedElementHandle args.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle w ? w.Original : h;
|
||||
|
||||
private async Task SelectPrologueAsync(ElementHandleSelectOptionOptions? options)
|
||||
{
|
||||
var t = await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, t.IsInput, _cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IElementHandle values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<string> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(SelectOptionValue values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<IElementHandle> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<SelectOptionValue> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Handle-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task<IElementHandle?> QuerySelectorAsync(string selector)
|
||||
{
|
||||
var h = await _inner.QuerySelectorAsync(selector).ConfigureAwait(false);
|
||||
return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IElementHandle>> QuerySelectorAllAsync(string selector)
|
||||
{
|
||||
var hs = await _inner.QuerySelectorAllAsync(selector).ConfigureAwait(false);
|
||||
return Humanize.WrapHandles(hs, _cursor, _cfg);
|
||||
}
|
||||
|
||||
public async Task<IElementHandle?> WaitForSelectorAsync(string selector, ElementHandleWaitForSelectorOptions? options = null)
|
||||
{
|
||||
var h = await _inner.WaitForSelectorAsync(selector, options).ConfigureAwait(false);
|
||||
return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IFrame"/>.
|
||||
///
|
||||
/// Frames have no Mouse/Keyboard of their own (those belong to the page), so the
|
||||
/// selector actions are humanized by resolving <c>frame.Locator(selector)</c> and
|
||||
/// running the shared locator humanizer with the page's cursor. Locator/frame
|
||||
/// returning members are re-wrapped; everything else is delegated by the generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IFrame))]
|
||||
public sealed partial class HumanizedFrame : IFrame
|
||||
{
|
||||
private readonly IFrame _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedFrame(IFrame inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright frame (escape hatch for raw speed).</summary>
|
||||
public IFrame Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IFrame Inner => _inner;
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg);
|
||||
private ILocator Loc(string selector) => _inner.Locator(selector);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized selector actions (routed through the locator humanizer).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(string selector, FrameClickOptions? options = null) =>
|
||||
LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task DblClickAsync(string selector, FrameDblClickOptions? options = null) =>
|
||||
LocatorHumanizer.DblClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task HoverAsync(string selector, FrameHoverOptions? options = null) =>
|
||||
LocatorHumanizer.HoverAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(string selector, FrameTapOptions? options = null) =>
|
||||
LocatorHumanizer.TapAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task FillAsync(string selector, string value, FrameFillOptions? options = null) =>
|
||||
LocatorHumanizer.FillAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), value);
|
||||
|
||||
public Task TypeAsync(string selector, string text, FrameTypeOptions? options = null) =>
|
||||
LocatorHumanizer.TypeAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressAsync(string selector, string key, FramePressOptions? options = null) =>
|
||||
LocatorHumanizer.PressAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), key);
|
||||
|
||||
public async Task CheckAsync(string selector, FrameCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync(selector).ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(string selector, FrameUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync(selector).ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(string selector, bool checkedState, FrameSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectOptionAsync (all 6 IFrame overloads) - humanized pre-roll.
|
||||
// Mirrors Python _frame_select_option: hover the <select> (curved move) +
|
||||
// pause, then delegate the real select (native popups can't be mouse-driven).
|
||||
// Unwrap any HumanizedElementHandle args so Playwright sees raw handles.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle w ? w.Original : h;
|
||||
|
||||
private Task SelectPrologueAsync(string selector, FrameSelectOptionOptions? options) =>
|
||||
LocatorHumanizer.SelectOptionPrologueAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IElementHandle values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<string> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, SelectOptionValue values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<IElementHandle> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<SelectOptionValue> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DragAndDropAsync - humanized drag. Mirrors Python _frame_drag_and_drop:
|
||||
// resolve source/target boxes via frame locators; if both present, drive a
|
||||
// curved press-move-release with the page cursor; else delegate raw.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task DragAndDropAsync(string source, string target, FrameDragAndDropOptions? options = null)
|
||||
{
|
||||
LocatorBoundingBoxResult? srcBox, tgtBox;
|
||||
try
|
||||
{
|
||||
srcBox = await _inner.Locator(source).BoundingBoxAsync().ConfigureAwait(false);
|
||||
tgtBox = await _inner.Locator(target).BoundingBoxAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
srcBox = tgtBox = null;
|
||||
}
|
||||
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
await _inner.DragAndDropAsync(source, target, options).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
double sx = srcBox.X + srcBox.Width / 2, sy = srcBox.Y + srcBox.Height / 2;
|
||||
double tx = tgtBox.X + tgtBox.Width / 2, ty = tgtBox.Y + tgtBox.Height / 2;
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(sx, sy);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(tx, ty);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator Locator(string selector, FrameLocatorOptions? options = null) => Wrap(_inner.Locator(selector, options));
|
||||
public ILocator GetByAltText(string text, FrameGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, FrameGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, FrameGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, FrameGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, FrameGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, FrameGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, FrameGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, FrameGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, FrameGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, FrameGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, FrameGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<IFrame> ChildFrames => Humanize.WrapFrames(_inner.ChildFrames, _cursor, _cfg);
|
||||
public IFrame? ParentFrame { get { var f = _inner.ParentFrame; return f == null ? null : Wrap(f); } }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IKeyboard"/>.
|
||||
///
|
||||
/// Intercepted (humanized): <c>TypeAsync</c>, <c>PressAsync</c>, <c>InsertTextAsync</c>.
|
||||
/// Low-level <c>DownAsync</c>/<c>UpAsync</c> are delegated to the inner keyboard by the
|
||||
/// source generator (they are deliberate single key transitions, not "typing").
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IKeyboard))]
|
||||
public sealed partial class HumanizedKeyboard : IKeyboard
|
||||
{
|
||||
private readonly IKeyboard _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedKeyboard(IKeyboard inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright keyboard (escape hatch for raw speed).</summary>
|
||||
public IKeyboard Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IKeyboard Inner => _inner;
|
||||
|
||||
public Task TypeAsync(string text, KeyboardTypeOptions? options = null) =>
|
||||
_cursor.HumanTypeAsync(text, _cfg);
|
||||
|
||||
public async Task PressAsync(string key, KeyboardPressOptions? options = null)
|
||||
{
|
||||
// A single human key press: brief aim delay, then the inner press (which
|
||||
// already presses down + up). Mirrors the press timing used elsewhere.
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _inner.PressAsync(key, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task InsertTextAsync(string text) =>
|
||||
// InsertText is an atomic IME-style insertion; humanize it as paced typing.
|
||||
_cursor.HumanTypeAsync(text, _cfg);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="ILocator"/>.
|
||||
///
|
||||
/// Intercepted (humanized): Click/DblClick/Hover/Tap/Fill/Type/PressSequentially/Press
|
||||
/// and Check/Uncheck/SetChecked (which route through a humanized click). All other
|
||||
/// members - assertions, queries, waits, getters - are delegated to the inner locator
|
||||
/// by the source generator. Locator-returning members are re-wrapped so chaining stays
|
||||
/// humanized.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(ILocator))]
|
||||
public sealed partial class HumanizedLocator : ILocator
|
||||
{
|
||||
private readonly ILocator _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedLocator(ILocator inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright locator (escape hatch for raw speed).</summary>
|
||||
public ILocator Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public ILocator Inner => _inner;
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(LocatorClickOptions? options = null) =>
|
||||
LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task DblClickAsync(LocatorDblClickOptions? options = null) =>
|
||||
LocatorHumanizer.DblClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task HoverAsync(LocatorHoverOptions? options = null) =>
|
||||
LocatorHumanizer.HoverAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(LocatorTapOptions? options = null) =>
|
||||
LocatorHumanizer.TapAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task FillAsync(string value, LocatorFillOptions? options = null) =>
|
||||
LocatorHumanizer.FillAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), value);
|
||||
|
||||
public Task TypeAsync(string text, LocatorTypeOptions? options = null) =>
|
||||
LocatorHumanizer.TypeAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressSequentiallyAsync(string text, LocatorPressSequentiallyOptions? options = null) =>
|
||||
LocatorHumanizer.PressSequentiallyAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressAsync(string key, LocatorPressOptions? options = null) =>
|
||||
LocatorHumanizer.PressAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), key);
|
||||
|
||||
public async Task CheckAsync(LocatorCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(LocatorUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(bool checkedState, LocatorSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync().ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DragToAsync(ILocator target, LocatorDragToOptions? options = null)
|
||||
{
|
||||
var realTarget = target is HumanizedLocator h ? h.Original : target;
|
||||
var srcBox = await _inner.BoundingBoxAsync().ConfigureAwait(false);
|
||||
var tgtBox = await realTarget.BoundingBoxAsync().ConfigureAwait(false);
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
await _inner.DragToAsync(realTarget, options).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
double sx = srcBox.X + srcBox.Width / 2, sy = srcBox.Y + srcBox.Height / 2;
|
||||
double tx = tgtBox.X + tgtBox.Width / 2, ty = tgtBox.Y + tgtBox.Height / 2;
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(sx, sy);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(tx, ty);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// --- SelectOptionAsync (all ILocator overloads) -------------------------
|
||||
// Human pre-roll = curved hover + pause, then delegate the real select (native
|
||||
// <select> popups can't be driven by synthetic mouse events). Unwrap any
|
||||
// HumanizedElementHandle args so Playwright sees the raw handles.
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IElementHandle values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<string> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(SelectOptionValue values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<IElementHandle> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<SelectOptionValue> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// --- ClearAsync ---------------------------------------------------------
|
||||
// Human path: focus (humanized click if needed) + select-all + Backspace,
|
||||
// instead of an instant value reset.
|
||||
|
||||
public Task ClearAsync(LocatorClearOptions? options = null) =>
|
||||
LocatorHumanizer.ClearAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle handle) =>
|
||||
handle is HumanizedElementHandle h ? h.Original : handle;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap so chains stay humanized.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator First => Wrap(_inner.First);
|
||||
public ILocator Last => Wrap(_inner.Last);
|
||||
public ILocator Nth(int index) => Wrap(_inner.Nth(index));
|
||||
public ILocator Or(ILocator locator) =>
|
||||
Wrap(_inner.Or(locator is HumanizedLocator h ? h.Original : locator));
|
||||
public ILocator And(ILocator locator) =>
|
||||
Wrap(_inner.And(locator is HumanizedLocator h ? h.Original : locator));
|
||||
|
||||
public ILocator Locator(string selectorOrLocator, LocatorLocatorOptions? options = null) =>
|
||||
Wrap(_inner.Locator(selectorOrLocator, options));
|
||||
public ILocator Locator(ILocator selectorOrLocator, LocatorLocatorOptions? options = null) =>
|
||||
Wrap(_inner.Locator(selectorOrLocator is HumanizedLocator h ? h.Original : selectorOrLocator, options));
|
||||
|
||||
public ILocator GetByAltText(string text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, LocatorGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IMouse"/>.
|
||||
///
|
||||
/// Intercepted (humanized): <c>MoveAsync</c>, <c>ClickAsync</c>, <c>DblClickAsync</c>,
|
||||
/// <c>DownAsync</c>, <c>UpAsync</c>, <c>WheelAsync</c>. Everything else is delegated
|
||||
/// to the inner mouse by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IMouse))]
|
||||
public sealed partial class HumanizedMouse : IMouse
|
||||
{
|
||||
private readonly IMouse _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedMouse(IMouse inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright mouse (escape hatch for raw speed).</summary>
|
||||
public IMouse Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IMouse Inner => _inner;
|
||||
|
||||
public async Task MoveAsync(float x, float y, MouseMoveOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
}
|
||||
|
||||
public async Task ClickAsync(float x, float y, MouseClickOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, isInput: false, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DblClickAsync(float x, float y, MouseDblClickOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
await _cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DownAsync(MouseDownOptions? options = null)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false);
|
||||
await _inner.DownAsync(options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpAsync(MouseUpOptions? options = null)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false);
|
||||
await _inner.UpAsync(options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WheelAsync(float deltaX, float deltaY)
|
||||
{
|
||||
await HumanScroll.SmoothWheelAsync(_cursor.RawMouse, deltaX, deltaY, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IPage"/>.
|
||||
///
|
||||
/// Selector-based interaction methods (Click/Fill/Type/Hover/Press/Tap/Check/...) are
|
||||
/// routed through the selector-driven <see cref="HumanPage"/> engine. <c>Mouse</c> and
|
||||
/// <c>Keyboard</c> return humanized wrappers; <c>Locator</c>/<c>GetBy*</c>/frames return
|
||||
/// re-wrapped objects so the whole chain stays humanized. Everything else is delegated
|
||||
/// to the inner page by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IPage))]
|
||||
public sealed partial class HumanizedPage : IPage
|
||||
{
|
||||
private readonly IPage _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly HumanPage _human;
|
||||
private readonly HumanizedMouse _mouse;
|
||||
private readonly HumanizedKeyboard _keyboard;
|
||||
|
||||
internal HumanizedPage(IPage inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
_human = new HumanPage(inner, cfg);
|
||||
_mouse = new HumanizedMouse(inner.Mouse, cursor, cfg);
|
||||
_keyboard = new HumanizedKeyboard(inner.Keyboard, cursor, cfg);
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright page (escape hatch for raw speed).</summary>
|
||||
public IPage Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IPage Inner => _inner;
|
||||
|
||||
private HumanActionOptions Opt(object? options) => new()
|
||||
{
|
||||
Timeout = OptionReader.Timeout(options),
|
||||
Force = OptionReader.Force(options),
|
||||
};
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized wrappers for nested objects
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IMouse Mouse => _mouse;
|
||||
public IKeyboard Keyboard => _keyboard;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized selector actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(string selector, PageClickOptions? options = null) =>
|
||||
_human.ClickAsync(selector, Opt(options));
|
||||
|
||||
public Task DblClickAsync(string selector, PageDblClickOptions? options = null) =>
|
||||
_human.DblClickAsync(selector, Opt(options));
|
||||
|
||||
public Task HoverAsync(string selector, PageHoverOptions? options = null) =>
|
||||
_human.HoverAsync(selector, Opt(options));
|
||||
|
||||
public Task TapAsync(string selector, PageTapOptions? options = null) =>
|
||||
_human.TapAsync(selector, Opt(options));
|
||||
|
||||
public Task FillAsync(string selector, string value, PageFillOptions? options = null) =>
|
||||
_human.FillAsync(selector, value, Opt(options));
|
||||
|
||||
public Task TypeAsync(string selector, string text, PageTypeOptions? options = null) =>
|
||||
_human.TypeAsync(selector, text, Opt(options));
|
||||
|
||||
public Task PressAsync(string selector, string key, PagePressOptions? options = null) =>
|
||||
_human.PressAsync(selector, key, Opt(options));
|
||||
|
||||
public Task CheckAsync(string selector, PageCheckOptions? options = null) =>
|
||||
_human.CheckAsync(selector, Opt(options));
|
||||
|
||||
public Task UncheckAsync(string selector, PageUncheckOptions? options = null) =>
|
||||
_human.UncheckAsync(selector, Opt(options));
|
||||
|
||||
public Task SetCheckedAsync(string selector, bool checkedState, PageSetCheckedOptions? options = null) =>
|
||||
_human.SetCheckedAsync(selector, checkedState, Opt(options));
|
||||
|
||||
public Task FocusAsync(string selector, PageFocusOptions? options = null) =>
|
||||
_human.FocusAsync(selector, Opt(options));
|
||||
|
||||
public Task DragAndDropAsync(string source, string target, PageDragAndDropOptions? options = null) =>
|
||||
_human.DragAndDropAsync(source, target, Opt(options));
|
||||
|
||||
public Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string values, PageSelectOptionOptions? options = null) =>
|
||||
_human.SelectOptionAsync(selector, new[] { values }, Opt(options));
|
||||
|
||||
public Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<string> values, PageSelectOptionOptions? options = null) =>
|
||||
_human.SelectOptionAsync(selector, values.ToArray(), Opt(options));
|
||||
|
||||
// SelectOption overloads taking handles / SelectOptionValue have no humanized
|
||||
// analogue; hover then delegate so the dropdown still gets a human approach.
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IElementHandle values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<IElementHandle> values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, SelectOptionValue values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<SelectOptionValue> values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle hh ? hh.Original : h;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator Locator(string selector, PageLocatorOptions? options = null) => Wrap(_inner.Locator(selector, options));
|
||||
public ILocator GetByAltText(string text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, PageGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<IFrame> Frames => Humanize.WrapFrames(_inner.Frames, _cursor, _cfg);
|
||||
public IFrame MainFrame => Wrap(_inner.MainFrame);
|
||||
public IFrame? Frame(string name) { var f = _inner.Frame(name); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(string url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(Regex url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(System.Func<string, bool> url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation - invalidate the isolated world after a navigation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task<IResponse?> GotoAsync(string url, PageGotoOptions? options = null)
|
||||
{
|
||||
var resp = await _inner.GotoAsync(url, options).ConfigureAwait(false);
|
||||
_cursor.InvalidateStealth();
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Humanized actions that operate directly on an <see cref="ILocator"/>.
|
||||
///
|
||||
/// The selector-based <see cref="HumanPage"/> drives motion from a CSS/XPath selector;
|
||||
/// locators don't expose their selector string publicly, so this helper drives the
|
||||
/// same Bezier-curve / human-typing engine from the locator's own bounding box and
|
||||
/// the shared <see cref="HumanCursor"/> state of the page it belongs to. The behavior
|
||||
/// (curves, aim points, timing, typing stealth path) is identical to <see cref="HumanPage"/>;
|
||||
/// only the element-resolution path differs.
|
||||
/// </summary>
|
||||
internal static class LocatorHumanizer
|
||||
{
|
||||
private static double RemainingMs(double deadline) => CloakBrowser.Human.Actionability.RemainingMs(deadline);
|
||||
|
||||
private static async Task<BoundingBox?> GetBoxAsync(ILocator locator, double timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var box = await locator.First.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)System.Math.Max(1, timeoutMs),
|
||||
}).ConfigureAwait(false);
|
||||
return box == null ? null : new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> IsInputAsync(ILocator locator)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await locator.First.EvaluateAsync<bool>(
|
||||
@"el => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}").ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { return false; }
|
||||
}
|
||||
|
||||
private static async Task<bool> IsFocusedAsync(ILocator locator)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await locator.First.EvaluateAsync<bool>(
|
||||
"el => el === document.activeElement").ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { return false; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core motion-to-target used by click/hover/dblclick.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static async Task<(double X, double Y, bool IsInput)> MoveToTargetAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double deadline, bool force)
|
||||
{
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
|
||||
if (cfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(cursor.RawMouse,
|
||||
HumanRandom.Rand(cfg.IdleBetweenDuration.Min, cfg.IdleBetweenDuration.Max),
|
||||
cursor.X, cursor.Y, cfg).ConfigureAwait(false);
|
||||
|
||||
var box = await GetBoxAsync(locator, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
bool isInput = await IsInputAsync(locator).ConfigureAwait(false);
|
||||
var target = HumanMouse.ClickTarget(
|
||||
box ?? new BoundingBox(cursor.X, cursor.Y, 1, 1), isInput, cfg);
|
||||
|
||||
await HumanMouse.HumanMoveAsync(cursor.RawMouse, cursor.X, cursor.Y, target.X, target.Y, cfg)
|
||||
.ConfigureAwait(false);
|
||||
cursor.Set(target.X, target.Y);
|
||||
return (target.X, target.Y, isInput);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public humanized actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static async Task ClickAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
// Let Playwright wait for actionability (raises on real problems / cancellation).
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
var t = await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(cursor.RawMouse, t.IsInput, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task DblClickAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
await cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task HoverAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task TapAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force) =>
|
||||
await ClickAsync(locator, cursor, cfg, timeout, force).ConfigureAwait(false);
|
||||
|
||||
public static async Task FillAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string value)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await cursor.HumanTypeAsync(value, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task TypeAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string text)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await cursor.HumanTypeAsync(text, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task PressSequentiallyAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string text) =>
|
||||
await TypeAsync(locator, cursor, cfg, timeout, force, text).ConfigureAwait(false);
|
||||
|
||||
public static async Task PressAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string key)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await cursor.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human pre-roll for <c>select_option</c>: move the cursor along a Bezier curve to
|
||||
/// the <select> element (humanized hover) and pause, mirroring the Python
|
||||
/// <c>_humanized_select_option</c>. The real Playwright select call is performed by
|
||||
/// the caller afterwards (native <select> popups can't be driven by mouse).
|
||||
/// </summary>
|
||||
public static async Task SelectOptionPrologueAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
await HoverAsync(locator, cursor, cfg, timeout, force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human <c>clear</c>: focus the field (humanized click if not already focused),
|
||||
/// select-all, then press Backspace - instead of an instant value reset. Mirrors the
|
||||
/// Python <c>_humanized_clear</c>.
|
||||
/// </summary>
|
||||
public static async Task ClearAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 100)).ConfigureAwait(false);
|
||||
await cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class BezierMathTests
|
||||
{
|
||||
// Mirrors Python _FakeRawMouse - records every movement point.
|
||||
private sealed class FakeRawMouse : IRawMouse
|
||||
{
|
||||
public List<(double X, double Y)> Moves { get; } = new();
|
||||
public Task MoveAsync(double x, double y) { Moves.Add((x, y)); return Task.CompletedTask; }
|
||||
public Task DownAsync() => Task.CompletedTask;
|
||||
public Task UpAsync() => Task.CompletedTask;
|
||||
public Task WheelAsync(double dx, double dy) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static HumanConfig Cfg() => new()
|
||||
{
|
||||
MouseOvershootChance = 0, // deterministic so the end-point assertion is stable
|
||||
MouseBurstPause = (0, 0),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratesMultiplePoints_AndEndsNearTarget()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 500, 300, Cfg());
|
||||
|
||||
Assert.True(raw.Moves.Count >= 10, $"too few points: {raw.Moves.Count}");
|
||||
var (lx, ly) = raw.Moves[^1];
|
||||
Assert.True(Math.Abs(lx - 500) < 10, $"end X off: {lx}");
|
||||
Assert.True(Math.Abs(ly - 300) < 10, $"end Y off: {ly}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoLargeJumps_BetweenConsecutivePoints()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 400, 400, Cfg());
|
||||
|
||||
double total = Math.Sqrt(400 * 400 + 400 * 400);
|
||||
double maxJump = total * 0.5;
|
||||
for (int i = 1; i < raw.Moves.Count; i++)
|
||||
{
|
||||
double dx = raw.Moves[i].X - raw.Moves[i - 1].X;
|
||||
double dy = raw.Moves[i].Y - raw.Moves[i - 1].Y;
|
||||
Assert.True(Math.Sqrt(dx * dx + dy * dy) < maxJump, $"jump too big at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotStraightLine_HasDeviation()
|
||||
{
|
||||
double maxDev = 0;
|
||||
for (int t = 0; t < 5; t++)
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 500, 0, Cfg());
|
||||
foreach (var (_, y) in raw.Moves)
|
||||
maxDev = Math.Max(maxDev, Math.Abs(y));
|
||||
}
|
||||
Assert.True(maxDev > 0.5, $"path is basically straight: maxDev={maxDev}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShortDistance_StillMovesOrNoOp()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 100, 100, 103, 102, Cfg());
|
||||
// Python requires >= 1; in .NET dist < 1 -> early return, here dist ~3.6 so it must move.
|
||||
Assert.True(raw.Moves.Count >= 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class BuildArgsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dedupes_By_FlagKey_UserOverridesStealth()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: true,
|
||||
extraArgs: new List<string> { "--no-sandbox=foo" },
|
||||
headless: true);
|
||||
// --no-sandbox should appear once, with the user's value winning.
|
||||
Assert.Single(args, a => a.StartsWith("--no-sandbox"));
|
||||
Assert.Contains("--no-sandbox=foo", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timezone_And_Locale_Flags_Injected()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: null,
|
||||
timezone: "America/New_York",
|
||||
locale: "en-US",
|
||||
headless: true);
|
||||
Assert.Contains("--fingerprint-timezone=America/New_York", args);
|
||||
Assert.Contains("--lang=en-US", args);
|
||||
Assert.Contains("--fingerprint-locale=en-US", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headed_Adds_IgnoreGpuBlocklist()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: false, extraArgs: null, headless: false);
|
||||
Assert.Contains("--ignore-gpu-blocklist", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DedicatedParams_Override_UserArgs()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: new List<string> { "--fingerprint-timezone=Europe/London" },
|
||||
timezone: "Asia/Tokyo",
|
||||
headless: true);
|
||||
Assert.Single(args, a => a.StartsWith("--fingerprint-timezone"));
|
||||
Assert.Contains("--fingerprint-timezone=Asia/Tokyo", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtensionPaths_Produce_LoadExtension_And_DisableExcept()
|
||||
{
|
||||
var tmp = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: null,
|
||||
extensionPaths: new List<string> { tmp });
|
||||
Assert.Contains(args, a => a.StartsWith("--load-extension="));
|
||||
Assert.Contains(args, a => a.StartsWith("--disable-extensions-except="));
|
||||
}
|
||||
finally { Directory.Delete(tmp); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoLocale_NoTimezone_NoFlags()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: false, extraArgs: null, headless: true);
|
||||
Assert.DoesNotContain(args, a => a.StartsWith("--lang="));
|
||||
Assert.DoesNotContain(args, a => a.StartsWith("--fingerprint-timezone="));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChromiumVersion_IsPinned()
|
||||
{
|
||||
Assert.Equal("146.0.7680.177.5", Config.ChromiumVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultViewport_Matches_Python()
|
||||
{
|
||||
Assert.Equal(1920, Config.DefaultViewportWidth);
|
||||
Assert.Equal(947, Config.DefaultViewportHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreDefaultArgs_IsNonEmpty()
|
||||
{
|
||||
Assert.NotEmpty(Config.IgnoreDefaultArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformTag_ReturnsKnownTag()
|
||||
{
|
||||
var tag = Config.GetPlatformTag();
|
||||
Assert.Contains(tag, new[] { "linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64", "windows-x64" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.177.5", "146.0.7680.177.5", 0)]
|
||||
[InlineData("146.0.7680.177.6", "146.0.7680.177.5", 1)]
|
||||
[InlineData("146.0.7680.177.4", "146.0.7680.177.5", -1)]
|
||||
[InlineData("147.0.0.0.0", "146.9.9.9.9", 1)]
|
||||
public void VersionTuple_Comparison(string a, string b, int sign)
|
||||
{
|
||||
var ta = Config.VersionTuple(a);
|
||||
var tb = Config.VersionTuple(b);
|
||||
int cmp = 0;
|
||||
int n = System.Math.Max(ta.Length, tb.Length);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int va = i < ta.Length ? ta[i] : 0;
|
||||
int vb = i < tb.Length ? tb[i] : 0;
|
||||
if (va != vb) { cmp = va.CompareTo(vb); break; }
|
||||
}
|
||||
Assert.Equal(sign, System.Math.Sign(cmp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionNewer_Works()
|
||||
{
|
||||
Assert.True(Config.VersionNewer("147.0.0.0.0", "146.0.0.0.0"));
|
||||
Assert.False(Config.VersionNewer("146.0.0.0.0", "146.0.0.0.0"));
|
||||
Assert.False(Config.VersionNewer("145.0.0.0.0", "146.0.0.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultStealthArgs_IncludesNoSandboxAndFingerprint()
|
||||
{
|
||||
var args = Config.GetDefaultStealthArgs();
|
||||
Assert.Contains("--no-sandbox", args);
|
||||
Assert.Contains(args, a => a.StartsWith("--fingerprint="));
|
||||
Assert.Contains(args, a => a.StartsWith("--fingerprint-platform="));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultStealthArgs_RandomSeed_InRange()
|
||||
{
|
||||
var args = Config.GetDefaultStealthArgs();
|
||||
var seedArg = args.First(a => a.StartsWith("--fingerprint="));
|
||||
int seed = int.Parse(seedArg.Split('=')[1]);
|
||||
Assert.InRange(seed, 10000, 99999);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ConfigVersionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.177.5", new[] { 146, 0, 7680, 177, 5 })]
|
||||
[InlineData("131.0.6778.33", new[] { 131, 0, 6778, 33 })]
|
||||
[InlineData("1.2", new[] { 1, 2 })]
|
||||
public void VersionTuple_parses_segments(string v, int[] expected)
|
||||
=> Assert.Equal(expected, Config.VersionTuple(v));
|
||||
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.178", "146.0.7680.177", true)] // higher patch
|
||||
[InlineData("147.0.0.0", "146.0.7680.177", true)] // higher major
|
||||
[InlineData("146.0.7680.177", "146.0.7680.177", false)] // equal
|
||||
[InlineData("146.0.7680.176", "146.0.7680.177", false)] // lower
|
||||
[InlineData("146.0.7680", "146.0.7680.177", false)] // shorter == older on the tail
|
||||
[InlineData("146.0.7680.177.5", "146.0.7680.177", true)] // longer == newer
|
||||
public void VersionNewer_compares_correctly(string a, string b, bool expected)
|
||||
=> Assert.Equal(expected, Config.VersionNewer(a, b));
|
||||
|
||||
[Fact]
|
||||
public void PlatformTag_is_known_value()
|
||||
=> Assert.Contains(Config.GetPlatformTag(), Config.AvailablePlatforms);
|
||||
|
||||
[Fact]
|
||||
public void ArchiveName_uses_tag_and_ext()
|
||||
{
|
||||
var name = Config.GetArchiveName("linux-x64");
|
||||
Assert.StartsWith("cloakbrowser-linux-x64", name);
|
||||
Assert.EndsWith(Config.GetArchiveExt(), name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadUrl_contains_base_and_archive()
|
||||
{
|
||||
var url = Config.GetDownloadUrl();
|
||||
Assert.StartsWith(Config.DownloadBaseUrl, url);
|
||||
Assert.Contains("cloakbrowser-", url);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChecksumParseTests
|
||||
{
|
||||
// Real 64-char hex digests (the parser now requires exactly 64 hex chars,
|
||||
// matching the Python/JS parser, so short placeholders are rejected).
|
||||
private const string H1 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
private const string H2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_reads_sha256sums_format()
|
||||
{
|
||||
// Standard format: "<64-hex hash> <filename>" (two spaces).
|
||||
var text =
|
||||
$"{H1} cloakbrowser-linux-x64.tar.gz\n" +
|
||||
$"{H2} cloakbrowser-win-x64.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Equal(H1, map["cloakbrowser-linux-x64.tar.gz"]);
|
||||
Assert.Equal(H2, map["cloakbrowser-win-x64.zip"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_uppercase_hash_is_lowercased()
|
||||
{
|
||||
var text = $"{H1.ToUpperInvariant()} file.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Equal(H1, map["file.zip"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_ignores_blank_malformed_and_non_64hex_lines()
|
||||
{
|
||||
// Blank lines, junk, short hashes ("abc") and the version= line are all dropped;
|
||||
// only a genuine 64-hex digest line survives.
|
||||
var text = $"\n \nnotavalidline\nabc short.zip\nversion=146.0.7680.177.5\n{H1} file.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Single(map);
|
||||
Assert.Equal(H1, map["file.zip"]);
|
||||
Assert.False(map.ContainsKey("short.zip"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="Download.WrapperVersionNewer(string, string)"/>, the dotted
|
||||
/// SemVer-ish comparison used by the NuGet wrapper-update check (faithful analog of
|
||||
/// Python's <c>_check_wrapper_update</c> version compare).
|
||||
/// </summary>
|
||||
public class WrapperVersionNewerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("0.4.0", "0.3.32", true)] // higher minor beats higher patch on older minor
|
||||
[InlineData("0.4.1", "0.4.0", true)] // higher patch
|
||||
[InlineData("1.0.0", "0.9.9", true)] // higher major
|
||||
[InlineData("0.4.0", "0.4.0", false)] // equal
|
||||
[InlineData("0.3.32", "0.4.0", false)] // lower minor
|
||||
[InlineData("0.4.0", "0.4.1", false)] // lower patch
|
||||
[InlineData("0.4", "0.4.0", false)] // shorter == equal on the zero-padded tail
|
||||
[InlineData("0.4.0.1", "0.4.0", true)] // longer == newer when tail is non-zero
|
||||
public void WrapperVersionNewer_compares_correctly(string a, string b, bool expected)
|
||||
=> Assert.Equal(expected, Download.WrapperVersionNewer(a, b));
|
||||
|
||||
[Fact]
|
||||
public void WrapperVersionNewer_tolerates_non_numeric_segments()
|
||||
{
|
||||
// Non-numeric segments parse to 0 rather than throwing.
|
||||
Assert.False(Download.WrapperVersionNewer("0.x.0", "0.4.0"));
|
||||
Assert.True(Download.WrapperVersionNewer("0.4.0", "0.x.0"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DownloadHttpError"/>, the typed HTTP-status carrier the
|
||||
/// v0.4.2 macOS Pro fallback relies on to distinguish a 404 (no Pro binary built
|
||||
/// for this platform yet) from transient download failures.
|
||||
/// </summary>
|
||||
public class DownloadHttpErrorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Carries_status_code_and_includes_it_in_message()
|
||||
{
|
||||
var ex = new DownloadHttpError(System.Net.HttpStatusCode.NotFound, "Not Found");
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, ex.Status);
|
||||
Assert.Contains("404", ex.Message);
|
||||
Assert.Contains("Not Found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Distinguishes_404_from_transient_5xx()
|
||||
{
|
||||
var notFound = new DownloadHttpError(System.Net.HttpStatusCode.NotFound, "Not Found");
|
||||
var transient = new DownloadHttpError(System.Net.HttpStatusCode.ServiceUnavailable, "Unavailable");
|
||||
// The macOS Pro fallback only triggers on 404; a 5xx stays a hard failure.
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, notFound.Status);
|
||||
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, transient.Status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the archive-extraction path-traversal (zip-slip) guard
|
||||
/// <see cref="Download.ResolveSafeEntryPath(string, string)"/>, shared by
|
||||
/// <c>ExtractTar</c> and <c>ExtractZip</c>.
|
||||
/// </summary>
|
||||
public class PathTraversalTests
|
||||
{
|
||||
private static string DestDir() =>
|
||||
Path.Combine(Path.GetTempPath(), "cloak-extract-test");
|
||||
|
||||
[Fact]
|
||||
public void Normal_entry_resolves_inside_destination()
|
||||
{
|
||||
var dest = DestDir();
|
||||
var resolved = Download.ResolveSafeEntryPath(dest, "sub/file.txt");
|
||||
|
||||
var destFull = Path.GetFullPath(dest);
|
||||
var expected = Path.GetFullPath(Path.Combine(destFull, "sub/file.txt"));
|
||||
|
||||
Assert.Equal(expected, resolved);
|
||||
// The resolved path stays under the destination directory.
|
||||
Assert.StartsWith(destFull + Path.DirectorySeparatorChar, resolved, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parent_relative_entry_throws()
|
||||
{
|
||||
var dest = DestDir();
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, "../evil.txt"));
|
||||
// The message names the offending entry.
|
||||
Assert.Contains("../evil.txt", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absolute_entry_path_throws()
|
||||
{
|
||||
var dest = DestDir();
|
||||
// An absolute entry path escapes the destination via Path.Combine semantics.
|
||||
var absolute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? @"C:\Windows\evil.txt"
|
||||
: "/etc/evil.txt";
|
||||
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, absolute));
|
||||
Assert.Contains(absolute, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_backslash_traversal_throws()
|
||||
{
|
||||
// "..\..\evil" is only a traversal where backslash is a path separator (Windows).
|
||||
// On other platforms backslash is an ordinary filename character, so skip.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var dest = DestDir();
|
||||
const string entry = @"..\..\evil";
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, entry));
|
||||
Assert.Contains(entry, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes test classes that mutate process environment variables
|
||||
/// (CLOAKBROWSER_DOWNLOAD_URL, CLOAKBROWSER_LICENSE_KEY, CLOAKBROWSER_CACHE_DIR, ...)
|
||||
/// so they don't race under xUnit's default cross-collection parallelism.
|
||||
/// </summary>
|
||||
[CollectionDefinition("env-serial")]
|
||||
public sealed class EnvSerialCollection { }
|
||||
@@ -0,0 +1,97 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports Python <c>TestNonAsciiKeyboard</c>: non-ASCII characters (Cyrillic, CJK)
|
||||
/// must be entered via <see cref="IRawKeyboard.InsertTextAsync"/> character-by-character,
|
||||
/// while ASCII characters go through <see cref="IRawKeyboard.DownAsync"/> /
|
||||
/// <see cref="IRawKeyboard.UpAsync"/>. Mistyping is disabled (MistypeChance = 0) so the
|
||||
/// key streams are deterministic.
|
||||
/// </summary>
|
||||
public class NonAsciiKeyboardTests
|
||||
{
|
||||
/// <summary>A fake raw keyboard that records the keys pressed and the text inserted.</summary>
|
||||
private sealed class RecordingKeyboard : IRawKeyboard
|
||||
{
|
||||
public List<string> DownKeys { get; } = new();
|
||||
public List<string> UpKeys { get; } = new();
|
||||
public List<string> Inserted { get; } = new();
|
||||
public List<string> Typed { get; } = new();
|
||||
|
||||
public Task DownAsync(string key) { DownKeys.Add(key); return Task.CompletedTask; }
|
||||
public Task UpAsync(string key) { UpKeys.Add(key); return Task.CompletedTask; }
|
||||
public Task TypeAsync(string text) { Typed.Add(text); return Task.CompletedTask; }
|
||||
public Task InsertTextAsync(string text) { Inserted.Add(text); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
// No delays, no typos: deterministic key streams.
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
};
|
||||
|
||||
private static bool IsAscii(string s) => s.All(c => c <= 0x7F);
|
||||
|
||||
[Fact]
|
||||
public async Task Cyrillic_uses_insert_text()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Привет", FastConfig());
|
||||
|
||||
// Whole word arrives through insertText, one character at a time.
|
||||
Assert.Equal("Привет", string.Concat(kb.Inserted));
|
||||
// No non-ASCII character was ever pressed as a key.
|
||||
Assert.All(kb.DownKeys, k => Assert.True(IsAscii(k), $"unexpected non-ASCII key down: {k}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_ascii_cyrillic_routes_each_char_correctly()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Hi Мир", FastConfig());
|
||||
|
||||
// ASCII letters are pressed as keys...
|
||||
Assert.Contains("H", kb.DownKeys);
|
||||
Assert.Contains("i", kb.DownKeys);
|
||||
// ...Cyrillic letters are inserted as text.
|
||||
Assert.Contains("М", string.Concat(kb.Inserted));
|
||||
Assert.Contains("и", string.Concat(kb.Inserted));
|
||||
Assert.Contains("р", string.Concat(kb.Inserted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cjk_uses_insert_text()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "你好", FastConfig());
|
||||
|
||||
Assert.Equal("你好", string.Concat(kb.Inserted));
|
||||
Assert.All(kb.DownKeys, k => Assert.True(IsAscii(k), $"unexpected non-ASCII key down: {k}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ascii_uppercase_goes_through_shifted_key_presses_not_insert()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Hi", FastConfig());
|
||||
|
||||
// ASCII never uses insertText.
|
||||
Assert.Empty(kb.Inserted);
|
||||
// Uppercase 'H' is produced by holding Shift.
|
||||
Assert.Contains("Shift", kb.DownKeys);
|
||||
Assert.Contains("H", kb.DownKeys);
|
||||
Assert.Contains("i", kb.DownKeys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Diagnostics;
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Tests.Wrappers; // Fake / FakeProxy test infrastructure
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports Python <c>TestPointerEventsFailOpen</c>: the pointer-events check must "fail open".
|
||||
/// When it cannot run (bounding box / evaluate throws - stale handle, execution context
|
||||
/// destroyed), the check returns promptly instead of blocking until the timeout. But a
|
||||
/// genuine "covered" result (hit == false) must still raise
|
||||
/// <see cref="ElementNotReceivingEventsError"/>.
|
||||
/// </summary>
|
||||
public class PointerEventsFailOpenTests
|
||||
{
|
||||
// Build a Task<PointerResult?> handler value for the FakeProxy. PointerResult is
|
||||
// internal (visible to tests via InternalsVisibleTo), so we can construct one.
|
||||
private static Task<Actionability.PointerResult?> CoveredResult(string covering) =>
|
||||
Task.FromResult<Actionability.PointerResult?>(
|
||||
new Actionability.PointerResult { Hit = false, Covering = covering });
|
||||
|
||||
// --- Locator (selector) variant ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Locator_fails_open_when_evaluate_throws()
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", _ => throw new PlaywrightException("no element"));
|
||||
locRec.On("EvaluateAsync", _ => throw new PlaywrightException("execution context destroyed"));
|
||||
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Locator", locator);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
// Must NOT throw and must return promptly (well under the 2000ms timeout).
|
||||
await Actionability.CheckPointerEventsAsync(page, "#x", 100, 100, timeoutMs: 2000);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 500,
|
||||
$"fail-open should return promptly, took {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locator_raises_when_covered()
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
|
||||
new LocatorBoundingBoxResult { X = 0, Y = 0, Width = 10, Height = 10 }));
|
||||
locRec.On("EvaluateAsync", _ => CoveredResult("DIV"));
|
||||
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Locator", locator);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElementNotReceivingEventsError>(
|
||||
() => Actionability.CheckPointerEventsAsync(page, "#x", 5, 5, timeoutMs: 200));
|
||||
Assert.Contains("DIV", ex.Message);
|
||||
}
|
||||
|
||||
// --- ElementHandle variant ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_fails_open_when_evaluate_throws()
|
||||
{
|
||||
var (el, elRec) = Fake.Of<IElementHandle>();
|
||||
elRec.On("BoundingBoxAsync", _ => throw new PlaywrightException("stale handle"));
|
||||
elRec.On("EvaluateAsync", _ => throw new PlaywrightException("execution context destroyed"));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Actionability.CheckPointerEventsHandleAsync(el, 100, 100, timeoutMs: 2000);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 500,
|
||||
$"fail-open should return promptly, took {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_raises_when_covered()
|
||||
{
|
||||
var (el, elRec) = Fake.Of<IElementHandle>();
|
||||
elRec.On("BoundingBoxAsync", Task.FromResult<ElementHandleBoundingBoxResult?>(
|
||||
new ElementHandleBoundingBoxResult { X = 0, Y = 0, Width = 10, Height = 10 }));
|
||||
elRec.On("EvaluateAsync", _ => CoveredResult("SPAN"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElementNotReceivingEventsError>(
|
||||
() => Actionability.CheckPointerEventsHandleAsync(el, 5, 5, timeoutMs: 200));
|
||||
Assert.Contains("SPAN", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports the spirit of Python <c>TestTimeoutBudget307</c> (issue #307): sequential
|
||||
/// operations share one deadline so the overall timeout budget is never multiplied.
|
||||
///
|
||||
/// The full end-to-end timing test needs a real browser to drive the retry loops, so it
|
||||
/// is marked <c>Skip</c>. What we CAN unit-test without a browser is the shared remaining
|
||||
/// time helper every step uses (<see cref="Actionability.RemainingMs(double)"/>): given a
|
||||
/// single deadline, it must never go negative and must monotonically shrink as time passes.
|
||||
/// </summary>
|
||||
public class TimeoutBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void RemainingMs_never_negative_past_deadline()
|
||||
{
|
||||
double pastDeadline = System.Environment.TickCount64 - 1000; // already expired
|
||||
Assert.Equal(0, Actionability.RemainingMs(pastDeadline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_at_deadline_is_zero()
|
||||
{
|
||||
double now = System.Environment.TickCount64;
|
||||
Assert.True(Actionability.RemainingMs(now) <= 0.0 + 1.0); // ~0 (clamped, never < 0)
|
||||
Assert.True(Actionability.RemainingMs(now) >= 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_positive_before_deadline()
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + 5000;
|
||||
double remaining = Actionability.RemainingMs(deadline);
|
||||
Assert.InRange(remaining, 1, 5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemainingMs_shrinks_as_time_passes()
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + 5000;
|
||||
double first = Actionability.RemainingMs(deadline);
|
||||
await Task.Delay(60);
|
||||
double second = Actionability.RemainingMs(deadline);
|
||||
|
||||
Assert.True(second < first, $"remaining should shrink: {first} -> {second}");
|
||||
Assert.True(second >= 0, "remaining must never go negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_budget_is_shared_not_multiplied()
|
||||
{
|
||||
// Three sequential "steps" computed from a SINGLE deadline must sum to <= the
|
||||
// original budget - they carve out of one budget rather than each getting the full
|
||||
// timeout (the bug behind issue #307).
|
||||
const double budget = 1000;
|
||||
double deadline = System.Environment.TickCount64 + budget;
|
||||
|
||||
double step1 = Actionability.RemainingMs(deadline);
|
||||
double step2 = Actionability.RemainingMs(deadline);
|
||||
double step3 = Actionability.RemainingMs(deadline);
|
||||
|
||||
// Each subsequent read is <= the previous (time only moves forward) and never
|
||||
// exceeds the single budget.
|
||||
Assert.True(step1 <= budget + 1);
|
||||
Assert.True(step2 <= step1 + 1);
|
||||
Assert.True(step3 <= step2 + 1);
|
||||
}
|
||||
|
||||
[Fact(Skip = "requires browser: drives the full page.click retry loop to measure end-to-end timing")]
|
||||
public void Page_click_total_time_within_budget()
|
||||
{
|
||||
// Python TestTimeoutBudget307.test_page_click_total_time_within_budget patches a
|
||||
// live page and asserts the wall-clock click time stays < 1.8x the timeout. That
|
||||
// exercises real Playwright locator waits and cannot be faithfully reproduced with
|
||||
// DispatchProxy fakes, so it is covered by the browser-backed integration suite.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class HumanConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultPreset_HasExpectedDefaults()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default);
|
||||
Assert.Equal(70, cfg.TypingDelay);
|
||||
Assert.Equal((15, 35), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
Assert.False(cfg.IdleBetweenActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarefulPreset_IsSlower()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Careful);
|
||||
Assert.Equal(100, cfg.TypingDelay);
|
||||
Assert.True(cfg.IdleBetweenActions);
|
||||
Assert.Equal((20, 45), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_SnakeCase_Keys_Applied()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["typing_delay"] = 200.0,
|
||||
["mistype_chance"] = 0.5,
|
||||
});
|
||||
Assert.Equal(200, cfg.TypingDelay);
|
||||
Assert.Equal(0.5, cfg.MistypeChance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_PascalCase_Keys_Applied()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["TypingDelay"] = 250.0,
|
||||
});
|
||||
Assert.Equal(250, cfg.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_Range_From_Tuple()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["key_hold"] = (50.0, 100.0),
|
||||
});
|
||||
Assert.Equal((50, 100), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_Range_From_Array()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["key_hold"] = new object[] { 60, 120 },
|
||||
});
|
||||
Assert.Equal((60, 120), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Keys_Ignored()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["does_not_exist"] = 5,
|
||||
});
|
||||
Assert.Equal(70, cfg.TypingDelay); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePreset_CaseInsensitive()
|
||||
{
|
||||
Assert.Equal(HumanPreset.Careful, HumanConfigFactory.ParsePreset("CAREFUL"));
|
||||
Assert.Equal(HumanPreset.Default, HumanConfigFactory.ParsePreset(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePreset_Invalid_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => HumanConfigFactory.ParsePreset("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_DoesNotMutate_Base()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 999.0 });
|
||||
Assert.Equal(70, baseCfg.TypingDelay);
|
||||
Assert.Equal(999, merged.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_ReturnsNewInstance_NotSameReference()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 123.0 });
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_NullOverrides_ReturnsEquivalentClone()
|
||||
{
|
||||
var baseCfg = new HumanConfig { TypingDelay = 42, MistypeChance = 0.3 };
|
||||
var merged = baseCfg.With(null);
|
||||
|
||||
// Equivalent values but a distinct instance (never the same reference).
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
Assert.Equal(42, merged.TypingDelay);
|
||||
Assert.Equal(0.3, merged.MistypeChance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_EmptyOverrides_ReturnsEquivalentClone()
|
||||
{
|
||||
var baseCfg = new HumanConfig { TypingDelay = 55 };
|
||||
var merged = baseCfg.With(new Dictionary<string, object>());
|
||||
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
Assert.Equal(55, merged.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_PreservesNonOverriddenFields()
|
||||
{
|
||||
var baseCfg = new HumanConfig(); // defaults
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 500.0 });
|
||||
|
||||
// The overridden field changed...
|
||||
Assert.Equal(500, merged.TypingDelay);
|
||||
// ...while every non-overridden field keeps its base value.
|
||||
Assert.Equal(baseCfg.MistypeChance, merged.MistypeChance);
|
||||
Assert.Equal((baseCfg.KeyHold.Min, baseCfg.KeyHold.Max), (merged.KeyHold.Min, merged.KeyHold.Max));
|
||||
Assert.Equal(baseCfg.MouseMinSteps, merged.MouseMinSteps);
|
||||
Assert.Equal(baseCfg.IdleBetweenActions, merged.IdleBetweenActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_UnknownKeys_DoNotThrow_AndLeaveConfigValid()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var ex = Record.Exception(() => baseCfg.With(new Dictionary<string, object>
|
||||
{
|
||||
["totally_unknown_key"] = 5,
|
||||
["another_bogus_one"] = "x",
|
||||
["typing_delay"] = 88.0, // a valid one alongside the junk
|
||||
}));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Equal(88, baseCfg.With(new Dictionary<string, object>
|
||||
{
|
||||
["totally_unknown_key"] = 5,
|
||||
["typing_delay"] = 88.0,
|
||||
}).TypingDelay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CloakBrowser Pro license validation, caching, key resolution, Pro-aware config,
|
||||
/// and the binary_info tier - port of Python <c>tests/test_license.py</c> and JS
|
||||
/// <c>js/tests/license.test.ts</c>.
|
||||
///
|
||||
/// Tests are serialized (a shared collection) because they manipulate process env
|
||||
/// vars and a temp cache dir.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class LicenseTests : IDisposable
|
||||
{
|
||||
private readonly string _tmp;
|
||||
private readonly string? _prevCacheDir;
|
||||
private readonly string? _prevLicenseEnv;
|
||||
private readonly string? _prevDownloadUrl;
|
||||
|
||||
public LicenseTests()
|
||||
{
|
||||
_tmp = Path.Combine(Path.GetTempPath(), $"cloak-lic-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tmp);
|
||||
_prevCacheDir = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
_prevLicenseEnv = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
_prevDownloadUrl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _tmp);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _prevCacheDir);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", _prevLicenseEnv);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", _prevDownloadUrl);
|
||||
License.ValidateLicenseOverride = null;
|
||||
License.ProLatestVersionOverride = null;
|
||||
try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string s) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
|
||||
|
||||
private void WriteCache(string key, bool valid, string plan, string? expires, double validatedAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["key_sha256"] = Sha256Hex(key),
|
||||
["valid"] = valid,
|
||||
["plan"] = plan,
|
||||
["expires"] = expires,
|
||||
["validated_at"] = validatedAt,
|
||||
});
|
||||
File.WriteAllText(Path.Combine(_tmp, ".license_cache"), payload);
|
||||
}
|
||||
|
||||
private static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
|
||||
// =======================================================================
|
||||
// ResolveLicenseKey - param > env > file > null
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ExplicitParam_wins()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("param-key", License.ResolveLicenseKey("param-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_fallback()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_absent()
|
||||
{
|
||||
Assert.Null(License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyString_param_uses_env()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_fallback()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tmp, "license.key"), "file-key\n");
|
||||
Assert.Equal("file-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_takes_precedence_over_file()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tmp, "license.key"), "file-key");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ValidateLicense - cache + server + stale fallback
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void FreshCache_skips_server()
|
||||
{
|
||||
WriteCache("k", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
// No override set, but a fresh cache must short-circuit before any HTTP.
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.NotNull(info);
|
||||
Assert.True(info!.Valid);
|
||||
Assert.Equal("team", info.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleCache_is_ignored_by_fresh_read()
|
||||
{
|
||||
// Older than 24h -> not returned from the fresh read; server override supplies a new one.
|
||||
WriteCache("k", valid: true, plan: "solo", expires: null, validatedAt: Now() - 90000);
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "team", null);
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.Equal("team", info!.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_rejection_returns_invalid()
|
||||
{
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(false, "solo", null);
|
||||
var info = License.ValidateLicense("bad");
|
||||
Assert.NotNull(info);
|
||||
Assert.False(info!.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_stores_hash_not_raw_key()
|
||||
{
|
||||
// The on-disk cache must store a SHA-256 of the key, never the raw secret.
|
||||
WriteCache("super-secret-key", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
var contents = File.ReadAllText(Path.Combine(_tmp, ".license_cache"));
|
||||
Assert.DoesNotContain("super-secret-key", contents);
|
||||
Assert.Contains(Sha256Hex("super-secret-key"), contents);
|
||||
// And a fresh read of that hashed entry round-trips.
|
||||
var info = License.ValidateLicense("super-secret-key");
|
||||
Assert.True(info!.Valid);
|
||||
Assert.Equal("team", info.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongKey_cache_ignored()
|
||||
{
|
||||
WriteCache("other-key", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "solo", null);
|
||||
var info = License.ValidateLicense("my-key");
|
||||
// Cache belongs to a different key -> ignored; server override result used.
|
||||
Assert.Equal("solo", info!.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredLicense_rejected_from_cache()
|
||||
{
|
||||
var pastIso = DateTimeOffset.UtcNow.AddDays(-1).ToString("o");
|
||||
WriteCache("k", valid: true, plan: "solo", expires: pastIso, validatedAt: Now());
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.NotNull(info);
|
||||
Assert.False(info!.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorruptedValidatedAt_does_not_crash()
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["key_sha256"] = Sha256Hex("k"),
|
||||
["valid"] = true,
|
||||
["plan"] = "solo",
|
||||
["expires"] = null,
|
||||
["validated_at"] = "not-a-number",
|
||||
});
|
||||
File.WriteAllText(Path.Combine(_tmp, ".license_cache"), payload);
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "team", null);
|
||||
// Corrupt cache treated as absent -> server override consulted, no crash.
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.Equal("team", info!.Plan);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// GetProLatestVersion - rate limiting + marker
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProLatestVersion_rate_limited_reads_marker()
|
||||
{
|
||||
var marker = Path.Combine(_tmp, ".last_pro_version_check");
|
||||
File.WriteAllText(marker, "148.0.7778.215.2");
|
||||
// Fresh marker (just written) -> returns cached value without server.
|
||||
Assert.Equal("148.0.7778.215.2", License.GetProLatestVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProLatestVersion_override_used()
|
||||
{
|
||||
License.ProLatestVersionOverride = () => "149.0.0.0";
|
||||
Assert.Equal("149.0.0.0", License.GetProLatestVersion());
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Config Pro paths
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void BinaryDir_pro_suffix()
|
||||
{
|
||||
var dir = Config.GetBinaryDir("148.0.7778.215.2", pro: true);
|
||||
Assert.EndsWith("chromium-148.0.7778.215.2-pro", dir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryDir_default_no_suffix()
|
||||
{
|
||||
var dir = Config.GetBinaryDir("146.0.7680.177.5", pro: false);
|
||||
Assert.EndsWith("chromium-146.0.7680.177.5", dir);
|
||||
Assert.DoesNotContain("-pro", Path.GetFileName(dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveVersion_pro_marker_without_binary_falls_back()
|
||||
{
|
||||
var marker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}");
|
||||
File.WriteAllText(marker, "148.0.7778.215.2");
|
||||
// Marker present but no Pro binary on disk -> falls back to bundled version.
|
||||
Assert.Equal(Config.GetChromiumVersion(), Config.GetEffectiveVersion(pro: true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
using Range = CloakBrowser.Human.Range;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class GeoIpTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("10.0.0.1", true)]
|
||||
[InlineData("192.168.1.1", true)]
|
||||
[InlineData("172.16.0.1", true)]
|
||||
[InlineData("127.0.0.1", true)]
|
||||
[InlineData("8.8.8.8", false)]
|
||||
[InlineData("1.1.1.1", false)]
|
||||
public void IsPrivateIp_Classifies(string ip, bool isPrivate)
|
||||
{
|
||||
Assert.Equal(isPrivate, GeoIp.IsPrivateIp(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountryLocaleMap_HasCommonCountries()
|
||||
{
|
||||
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("US"));
|
||||
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("DE"));
|
||||
}
|
||||
}
|
||||
|
||||
public class HumanRandomTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rand_InRange()
|
||||
{
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
double v = HumanRandom.Rand(5, 10);
|
||||
Assert.InRange(v, 5, 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandInt_Inclusive()
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
for (int i = 0; i < 1000; i++)
|
||||
seen.Add(HumanRandom.RandInt(1, 3));
|
||||
Assert.Equal(new HashSet<int> { 1, 2, 3 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandRange_FromRange()
|
||||
{
|
||||
var r = new Range(2, 4);
|
||||
for (int i = 0; i < 500; i++)
|
||||
Assert.InRange(HumanRandom.RandRange(r), 2, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choice_FromString()
|
||||
{
|
||||
var seen = new HashSet<char>();
|
||||
for (int i = 0; i < 500; i++)
|
||||
seen.Add(HumanRandom.Choice("abc"));
|
||||
Assert.Equal(new HashSet<char> { 'a', 'b', 'c' }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SleepMs_Zero_IsNoOp()
|
||||
{
|
||||
// Should not throw and should return quickly.
|
||||
HumanRandom.SleepMs(0);
|
||||
Assert.True(HumanRandom.SleepMsAsync(0).IsCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
public class KeyboardTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShiftSymbols_Contains_Expected()
|
||||
{
|
||||
foreach (char c in "@#!$%^&*()_+{}|:\"<>?~")
|
||||
Assert.Contains(c, HumanKeyboard.ShiftSymbols);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearbyKeys_HasQwertyNeighbors()
|
||||
{
|
||||
Assert.Equal("sqwz", HumanKeyboard.NearbyKeys['a']);
|
||||
Assert.Equal("ol", HumanKeyboard.NearbyKeys['p']);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActionabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void CheckSets_Match_Python()
|
||||
{
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksClick));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksHover));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled", "editable", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksInput));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled" },
|
||||
new HashSet<string>(Actionability.ChecksFocus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorHierarchy_AllSubclassActionabilityError()
|
||||
{
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotAttachedError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotVisibleError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotStableError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotEnabledError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotEditableError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotReceivingEventsError("#x", "div"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementNotReceivingEvents_Message_IncludesCoveringTag()
|
||||
{
|
||||
var e = new ElementNotReceivingEventsError("#x", "span");
|
||||
Assert.Contains("span", e.Message);
|
||||
Assert.Equal("pointer_events", e.Check);
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClickTarget_Input_Within_Box()
|
||||
{
|
||||
var box = new BoundingBox(100, 200, 300, 40);
|
||||
var cfg = new HumanConfig();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var p = HumanMouse.ClickTarget(box, isInput: true, cfg);
|
||||
Assert.InRange(p.X, box.X, box.X + box.Width);
|
||||
Assert.InRange(p.Y, box.Y, box.Y + box.Height);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickTarget_Button_Clusters_Center()
|
||||
{
|
||||
var box = new BoundingBox(0, 0, 100, 100);
|
||||
var cfg = new HumanConfig();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var p = HumanMouse.ClickTarget(box, isInput: false, cfg);
|
||||
Assert.InRange(p.X, 35, 65);
|
||||
Assert.InRange(p.Y, 35, 65);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ProxyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Null_Proxy_Returns_Empty()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(null);
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Empty(r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_String_With_Creds_Uses_ProxyServerArg()
|
||||
{
|
||||
var r = ProxyResolver.Resolve("socks5://user:pass@host:1080");
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Single(r.ExtraArgs);
|
||||
Assert.StartsWith("--proxy-server=socks5://", r.ExtraArgs[0]);
|
||||
Assert.Contains("user:pass@host:1080", r.ExtraArgs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_Dict_With_Creds_And_Bypass()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(new ProxySettings
|
||||
{
|
||||
Server = "socks5://host:1080",
|
||||
Username = "u",
|
||||
Password = "p",
|
||||
Bypass = ".google.com",
|
||||
});
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Contains(r.ExtraArgs, a => a.StartsWith("--proxy-server=socks5://u:p@host:1080"));
|
||||
Assert.Contains("--proxy-bypass-list=.google.com", r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_Creds_With_Special_Chars_Are_Encoded()
|
||||
{
|
||||
// Password contains '=' and '@' which Chromium would truncate; expect encoding.
|
||||
var r = ProxyResolver.Resolve("socks5://user:p=ss@word@host:1080");
|
||||
Assert.Single(r.ExtraArgs);
|
||||
// '=' -> %3D, the literal '@' inside the password -> %40
|
||||
Assert.Contains("%3D", r.ExtraArgs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_Without_Creds_Uses_PlaywrightProxy()
|
||||
{
|
||||
var r = ProxyResolver.Resolve("http://host:8080");
|
||||
Assert.NotNull(r.PlaywrightProxy);
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
Assert.Empty(r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_Dict_Without_Creds_Uses_PlaywrightProxy()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(new ProxySettings { Server = "http://host:8080" });
|
||||
Assert.NotNull(r.PlaywrightProxy);
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_With_Creds_Parses_Into_PlaywrightFields_When_InlineAuth_Unsupported()
|
||||
{
|
||||
// On platforms NOT in the inline-auth set (e.g. linux-arm64, darwin-*),
|
||||
// HTTP creds go to the Playwright proxy dict. On linux-x64/windows-x64 with
|
||||
// a recent binary they go to --proxy-server. Accept either valid outcome.
|
||||
var r = ProxyResolver.Resolve("http://user:pass@host:8080");
|
||||
bool inlineArg = r.ExtraArgs.Any(a => a.StartsWith("--proxy-server="));
|
||||
bool pwProxy = r.PlaywrightProxy != null;
|
||||
Assert.True(inlineArg ^ pwProxy, "Exactly one of inline-arg or playwright-proxy should be set");
|
||||
if (pwProxy)
|
||||
{
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
Assert.Equal("user", r.PlaywrightProxy.Username);
|
||||
Assert.Equal("pass", r.PlaywrightProxy.Password);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractProxyUrl_AddsScheme_For_Bare()
|
||||
{
|
||||
Assert.Equal("http://host:8080", ProxyResolver.ExtractProxyUrl("host:8080"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractProxyUrl_Socks_Dict_Reconstructs_With_Creds()
|
||||
{
|
||||
var url = ProxyResolver.ExtractProxyUrl(new ProxySettings
|
||||
{
|
||||
Server = "socks5://host:1080", Username = "u", Password = "p",
|
||||
});
|
||||
Assert.Equal("socks5://u:p@host:1080", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSocksProxy_Detects_Variants()
|
||||
{
|
||||
Assert.True(ProxyResolver.IsSocksProxy("socks5://h:1"));
|
||||
Assert.True(ProxyResolver.IsSocksProxy("socks5h://h:1"));
|
||||
Assert.False(ProxyResolver.IsSocksProxy("http://h:1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the headed no-viewport scroll fallback (port of upstream 9c3ed2d /
|
||||
/// v0.4.1). Headed launches default to no_viewport so <c>page.ViewportSize</c> is
|
||||
/// null; human scroll must fall back to the live <c>window.innerWidth/innerHeight</c>
|
||||
/// instead of crashing with "Viewport size not available".
|
||||
/// </summary>
|
||||
public class ScrollFallbackTests
|
||||
{
|
||||
private sealed class FakeRawMouse : IRawMouse
|
||||
{
|
||||
public Task MoveAsync(double x, double y) => Task.CompletedTask;
|
||||
public Task DownAsync() => Task.CompletedTask;
|
||||
public Task UpAsync() => Task.CompletedTask;
|
||||
public Task WheelAsync(double dx, double dy) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Scroll page whose ViewportSize is null (headed) but live window dims resolve.</summary>
|
||||
private sealed class NoViewportPage : IRawScrollPage
|
||||
{
|
||||
private readonly (int, int)? _live;
|
||||
public int LiveCalls { get; private set; }
|
||||
public NoViewportPage((int, int)? live) => _live = live;
|
||||
|
||||
public (int Width, int Height)? ViewportSize => null;
|
||||
|
||||
public Task<(int Width, int Height)?> GetLiveWindowSizeAsync()
|
||||
{
|
||||
LiveCalls++;
|
||||
return Task.FromResult(_live);
|
||||
}
|
||||
}
|
||||
|
||||
// Zero out the timing ranges so the scroll loop runs instantly in tests.
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
ScrollPreMoveDelay = (0, 0),
|
||||
ScrollPauseFast = (0, 0),
|
||||
ScrollPauseSlow = (0, 0),
|
||||
ScrollSettleDelay = (0, 0),
|
||||
ScrollOvershootChance = 0,
|
||||
MouseMinSteps = 1,
|
||||
MouseMaxSteps = 2,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_falls_back_to_live_window_dimensions()
|
||||
{
|
||||
var page = new NoViewportPage((1280, 800));
|
||||
var raw = new FakeRawMouse();
|
||||
// Element far below the fold so a scroll is required (forces use of viewport height).
|
||||
BoundingBox? boxBelowFold = new BoundingBox(100, 5000, 50, 20);
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult(boxBelowFold);
|
||||
|
||||
var result = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
page, raw, getBox, cursorX: 0, cursorY: 0, FastConfig());
|
||||
|
||||
Assert.Equal(1, page.LiveCalls); // the fallback was consulted
|
||||
Assert.True(result.DidScroll); // and it actually scrolled (no crash)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_and_no_live_dims_throws()
|
||||
{
|
||||
var page = new NoViewportPage(null); // live fallback also unavailable
|
||||
var raw = new FakeRawMouse();
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(0, 0, 10, 10));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
HumanScroll.HumanScrollIntoViewAsync(page, raw, getBox, 0, 0, FastConfig()));
|
||||
Assert.Equal("Viewport size not available", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_with_zero_height_live_dims_throws()
|
||||
{
|
||||
// A live read that returns a 0 height is treated as unusable (matches the
|
||||
// Python `not viewport.get("height")` guard).
|
||||
var page = new NoViewportPage((1280, 0));
|
||||
var raw = new FakeRawMouse();
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(0, 0, 10, 10));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
HumanScroll.HumanScrollIntoViewAsync(page, raw, getBox, 0, 0, FastConfig()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CloakBrowser;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 binary-signature verification - port of Python <c>tests/test_update.py</c>
|
||||
/// (TestSignatureVerification, TestVerifyDownloadChecksumSigned, TestVersionBinding)
|
||||
/// and JS <c>js/tests/signature.test.ts</c>. Closes #308: a compromised download
|
||||
/// mirror can no longer certify a tampered binary.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class SignatureTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Ed25519 key/signature helpers (mirror _make_key / _sign in Python).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static (Ed25519PrivateKeyParameters Priv, string PubB64) MakeKey()
|
||||
{
|
||||
var gen = new Ed25519KeyPairGenerator();
|
||||
gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
||||
var pair = gen.GenerateKeyPair();
|
||||
var priv = (Ed25519PrivateKeyParameters)pair.Private;
|
||||
var pub = (Ed25519PublicKeyParameters)pair.Public;
|
||||
return (priv, Convert.ToBase64String(pub.GetEncoded()));
|
||||
}
|
||||
|
||||
/// <summary>Return SHA256SUMS.sig content (base64 of the raw signature), as served.</summary>
|
||||
private static byte[] Sign(Ed25519PrivateKeyParameters priv, byte[] manifest)
|
||||
{
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(true, priv);
|
||||
signer.BlockUpdate(manifest, 0, manifest.Length);
|
||||
var raw = signer.GenerateSignature();
|
||||
return Encoding.ASCII.GetBytes(Convert.ToBase64String(raw));
|
||||
}
|
||||
|
||||
private static byte[] Utf8(string s) => Encoding.UTF8.GetBytes(s);
|
||||
|
||||
private static string Sha256Hex(byte[] data)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
return Convert.ToHexString(sha.ComputeHash(data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Run an action with overridden pinned keys / manifest, always restoring afterwards.</summary>
|
||||
private static void WithOverrides(
|
||||
string[]? pubkeys,
|
||||
Func<string?, (byte[], byte[])?>? manifest,
|
||||
Action body)
|
||||
{
|
||||
var prevKeys = Download.SigningPubkeysOverride;
|
||||
var prevManifest = Download.SignedManifestOverride;
|
||||
var prevCustomUrl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
try
|
||||
{
|
||||
Download.SigningPubkeysOverride = pubkeys;
|
||||
Download.SignedManifestOverride = manifest;
|
||||
// Force the official path (no custom mirror).
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", null);
|
||||
body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Download.SigningPubkeysOverride = prevKeys;
|
||||
Download.SignedManifestOverride = prevManifest;
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", prevCustomUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tarball() => Config.GetArchiveName();
|
||||
|
||||
private static byte[] Manifest(string body, string? version = null)
|
||||
{
|
||||
var v = version ?? Config.GetChromiumVersion();
|
||||
return Utf8($"version={v}\n{body}");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestSignatureVerification - the cryptographic gate over manifest bytes.
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidSignature_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(new[] { pub }, null, () => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedManifest_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
var tampered = Utf8("xyz cloakbrowser-linux-x64.tar.gz\n");
|
||||
WithOverrides(new[] { pub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => Download.VerifySignature(tampered, sig));
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongKey_fails()
|
||||
{
|
||||
var (priv, _) = MakeKey();
|
||||
var (_, otherPub) = MakeKey();
|
||||
var manifest = Utf8("data\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(new[] { otherPub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => Download.VerifySignature(manifest, sig));
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedSignature_fails()
|
||||
{
|
||||
var (_, pub) = MakeKey();
|
||||
WithOverrides(new[] { pub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => Download.VerifySignature(Utf8("data\n"), Encoding.ASCII.GetBytes("!!!not base64!!!")));
|
||||
Assert.Contains("Malformed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceholderKey_is_skipped_not_crashing()
|
||||
{
|
||||
// An unparseable pinned key (placeholder) must not abort - a real key still validates.
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("data\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(
|
||||
new[] { "REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub },
|
||||
null,
|
||||
() => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyRotation_second_key_accepts()
|
||||
{
|
||||
// A manifest signed with the new key validates while the old key stays pinned.
|
||||
var (_, oldPub) = MakeKey();
|
||||
var (newPriv, newPub) = MakeKey();
|
||||
var manifest = Utf8("rotated\n");
|
||||
var sig = Sign(newPriv, manifest);
|
||||
WithOverrides(new[] { oldPub, newPub }, null, () => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestVerifyDownloadChecksumSigned - official path: sig + version + hash, fail-closed.
|
||||
// =======================================================================
|
||||
|
||||
private static string WriteTemp(byte[] bytes)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"cloak-sig-test-{Guid.NewGuid():N}");
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidManifestAndHash_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = Manifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedBinary_fails_hash()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("a malicious binary"));
|
||||
var manifest = Manifest($"{Sha256Hex(Utf8("the real binary"))} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Checksum verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongVersion_fails_downgrade()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
// Manifest declares an old version, but we ask for the current one.
|
||||
var manifest = Manifest($"{Sha256Hex(binary)} {Tarball()}\n", version: "1.0.0.0");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVersionLine_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = Utf8($"{Sha256Hex(binary)} {Tarball()}\n"); // no version=
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingSignedManifest_fails_closed()
|
||||
{
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
try
|
||||
{
|
||||
WithOverrides(null, _ => null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("signed SHA256SUMS", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestWithoutEntry_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var manifest = Manifest($"{new string('d', 64)} some-other-file.tar.gz\n"); // no entry for our tarball
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("no entry for", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomUrl_uses_plain_checksum_and_skip()
|
||||
{
|
||||
// Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path,
|
||||
// and the signature path must NOT be consulted for a custom mirror.
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var prevDl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
var prevSkip = Environment.GetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM");
|
||||
var prevManifest = Download.SignedManifestOverride;
|
||||
bool manifestConsulted = false;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", "https://my-mirror.test");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM", "true");
|
||||
Download.SignedManifestOverride = _ => { manifestConsulted = true; return null; };
|
||||
|
||||
// Skip honored, no throw.
|
||||
Download.VerifyDownloadChecksum(archive);
|
||||
Assert.False(manifestConsulted, "signature path must not be consulted for a custom mirror");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", prevDl);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM", prevSkip);
|
||||
Download.SignedManifestOverride = prevManifest;
|
||||
File.Delete(archive);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestVersionBinding - the 'version=<v>' line.
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseManifestVersion_reads_line()
|
||||
{
|
||||
var manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n";
|
||||
Assert.Equal("146.0.7680.177.5", Download.ParseManifestVersion(manifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseManifestVersion_absent_returns_null()
|
||||
{
|
||||
Assert.Null(Download.ParseManifestVersion("abc cloakbrowser-linux-x64.tar.gz\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldChecksumParser_ignores_version_line()
|
||||
{
|
||||
// Regression: the version line must not pollute the hash map, and a short
|
||||
// (non-64-hex) hash like "abc" must be rejected too.
|
||||
var h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
var manifest = $"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n";
|
||||
var result = Download.ParseChecksums(manifest);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(h, result["cloakbrowser-linux-x64.tar.gz"]);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Pro download verification - VerifyProDownloadAsync uses the SAME pinned
|
||||
// Ed25519 signature gate as the free path, but classifies failures:
|
||||
// tampering (bad sig / wrong version / bad hash) -> BinaryVerificationError
|
||||
// transient (manifest fetch failed) -> InvalidOperationException
|
||||
// Port of JS js/tests/signature.test.ts Pro cases + Python TestVerifyProDownload.
|
||||
// =======================================================================
|
||||
|
||||
private const string ProVersion = "148.0.7778.215.2";
|
||||
|
||||
private static byte[] ProManifest(string body, string? version = null) =>
|
||||
Utf8($"version={version ?? ProVersion}\n{body}");
|
||||
|
||||
private static void WithProOverrides(
|
||||
string[]? pubkeys, Func<string, (byte[], byte[])?>? manifest, Action body)
|
||||
{
|
||||
var prevKeys = Download.SigningPubkeysOverride;
|
||||
var prevManifest = Download.ProSignedManifestOverride;
|
||||
try
|
||||
{
|
||||
Download.SigningPubkeysOverride = pubkeys;
|
||||
Download.ProSignedManifestOverride = manifest;
|
||||
body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Download.SigningPubkeysOverride = prevKeys;
|
||||
Download.ProSignedManifestOverride = prevManifest;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_validManifestAndHash_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_tamperedBinary_throws_verificationError()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("a malicious pro binary"));
|
||||
var manifest = ProManifest($"{Sha256Hex(Utf8("the real pro binary"))} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("Checksum verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_badSignature_throws_verificationError()
|
||||
{
|
||||
var (priv, _) = MakeKey();
|
||||
var (_, otherPub) = MakeKey();
|
||||
var binary = Utf8("pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { otherPub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_wrongVersion_throws_verificationError_downgrade()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
// Manifest declares an older version than the one requested.
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n", version: "1.0.0.0");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_missingEntry_throws_verificationError()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var manifest = ProManifest($"{new string('d', 64)} some-other-file.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("no entry for", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_manifestFetchFails_is_transient_not_tampering()
|
||||
{
|
||||
// A failed manifest fetch (null) is transient -> plain InvalidOperationException,
|
||||
// NOT a BinaryVerificationError, so the router can surface "unavailable, retry".
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
try
|
||||
{
|
||||
WithProOverrides(null, _ => null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.IsNotType<BinaryVerificationError>(ex);
|
||||
Assert.Contains("Could not fetch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using CloakBrowser;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Window-geometry / no_viewport behavior - port of upstream 50bf14b tests
|
||||
/// (Python tests/test_launch_context.py + test_persistent_context.py, JS
|
||||
/// js/tests/launch.test.ts + puppeteer.test.ts).
|
||||
///
|
||||
/// Headed launches must NOT layer an emulated viewport on top of the real
|
||||
/// browser window: CDP viewport emulation forces outerWidth < innerWidth, a
|
||||
/// physically impossible window (= bot tell). Headless keeps a fixed, coherent
|
||||
/// (outer == inner) DEFAULT_VIEWPORT for deterministic dimensions.
|
||||
/// </summary>
|
||||
public class ViewportTests
|
||||
{
|
||||
// ViewportSize uses reference equality, so compare by the sentinel value that
|
||||
// Playwright's ViewportSize.NoViewport carries ({ Width = -1, Height = -1 }).
|
||||
private static bool IsNoViewport(ViewportSize? vp) =>
|
||||
vp != null && vp.Width == -1 && vp.Height == -1;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ResolveContextViewport - the headless-aware viewport selector used by
|
||||
// launch_context / launch_persistent_context.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Headless_unset_viewport_uses_default()
|
||||
{
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions { Headless = true });
|
||||
Assert.NotNull(vp);
|
||||
Assert.False(IsNoViewport(vp));
|
||||
Assert.Equal(Config.DefaultViewportWidth, vp!.Width);
|
||||
Assert.Equal(Config.DefaultViewportHeight, vp.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headed_unset_viewport_uses_no_viewport()
|
||||
{
|
||||
// The core fix: headed + no explicit viewport => track the real window.
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions { Headless = false });
|
||||
Assert.True(IsNoViewport(vp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_viewport_honored_when_headed()
|
||||
{
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
|
||||
{
|
||||
Headless = false,
|
||||
Viewport = (1280, 720),
|
||||
});
|
||||
Assert.NotNull(vp);
|
||||
Assert.Equal(1280, vp!.Width);
|
||||
Assert.Equal(720, vp.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_viewport_honored_when_headless()
|
||||
{
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
|
||||
{
|
||||
Headless = true,
|
||||
Viewport = (800, 600),
|
||||
});
|
||||
Assert.NotNull(vp);
|
||||
Assert.Equal(800, vp!.Width);
|
||||
Assert.Equal(600, vp.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_no_viewport_honored_when_headless()
|
||||
{
|
||||
// NoViewport always wins, even headless (caller opted out of emulation).
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
|
||||
{
|
||||
Headless = true,
|
||||
NoViewport = true,
|
||||
});
|
||||
Assert.True(IsNoViewport(vp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoViewport_takes_precedence_over_viewport()
|
||||
{
|
||||
// Explicit NoViewport beats an explicit viewport (matches Python's
|
||||
// _drop_conflicting_viewport: no_viewport wins).
|
||||
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
|
||||
{
|
||||
Headless = true,
|
||||
NoViewport = true,
|
||||
Viewport = (1920, 1080),
|
||||
});
|
||||
Assert.True(IsNoViewport(vp));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ViewportDefaults.ApplyHeadedNoViewport - the shared headed-default applied
|
||||
// on EVERY page/context creation path (the raw handle AND the humanize
|
||||
// wrapper), mirroring Python's _default_no_viewport which patches the browser
|
||||
// so the default holds regardless of which path creates the page.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void HeadedDefault_page_unset_becomes_no_viewport()
|
||||
{
|
||||
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewPageOptions?)null, headless: false);
|
||||
Assert.True(IsNoViewport(o.ViewportSize));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadedDefault_context_unset_becomes_no_viewport()
|
||||
{
|
||||
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewContextOptions?)null, headless: false);
|
||||
Assert.True(IsNoViewport(o.ViewportSize));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessDefault_page_left_untouched()
|
||||
{
|
||||
// Headless: don't impose no_viewport - Playwright's default stays (coherent there).
|
||||
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewPageOptions?)null, headless: true);
|
||||
Assert.Null(o.ViewportSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadedDefault_explicit_viewport_honored()
|
||||
{
|
||||
var input = new BrowserNewPageOptions { ViewportSize = new ViewportSize { Width = 1024, Height = 768 } };
|
||||
var o = ViewportDefaults.ApplyHeadedNoViewport(input, headless: false);
|
||||
Assert.Equal(1024, o.ViewportSize!.Width);
|
||||
Assert.Equal(768, o.ViewportSize.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadedDefault_explicit_no_viewport_honored()
|
||||
{
|
||||
var input = new BrowserNewContextOptions { ViewportSize = ViewportSize.NoViewport };
|
||||
var o = ViewportDefaults.ApplyHeadedNoViewport(input, headless: false);
|
||||
Assert.True(IsNoViewport(o.ViewportSize));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Port-parity tests for <see cref="Widevine"/> (mirrors Python tests/test_widevine.py
|
||||
/// and js/tests/widevine.test.ts). Seeding is Linux-only, so the write-path
|
||||
/// assertions are gated on Linux; the platform-independent behaviour (no-op
|
||||
/// gates, CDM resolution, kill switch) is exercised everywhere.
|
||||
///
|
||||
/// Mutates CLOAKBROWSER_WIDEVINE* env vars, so it joins the env-serial
|
||||
/// collection to avoid racing other env-mutating suites.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public sealed class WidevineTests : IDisposable
|
||||
{
|
||||
private const string HintFilename = "latest-component-updated-widevine-cdm";
|
||||
|
||||
private readonly string? _prevWidevine;
|
||||
private readonly string? _prevCdm;
|
||||
private readonly string _tmp;
|
||||
|
||||
public WidevineTests()
|
||||
{
|
||||
_prevWidevine = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE");
|
||||
_prevCdm = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", null);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", null);
|
||||
_tmp = Path.Combine(Path.GetTempPath(), "cb-widevine-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tmp);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", _prevWidevine);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", _prevCdm);
|
||||
try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static bool OnLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||
|
||||
/// <summary>Create a fake chrome binary with a sideloaded WidevineCdm/manifest.json next to it.</summary>
|
||||
private string MakeBinaryWithCdm()
|
||||
{
|
||||
var binDir = Path.Combine(_tmp, "bin");
|
||||
Directory.CreateDirectory(binDir);
|
||||
var binaryPath = Path.Combine(binDir, "chrome");
|
||||
File.WriteAllText(binaryPath, "#!/bin/sh\n");
|
||||
var cdmDir = Path.Combine(binDir, "WidevineCdm");
|
||||
Directory.CreateDirectory(cdmDir);
|
||||
File.WriteAllText(Path.Combine(cdmDir, "manifest.json"), "{\"version\":\"1.0\"}");
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// ---- ResolveWidevineCdmDir ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FindsCdmNextToBinary()
|
||||
{
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var resolved = Widevine.ResolveWidevineCdmDir(binaryPath);
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(binaryPath)!, "WidevineCdm")),
|
||||
resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoManifest_ReturnsNull()
|
||||
{
|
||||
var binDir = Path.Combine(_tmp, "nomanifest");
|
||||
Directory.CreateDirectory(binDir);
|
||||
var binaryPath = Path.Combine(binDir, "chrome");
|
||||
File.WriteAllText(binaryPath, "x");
|
||||
Directory.CreateDirectory(Path.Combine(binDir, "WidevineCdm")); // dir but no manifest.json
|
||||
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EnvVar_OverridesAutoDetect()
|
||||
{
|
||||
// Auto-detect would find the binary-adjacent CDM, but the env var wins exclusively.
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var customDir = Path.Combine(_tmp, "custom");
|
||||
Directory.CreateDirectory(customDir);
|
||||
File.WriteAllText(Path.Combine(customDir, "manifest.json"), "{}");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", customDir);
|
||||
|
||||
var resolved = Widevine.ResolveWidevineCdmDir(binaryPath);
|
||||
Assert.Equal(Path.GetFullPath(customDir), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EnvVar_InvalidPath_SkipsSeeding()
|
||||
{
|
||||
// A present-but-bogus env var is used exclusively => null (does NOT fall back).
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM",
|
||||
Path.Combine(_tmp, "does-not-exist"));
|
||||
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EnvVar_Whitespace_SkipsSeeding()
|
||||
{
|
||||
// A present-but-blank value must be treated as an unusable path and skip
|
||||
// seeding (rather than probing the CWD via Path.Combine). Note: .NET's
|
||||
// SetEnvironmentVariable("") *unsets* the variable, so a truly-empty value
|
||||
// is unreachable through the managed API and only happens via the shell
|
||||
// (export X=); whitespace is the testable proxy for that "set-but-blank"
|
||||
// case and exercises the same guard in ResolveWidevineCdmDir.
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", " ");
|
||||
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
|
||||
}
|
||||
|
||||
// ---- SeedWidevineHint (write path, Linux only) ---------------------------
|
||||
|
||||
[Fact]
|
||||
public void Seed_WritesHintFile_WithCompactJson()
|
||||
{
|
||||
if (!OnLinux) return;
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var profile = Path.Combine(_tmp, "profile");
|
||||
Directory.CreateDirectory(profile);
|
||||
|
||||
Widevine.SeedWidevineHint(profile, binaryPath);
|
||||
|
||||
var hintFile = Path.Combine(profile, "WidevineCdm", HintFilename);
|
||||
Assert.True(File.Exists(hintFile));
|
||||
|
||||
var cdmDir = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(binaryPath)!, "WidevineCdm"));
|
||||
// Byte-match the JS wrapper's JSON.stringify({ Path }) output: compact, no BOM.
|
||||
var expected = "{\"Path\":\"" + cdmDir + "\"}";
|
||||
var actual = File.ReadAllText(hintFile);
|
||||
Assert.Equal(expected, actual);
|
||||
// No UTF-8 BOM (the JS/Python wrappers write raw UTF-8).
|
||||
var bytes = File.ReadAllBytes(hintFile);
|
||||
Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_Idempotent_DoesNotRewriteIdenticalHint()
|
||||
{
|
||||
if (!OnLinux) return;
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var profile = Path.Combine(_tmp, "profile");
|
||||
Directory.CreateDirectory(profile);
|
||||
|
||||
Widevine.SeedWidevineHint(profile, binaryPath);
|
||||
var hintFile = Path.Combine(profile, "WidevineCdm", HintFilename);
|
||||
var firstWrite = File.GetLastWriteTimeUtc(hintFile);
|
||||
|
||||
System.Threading.Thread.Sleep(20);
|
||||
Widevine.SeedWidevineHint(profile, binaryPath); // second seed, identical content
|
||||
var secondWrite = File.GetLastWriteTimeUtc(hintFile);
|
||||
|
||||
Assert.Equal(firstWrite, secondWrite); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_NoCdm_NoHintWritten()
|
||||
{
|
||||
if (!OnLinux) return;
|
||||
var binDir = Path.Combine(_tmp, "bare");
|
||||
Directory.CreateDirectory(binDir);
|
||||
var binaryPath = Path.Combine(binDir, "chrome");
|
||||
File.WriteAllText(binaryPath, "x");
|
||||
var profile = Path.Combine(_tmp, "profile2");
|
||||
Directory.CreateDirectory(profile);
|
||||
|
||||
Widevine.SeedWidevineHint(profile, binaryPath);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_KillSwitch_NoHintWritten()
|
||||
{
|
||||
if (!OnLinux) return;
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", "0");
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var profile = Path.Combine(_tmp, "profile3");
|
||||
Directory.CreateDirectory(profile);
|
||||
|
||||
Widevine.SeedWidevineHint(profile, binaryPath);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("false")]
|
||||
[InlineData("off")]
|
||||
[InlineData("no")]
|
||||
[InlineData("FALSE")]
|
||||
[InlineData(" Off ")]
|
||||
public void Seed_KillSwitch_AcceptsFalseyVariants(string value)
|
||||
{
|
||||
if (!OnLinux) return;
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", value);
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
var profile = Path.Combine(_tmp, "profile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(profile);
|
||||
|
||||
Widevine.SeedWidevineHint(profile, binaryPath);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_EmptyUserDataDir_NoOp()
|
||||
{
|
||||
// No exception, no write — ephemeral profile path.
|
||||
var binaryPath = MakeBinaryWithCdm();
|
||||
Widevine.SeedWidevineHint("", binaryPath);
|
||||
Widevine.SeedWidevineHint(null, binaryPath);
|
||||
// Nothing to assert beyond "did not throw"; the binary-adjacent CDM must
|
||||
// not have been touched.
|
||||
Assert.True(File.Exists(binaryPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HumanizedBrowser"/> / <see cref="HumanizedBrowserContext"/>:
|
||||
/// the wrapping chain must be complete - a wrapped browser produces wrapped contexts
|
||||
/// and pages, and a wrapped context produces wrapped pages, so there are no raw leaks.
|
||||
/// </summary>
|
||||
public class BrowserContextWrapperTests
|
||||
{
|
||||
private static IPage MakeFakePage()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 800, Height = 600 });
|
||||
return page;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Context
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Context_NewPageAsync_returns_wrapped_page()
|
||||
{
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("NewPageAsync", Task.FromResult(MakeFakePage()));
|
||||
|
||||
var human = Humanize.Context(ctx, new HumanConfig());
|
||||
var page = await human.NewPageAsync();
|
||||
|
||||
Assert.IsType<HumanizedPage>(page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_Pages_returns_wrapped_pages()
|
||||
{
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("Pages", new List<IPage> { MakeFakePage(), MakeFakePage() });
|
||||
|
||||
var human = Humanize.Context(ctx, new HumanConfig());
|
||||
|
||||
Assert.Equal(2, human.Pages.Count);
|
||||
Assert.All(human.Pages, p => Assert.IsType<HumanizedPage>(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_delegated_member_forwards_to_inner()
|
||||
{
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("CookiesAsync", Task.FromResult<IReadOnlyList<BrowserContextCookiesResult>>(
|
||||
new List<BrowserContextCookiesResult>()));
|
||||
|
||||
var human = Humanize.Context(ctx, new HumanConfig());
|
||||
await human.CookiesAsync();
|
||||
|
||||
Assert.True(ctxRec.WasCalled("CookiesAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_Original_exposes_inner()
|
||||
{
|
||||
var (ctx, _) = Fake.Of<IBrowserContext>();
|
||||
var human = (HumanizedBrowserContext)Humanize.Context(ctx, new HumanConfig());
|
||||
Assert.Same(ctx, human.Original);
|
||||
Assert.Same(ctx, human.Inner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_wrapping_is_idempotent()
|
||||
{
|
||||
var (ctx, _) = Fake.Of<IBrowserContext>();
|
||||
var once = Humanize.Context(ctx, new HumanConfig());
|
||||
var twice = Humanize.Context(once, new HumanConfig());
|
||||
Assert.Same(once, twice);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Browser
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_NewPageAsync_returns_wrapped_page()
|
||||
{
|
||||
var (browser, browserRec) = Fake.Of<IBrowser>();
|
||||
browserRec.On("NewPageAsync", Task.FromResult(MakeFakePage()));
|
||||
|
||||
var human = Humanize.Browser(browser, new HumanConfig());
|
||||
var page = await human.NewPageAsync();
|
||||
|
||||
Assert.IsType<HumanizedPage>(page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_NewContextAsync_returns_wrapped_context()
|
||||
{
|
||||
var (ctx, _) = Fake.Of<IBrowserContext>();
|
||||
var (browser, browserRec) = Fake.Of<IBrowser>();
|
||||
browserRec.On("NewContextAsync", Task.FromResult(ctx));
|
||||
|
||||
var human = Humanize.Browser(browser, new HumanConfig());
|
||||
var context = await human.NewContextAsync();
|
||||
|
||||
Assert.IsType<HumanizedBrowserContext>(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Browser_Contexts_returns_wrapped_contexts()
|
||||
{
|
||||
var (ctx1, _) = Fake.Of<IBrowserContext>();
|
||||
var (ctx2, _) = Fake.Of<IBrowserContext>();
|
||||
var (browser, browserRec) = Fake.Of<IBrowser>();
|
||||
browserRec.On("Contexts", new List<IBrowserContext> { ctx1, ctx2 });
|
||||
|
||||
var human = Humanize.Browser(browser, new HumanConfig());
|
||||
|
||||
Assert.Equal(2, human.Contexts.Count);
|
||||
Assert.All(human.Contexts, c => Assert.IsType<HumanizedBrowserContext>(c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_full_chain_browser_to_context_to_page_is_all_wrapped()
|
||||
{
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
|
||||
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
|
||||
ctxRec.On("NewPageAsync", Task.FromResult<IPage>(page));
|
||||
|
||||
var (browser, browserRec) = Fake.Of<IBrowser>();
|
||||
browserRec.On("NewContextAsync", Task.FromResult(ctx));
|
||||
|
||||
var human = Humanize.Browser(browser, new HumanConfig());
|
||||
var context = await human.NewContextAsync();
|
||||
var leaf = await context.NewPageAsync();
|
||||
|
||||
// No raw leaks anywhere along the chain.
|
||||
Assert.IsType<HumanizedBrowserContext>(context);
|
||||
Assert.IsType<HumanizedPage>(leaf);
|
||||
Assert.IsType<HumanizedMouse>(leaf.Mouse);
|
||||
Assert.IsType<HumanizedKeyboard>(leaf.Keyboard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Browser_delegated_member_exception_propagates()
|
||||
{
|
||||
var (browser, browserRec) = Fake.Of<IBrowser>();
|
||||
browserRec.On("NewContextAsync", _ => throw new PlaywrightException("launch failed"));
|
||||
|
||||
var human = Humanize.Browser(browser, new HumanConfig());
|
||||
|
||||
await Assert.ThrowsAsync<PlaywrightException>(() => human.NewContextAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Browser_Original_exposes_inner()
|
||||
{
|
||||
var (browser, _) = Fake.Of<IBrowser>();
|
||||
var human = (HumanizedBrowser)Humanize.Browser(browser, new HumanConfig());
|
||||
Assert.Same(browser, human.Original);
|
||||
Assert.Same(browser, human.Inner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// A tiny <see cref="DispatchProxy"/>-based fake for any interface. Records every
|
||||
/// member access and lets a test register handlers for specific members by name;
|
||||
/// unregistered members return a sensible default (e.g. <c>Task.CompletedTask</c>).
|
||||
///
|
||||
/// This is test-only infrastructure - it lets us verify the production wrappers
|
||||
/// (which use the source generator, not reflection) without spinning up a real
|
||||
/// browser. The wrappers forward to <c>_inner</c>; here <c>_inner</c> is one of these
|
||||
/// fakes, so we can assert exactly which inner calls happened.
|
||||
/// </summary>
|
||||
public sealed class CallRecord
|
||||
{
|
||||
public string Member { get; init; } = "";
|
||||
public object?[] Args { get; init; } = System.Array.Empty<object?>();
|
||||
}
|
||||
|
||||
public class FakeProxy : DispatchProxy
|
||||
{
|
||||
private readonly List<CallRecord> _calls = new();
|
||||
private readonly Dictionary<string, System.Func<object?[], object?>> _handlers = new();
|
||||
|
||||
public IReadOnlyList<CallRecord> Calls => _calls;
|
||||
|
||||
public IEnumerable<string> CallNames => _calls.Select(c => c.Member);
|
||||
|
||||
public int CountOf(string member) => _calls.Count(c => c.Member == member);
|
||||
|
||||
public bool WasCalled(string member) => _calls.Any(c => c.Member == member);
|
||||
|
||||
public CallRecord? Last(string member) => _calls.LastOrDefault(c => c.Member == member);
|
||||
|
||||
/// <summary>Register a handler for a member (method or property getter) by name.</summary>
|
||||
public void On(string member, System.Func<object?[], object?> handler) => _handlers[member] = handler;
|
||||
|
||||
public void On(string member, object? returnValue) => _handlers[member] = _ => returnValue;
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
if (targetMethod == null)
|
||||
return null;
|
||||
|
||||
string name = NormalizeName(targetMethod.Name);
|
||||
args ??= System.Array.Empty<object?>();
|
||||
_calls.Add(new CallRecord { Member = name, Args = args });
|
||||
|
||||
if ((_handlers.TryGetValue(targetMethod.Name, out var handler) ||
|
||||
_handlers.TryGetValue(name, out handler)) && handler != null)
|
||||
{
|
||||
return handler(args);
|
||||
}
|
||||
|
||||
return DefaultFor(targetMethod.ReturnType);
|
||||
}
|
||||
|
||||
private static string NormalizeName(string methodName)
|
||||
{
|
||||
// Property getters/setters arrive as get_X / set_X.
|
||||
if (methodName.StartsWith("get_") || methodName.StartsWith("set_"))
|
||||
return methodName.Substring(4);
|
||||
return methodName;
|
||||
}
|
||||
|
||||
private static object? DefaultFor(System.Type t)
|
||||
{
|
||||
if (t == typeof(void)) return null;
|
||||
if (t == typeof(Task)) return Task.CompletedTask;
|
||||
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Task<>))
|
||||
{
|
||||
var inner = t.GetGenericArguments()[0];
|
||||
object? innerDefault = inner.IsValueType ? System.Activator.CreateInstance(inner) : null;
|
||||
var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(inner);
|
||||
return fromResult.Invoke(null, new[] { innerDefault });
|
||||
}
|
||||
return t.IsValueType ? System.Activator.CreateInstance(t) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory helpers for creating recording fakes.</summary>
|
||||
public static class Fake
|
||||
{
|
||||
public static (T Proxy, FakeProxy Recorder) Of<T>() where T : class
|
||||
{
|
||||
var proxy = DispatchProxy.Create<T, FakeProxy>();
|
||||
var recorder = (FakeProxy)(object)proxy;
|
||||
return ((T)(object)proxy, recorder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Ports Python <c>TestFocusCheck</c>: <see cref="HumanizedLocator.PressAsync"/> and
|
||||
/// <see cref="HumanizedLocator.ClearAsync"/> only perform the humanized focus-click when
|
||||
/// the element is not already focused. The focus state is probed via
|
||||
/// <c>EvaluateAsync<bool>("el => el === document.activeElement")</c>; we mock that
|
||||
/// to return true/false and assert whether the cursor moved (i.e. whether a click ran).
|
||||
/// </summary>
|
||||
public class FocusCheckTests
|
||||
{
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0,
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
ClickAimDelayInput = (0, 0),
|
||||
ClickHoldInput = (0, 0),
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
InitialCursorX = (100, 100),
|
||||
InitialCursorY = (100, 100),
|
||||
};
|
||||
|
||||
private static (IPage page, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
|
||||
{
|
||||
var (mouse, mouseRec) = Fake.Of<IMouse>();
|
||||
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
|
||||
return (page, mouseRec, kbRec);
|
||||
}
|
||||
|
||||
private static ILocator BuildLocator(bool focused)
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
|
||||
new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
|
||||
locRec.On("ScrollIntoViewIfNeededAsync", Task.CompletedTask);
|
||||
// IsFocusedAsync / IsInputAsync both go through EvaluateAsync<bool>.
|
||||
locRec.On("EvaluateAsync", Task.FromResult(focused));
|
||||
return locator;
|
||||
}
|
||||
|
||||
// --- PressAsync ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task PressAsync_skips_click_when_focused()
|
||||
{
|
||||
var (page, mouseRec, kbRec) = BuildPage();
|
||||
var human = new HumanizedLocator(BuildLocator(focused: true), new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.PressAsync("Enter");
|
||||
|
||||
Assert.Equal(0, mouseRec.CountOf("MoveAsync")); // cursor did not move
|
||||
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no click
|
||||
Assert.True(kbRec.CountOf("PressAsync") >= 1, "the key was still pressed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PressAsync_clicks_when_not_focused()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var human = new HumanizedLocator(BuildLocator(focused: false), new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.PressAsync("Enter");
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "cursor moved to focus the element");
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync")); // humanized click happened
|
||||
}
|
||||
|
||||
// --- ClearAsync ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_skips_click_when_focused()
|
||||
{
|
||||
var (page, mouseRec, kbRec) = BuildPage();
|
||||
var human = new HumanizedLocator(BuildLocator(focused: true), new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.ClearAsync();
|
||||
|
||||
Assert.Equal(0, mouseRec.CountOf("MoveAsync")); // no focus click
|
||||
Assert.Equal(0, mouseRec.CountOf("DownAsync"));
|
||||
// Still selects-all + Backspace through the keyboard.
|
||||
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + Backspace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_clicks_when_not_focused()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var human = new HumanizedLocator(BuildLocator(focused: false), new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.ClearAsync();
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "cursor moved to focus the element");
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the Roslyn source generator's auto-delegation is correct: every
|
||||
/// non-intercepted interface member forwards to the inner object verbatim, with
|
||||
/// arguments and return values passed through, exceptions propagated, and overloads
|
||||
/// distinguished.
|
||||
/// </summary>
|
||||
public class GeneratorDelegationTests
|
||||
{
|
||||
private static (HumanizedLocator human, FakeProxy rec) Locator()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
var (inner, rec) = Fake.Of<ILocator>();
|
||||
return (new HumanizedLocator(inner, new HumanCursor(page), new HumanConfig()), rec);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Methods delegate with arguments + return values intact.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Method_with_return_value_delegates()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("CountAsync", Task.FromResult(42));
|
||||
Assert.Equal(42, await human.CountAsync());
|
||||
Assert.True(rec.WasCalled("CountAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Method_arguments_are_forwarded_unchanged()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("GetAttributeAsync", Task.FromResult<string?>("yes"));
|
||||
await human.GetAttributeAsync("data-id");
|
||||
Assert.Equal("data-id", rec.Last("GetAttributeAsync")!.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Method_with_optional_options_delegates_without_throwing()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("InnerTextAsync", Task.FromResult("text"));
|
||||
// Call without supplying the optional options argument.
|
||||
Assert.Equal("text", await human.InnerTextAsync());
|
||||
Assert.True(rec.WasCalled("InnerTextAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_returning_method_delegates_with_type_intact()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("EvaluateAsync", Task.FromResult(123));
|
||||
// ILocator has a generic EvaluateAsync<T>; ensure delegation preserves T=int.
|
||||
int result = await human.EvaluateAsync<int>("el => 123");
|
||||
Assert.Equal(123, result);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Properties delegate.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Property_getter_delegates()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
// No handler registered: the getter still delegates and is recorded; the
|
||||
// generator returns the inner value (default null for IPage here).
|
||||
_ = human.Page;
|
||||
Assert.True(rec.WasCalled("Page"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exceptions from delegated members propagate unchanged (not swallowed).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_exception_propagates_with_same_type_and_message()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("InnerHTMLAsync", _ => throw new TimeoutException("timed out"));
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() => human.InnerHTMLAsync());
|
||||
Assert.Equal("timed out", ex.Message);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The marker attribute was generated into the consuming assembly.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Generated_marker_attribute_exists()
|
||||
{
|
||||
var attrType = typeof(HumanizedPage).Assembly
|
||||
.GetType("CloakBrowser.Wrappers.GenerateInterfaceDelegationAttribute");
|
||||
Assert.NotNull(attrType);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Every wrapper actually implements its full Playwright interface - proof
|
||||
// that the generator filled in all non-hand-written members (a missing
|
||||
// implementation would not even compile, but this asserts the contract too).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(HumanizedPage), typeof(IPage))]
|
||||
[InlineData(typeof(HumanizedLocator), typeof(ILocator))]
|
||||
[InlineData(typeof(HumanizedMouse), typeof(IMouse))]
|
||||
[InlineData(typeof(HumanizedKeyboard), typeof(IKeyboard))]
|
||||
[InlineData(typeof(HumanizedFrame), typeof(IFrame))]
|
||||
[InlineData(typeof(HumanizedElementHandle), typeof(IElementHandle))]
|
||||
[InlineData(typeof(HumanizedBrowser), typeof(IBrowser))]
|
||||
[InlineData(typeof(HumanizedBrowserContext), typeof(IBrowserContext))]
|
||||
public void Wrapper_implements_full_interface(System.Type wrapper, System.Type iface)
|
||||
{
|
||||
Assert.True(iface.IsAssignableFrom(wrapper),
|
||||
$"{wrapper.Name} must implement {iface.Name}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(HumanizedPage))]
|
||||
[InlineData(typeof(HumanizedLocator))]
|
||||
[InlineData(typeof(HumanizedMouse))]
|
||||
[InlineData(typeof(HumanizedKeyboard))]
|
||||
[InlineData(typeof(HumanizedFrame))]
|
||||
[InlineData(typeof(HumanizedElementHandle))]
|
||||
[InlineData(typeof(HumanizedBrowser))]
|
||||
[InlineData(typeof(HumanizedBrowserContext))]
|
||||
public void Wrapper_exposes_Original_and_Inner_escape_hatch(System.Type wrapper)
|
||||
{
|
||||
Assert.NotNull(wrapper.GetProperty("Original"));
|
||||
Assert.NotNull(wrapper.GetProperty("Inner"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Guard: NO interaction method may be left to the source generator to
|
||||
// delegate straight to raw Playwright (that would silently bypass
|
||||
// humanization). Previously enforced for Locator only; now covers every
|
||||
// interactive wrapper. A future overload that falls through to the
|
||||
// generator fails the build here.
|
||||
//
|
||||
// This is what caught the Frame.SelectOptionAsync / DragAndDropAsync and
|
||||
// ElementHandle.SelectOptionAsync / SetCheckedAsync gaps: the generator had
|
||||
// been forwarding those raw because no override was hand-written.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static bool IsGenerated(System.Reflection.MethodInfo m) =>
|
||||
m.GetCustomAttributes(typeof(System.CodeDom.Compiler.GeneratedCodeAttribute), false).Length > 0;
|
||||
|
||||
public static IEnumerable<object[]> InteractionMethodsByWrapper() => new[]
|
||||
{
|
||||
// HumanizedPage
|
||||
new object[] { typeof(HumanizedPage), "ClickAsync" },
|
||||
new object[] { typeof(HumanizedPage), "DblClickAsync" },
|
||||
new object[] { typeof(HumanizedPage), "HoverAsync" },
|
||||
new object[] { typeof(HumanizedPage), "TapAsync" },
|
||||
new object[] { typeof(HumanizedPage), "FillAsync" },
|
||||
new object[] { typeof(HumanizedPage), "TypeAsync" },
|
||||
new object[] { typeof(HumanizedPage), "PressAsync" },
|
||||
new object[] { typeof(HumanizedPage), "CheckAsync" },
|
||||
new object[] { typeof(HumanizedPage), "UncheckAsync" },
|
||||
new object[] { typeof(HumanizedPage), "SetCheckedAsync" },
|
||||
new object[] { typeof(HumanizedPage), "SelectOptionAsync" },
|
||||
new object[] { typeof(HumanizedPage), "DragAndDropAsync" },
|
||||
// HumanizedFrame
|
||||
new object[] { typeof(HumanizedFrame), "ClickAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "DblClickAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "HoverAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "TapAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "FillAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "TypeAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "PressAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "CheckAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "UncheckAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "SetCheckedAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "SelectOptionAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "DragAndDropAsync" },
|
||||
// HumanizedElementHandle (no DragAndDropAsync on IElementHandle)
|
||||
new object[] { typeof(HumanizedElementHandle), "ClickAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "DblClickAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "HoverAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "TapAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "FillAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "TypeAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "PressAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "CheckAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "UncheckAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "SetCheckedAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "SelectOptionAsync" },
|
||||
// HumanizedMouse
|
||||
new object[] { typeof(HumanizedMouse), "MoveAsync" },
|
||||
new object[] { typeof(HumanizedMouse), "ClickAsync" },
|
||||
new object[] { typeof(HumanizedMouse), "DblClickAsync" },
|
||||
new object[] { typeof(HumanizedMouse), "DownAsync" },
|
||||
new object[] { typeof(HumanizedMouse), "UpAsync" },
|
||||
new object[] { typeof(HumanizedMouse), "WheelAsync" },
|
||||
// HumanizedKeyboard
|
||||
new object[] { typeof(HumanizedKeyboard), "TypeAsync" },
|
||||
new object[] { typeof(HumanizedKeyboard), "PressAsync" },
|
||||
new object[] { typeof(HumanizedKeyboard), "InsertTextAsync" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InteractionMethodsByWrapper))]
|
||||
public void Interaction_method_is_humanized_not_generator_delegated(System.Type wrapper, string methodName)
|
||||
{
|
||||
var methods = wrapper
|
||||
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => m.Name == methodName)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(methods); // the method exists on the wrapper
|
||||
// EVERY overload of an interaction method must be hand-written, not
|
||||
// emitted by the generator (which would delegate straight to Playwright).
|
||||
Assert.All(methods, m =>
|
||||
Assert.False(IsGenerated(m),
|
||||
$"{wrapper.Name}.{methodName} must be humanized (hand-written), not generator-delegated"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the transparent <see cref="HumanizedLocator"/> decorator: humanized
|
||||
/// actions, nested locator re-wrapping (so chains stay humanized), correct delegation
|
||||
/// of non-interaction members, and exception/cancellation propagation.
|
||||
/// </summary>
|
||||
public class LocatorWrapperTests
|
||||
{
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0,
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
ClickAimDelayInput = (0, 0),
|
||||
ClickHoldInput = (0, 0),
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
InitialCursorX = (100, 100),
|
||||
InitialCursorY = (100, 100),
|
||||
};
|
||||
|
||||
private static (IPage page, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
|
||||
{
|
||||
var (mouse, mouseRec) = Fake.Of<IMouse>();
|
||||
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
|
||||
return (page, mouseRec, kbRec);
|
||||
}
|
||||
|
||||
private static (ILocator locator, FakeProxy locRec) BuildLocator(
|
||||
LocatorBoundingBoxResult? box = null, bool evaluateResult = false)
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
// .First returns itself so motion code resolving First works.
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
|
||||
box ?? new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
|
||||
locRec.On("ScrollIntoViewIfNeededAsync", Task.CompletedTask);
|
||||
// EvaluateAsync<bool> backs both IsInput and IsFocused checks. The wrapper
|
||||
// awaits a Task<bool>, so the handler must return a real Task<bool>.
|
||||
locRec.On("EvaluateAsync", Task.FromResult(evaluateResult));
|
||||
return (locator, locRec);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Interception
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClickAsync_runs_humanized_motion_and_press()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var (locator, _) = BuildLocator();
|
||||
var cursor = new HumanCursor(page);
|
||||
var human = new HumanizedLocator(locator, cursor, FastConfig());
|
||||
|
||||
await human.ClickAsync();
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move along a curve");
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
|
||||
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FillAsync_clears_then_types_via_keyboard()
|
||||
{
|
||||
var (page, _, kbRec) = BuildPage();
|
||||
var (locator, _) = BuildLocator();
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.FillAsync("hi");
|
||||
|
||||
// Select-all + Backspace + per-char typing all go through the page keyboard.
|
||||
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
|
||||
Assert.True(kbRec.CountOf("DownAsync") >= 2, "typed characters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectOptionAsync_runs_humanized_hover_then_delegates()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
IReadOnlyList<string> selected = new[] { "opt1" };
|
||||
locRec.On("SelectOptionAsync", Task.FromResult(selected));
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
var result = await human.SelectOptionAsync("opt1");
|
||||
|
||||
// The cursor must travel to the <select> along a curve before the native select.
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor to the element");
|
||||
// The real Playwright select is still performed (and its result flows back).
|
||||
Assert.True(locRec.WasCalled("SelectOptionAsync"));
|
||||
Assert.Equal(new[] { "opt1" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectOptionAsync_overloads_all_humanize_and_delegate()
|
||||
{
|
||||
IReadOnlyList<string> selected = new[] { "v" };
|
||||
|
||||
// Every ILocator SelectOptionAsync overload should hover (move) then delegate.
|
||||
async Task AssertOverload(System.Func<HumanizedLocator, Task> call)
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
locRec.On("SelectOptionAsync", Task.FromResult(selected));
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await call(human);
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor");
|
||||
Assert.True(locRec.WasCalled("SelectOptionAsync"), "should delegate to inner");
|
||||
}
|
||||
|
||||
var (handle, _) = Fake.Of<IElementHandle>();
|
||||
await AssertOverload(h => h.SelectOptionAsync("v"));
|
||||
await AssertOverload(h => h.SelectOptionAsync(new[] { "v" }));
|
||||
await AssertOverload(h => h.SelectOptionAsync(new SelectOptionValue { Value = "v" }));
|
||||
await AssertOverload(h => h.SelectOptionAsync(new[] { new SelectOptionValue { Value = "v" } }));
|
||||
await AssertOverload(h => h.SelectOptionAsync(handle));
|
||||
await AssertOverload(h => h.SelectOptionAsync(new[] { handle }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_focuses_selects_all_and_backspaces()
|
||||
{
|
||||
var (page, mouseRec, kbRec) = BuildPage();
|
||||
var (locator, _) = BuildLocator(); // EvaluateAsync(IsFocused) -> false
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.ClearAsync();
|
||||
|
||||
// Not focused -> humanized click to focus the field (curve + press).
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor to focus");
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
|
||||
// Then select-all + Backspace via the keyboard (NOT an instant value reset).
|
||||
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearAsync_when_focused_skips_click_but_still_selects_and_deletes()
|
||||
{
|
||||
var (page, mouseRec, kbRec) = BuildPage();
|
||||
// Field is already focused -> no humanized click, just select-all + backspace.
|
||||
var (locator, _) = BuildLocator(evaluateResult: true);
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.ClearAsync();
|
||||
|
||||
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no focus click needed
|
||||
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_clicks_only_when_not_already_checked()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
locRec.On("IsCheckedAsync", Task.FromResult(true)); // already checked
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.CheckAsync();
|
||||
|
||||
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no click performed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_clicks_when_unchecked()
|
||||
{
|
||||
var (page, mouseRec, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
locRec.On("IsCheckedAsync", Task.FromResult(false));
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await human.CheckAsync();
|
||||
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync")); // click performed
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Nested re-wrapping: locator-returning members return humanized locators.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Nested_locator_members_return_wrapped_locators()
|
||||
{
|
||||
var (page, _, _) = BuildPage();
|
||||
var (inner, innerRec) = Fake.Of<ILocator>();
|
||||
var (child, _) = Fake.Of<ILocator>();
|
||||
innerRec.On("First", child);
|
||||
innerRec.On("Last", child);
|
||||
innerRec.On("Nth", child);
|
||||
innerRec.On("Locator", child);
|
||||
innerRec.On("GetByTestId", child);
|
||||
innerRec.On("GetByText", child);
|
||||
|
||||
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
|
||||
|
||||
Assert.IsType<HumanizedLocator>(human.First);
|
||||
Assert.IsType<HumanizedLocator>(human.Last);
|
||||
Assert.IsType<HumanizedLocator>(human.Nth(0));
|
||||
Assert.IsType<HumanizedLocator>(human.Locator("a"));
|
||||
Assert.IsType<HumanizedLocator>(human.GetByTestId("t"));
|
||||
Assert.IsType<HumanizedLocator>(human.GetByText("x"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delegation: non-interaction members forward to the inner locator.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Query_members_delegate_to_inner()
|
||||
{
|
||||
var (page, _, _) = BuildPage();
|
||||
var (inner, innerRec) = Fake.Of<ILocator>();
|
||||
innerRec.On("CountAsync", Task.FromResult(7));
|
||||
innerRec.On("TextContentAsync", Task.FromResult<string?>("hello"));
|
||||
innerRec.On("IsVisibleAsync", Task.FromResult(true));
|
||||
|
||||
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
|
||||
|
||||
Assert.Equal(7, await human.CountAsync());
|
||||
Assert.Equal("hello", await human.TextContentAsync());
|
||||
Assert.True(await human.IsVisibleAsync());
|
||||
Assert.True(innerRec.WasCalled("CountAsync"));
|
||||
Assert.True(innerRec.WasCalled("TextContentAsync"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Completeness - port of Python test_locator_methods_patched.
|
||||
// Every interaction method must be hand-written (humanized/intercepted),
|
||||
// NOT left to the source generator to delegate straight to Playwright.
|
||||
// The generator marks the members it emits with [GeneratedCode]; an
|
||||
// intercepted method carries no such marker.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static IEnumerable<object[]> InteractionMethodNames() => new[]
|
||||
{
|
||||
new object[] { "ClickAsync" },
|
||||
new object[] { "DblClickAsync" },
|
||||
new object[] { "HoverAsync" },
|
||||
new object[] { "TapAsync" },
|
||||
new object[] { "FillAsync" },
|
||||
new object[] { "TypeAsync" },
|
||||
new object[] { "PressSequentiallyAsync" },
|
||||
new object[] { "PressAsync" },
|
||||
new object[] { "CheckAsync" },
|
||||
new object[] { "UncheckAsync" },
|
||||
new object[] { "SetCheckedAsync" },
|
||||
new object[] { "DragToAsync" },
|
||||
new object[] { "SelectOptionAsync" },
|
||||
new object[] { "ClearAsync" },
|
||||
};
|
||||
|
||||
private static bool IsGenerated(System.Reflection.MethodInfo m) =>
|
||||
m.GetCustomAttributes(typeof(System.CodeDom.Compiler.GeneratedCodeAttribute), false).Length > 0;
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InteractionMethodNames))]
|
||||
public void Interaction_method_is_humanized_not_generator_delegated(string methodName)
|
||||
{
|
||||
var methods = typeof(HumanizedLocator)
|
||||
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => m.Name == methodName)
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(methods); // the method exists on the wrapper
|
||||
// EVERY overload of an interaction method must be hand-written.
|
||||
Assert.All(methods, m =>
|
||||
Assert.False(IsGenerated(m),
|
||||
$"{methodName} must be humanized (hand-written), not generator-delegated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_fourteen_interaction_methods_are_present_and_humanized()
|
||||
{
|
||||
var humanizedNames = typeof(HumanizedLocator)
|
||||
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => !IsGenerated(m))
|
||||
.Select(m => m.Name)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var row in InteractionMethodNames())
|
||||
Assert.Contains((string)row[0], humanizedNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_interaction_member_is_generator_delegated()
|
||||
{
|
||||
// Sanity check that the [GeneratedCode] discriminator actually works:
|
||||
// a pure query like CountAsync is delegated by the generator.
|
||||
var count = typeof(HumanizedLocator).GetMethod("CountAsync");
|
||||
Assert.NotNull(count);
|
||||
Assert.True(IsGenerated(count!), "CountAsync should be generator-delegated");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Escape hatch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Original_and_Inner_expose_unwrapped_locator()
|
||||
{
|
||||
var (page, _, _) = BuildPage();
|
||||
var (inner, _) = Fake.Of<ILocator>();
|
||||
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
|
||||
Assert.Same(inner, human.Original);
|
||||
Assert.Same(inner, human.Inner);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exception & cancellation propagation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Inner_exception_during_action_propagates()
|
||||
{
|
||||
var (page, _, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
locRec.On("ScrollIntoViewIfNeededAsync", _ => throw new PlaywrightException("detached"));
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await Assert.ThrowsAsync<PlaywrightException>(() => human.ClickAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_as_OperationCanceled()
|
||||
{
|
||||
var (page, _, _) = BuildPage();
|
||||
var (locator, locRec) = BuildLocator();
|
||||
locRec.On("ScrollIntoViewIfNeededAsync", _ => throw new OperationCanceledException());
|
||||
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => human.ClickAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the transparent <see cref="HumanizedMouse"/> / <see cref="HumanizedKeyboard"/>
|
||||
/// decorators: intercepted methods run humanize logic (multiple inner calls), and the
|
||||
/// escape-hatch exposes the original object.
|
||||
/// </summary>
|
||||
public class MouseKeyboardWrapperTests
|
||||
{
|
||||
// A fast config: no idle, minimal motion steps, tiny ranges, so tests run quickly.
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstSize = (5, 5),
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0, // deterministic: no overshoot
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
ClickAimDelayInput = (0, 0),
|
||||
ClickHoldInput = (0, 0),
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
InitialCursorX = (100, 100),
|
||||
InitialCursorY = (100, 100),
|
||||
};
|
||||
|
||||
private static (IPage page, FakeProxy pageRec, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
|
||||
{
|
||||
var (mouse, mouseRec) = Fake.Of<IMouse>();
|
||||
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
|
||||
return (page, pageRec, mouseRec, kbRec);
|
||||
}
|
||||
|
||||
private static HumanCursor MakeCursor(IPage page)
|
||||
{
|
||||
var cursor = new HumanCursor(page);
|
||||
// Don't call InitStealthAsync (no CDP in a fake) - typing falls back to evaluate.
|
||||
return cursor;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Interception: humanized methods produce multiple low-level inner calls.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Mouse_MoveAsync_is_humanized_into_multiple_inner_moves()
|
||||
{
|
||||
var (page, _, mouseRec, _) = BuildPage();
|
||||
var cfg = FastConfig();
|
||||
var cursor = MakeCursor(page);
|
||||
var mouse = new HumanizedMouse(page.Mouse, cursor, cfg);
|
||||
|
||||
await mouse.MoveAsync(500, 400);
|
||||
|
||||
// Bezier motion => more than one inner MoveAsync (the single-call API became many).
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") > 1,
|
||||
$"expected humanized multi-step move, got {mouseRec.CountOf("MoveAsync")}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mouse_ClickAsync_moves_then_presses()
|
||||
{
|
||||
var (page, _, mouseRec, _) = BuildPage();
|
||||
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
|
||||
|
||||
await mouse.ClickAsync(300, 300);
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1);
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
|
||||
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
|
||||
// Down must come before Up (correct ordering preserved).
|
||||
int down = mouseRec.CallNames.ToList().IndexOf("DownAsync");
|
||||
int up = mouseRec.CallNames.ToList().IndexOf("UpAsync");
|
||||
Assert.True(down < up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mouse_WheelAsync_is_chunked_into_multiple_inner_wheels()
|
||||
{
|
||||
var (page, _, mouseRec, _) = BuildPage();
|
||||
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
|
||||
|
||||
await mouse.WheelAsync(0, 300);
|
||||
|
||||
Assert.True(mouseRec.CountOf("WheelAsync") > 1,
|
||||
"wheel should be broken into small inertia bursts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task Mouse_Original_and_Inner_expose_the_unwrapped_object()
|
||||
{
|
||||
var (page, _, _, _) = BuildPage();
|
||||
var inner = page.Mouse;
|
||||
var mouse = new HumanizedMouse(inner, MakeCursor(page), FastConfig());
|
||||
Assert.Same(inner, mouse.Original);
|
||||
Assert.Same(inner, mouse.Inner);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delegation: non-intercepted members forward verbatim to the inner object.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Keyboard_DownAsync_UpAsync_delegate_to_inner()
|
||||
{
|
||||
var (page, _, _, kbRec) = BuildPage();
|
||||
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
|
||||
|
||||
await kb.DownAsync("Shift");
|
||||
await kb.UpAsync("Shift");
|
||||
|
||||
Assert.Equal(1, kbRec.CountOf("DownAsync"));
|
||||
Assert.Equal(1, kbRec.CountOf("UpAsync"));
|
||||
Assert.Equal("Shift", kbRec.Last("DownAsync")!.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Keyboard_TypeAsync_produces_per_character_inner_key_events()
|
||||
{
|
||||
var (page, _, _, kbRec) = BuildPage();
|
||||
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
|
||||
|
||||
await kb.TypeAsync("abc");
|
||||
|
||||
// Human typing presses each key down+up individually (not one inner TypeAsync).
|
||||
Assert.Equal(0, kbRec.CountOf("TypeAsync"));
|
||||
Assert.True(kbRec.CountOf("DownAsync") >= 3, "expected per-char key downs");
|
||||
Assert.True(kbRec.CountOf("UpAsync") >= 3, "expected per-char key ups");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task Keyboard_Original_and_Inner_expose_the_unwrapped_object()
|
||||
{
|
||||
var (page, _, _, _) = BuildPage();
|
||||
var inner = page.Keyboard;
|
||||
var kb = new HumanizedKeyboard(inner, MakeCursor(page), FastConfig());
|
||||
Assert.Same(inner, kb.Original);
|
||||
Assert.Same(inner, kb.Inner);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exception propagation: inner failures bubble up unchanged (not swallowed).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Mouse_inner_exception_propagates()
|
||||
{
|
||||
var (page, _, mouseRec, _) = BuildPage();
|
||||
mouseRec.On("MoveAsync", _ => throw new InvalidOperationException("boom"));
|
||||
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => mouse.ClickAsync(200, 200));
|
||||
Assert.Equal("boom", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Keyboard_inner_exception_propagates()
|
||||
{
|
||||
var (page, _, _, kbRec) = BuildPage();
|
||||
kbRec.On("DownAsync", _ => throw new PlaywrightException("kb fail"));
|
||||
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
|
||||
|
||||
await Assert.ThrowsAsync<PlaywrightException>(() => kb.TypeAsync("a"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the transparent <see cref="HumanizedPage"/> decorator: nested objects
|
||||
/// (Mouse/Keyboard/Locator/Frame) are returned wrapped, selector actions run through
|
||||
/// the humanize engine, non-interaction members delegate, and the escape hatch works.
|
||||
/// </summary>
|
||||
public class PageWrapperTests
|
||||
{
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0,
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
ClickAimDelayInput = (0, 0),
|
||||
ClickHoldInput = (0, 0),
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
InitialCursorX = (100, 100),
|
||||
InitialCursorY = (100, 100),
|
||||
};
|
||||
|
||||
/// <summary>Build a fake page whose Locator(...) returns an actionable fake locator.</summary>
|
||||
private static (HumanizedPage human, FakeProxy pageRec, FakeProxy mouseRec) BuildHumanizedPage()
|
||||
{
|
||||
var (mouse, mouseRec) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
|
||||
new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
|
||||
locRec.On("IsVisibleAsync", Task.FromResult(true));
|
||||
locRec.On("IsEnabledAsync", Task.FromResult(true));
|
||||
locRec.On("IsEditableAsync", Task.FromResult(true));
|
||||
locRec.On("WaitForAsync", Task.CompletedTask);
|
||||
locRec.On("EvaluateAsync", Task.FromResult(
|
||||
System.Text.Json.JsonSerializer.SerializeToElement(new { hit = true })));
|
||||
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
|
||||
pageRec.On("Locator", locator);
|
||||
pageRec.On("EvaluateAsync", Task.FromResult(
|
||||
System.Text.Json.JsonSerializer.SerializeToElement(false)));
|
||||
|
||||
var cursor = new HumanCursor(page);
|
||||
var human = new HumanizedPage(page, cursor, FastConfig());
|
||||
return (human, pageRec, mouseRec);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Nested objects are wrapped
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Mouse_and_Keyboard_are_humanized_wrappers()
|
||||
{
|
||||
var (human, _, _) = BuildHumanizedPage();
|
||||
Assert.IsType<HumanizedMouse>(human.Mouse);
|
||||
Assert.IsType<HumanizedKeyboard>(human.Keyboard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Locator_and_GetBy_return_humanized_locators()
|
||||
{
|
||||
var (human, _, _) = BuildHumanizedPage();
|
||||
Assert.IsType<HumanizedLocator>(human.Locator("#a"));
|
||||
Assert.IsType<HumanizedLocator>(human.GetByTestId("t"));
|
||||
Assert.IsType<HumanizedLocator>(human.GetByText("x"));
|
||||
Assert.IsType<HumanizedLocator>(human.GetByRole(AriaRole.Button));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainFrame_and_Frames_return_humanized_frames()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (frame, _) = Fake.Of<IFrame>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("MainFrame", frame);
|
||||
pageRec.On("Frames", new List<IFrame> { frame });
|
||||
|
||||
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
|
||||
|
||||
Assert.IsType<HumanizedFrame>(human.MainFrame);
|
||||
Assert.All(human.Frames, f => Assert.IsType<HumanizedFrame>(f));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Selector action interception drives the humanize engine.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClickAsync_selector_runs_humanized_motion()
|
||||
{
|
||||
var (human, _, mouseRec) = BuildHumanizedPage();
|
||||
|
||||
await human.ClickAsync("#submit");
|
||||
|
||||
Assert.True(mouseRec.CountOf("MoveAsync") >= 1);
|
||||
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
|
||||
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delegation: non-interaction members forward to the inner page.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task TitleAsync_and_Url_delegate_to_inner()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("TitleAsync", Task.FromResult("Example Domain"));
|
||||
pageRec.On("Url", "https://example.com/");
|
||||
|
||||
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
|
||||
|
||||
Assert.Equal("Example Domain", await human.TitleAsync());
|
||||
Assert.Equal("https://example.com/", human.Url);
|
||||
Assert.True(pageRec.WasCalled("TitleAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GotoAsync_delegates_and_returns_inner_response()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (response, _) = Fake.Of<IResponse>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("GotoAsync", Task.FromResult<IResponse?>(response));
|
||||
|
||||
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
|
||||
|
||||
var result = await human.GotoAsync("https://example.com");
|
||||
Assert.Same(response, result);
|
||||
Assert.True(pageRec.WasCalled("GotoAsync"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Escape hatch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Original_and_Inner_expose_unwrapped_page()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
|
||||
Assert.Same(page, human.Original);
|
||||
Assert.Same(page, human.Inner);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exception propagation through a delegated member.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_member_exception_propagates()
|
||||
{
|
||||
var (mouse, _) = Fake.Of<IMouse>();
|
||||
var (keyboard, _) = Fake.Of<IKeyboard>();
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Mouse", mouse);
|
||||
pageRec.On("Keyboard", keyboard);
|
||||
pageRec.On("ContentAsync", _ => throw new PlaywrightException("closed"));
|
||||
|
||||
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
|
||||
|
||||
await Assert.ThrowsAsync<PlaywrightException>(() => human.ContentAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user