# DGT Automation — protocol & method reference

Trình duyệt DGT có sẵn một **automation server** chạy trên TCP loopback. Đây là kênh mà trang
Automation và scheduler trong app đang dùng, **không phải** Selenium/WebDriver/CDP. Tài liệu này
mô tả cách kết nối và toàn bộ method server hỗ trợ, để bạn điều khiển trình duyệt từ ứng dụng
của mình.

Project mẫu chạy được ngay: [`TSTAutomation.Sample`](../TSTAutomation.Sample) —
`dotnet run --project TSTAutomation.Sample -- --profile <PROFILE_ID>`.

---

## 1. Mở trình duyệt và lấy cổng

Automation server chỉ bật khi profile được launch kèm block `automation` trong fingerprint. Từ
ngoài, gọi local HTTP API của app (mặc định `127.0.0.1:12345`, đổi được ở trang API):

```bash
curl -X POST http://127.0.0.1:12345/api/profiles/PROFILE_ID/start_automation \
     -H "Content-Type: application/json" -d "{}"
```

Body (tất cả optional):

| Field  | Kiểu     | Ý nghĩa |
|--------|----------|---------|
| `port` | int      | Ghim cổng automation. Bỏ trống = app tự chọn cổng trống trong 9333–9999. |
| `args` | string[] | Argument thêm vào dòng lệnh trình duyệt, vd `["--width=1280"]`. |

Response `200`:

```json
{
  "success": true,
  "profileId": "abc123",
  "browser": "firefox",
  "protocol": "dgt-automation",
  "host": "127.0.0.1",
  "port": 9333
}
```

API **chỉ trả về sau khi cổng thật sự nhận kết nối** (deadline 180s — lần đầu launch có thể phải
tải cả bộ trình duyệt), nên connect được ngay sau khi nhận response.

| Status | Ý nghĩa |
|--------|---------|
| `200` | Sẵn sàng. Có thêm `alreadyRunning: true` nếu profile đã được mở bởi một lần `start_automation` trước đó — `port` khi đó là cổng của phiên đang chạy. |
| `404 profile_not_found` | Sai id. |
| `409 already_running` | Profile đang mở nhưng **không có** automation server (vd bấm Start trong UI). Đóng nó rồi gọi lại. |
| `500` | Launch lỗi, `error` là thông điệp từ luồng launch. |
| `504 timeout_waiting_browser_ready` | Quá 180s mà cổng chưa lên. |
| `503 not_ready` | App vừa khởi động, handler chưa wire xong. |

Đóng trình duyệt: `POST /api/profiles/{id}/stop` (idempotent).

> Cần Selenium/Puppeteer/Playwright thay vì kênh này? Dùng `POST /api/profiles/{id}/start` —
> nó trả về CDP `webSocketDebuggerUrl` (chromium) hoặc cổng Marionette (Firefox).

---

## 2. Wire protocol

TCP, **line-delimited JSON** (UTF-8, không BOM, mỗi message một dòng kết thúc bằng `\n`).

Request:

```json
{"id":1,"method":"navigate","params":{"url":"https://example.com","as":"bookmark"}}
```

Response — khớp theo `id`, có thể về **không theo thứ tự** nên phải map bằng `id`, đừng giả định
FIFO:

```json
{"id":1,"result":{...}}
{"id":1,"error":{"message":"element not found: #foo"}}
```

Event (push, **không có `id`**):

```json
{"event":"dialog","data":{...}}
```

Client C# có sẵn: [`DgtAutomationClient`](../TSTAutomation.Core/Network/DgtAutomationClient.cs)
(implement [`IBrowserClient`](../TSTAutomation.Core/Network/IBrowserClient.cs)) — tự sinh `id`,
map response, expose `EventReceived`. `CallAsync` trả thẳng phần `result`, và ném
`BrowserAutomationException` khi server trả `error`.

```csharp
await using var client = new DgtAutomationClient();
await client.ConnectAsync("127.0.0.1", port);
await client.CallAsync("navigate", new { url = "https://example.com", @as = "bookmark" });
var text = (await client.CallAsync("getText", new { selector = "h1" }))?["text"]?.ToString();
```

Muốn client độc lập không kéo theo `TSTAutomation.Core`: copy 2 file trên sang project của bạn,
hoặc tự viết — protocol chỉ có vậy.

---

## 3. Method reference

`selector` ở mọi method là **CSS**. Server **không** hiểu XPath: muốn dùng XPath thì `evaluate`
một đoạn JS đánh dấu element (vd set `data-x="1"`) rồi thao tác qua attribute selector — đúng
cách `BrowserActionBase.ResolveSelectorAsync` trong Core đang làm.

Cột "Trả về" mô tả nội dung `result`; `—` nghĩa là không có gì đáng đọc.

### Điều hướng

| Method | Params | Trả về |
|--------|--------|--------|
| `navigate` | `url`, `as` (`typed` \| `bookmark` \| `link`, ảnh hưởng referrer/transition) | — |
| `getUrl` | — | `{url}` — URL tab hiện tại (top-level, không theo frame đang switch) |

`navigate` trả về ngay khi lệnh được nhận, **không đợi trang load**. Poll
`document.readyState` qua `evaluate` (xem `BrowserOps.WaitForReadyAsync` trong sample) — và
delay ~2s trước khi poll, nếu không bạn đang đọc readyState của trang **cũ**.

### Element

| Method | Params | Trả về |
|--------|--------|--------|
| `waitForSelector` | `selector`, `timeoutMs` | — (lỗi khi hết timeout) |
| `elementState` | `selector` | `{exists, visible}` — không ném khi element không tồn tại |
| `getText` | `selector` | `{text}` — innerText |
| `getAttribute` | `selector`, `name` | `{value}` — attribute trong markup |
| `getProperty` | `selector`, `name` | `{value}` — property DOM (runtime) |
| `getBoundingBox` | `selector` | `{x, y, width, height}` — toạ độ viewport |
| `click` | `selector`, `button` (0/1/2), `clickCount` | — |
| `clear` | `selector` | — |
| `type` | `text`, `selector?`, `append?`, `instant?`, `charDelayMs?`, `typoRate?`, `pauseRate?` | `{typed, typos}` |

**`getAttribute` vs `getProperty`**: đọc nội dung vừa gõ vào ô input phải dùng
`getProperty(name: "value")`. `getAttribute("value")` chỉ trả giá trị khởi tạo ghi trong HTML.

**`type`**: bỏ trống `selector` = gõ vào `document.activeElement` (chain sau một `click`).
`instant: true` dán một nhát; mặc định gõ từng phím với timing người. Chỉ gửi các key bạn thật
sự cần — browser có default riêng cho `charDelayMs`/`typoRate`/`pauseRate`, gửi `0` là **ghi đè**
default đó thành "không delay". `typoRate` (0–1) = xác suất gõ sai rồi tự xoá gõ lại.

### Chuột (toạ độ thật, có đường di chuyển)

| Method | Params | Trả về |
|--------|--------|--------|
| `mouseMove` | `x`, `y`, `moveSteps`, `moveMs` | — |
| `mouseClick` | `x`, `y`, `button`, `clickCount`, `moveSteps`, `moveMs` — hoặc `useCurrent: true` để click tại chỗ con trỏ đang đứng | — |
| `mouseScroll` | `deltaY`, `deltaX?`, `selector?` \| `x?`,`y?`, `steps?`, `tickSize?`, `stepMs?`, `moveSteps?`, `moveMs?`, `tabId?` | — |

`click` qua selector là đủ cho hầu hết việc. Nhóm `mouse*` sinh đường chuột thật (`moveSteps`
chặng trong `moveMs` ms) — dùng khi trang soi hành vi chuột. `mouseScroll` có `selector` thì
lăn trong đúng container đó thay vì cả trang; `deltaY` dương = xuống.

### JavaScript

| Method | Params | Trả về |
|--------|--------|--------|
| `evaluate` | `script`, `world` (`isolated` mặc định \| `main`) | `{result}` |

**Quy tắc bắt buộc**: `script` phải là **bare expression** — không mở đầu bằng `return`, không
kết thúc bằng `;`. Browser tự wrap `return` cho expression; thêm `;` biến nó thành statement và
kết quả bị nuốt thành `undefined`. Nhiều dòng thì bọc IIFE:

```js
(function(){ var el = document.querySelector('#x'); return el ? el.textContent : null })()
```

`world`: `isolated` tách khỏi JS của trang (mặc định, an toàn); `main` dùng chung global với
trang — cần khi phải đọc biến do site tạo ra.

### Frame

| Method | Params | Trả về |
|--------|--------|--------|
| `switchToFrame` | `selector` **hoặc** `index` | `{id}` |
| `switchToParentFrame` | — | `{id}` |
| `switchToTopFrame` | — | `{id}` |

Sau khi switch, mọi lệnh element/evaluate chạy trong frame đó cho tới lần switch tiếp theo.
Riêng `getUrl` luôn là top-level.

### Cookie

| Method | Params | Trả về |
|--------|--------|--------|
| `getCookies` | `url?` (bỏ trống = toàn bộ jar) | mảng cookie |
| `setCookie` | `cookies: [...]` | `{added, errors[]}` |
| `deleteAllCookies` | `url?` (bỏ trống = xoá tất cả) | — |

Shape cookie của server theo **Firefox internal**, không phải Playwright:

```json
{"host":".example.com","name":"sid","value":"...","path":"/",
 "isSecure":true,"isHttpOnly":true,"isSession":false,"expiry":1767225600,
 "sameSite":"Lax"}
```

`host` (không phải `domain`), `isSecure`/`isHttpOnly` (không phải `secure`/`httpOnly`), `expiry`
là **unix seconds** (không phải ms). `sameSite` là string `None`/`Lax`/`Strict` — truyền số kiểu
enum Firefox sẽ bị bỏ qua âm thầm. Cookie thiếu `expiry` thành session cookie, mất khi đóng
trình duyệt. `SetCookieAction`/`GetCookieAction` trong Core có sẵn phần dịch qua lại với format
Playwright/Cookie-Editor nếu bạn cần.

### File, clipboard, ảnh, cửa sổ

| Method | Params | Trả về |
|--------|--------|--------|
| `screenshot` | — | `{image}` — PNG base64, vùng viewport |
| `setClipboard` | `text` | — |
| `uploadFile` | `selector` (element bấm để mở picker), `filePaths[]`, `timeoutMs` | `{count}` |
| `dragDropFile` | `selector`, `filePath`, `mimeType?`, `holdMs` | `{size}` |
| `getWindowRect` | — | `{innerWidth, innerHeight, ...}` |
| `setWindowRect` | `x`, `y`, `width`, `height` | — |

`uploadFile` hook vào file picker: nó **click** `selector` rồi bơm đường dẫn vào hộp thoại vừa
mở — selector là nút/label mở picker, không phải `input[type=file]`. `dragDropFile` thì giả lập
kéo-thả file vào element (dropzone), không đụng tới picker.

`setWindowRect` tin cậy hơn `SetWindowPos` ở tầng OS vì browser tự xử lý trong message loop
của nó.

---

## 4. Lỗi và tình huống hay gặp

**`error.message` phổ biến:**

- `element not found: <selector>` — hết `timeoutMs` hoặc element không có vùng hiển thị
  (width/height = 0).
- `Actor destroyed` / lỗi khi navigate giữa chừng — `evaluate`, `click`, `type` fail vì trang
  vừa điều hướng và JS actor bị tear-down. Thường thao tác **đã có hiệu lực** (chính nó gây
  navigation). Core xử lý bằng cách hạ xuống warning và chạy tiếp; nên bắt riêng thay vì coi là
  fatal.
- `xpath not found` — do phía client tự poll, không phải từ server.

**Kết nối đứt** (đóng trình duyệt, crash): mọi call đang chờ bị fail bằng
`IOException("Connection closed")`. `DgtAutomationClient` không tự reconnect — tạo client mới và
gọi lại `start_automation`.

**Đừng dùng chung một cổng cho 2 profile.** Cấp phát dùng chung với scheduler qua
`WorkflowRunner.NextPort()` (rotate 9333–9999 và bỏ qua cổng đang có người listen), nên bỏ trống
`port` là an toàn nhất; chỉ ghim khi bạn tự quản lý dải cổng.

**Một profile = một kết nối tại một thời điểm.** Chạy campaign trong app trên đúng profile đó
song song với client ngoài sẽ tranh nhau điều khiển.
