# Workflow Automation — Hướng dẫn viết workflow

Tài liệu dành cho **người viết workflow** (end-user dev). Phạm vi: cú pháp template,
biến hệ thống, control flow, và tham chiếu nhanh từng action. Không bao gồm hướng dẫn
extend engine hoặc viết action mới.

---

## 1. Tổng quan

Workflow là một chuỗi **action** được thực thi tuần tự theo profile. Engine sống trong
`TSTAutomation.Core`, được TSTProfileManager gọi khi user "Run" workflow cho 1 profile
(hoặc 1 campaign nhiều profile chạy song song).

Vòng đời 1 lần chạy:

1. Runner mở browser của profile (nếu workflow có browser action).
2. Tạo `ExecutionContext` với:
   - `Vars["campaign"]`, `Vars["profile"]`, `Vars["account"]`, `Vars["globals"]` — bind từ
     entity DB/runtime.
   - `Vars["profile_dir"]` — đường dẫn thư mục dữ liệu on-disk của profile đang chạy
     (`{{profile_dir}}`), dùng cho `delete_path` / `write_file` nhắm vào thư mục profile.
   - Browser client (TCP) trỏ vào browser vừa mở.
3. Chạy action lần lượt. Mỗi action `Resolve()` các `{{var}}` trong param của mình, rồi
   gọi API browser / hệ thống.
4. Kết thúc khi: hết action, gặp `Return`, throw không có `Try` bắt, hoặc user cancel.

Workflow lưu dưới dạng JSON (qua `WorkflowSerializer`). UI editor chỉ là wrapper —
mọi thứ chạy được bằng tay đều có thể edit JSON trực tiếp.

---

## 2. Template `{{...}}`

Hầu hết param kiểu `Expression` / `MultilineString` đều hỗ trợ template. Engine sẽ thay
thế `{{...}}` bằng giá trị biến trước khi action chạy.

### Cú pháp

| Mẫu | Ý nghĩa | Ví dụ |
|---|---|---|
| `{{var}}` | Đọc biến đơn | `{{username}}` |
| `{{obj.prop}}` | Property hoặc key của dict | `{{profile.Name}}`, `{{response.user.email}}` |
| `{{list[0]}}` | Index literal (0-based) | `{{lines[0]}}` |
| `{{list[-1]}}` | Index âm tính từ cuối (Python-style) | `{{lines[-1]}}` — phần tử cuối |
| `{{list[i]}}` | Index từ một biến khác | `{{items[idx]}}` |
| `{{obj.items[2].name}}` | Chain dot + bracket | `{{response.users[0].id}}` |

Quy tắc:

- Tên biến: `[A-Za-z_][A-Za-z0-9_]*`. Không có space.
- Identifier trong `[...]` luôn lookup từ **root scope** (`Vars`), không bám theo
  `obj` đang chain — `{{list[i]}}` thì `i` là biến độc lập, không phải property của `list`.
- Biến index phải convert được sang `int` (`int`/`long`/`double`/`float`/`decimal`/`bool` hoặc
  string parse được). Không hợp lệ → segment trả `null` → token render thành chuỗi rỗng.
- Index ngoài range → `null` → chuỗi rỗng, không throw.
- Không hỗ trợ nested template `{{a[{{i}}]}}`. Dùng `{{a[i]}}`.
- Không hỗ trợ biểu thức `{{a+1}}` hay `{{a > b}}`. Cần tính toán phải dùng action
  `Math` / `If`.

### Khi nào template được resolve

- **Mỗi lần action chạy** — không cache. Vd `{{i}}` trong vòng `While` đọc lại mỗi iteration.
- Param số (`Number`) và `Bool` thường **không** resolve template (giá trị literal đã được
  bind sẵn từ JSON). Khi muốn dynamic, dùng action có param `Expression` (vd `Math` cho
  số, `If` cho điều kiện).
  - **Ngoại lệ:** vài param "số nhưng kiểu Expression" vẫn nhận `{{var}}` rồi parse lúc chạy —
    `Repeat → Times`, `Scroll up/down → Pixels`. Các param số khác của scroll
    (`Min/Max step`, `Min/Max delay`, `Overshoot %`) vẫn phải là literal.
- Action `Set variable` / `Set global` resolve `Value` rồi gán vào `Name` — `Name` luôn là
  tên biến literal, không phải expression.

---

## 3. Biến hệ thống

Engine không tạo các biến này nếu workflow chạy đơn lẻ, nhưng khi chạy qua TSTProfileManager
runner thì luôn có:

| Biến | Kiểu | Nội dung |
|---|---|---|
| `campaign` | object | Thông tin campaign (Name, AccountType, ...). Vd `{{campaign.Name}}`. |
| `profile` | object | Profile entity. Vd `{{profile.Name}}`, `{{profile.Config.Host}}`, `{{profile.Config.Browser}}`. |
| `account` | object | Account bind vào profile (nếu có). Vd `{{account.Username}}`, `{{account.Password}}`. |
| `globals` | dict | Dictionary share **by reference** giữa các function call. Set qua `Set global` action. |
| `inputs` | dict | Tham số người dùng nhập khi tạo Campaign (khai báo ở `Inputs` của workflow). Vd `{{inputs.keyword}}`. |
| `profile_dir` | string | Đường dẫn thư mục data on-disk của profile đang chạy. Vd `{{profile_dir}}\\keywords.txt`. |

Các biến này **tự được kế thừa** vào scope của function con (xem mục 4.6) mà không cần
khai báo `Arguments`. User-defined vars **không** được inherit.

> **Inputs (tham số campaign):** workflow có thể khai báo `Inputs` (Text / Number / Bool /
> Select / MultiSelect / File…) để người chạy nhập khi tạo Campaign, đọc lại bằng
> `{{inputs.<Name>}}`. Chi tiết format JSON xem `workflow-script-guide.md` mục 3.

### Phân biệt Firefox / Chrome

Loại trình duyệt của profile đang chạy đọc từ `{{profile.Config.Browser}}` — giá trị là id
**lowercase**: `"firefox"` hoặc `"chrome"` (`"edge"` dành sẵn, chưa dùng). Lưu ý `==` của
`If` so sánh **case-sensitive** (Ordinal), nên phải viết đúng chữ thường.

Branch bằng `If` khi hai browser cần step khác nhau:

```
If {{profile.Config.Browser}} == chrome
  Then: <step cho Chrome>
  Else: <step cho Firefox>
```

Hoặc nhúng thẳng vào `Script` của `Evaluate JavaScript` (param `Script` là MultilineString
nên vẫn resolve template) khi chỉ khác vài dòng JS:

```
(function(){
  var isFirefox = "{{profile.Config.Browser}}" === "firefox";
  return isFirefox ? doFirefoxWay() : doChromeWay();
})()
```

### Biến được engine/action ghi tự động

| Biến | Khi nào set | Dùng để |
|---|---|---|
| `last_error` | Khi gặp lỗi `selector not found` hoặc nhánh `Catch` của `Try/Catch` chạy | Đọc message lỗi gần nhất |
| `workflow_status` | Khi `Return` chạy ở main scope | `"success"` / `"failed"` |
| `workflow_result` | Khi `Return` chạy ở main scope | Giá trị bạn trả về |
| `profile_status`, `profile_status_text` | `Set profile status` action | Runner đẩy lên DB |
| `account_status`, `account_status_text` | `Set account status` action | Runner đẩy lên DB |
| `profile_proxy_dirty` | `Set profile proxy` action (fallback path) | Runner persist proxy cuối run |

---

## 4. Control flow

### 4.1 If / Else

So sánh `Left Operator Right`, chọn nhánh `Then` hoặc `Else`.

**Operator:** `==`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `starts_with`, `ends_with`,
`is_empty`, `is_not_empty`.

- `==`, `!=`, `contains`, `starts_with`, `ends_with` → so sánh **string ordinal** (case-sensitive).
- `>`, `>=`, `<`, `<=` → ép cả 2 sang `double` (InvariantCulture). Không parse được → `false`.
- `is_empty`, `is_not_empty` → chỉ xét `Left`, `Right` bỏ qua.

```
If  {{status}} == "active"
  Then: ...
  Else: ...
```

### 4.2 While

Lặp body **khi điều kiện đúng**. Operator giống `If`. Tự sinh biến index (default `i`).

| Param | Mặc định | Ghi chú |
|---|---|---|
| `Left`, `Operator`, `Right` | — | Điều kiện như `If` |
| `Index name` | `i` | Biến đếm 0-based |
| `Max iterations` | `10000` | Hard cap chống loop vô hạn — vượt thì throw |

### 4.3 ForEach

Lặp từng phần tử trong list. `List variable / values` chấp nhận:

- Tên biến `{{lines}}` (resolve sang `IList`).
- Literal CSV: `a,b,c` (sẽ split thành 3 string).

```
ForEach  {{lines}}  as item  index i
  Log: line #{{i}} = {{item}}
```

### 4.4 Repeat (For N times)

Lặp đúng N lần. Đơn giản hơn `While` nếu chỉ cần đếm lần.

| Param | Mặc định | Ghi chú |
|---|---|---|
| `Times` | `1` | **Số lần** lặp (không phải giá trị kết thúc) |
| `Start index` | `0` | Giá trị đầu của biến index |
| `Index name` | `i` | Tên biến đếm |

Biến index chạy từ `Start index` tới `Start index + Times - 1`. Để trống `Start index` (hoặc để
`0`) là hành vi 0-based như cũ.

```
For N times:  Times = 3  Start index = 2      →  i = 2, 3, 4
For N times:  Times = 3                       →  i = 0, 1, 2
```

`Start index` tiện nhất khi số vòng lặp đã khớp sẵn với số dòng bảng tính — đọc Excel bỏ dòng
tiêu đề thì đặt `Start index = 2`, khỏi phải `Math: i + 2` trong mỗi vòng:

```
For N times:
  Times = {{so_dong}}
  Start index = 2
  Index name = dong

  Read Excel File:  Mode = cell  Column = A  Row index = {{dong}}  Output = email
```

Cả `Times` lẫn `Start index` đều là param **Expression** nên nhận `{{var}}` (vd
`{{inputs.count}}`) — resolve + parse lúc chạy. Giá trị không phải số → log Error, chạy 0 vòng
(không throw). `Times` âm → 0; `Start index` âm thì vẫn chạy bình thường (index ra số âm).

### 4.5 Break / Continue / Return

- `Break` — thoát vòng lặp gần nhất.
- `Continue` — bỏ phần còn lại của body, sang iteration kế tiếp.
- `Return` — thoát function (đặt giá trị vào `Output variable` của `Call function`).
  Ở main scope: kết thúc workflow, ghi `{{workflow_status}}` + `{{workflow_result}}`.

`Break` / `Continue` ngoài vòng lặp → log Error, bị bỏ qua. `Break`/`Continue`/`Goto`
**không escape** ra khỏi function — nếu bubble đến cùng, engine log Error rồi nuốt.

### 4.6 Label / Goto

Label đặt điểm danh dấu, `Goto label` nhảy đến. Scope-local: Goto **không** nhảy ra
ngoài function hiện tại.

```
Label: retry
Click: #submit
If {{last_error}} is_not_empty
  Then: Sleep 2000
        Goto: retry
```

### 4.7 Try / Catch / Finally

Bắt mọi exception trừ control-flow signal:

- Bị bắt: lỗi browser, lỗi I/O, lỗi parse JSON, throw thông thường.
- **Không** bị bắt: `Continue`, `Break`, `Return`, `Goto`, `OperationCanceledException`
  (user cancel). Finally vẫn chạy trước khi signal bay tiếp.

Khi vào nhánh Catch: `{{last_error}}` = message của exception đó.

### 4.8 Functions — `Call function`

Function khai báo trong panel Functions của editor. Mỗi function có:

- Tên (gọi bằng `Function name` param).
- Danh sách param (mỗi dòng 1 tên).
- Body (chuỗi action).

`Arguments` của Call function nhập theo dạng key=value mỗi dòng:

```
url=https://example.com
retries=3
user={{username}}
```

**Quy tắc scope** (quan trọng):

- Function nhận scope **mới**. User-defined vars của caller **không** vào trong.
- 4 biến hệ thống (`campaign`, `profile`, `account`, `globals`) được inherit tự động.
- `globals` share **by reference** — `Set global X = 1` trong function thì caller cũng thấy
  `{{globals.X}} == 1`.
- `Return value` → assign vào `Output variable` của caller. Không gọi `Return` → output là rỗng.

---

## 5. Tham chiếu action

Mỗi action ghi: **type_id** (key JSON) — **DisplayName** — params chính — hành vi.

### 5.1 Browser

| Action | type_id | Params (rút gọn) | Hành vi |
|---|---|---|---|
| Navigate to URL | `browser_navigate` | `URL`, `As` (typed/bookmark/link) | Mở URL trong tab hiện tại + settle 1s |
| Click element | `browser_click` | `Selector`, `Selector type` (css/xpath), `Button` (left/right), `Click count` | Click có jitter human-like. Selector not found → log Warn, lưu `{{last_error}}`, không dừng |
| Type text | `browser_type` | `Text`, `Selector` (optional), `Append`, `Instant`, `Char delay`, `Typo rate`, `Pause rate`, `Output` | Gõ per-char với timing/typo/correct. Selector trống → activeElement |
| Paste text | `browser_paste` | `Text`, `Selector` (optional) | Paste qua OS clipboard + ClipboardEvent thật |
| Clear input | `browser_clear` | `Selector` | Xoá value input/textarea/contenteditable + fire `input`/`change` |
| Wait for selector | `browser_wait_selector` | `Selector`, `Selector type`, `Timeout (ms)` (10000) | CSS: MutationObserver. XPath: poll 200ms. Throw nếu timeout |
| IsElementExist | `browser_check_selector` | `Selector`, `Mode` (exists/visible), `Output variable` | Kiểm tra không throw, ghi bool ra Output |
| Get element text | `browser_get_text` | `Selector`, `Output variable` | Đọc `textContent` |
| Get attribute | `browser_get_attribute` | `Selector`, `Attribute name`, `Output variable` | Đọc attribute (`href`, `data-*`, ...) |
| Get element value | `browser_get_value` | `Selector`, `Output variable` | Đọc DOM property `value` (current state, khác attribute) |
| Get element BoundingBox | `browser_get_bounding_box` | `Selector`, `Output variable` | Trả dict `{x, y, width, height}` viewport px |
| Scroll up / Scroll down | `browser_scroll_up`, `browser_scroll_down` | `Pixels`, `Min/Max step`, `Min/Max delay`, `Overshoot %` | Cuộn nhiều bước nhỏ + delay random + overshoot. **Lưu ý:** param `Smooth` bị ignore (Firefox fork bug, xem mục 8.4) |
| Scroll to element | `browser_scroll_to_element` | `Selector`, `Block` (center/start/end/nearest), `Smooth` | `scrollIntoView` |
| Mouse scroll | `browser_mouse_scroll` | `Delta Y`, `Delta X`, `Selector`/`X`/`Y` (optional target), `Steps`, `Tick size`, ... | Wheel event qua `sendWheelEvent` (khác `scrollBy`) — anti-bot tốt hơn |
| Mouse move to position | `browser_mouse_move_position` | `X`, `Y`, `Move steps`, `Move ms` | Di chuột smooth tới viewport px |
| Mouse move to element | `browser_mouse_move_element` | `Selector`, `Move steps`, `Move ms` | Di tới giữa element ±20% jitter |
| Mouse click at position | `browser_mouse_click_position` | `Click tại vị trí hiện tại`, `X`, `Y`, `Button`, `Click count` | Move + click tại toạ độ, hoặc click ngay tại vị trí chuột hiện tại |
| Evaluate JavaScript | `browser_evaluate` | `Script`, `Output variable` (default `result`) | Chạy JS. Expression auto-wrap `return`. Statements phải có `return`. Lỗi → log Warn, clear output |
| Screenshot to file | `browser_screenshot` | `Save to` | PNG viewport. Path hỗ trợ `~/...`, `%VAR%`, `$VAR` |
| Wait for page loaded | `browser_wait_page_loaded` | `Ready state` (complete/interactive), `Timeout`, `Poll interval` | Đợi `document.readyState` |
| Get current URL | `browser_current_url` | `Output variable` | Đọc `location.href` |
| Get cookies | `browser_get_cookie` | `URL`, `Output variable` | List cookie của URL |
| Set cookies | `browser_set_cookie` | `Cookies` (JSON literal hoặc `{{cookies}}`), `Default expiry days` | Batch set. Hỗ trợ format Playwright/Puppeteer |
| Clear cookies | `browser_clear_cookie` | `URL` (optional) | Xoá cookie ở tầng service (xoá được cả HttpOnly). URL trống → xoá toàn bộ jar; nhiều URL cách nhau `;`. Chạy trước Navigate nếu muốn xoá sạch trước khi load |
| Human idle | `browser_human_idle` | `Duration (seconds)`, `Min/Max interval`, `Mouse move`, `Scroll`, `Click` | Mô phỏng idle: random move + scroll (70% down) + thỉnh thoảng click |
| Upload file (click + hook picker) | `browser_upload_file` | `Selector` (button/element trigger upload), `File path(s)` (`;` cho multi), `Timeout (ms)` | Click selector → intercept `nsIFilePicker` dialog → trả file silently. Cùng cách Playwright/Puppeteer. Work mọi site có button trigger upload. Selector có thể là button, `[aria-label='Upload']`, hoặc trực tiếp `input[type=file]`. |
| Drag-drop file | `browser_drag_drop_file` | `Selector` (drop zone), `File path`, `MIME type`, `Hold ms` | Synthetic HTML5 drag-drop event sequence. Giới hạn: principal mismatch nên KHÔNG work với site phức tạp như Google Drive. Dùng `browser_upload_file` thay vì cái này khi có thể. |
| Switch to frame | `browser_switch_to_frame` | `Selector (CSS)` (optional), `Index` (0-based), `Output` (optional) | Đổi context sang iframe con. Ưu tiên `Selector` nếu có, không thì dùng `Index`. Sau khi switch, mọi action DOM chạy bên trong frame đó |
| Switch to parent frame | `browser_switch_to_parent_frame` | `Output` (optional) | Đi lên 1 cấp frame. Đang ở top → không đổi |
| Switch to top frame | `browser_switch_to_top_frame` | `Output` (optional) | Về top document (thoát mọi iframe). Reset context sau khi switch frame xong |

**Selector type:** mặc định `css`. Đổi sang `xpath` cho selector dạng `//div[@class='x']`.

### 5.2 Data

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| JSON path | `json_path` | `Source` (JSON string), `Path` (`$.user.name`), `Output variable` | Parse + extract qua JSONPath |
| To JSON | `to_json` | `Source` (`{{var}}`), `Pretty`, `As JSON string`, `Output variable` | Serialize list/dict/object (vd cookies từ Get cookies) thành chuỗi JSON. `Pretty`=xuống dòng; `As JSON string`=bọc + escape thành 1 chuỗi |
| List op | `list_op` | `List variable`, `Mode`, `Value`, `Index`, `Delimiter`, `Descending`, `Numeric sort`, `Output` | Thao tác list (xem bảng mode bên dưới). Op truy vấn (`get`/`count`/`contains`/`index_of`/`join`) bắt buộc `Output` |
| Get machine info | `get_machine_info` | `Output variable` | Ghi dict info máy: `HostName`, `AppVersion`, `OsType`, `LocalIp`, `RunningProfiles`, `MacAddresses`. Đọc qua `{{machine.HostName}}` |
| Extract text (regex) | `extract_text` | `Source`, `Regex pattern`, `Group` (0=toàn match), `Case insensitive`, `Multiline`, `All matches` (list), `Output variable` | Trích chuỗi qua regex |
| Split text | `split_text` | `Source`, `Delimiter`, `Trim each part`, `Remove empty entries`, `Limit`, `Index` (optional), `Output variable` | Cắt theo delimiter (`\t \n \r \\` escape được). Index trống → list. Index số → phần tử (âm = từ cuối). `Limit`>0 → cắt tối đa N phần, phần dư giữ nguyên ở phần tử cuối |
| String transform | `string_transform` | `Source`, `Mode`, ... | Xem bảng mode bên dưới |
| Math | `math` | `Left`, `Operator` (`+ - * / % ^ min max`), `Right`, `Output variable` | 2 int → `long`. Có float → `double` |
| Random number | `random_number` | `Min`, `Max`, `Decimal`, `Decimal places`, `Output variable` | `[Min, Max]`. Decimal=true → double có thể round |
| Random text | `random_text` | `Length`, `Charset` (`alphanumeric` / `alpha` / `lower` / `upper` / `digits` / `hex` / `custom`), `Custom chars`, `Output variable` | Sinh chuỗi. Length đếm theo grapheme (custom emoji OK) |

**String transform — mode:**

| Mode | Param thêm |
|---|---|
| `replace`, `replace_regex` | `Find / Pattern`, `Replacement`, `Case insensitive` |
| `lowercase`, `uppercase`, `trim`, `trim_start`, `trim_end`, `reverse`, `length` | (không có) |
| `substring` | `Start`, `Length` |
| `pad_left`, `pad_right` | `Total width`, `Pad char` |
| `repeat` | `Length` (số lần) |

**List op — mode (`list_op`):**

| Mode | Param dùng | Ghi chú |
|---|---|---|
| `add` | `Value` | Thêm vào cuối |
| `insert` | `Value`, `Index` | `Index` = `count` → append cuối |
| `remove_at` | `Index` | Index âm = đếm từ cuối (`-1` = cuối) |
| `remove_value` | `Value` | Xoá phần tử đầu khớp (so theo `ToString()`) |
| `set` | `Index`, `Value` | Ghi đè phần tử |
| `get` | `Index`, `Output` | Đọc phần tử ra `Output` |
| `count` | `Output` | Số phần tử |
| `contains` | `Value`, `Output` | Bool (`True`/`False`) |
| `index_of` | `Value`, `Output` | Vị trí (−1 nếu không có) |
| `clear` | — | Xoá hết |
| `shuffle` | — | Trộn ngẫu nhiên (Fisher–Yates) |
| `sort` | `Numeric sort`, `Descending` | `Numeric`=sort số, `Descending`=giảm dần |
| `reverse` | — | Đảo thứ tự |
| `unique` | — | Loại trùng (theo `ToString()`) |
| `join` | `Delimiter`, `Output` | Nối thành chuỗi. Delimiter hỗ trợ escape `\t \n \r \\` |

### 5.3 File

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| Read file | `read_file` | `Path`, `Mode` (`text`/`lines`/`random_line`/`first_line`/`last_line`), `Encoding`, `Trim end whitespace`, `Skip empty lines`, `Consume line`, `Output variable` | Đọc file. Mode `lines` → `List<string>`. `Consume line` = xoá dòng vừa đọc khỏi file (cho danh sách proxy/account dùng 1 lần) |
| Write file | `write_file` | `Path`, `Content`, `Mode` (`overwrite`/`append`/`append_line`), `Encoding`, `Create parent directory` | Ghi text. `append_line` thêm `\n` cuối |
| Delete file/folder | `delete_path` | `Path`, `Recursive`, `Ignore if not exists` | Xoá file hoặc thư mục. Thư mục có nội dung phải bật `Recursive`. `Ignore if not exists`=true (mặc định) → không lỗi khi path không tồn tại |
| List folders | `list_folders` | `Path`, `Pattern`, `Recursive`, `Output format`, `Ignore if not exists`, `Output variable` | Liệt kê thư mục con → `List<string>`. `Recursive`=false chỉ lấy 1 cấp, =true quét cả cây con |
| Read Excel File | `excel_read` | `File path`, `Sheet index`, `Mode` (cell/row/column/range), `Column name or index`, `Row index`, `Range A1`, `Output variable` | Đọc thẳng `.xlsx` / `.xls`. Output: chuỗi (`cell`) / list (`row`, `column`) / list của list (`range`). Chi tiết mục 7.1 |
| Write Excel File | `excel_write` | `File path`, `Sheet index`, `Mode` (cell/row/append_row), `Column name or index`, `Row index`, `Value`, `Values delimiter`, `Value input`, `Create if missing`, `Output variable` | Ghi `.xlsx`. Tự tạo file/sheet, khoá theo file nên nhiều profile append song song không mất dòng. Chi tiết mục 7.2 |
| List files | `list_files` | `Path`, `Pattern`, `Recursive`, `Output format`, `Ignore if not exists`, `Output variable` | Liệt kê file → `List<string>`. `Pattern` lọc theo tên (`*.txt`), `Recursive` như trên |

Hai action liệt kê dùng chung quy ước:

- Kết quả là `List<string>` — đưa thẳng vào `For each`, hoặc `List op` để đếm/trộn/lấy phần tử
- `Output format`: `full_path` (mặc định) | `name` (chỉ tên) | `relative` (tính từ `Path`)
- Luôn sort theo tên — thứ tự filesystem trả về không ổn định, workflow cần lặp lại được giữa các lần chạy
- Thư mục con không có quyền đọc bị bỏ qua, không làm hỏng cả lần liệt kê
- File/thư mục Hidden hoặc System **vẫn** được liệt kê (thư mục profile hay bị đánh dấu Hidden)
- `Ignore if not exists`=false (mặc định) → báo lỗi khi thư mục không tồn tại; bật lên thì trả danh sách rỗng

Ô nhập đường dẫn trong editor có nút **…** để chọn file/thư mục cho nhanh — nút chỉ điền hộ vào ô,
ô vẫn là text bình thường nên gõ tay hay dùng `{{var}}` đều được như cũ.

**Path** ở các action file đã được normalize:

- `~/data.txt` → `$HOME/data.txt` (cả Win/Mac/Linux)
- `%APPDATA%/x.txt`, `$HOME/x.txt`, `${HOME}/x.txt` → expand env var
- Trên Mac/Linux: `\` tự đổi sang `/`

### 5.4 HTTP

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| HTTP Request | `http_request` | `URL`, `Method`, `Body type` (none/raw/form-urlencoded/multipart), `Body`, `Content-Type`, `Headers`, `Cookies`, `Proxy`, `Timeout (ms)`, `Output variable` | Gọi HTTP. URL/Headers/Body đều hỗ trợ `{{var}}`. Ghi response body → `{{Output}}`, status code → `{{Output}}_status` |

**Headers** / **Cookies** nhập mỗi dòng `Name: value` (Headers) hoặc `name=value` (Cookies).

### 5.5 Network

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| Check proxy | `check_proxy` | `Source` (profile/custom), `Proxy`, `Type` (auto/http/socks5), `Test URL` (default `https://api.ipify.org`), `Timeout (ms)`, `Output variable` | GET qua proxy, timeout ngắn. Ghi `'live'` / `'dead'` / `'none'` / `'skip'` → Output |

### 5.6 Control (tổng hợp)

Tất cả đã mô tả ở mục 4. type_id:

| Action | type_id |
|---|---|
| Set variable | `set_variable` |
| Set global | `set_global` |
| If / Else | `if` |
| While | `while` |
| For each | `for_each` |
| For N times | `repeat` |
| Break | `break` |
| Continue | `continue` |
| Return | `return` |
| Label | `label` |
| Goto label | `goto` |
| Try / Catch / Finally | `try_catch` |
| Call function | `call_function` |
| Sleep | `sleep` |
| Log | `log` |
| Stopwatch | `stopwatch` |
| Set profile status | `set_profile_status` |
| Set account status | `set_account_status` |
| Set profile proxy | `set_proxy` |

Hai action ghi DB (không thuộc control-flow, mô tả ở đây cho đủ):

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| Add account | `add_account` | `Username`, `Password`, `Recovery email`, `2FA`, `Profile`, `Note`, `Lot ID`, `Account type`, `Account details`, `Status`, `Status text`, `Cookies`, `Output` (Id mới), `Assign to workflow account` | Thêm account mới vào DB. `Assign to workflow account`=true (mặc định) → gán vào `{{account.*}}` cho các step sau |
| Remove account | `remove_account` | `Mode` (`current`/`username`/`id`), `Username`, `Id`, `Output` (số đã xoá) | Xoá account khỏi DB. `current` = account đang bind vào workflow |

### 5.7 Google

| Action | type_id | Params chính | Hành vi |
|---|---|---|---|
| Read Google Sheet | `google_sheet_read` | `Credential file (.json)`, `File ID`, `Sheet ID`, `Mode` (cell/row/column/range), `Column name or index`, `Row index`, `Range A1`, `Output variable` | Đọc Sheets API v4 bằng Service Account. Output: chuỗi (`cell`) / list (`row`, `column`) / list của list (`range`). Chi tiết mục 7.4 |
| Write Google Sheet | `google_sheet_write` | `Credential file (.json)`, `File ID`, `Sheet ID`, `Mode` (cell/row/append_row), `Column name or index`, `Row index`, `Value`, `Values delimiter`, `Value input`, `Output variable` | Ghi Sheets API v4. `append_row` để Google tự tìm dòng cuối bảng — an toàn khi nhiều profile chạy song song. Chi tiết mục 7.5 |

`File ID` nhận cả URL sheet lẫn id thuần. `Sheet ID` là số thứ tự tab (0-based) hoặc tên tab.
Cách tạo Credential file và share sheet: mục 7.3.

---

## 6. Patterns thường gặp

### 6.1 Đọc danh sách proxy/account từ file (1 dòng / profile)

```
Read file:
  Path = ~/lists/proxies.txt
  Mode = first_line
  Consume line = true
  Output = my_proxy

Set profile proxy:
  Mode = raw
  Raw proxy = {{my_proxy}}
  Type = socks5
```

Mỗi lần workflow chạy lấy dòng đầu, xoá khỏi file → profile sau dùng dòng kế tiếp.

### 6.2 Retry click với tối đa N lần

```
Set variable: tries = 0

Label: do_click
Click: #submit

If {{last_error}} is_not_empty
  Then:
    Math: tries + 1 → tries
    If {{tries}} < 3
      Then:
        Sleep 2000
        Set variable: last_error = ""
        Goto: do_click
      Else:
        Log Error: "Click failed after 3 tries"
        Return status=failed
```

### 6.3 Loop pagination

```
Set variable: page = 1

While {{page}} <= 10
  Click: a.page-link[data-page="{{page}}"]
  Wait for selector: .results

  ForEach {{titles}} as title index t
    Write file:
      Path = ~/scraped.txt
      Content = {{title}}
      Mode = append_line

  Math: page + 1 → page
```

### 6.4 Branch theo dữ liệu profile/account

```
If {{account.Status}} == "locked"
  Then:
    Set account status: locked, "Detected on login"
    Return status=failed
  Else:
    Type: #password = {{account.Password}}
    Click: #login
```

### 6.5 Random human-like delay

```
Sleep:
  Milliseconds (min) = 800
  Max milliseconds (random) = 2400
```

Hoặc dùng `Human idle` 5–10 giây giữa các action quan trọng.

### 6.6 Reusable function

Function `try_login(username, password)`:

```
Type: #user = {{username}}
Type: #pass = {{password}}
Click: #submit
Wait for page loaded
IsElementExist: .error-msg → has_error
If {{has_error}} == "True"
  Then:
    Get element text: .error-msg → err
    Return status=failed value={{err}}
  Else:
    Return status=success value=ok
```

Gọi:

```
Call function:
  Function name = try_login
  Arguments:
    username = {{account.Username}}
    password = {{account.Password}}
  Output variable = login_result

If {{login_result}} == "ok"
  Then: Log Info: "Logged in"
  Else: Log Warn: "Login failed: {{login_result}}"
```

---

## 7. Tương tác với Excel và Google Sheets

Engine **không có action đọc/ghi `.xlsx` trực tiếp** — không có thư viện Excel nào được nhúng
trong `TSTAutomation.Core`. Mọi tương tác bảng tính đi qua một trong ba đường:

| Đường | Hợp với | Action dùng |
|---|---|---|
| **File Excel** | Đọc `.xlsx` / `.xls`, ghi `.xlsx` ngay trên đĩa | `excel_read`, `excel_write` |
| **File CSV/TSV** | Ghi kết quả ra file, hoặc danh sách tiêu hao dần | `read_file`, `write_file`, `split_text`, `list_op` |
| **Google Sheets API** | Sheet online — đọc **và ghi**, kể cả sheet riêng tư | `google_sheet_read`, `google_sheet_write` |

Hai action Google Sheets xác thực bằng **Credential file** (key JSON của Service Account), không
đụng tới trình duyệt — sheet không cần share public, profile không cần đăng nhập Google.

### 7.1 Excel — đọc dữ liệu (`Read Excel File`)

Action `Read Excel File` (`excel_read`) đọc thẳng `.xlsx` / `.xls` — không cần Save As CSV, không
cần mở Excel, không cần cài Office trên máy chạy.

| Param | Ý nghĩa |
|---|---|
| `File path` | Đường dẫn file. Nhận `~/...`, `%APPDATA%\...`, `{{profile_dir}}\...` như các action File khác |
| `Sheet index` | `0` = sheet đầu tiên (0-based). Gõ chữ thì hiểu là **tên sheet** |
| `Mode` | `cell` / `row` / `column` / `range` |
| `Column name or index` | `A` hoặc `1` (1 = cột A). Dùng cho `cell`, `column` |
| `Row index` | Bắt đầu từ `1`, **tính cả dòng tiêu đề**. Dùng cho `cell`, `row` |
| `Range A1` | Vd `A2:D100`. Chỉ dùng cho mode `range` |
| `Output variable` | Biến nhận kết quả |

Kiểu Output theo mode giống hệt `Read Google Sheet` (mục 7.4): `cell` → chuỗi, `row` / `column` →
list, `range` → list của list. Mọi param đều nhận `{{var}}`.

Đọc 1 ô:

```
Read Excel File:
  File path = D:\data\accounts.xlsx
  Sheet index = 0
  Mode = cell
  Column name or index = A
  Row index = 2
  Output = email
```

Duyệt cả bảng account:

```
Read Excel File:
  File path = {{profile_dir}}\accounts.xlsx
  Mode = range
  Range A1 = A2:C500
  Output = rows

ForEach {{rows}} as row index r
  Set variable: email = {{row[0]}}
  Set variable: pass  = {{row[1]}}
  Log Info: [{{r}}] {{email}}
```

Lấy nguyên một cột làm danh sách keyword:

```
Read Excel File:
  Mode = column
  Column name or index = A
  Output = keywords

ForEach {{keywords}} as kw index i
  ...
```

Giá trị trong ô được đổi sang chuỗi như sau:

| Ô trong Excel | Workflow nhận |
|---|---|
| Text | nguyên văn |
| Số | `1234567890123` — không bao giờ ra `1.23E+12`, nên id/số điện thoại không bị hỏng |
| Ngày | `2026-01-15`; có giờ thì `2026-01-15 14:30:00` |
| TRUE / FALSE | `True` / `False` (so bằng `If {{x}} == True`) |
| Ô trống | chuỗi rỗng |

Bốn điểm đáng nhớ:

- Đọc được **cả khi file đang mở trong Excel** — action mở ở chế độ chia sẻ, không đòi khoá file.
- Đọc quá dòng cuối trả **rỗng chứ không lỗi**, nên vòng lặp "đọc tới khi hết" chỉ cần
  `If {{email}} is_empty → Break`.
- Mode `row` / `column` tự cắt bỏ đuôi ô trống nên `List op → count` đúng với những gì bạn nhìn
  thấy; mode `range` thì giữ nguyên độ rộng vùng yêu cầu để `{{row[2]}}` luôn là đúng cột đó.
- File `.csv` **không** dùng action này — dùng `Read file` (mục 5.3), nhanh hơn nhiều.

### 7.2 Excel — ghi dữ liệu (`Write Excel File`)

Action `Write Excel File` (`excel_write`) ghi thẳng vào `.xlsx`. Không cần Excel đang mở, và tự
tạo file lẫn sheet nếu chưa có.

| Param | Ý nghĩa |
|---|---|
| `File path` | File `.xlsx`. `.xls` **không ghi được** — mở bằng Excel rồi Save As `.xlsx` (đọc thì `.xls` vẫn được) |
| `Sheet index` | `0` = sheet đầu tiên. Gõ chữ thì hiểu là **tên sheet** — chưa có thì tạo mới |
| `Mode` | `cell` (1 ô) / `row` (ghi đè 1 dòng từ ô bắt đầu) / `append_row` (thêm ngay dưới dòng cuối có dữ liệu) |
| `Column name or index`, `Row index` | Ô bắt đầu. `append_row` chỉ dùng `Column` (dòng do action tự tìm) |
| `Value` | `cell`: một giá trị. `row`/`append_row`: biến list (`{{cells}}`) hoặc chuỗi có delimiter |
| `Values delimiter` | Dấu cắt khi `Value` là chuỗi (mặc định `,`, nhận `\t`) |
| `Value input` | `AUTO` (mặc định) hoặc `TEXT` — xem bảng bên dưới |
| `Create if missing` | Mặc định bật. Tắt đi để workflow **báo lỗi** thay vì lặng lẽ tạo file mới khi gõ sai đường dẫn |
| `Output variable` | Optional — nhận ô/vùng đã ghi, vd `Sheet1!A7:C7` |

Ghi kết quả mỗi profile thành một dòng mới:

```
Write Excel File:
  File path = ~/reports/ket-qua.xlsx
  Sheet index = KetQua
  Mode = append_row
  Value = {{profile.Name}},{{account.Username}},{{workflow_result}}
  Output = written

Log Info: Đã ghi {{written}}       # vd KetQua!A7:C7
```

Cập nhật đúng một ô trên dòng của profile:

```
Write Excel File:
  File path = D:\data\accounts.xlsx
  Mode = cell
  Column name or index = D
  Row index = {{sheet_row}}
  Value = done
```

Giá trị có dấu phẩy thì gom bằng `List op` rồi đưa cả list vào `Value`, khỏi lo delimiter cắt nhầm:

```
List op:  List variable = cells  Mode = add  Value = {{profile.Name}}
List op:  List variable = cells  Mode = add  Value = {{note}}        # "a, b, c" vẫn an toàn

Write Excel File:
  Mode = append_row
  Value = {{cells}}
```

**`Value input`** quyết định ô ra số hay ra text:

| Chuỗi ghi vào | `AUTO` | `TEXT` |
|---|---|---|
| `123`, `1.5` | ô số | ô text |
| `0123` | ô **text** (giữ số 0 đầu) | ô text |
| `+5`, id 20 chữ số | ô **text** (số hoá sẽ hỏng giá trị) | ô text |
| `true` / `false` | ô boolean | ô text |
| còn lại | ô text | ô text |

Quy tắc của `AUTO`: chỉ ghi thành số khi chuỗi biểu diễn **đúng bằng** con số đó. Nhờ vậy id,
số điện thoại, mã có số 0 đầu không bị Excel nuốt mất.

Ba điểm đáng nhớ:

- **Nhiều profile ghi song song an toàn.** Action khoá theo từng file nên 25 profile cùng
  `append_row` vào một workbook vẫn ra đủ 25 dòng, không đè nhau. Khoá này chỉ có tác dụng
  **trong cùng một app** — mở 2 instance TSTProfileManager cùng ghi 1 file thì vẫn hỏng.
- **Không ghi được khi file đang mở trong Excel** (Excel khoá độc quyền). Action báo đúng nguyên
  nhân, không throw khó hiểu. Lưu ý *đọc* thì vẫn được kể cả khi đang mở.
- Ghi vào file có sẵn **giữ nguyên phần còn lại** — các sheet khác, định dạng, ngày tháng đều
  không bị đụng tới.

**Vẫn muốn dùng CSV?** `write_file` với `Mode = append_line` và `Encoding = utf-8-bom` (thiếu BOM
là Excel đọc tiếng Việt thành `TiÃªÌ‰ng ViÃªÌ£t`). Nhớ `List op → join` ra chuỗi trước — `Content`
chỉ có đúng một token `{{var}}` mà biến là list thì `write_file` serialize thành **JSON** chứ không
phải dòng CSV. Nhưng `write_file` **không khoá file**, nên nhiều profile cùng append thì nên ghi
mỗi profile một file rồi gộp.

### 7.3 Google Sheets — chuẩn bị Credential file

Hai action `Read Google Sheet` / `Write Google Sheet` nói chuyện thẳng với Google Sheets API v4
bằng **Service Account** — không mở trình duyệt, không cần đăng nhập, không cần share sheet ra
public.

Làm một lần cho cả máy:

1. Vào [Google Cloud Console](https://console.cloud.google.com/) → tạo (hoặc chọn) một Project.
2. **APIs & Services → Library** → bật **Google Sheets API**.
3. **IAM & Admin → Service Accounts → Create service account** → đặt tên bất kỳ → Done.
4. Mở service account vừa tạo → tab **Keys** → **Add key → Create new key → JSON** → tải file về.
   File JSON đó chính là **Credential file**.
5. Mở file JSON, copy `client_email` (dạng `ten-bot@project-id.iam.gserviceaccount.com`).
6. Mở Google Sheet cần dùng → **Share** → dán `client_email` → chọn **Viewer** (chỉ đọc) hoặc
   **Editor** (cần ghi) → Send.

Bước 6 là bước hay quên nhất — thiếu nó thì API trả **403/404** dù File ID đúng. Message lỗi của
action in sẵn email cần share nên không phải mở lại file JSON.

Đường dẫn Credential file đi qua cùng bộ normalize như các action File, nên viết được
`~/google/bot.json`, `%APPDATA%\tst\bot.json`, `{{profile_dir}}\bot.json`.

> **Bảo mật:** ai có file này là đọc/ghi được mọi sheet đã share cho service account đó. Đừng để
> trong thư mục profile được đồng bộ, đừng commit vào git. Lỡ lộ thì vào Cloud Console → Keys →
> xoá key cũ, tạo key mới.

Access token được cache theo (credential file + scope) và tự làm mới trước khi hết hạn, nên
campaign 50 profile chỉ đổi token 1 lần chứ không phải 50 lần.

### 7.4 `Read Google Sheet` (`google_sheet_read`)

| Param | Ý nghĩa |
|---|---|
| `Credential file (.json)` | Đường dẫn key Service Account (mục 7.3) |
| `File ID` | ID của sheet — dán nguyên URL cũng được, engine tự cắt phần giữa `/d/` và `/edit` |
| `Sheet ID` | `0` = tab đầu tiên (0-based, giống GPM). Gõ chữ thì hiểu là **tên tab** |
| `Mode` | `cell` / `row` / `column` / `range` |
| `Column name or index` | `A` hoặc `1` (1 = cột A). Dùng cho `cell`, `column` |
| `Row index` | Bắt đầu từ `1`. Dùng cho `cell`, `row` |
| `Range A1` | Vd `A2:D100`. Chỉ dùng cho mode `range` |
| `Output variable` | Biến nhận kết quả |

Kiểu dữ liệu của Output theo mode:

| Mode | Output | Đọc bằng |
|---|---|---|
| `cell` | chuỗi | `{{value}}` |
| `row` | list các ô của dòng | `{{value[0]}}`, `ForEach` |
| `column` | list các ô của cột | `ForEach {{value}} as item` |
| `range` | list của list (dòng → ô) | `{{value[0][1]}}`, `ForEach` + `{{row[0]}}` |

Đọc 1 ô:

```
Read Google Sheet:
  Credential file = ~/google/tst-bot.json
  File ID = https://docs.google.com/spreadsheets/d/1AbC.../edit#gid=0
  Sheet ID = 0
  Mode = cell
  Column name or index = B
  Row index = 2
  Output = keyword
```

Duyệt cả bảng account:

```
Read Google Sheet:
  Credential file = ~/google/tst-bot.json
  File ID = 1AbC...
  Sheet ID = 0
  Mode = range
  Range A1 = A2:C500
  Output = rows

ForEach {{rows}} as row index r
  Set variable: email = {{row[0]}}
  Set variable: pass  = {{row[1]}}
  Log Info: [{{r}}] {{email}}
```

Lấy nguyên một cột làm danh sách keyword:

```
Read Google Sheet:
  Mode = column
  Column name or index = A
  Output = keywords

ForEach {{keywords}} as kw index i
  ...
```

Google **cắt bỏ các ô rỗng ở cuối** dòng/vùng: hàng thiếu ô thì `{{row[2]}}` ra chuỗi rỗng chứ
không throw, và số dòng trả về có thể ít hơn range yêu cầu (dừng ở dòng cuối có dữ liệu).

### 7.5 `Write Google Sheet` (`google_sheet_write`)

| Param | Ý nghĩa |
|---|---|
| `Credential file (.json)`, `File ID`, `Sheet ID` | Như action đọc — nhưng sheet phải share quyền **Editor** |
| `Mode` | `cell` (1 ô) / `row` (ghi đè 1 dòng từ ô bắt đầu) / `append_row` (thêm dòng vào cuối bảng) |
| `Column name or index`, `Row index` | Ô bắt đầu — dùng cho `cell` và `row` |
| `Value` | `cell`: một giá trị. `row`/`append_row`: biến list (`{{cells}}`) hoặc chuỗi có delimiter (`a,b,c`) |
| `Values delimiter` | Dấu cắt khi `Value` là chuỗi (mặc định `,`, nhận `\t` `\n`) |
| `Value input` | `USER_ENTERED` (mặc định — số ra số, `=SUM()` thành công thức) hoặc `RAW` (giữ nguyên chuỗi) |
| `Output variable` | Optional — nhận range đã ghi, vd `Log!A7:C7` |

Ghi kết quả mỗi profile vào dòng cuối bảng:

```
Write Google Sheet:
  Credential file = ~/google/tst-bot.json
  File ID = 1AbC...
  Sheet ID = Log                       # gõ tên tab thay cho index cũng được
  Mode = append_row
  Value = {{profile.Name}},{{account.Username}},{{workflow_result}}
  Output = written_range

Log Info: Đã ghi {{written_range}}
```

Giá trị có dấu phẩy thì đừng ghép chuỗi — gom bằng `List op` rồi đưa cả list vào `Value`:

```
List op:  List variable = cells  Mode = add  Value = {{profile.Name}}
List op:  List variable = cells  Mode = add  Value = {{note}}          # "a, b, c" vẫn an toàn

Write Google Sheet:
  Mode = append_row
  Value = {{cells}}
```

Cập nhật đúng 1 ô trạng thái:

```
Write Google Sheet:
  Mode = cell
  Column name or index = D
  Row index = {{sheet_row}}
  Value = done
```

`append_row` để **Google tự tìm dòng trống cuối bảng**, nên nhiều profile chạy song song ghi cùng
lúc vẫn không đè lên nhau — khác hẳn `write_file` (xem 7.6). `cell` / `row` ghi đúng toạ độ bạn
đưa, hai profile trỏ cùng một ô thì vẫn đè nhau như thường.

### 7.6 Giới hạn & lỗi hay gặp

| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| `Write Excel File` báo *file đang mở trong Excel* | Excel khoá độc quyền workbook đang mở | Đóng file trong Excel rồi chạy lại (đọc thì không cần đóng) |
| Ghi `.xls` báo lỗi | Chỉ ghi được `.xlsx` | Mở bằng Excel → Save As `.xlsx` |
| Chạy 2 instance app cùng ghi 1 workbook → hỏng file | Khoá theo file chỉ có tác dụng trong cùng một tiến trình | Mỗi instance ghi file riêng, hoặc dùng `Write Google Sheet` (7.5) |
| Gõ sai đường dẫn → tự sinh file rỗng ở chỗ lạ | `Create if missing` mặc định bật | Tắt `Create if missing` cho workflow ghi vào file có sẵn |
| `Read Excel File` trả rỗng hết | Nhầm gốc đếm: `Sheet index` từ **0**, `Row index` từ **1** và tính cả dòng tiêu đề | Dòng dữ liệu đầu tiên của bảng có tiêu đề là `Row index = 2` |
| Ô ngày ra dãy số kiểu `46037` | Ô đó Excel không định dạng là Date, chỉ là số thường | Format ô thành Date trong Excel rồi lưu lại |
| Đọc `.csv` bằng `Read Excel File` báo lỗi | Action chỉ nhận `.xlsx` / `.xls` | Dùng `Read file` |
| `write_file` lỗi *being used by another process* | File CSV đang mở trong Excel (Excel khoá file) | Đóng file trong Excel trước khi chạy, hoặc ghi ra tên file khác |
| Campaign nhiều profile → file CSV mất dòng / lỗi I/O | `write_file` **không** khoá file, nhiều profile append cùng lúc | Ghi file riêng `~/reports/{{profile.Name}}.csv` rồi gộp, hoặc dùng `Write Google Sheet` mode `append_row` |
| Cột lệch khi ô có dấu phẩy | `split_text` cắt thô, không hiểu quote CSV `"a,b"` | Xuất TSV rồi split `\t` |
| Tiếng Việt lỗi font khi Excel mở CSV | Thiếu BOM | `Encoding = utf-8-bom` khi `write_file` |
| File mất BOM sau vài lần chạy | `Consume line` ghi lại file bằng encoding đang chọn (`utf-8` = không BOM) | File cần Excel đọc thì đừng dùng `Consume line` |
| Sheets API trả **403 / 404** dù File ID đúng | Sheet chưa share cho service account | Share cho `client_email` (Editor nếu cần ghi) — email này có sẵn trong message lỗi |
| `Credential file phải là key của Service Account` | Đang đưa file `client_secret_*.json` của OAuth client ID | Tạo key ở IAM & Admin → Service Accounts → Keys (mục 7.3) |
| Token lỗi **400 `invalid_grant`** | Đồng hồ máy lệch > 5 phút so với giờ thực, hoặc key đã bị xoá | Bật đồng bộ giờ Windows; tạo key mới trong Cloud Console |
| **403 `Google Sheets API has not been used in project…`** | Chưa bật Sheets API cho project | APIs & Services → Library → enable Google Sheets API |
| `Sheet ID N ngoài phạm vi` | `Sheet ID` là **số thứ tự tab (0-based)**, không phải `gid` trên URL | Dùng số thứ tự, hoặc gõ thẳng tên tab |
| **429 / `Quota exceeded`** | Sheets API giới hạn ~60 request/phút/user | Mỗi profile ghi gộp 1 lần bằng `append_row`, thêm `Sleep` random, bọc `Try/Catch` + retry (mục 6.2) |
| Ghi số nhưng sheet hiện thành text | `Value input = RAW` | Đổi sang `USER_ENTERED` |
| Scrape sheet bằng selector không ra gì | Grid Google Sheets vẽ bằng canvas, DOM không chứa dữ liệu ô | Dùng `Read Google Sheet`, browser action không đọc được bảng |
| CSV vài trăm nghìn dòng chạy chậm / ngốn RAM | `Mode = lines` nạp toàn bộ file vào biến | Dùng `first_line` + `Consume line`, hoặc chia nhỏ file theo lô |

---

## 8. Troubleshooting

### 8.1 Selector not found không dừng workflow

Engine **cố ý** bắt mọi `BrowserAutomationException` có message chứa `"selector not found"`,
`"no element matches"`, `"no element for"`, `"element not found"`, `"xpath not found"`:

- Log Warn với selector đã resolve.
- Ghi `{{last_error}} = "selector not found: <selector>"`.
- `Sleep 2000` để page settle.
- Tiếp tục action kế tiếp.

Lý do: SPA / lazy-load thường gây miss tạm thời. Nếu bạn muốn fail-fast: bọc trong
`Try/Catch` và check `{{last_error}}` xong `Return status=failed`.

### 8.2 `{{var}}` ra rỗng

Theo thứ tự, kiểm tra:

1. Tên biến đúng case (`{{Profile.Name}}` ≠ `{{profile.Name}}`).
2. Có đang ở trong function không — user vars không inherit.
3. Path: `{{obj.prop}}` chỉ work khi `obj` là dict hoặc có property/field tên `prop`.
4. Index biến: biến đó có resolve được sang `int` không (`Math` trả `long`/`double` đều OK).

Bật `Log` với `{{var}}` cụ thể để debug.

### 8.3 Function call không trả về giá trị

- Có gọi `Return value=...` trong function không? Không có → output rỗng.
- `Break`/`Continue`/`Goto` bubble đến hết function → log Error, function trả `null`.

### 8.4 `Scroll up` / `Scroll down` không cuộn smooth

Đã biết: Firefox fork hiện no-op `window.scrollBy({top, behavior})` — engine luôn dùng
form `scrollBy(x, y)`. Tham số `Smooth` bị ignore. Bù lại bằng `Min step` / `Max step` /
`Min delay` / `Max delay` cho human-like.

### 8.5 Path file không tìm thấy trên Mac/Linux

`Read file` / `Write file` / `Screenshot to file` đều normalize path:

- `~/...` → home directory.
- `%VAR%`, `$VAR`, `${VAR}` → env var.
- `\` → `/` (chỉ trên Mac/Linux).

Nếu path tuyệt đối Windows (`C:\data\x.txt`) chạy trên Mac/Linux → vẫn fail. Workflow
cross-OS nên dùng `~/...` hoặc env var.

### 8.6 Workflow stop giữa chừng không rõ lý do

Các nguyên nhân không log:

- User bấm Cancel → `OperationCanceledException`, không bị `Try/Catch` bắt.
- `Return` ở main scope → set `workflow_status=success` (hoặc `failed`) rồi kết thúc.
- Exception thoát ra ngoài `Try/Catch` → runner log ở tầng trên.

Mở panel Log của editor, lọc level Debug để xem trace của từng `Resolve()` và `If` compare.
