> ## Documentation Index
> Fetch the complete documentation index at: https://docs.quarkybot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing

# Full UI testing

Harness drives Discord **web** through `#clicker` → [`.chrome-clicker`](../.chrome-clicker/) (Chrome CDP). Fixtures under `tests/e2e/` issue real slash commands and button presses.

```bat theme={null}
npm test -- profile
npm test -- tickets:admin
```

`npm test` reuses Chrome when CDP is already reachable; otherwise its pretest
starts Chrome with a dedicated profile automatically. Log into Discord web once
in that profile. Optional `CLICKER_CDP_URL` defaults to
`http://127.0.0.1:9222`. See [`.chrome-clicker/README.md`](../.chrome-clicker/README.md).

Preprocess does a one-time `navigate` to
`https://discord.com/channels/{TEST_SERVER}/{TEST_LOGS_CHANNEL}`, then opens the
harness thread with `openThread` (sidebar click on the logs channel, then the
thread starter). Mid-run focus never changes the URL — only `openChannel` /
`openThread` clicks.

## Independent suites

Each `tests/e2e/<area>/<name>.json` file is a **standalone suite**. It must:

1. Optionally delete leftover names (`"optional": true`)
2. **Seed** — create every entity it needs
3. **Act** — behavior under test
4. **Teardown** — delete what it created

Do **not** depend on another JSON file (e.g. `panel.json` must not assume `admin.json` already created `e2e_embed`). Use distinct `e2e_*` names per suite file when they might collide. Cases **inside** one file may share state; if an earlier create fails, a later get/press fails — that is enough signal (no DB asserts).

Optional same-file metadata for those chains:

```json theme={null}
{
  "id": "ops-claim-close",
  "dependsOn": ["ops-create-embed"],
  "description": "**Ticket Ops: claim → unclaim → close**",
  "steps": [ … ]
}
```

* `id` — stable slug, unique in the file; required if another case lists it in `dependsOn`
* `dependsOn` — string or string\[] of **same-file** ids only (act/seed cases)
* `teardown: true` — cleanup case; **must not** use `dependsOn`; usually also `optional: true`. On filtered runs, early teardowns (before first act in the file) run first; late teardowns run after the act chain.

**Case filter:** `npm test -- tickets:ops#ops-claim-close` or multiple `tickets:ops#ops-claim-close,ops-slash-claim-close`. Harness expands `dependsOn` transitively (topo order, file-order tie-break) and appends file teardowns. If a required dep failed earlier, it is **re-run once**; if it fails again, the dependent fails with `dependency **#N** failed: …` (N = 1-based case number).

### Global env vs feature `.env.json`

| Global (`.env` / process)                                                | Per feature folder                                   |
| ------------------------------------------------------------------------ | ---------------------------------------------------- |
| `TEST_SERVER`, `TEST_LOGS_CHANNEL`, `ADMIN_USER`, Chrome CDP, `BOT=BETA` | `tests/e2e/<area>/.env.json` — guild/product prereqs |

Example [`tests/e2e/tickets/.env.json`](../tests/e2e/tickets/.env.json):

```json theme={null}
{
  "features": ["ticket"],
  "categories": ["Tickets"],
  "roles": ["Support"],
  "channels": ["e2e-ticket-send"]
}
```

QMod example [`tests/e2e/mod/.env.json`](../tests/e2e/mod/.env.json):

```json theme={null}
{
  "features": ["qmod"],
  "roles": ["Moderator"],
  "channels": ["e2e-mod-alerts", "e2e-automod-alerts"],
  "members": ["Arena", "e2e-target"]
}
```

Omit unused bags. The harness loads `.env.json` for each feature folder in the run, **skips** it during fixture discovery, and **gates** before tests: each feature must be activated; each category/role/channel/member name must exist in `TEST_SERVER`. Failures look like `tickets/.env.json: missing role "Support"` or `mod/.env.json: missing member "e2e-target"`.

`members` entries are display names, usernames, or snowflakes. Fixtures use those names in `select` / slash `user:#Name#[1]`. For string options that need a snowflake (e.g. `/mod log user-id:`), use `{member:Name}` — the harness substitutes the gated id.

Suggestion admin commands use autocomplete **`name:`** like ticket embeds — e.g. `name:[~e2e_suggest_admin]` (contains-match on the option label). Do not use numeric ids or `{suggestion:last}`.

Fixture `select` / command values use those **display names** (e.g. `"e2e-ticket-send"`), not bag indexes and not blind `[1]`.

### Stable targets

| Kind                                       | How to target                                    |
| ------------------------------------------ | ------------------------------------------------ |
| Category / role / channel / member prereqs | Names listed in `.env.json`                      |
| Snowflake-only slash fields                | `{member:Name}` from the members bag             |
| Suggestion admin / ticket get              | `name:[~e2e_…]` autocomplete contains-match      |
| Suite-created entities                     | Exact or `[~e2e_…]` this suite created           |
| Harness navigation                         | `test-logs`, `test-thread`, `form-channel`       |
| Positional `[1]` / `[n]`                   | Only when the same case fully controls that menu |

### Scoping by id

An open thread leaves its parent channel mounted, so two composers and two message lists exist at once. The harness passes `threadId` (and the `ui-ready` `messageId` when known) to `runPlan`, and the driver anchors on Discord's message-row ids (`chat-messages-{channelId}-{messageId}`):

* **type** → composer inside the thread panel, exact label before contains
* **select** → triggers inside that message row, else the thread's message list
* **press** / **modal** → the message row alone when its id is known, else thread panel then page-wide

A known message row is never widened to the thread or the page: earlier runs leave dead copies of the same button in the thread, and clicking one looks like a successful press that Discord silently drops.

Clicks settle before they fire: the driver scrolls the target into view, waits for its rect to stop moving (embeds loading and new messages shift the list), and only dispatches once the click point hit-tests back to the target. Without that, the mouse events land on a neighbour and the press looks fine while Discord sends no interaction at all. A click that never gets a clear point names whatever sat on top of it. `modal` also waits for the dialog to unmount after Submit, since the backdrop outlives the submit and swallows the next step's click.

Slash commands commit with a single Tab. The Windows plans send two, but on the web the second Tab starts filling the command's first argument and the trailing Enter then only closes that picker, leaving the command unsent. The composer is also cleared with Ctrl+A before typing, since a committed command chip survives a triple-click selection and a leftover draft stops the command picker from opening at all.

Before each `press` / `modal` / `select`, the driver logs every visible pressable it can see in that scope, so a miss shows what was actually on screen:

```
[clicker] buttons (message) [✨ Customize] [Save] [📝 About Me]
```

## Step shape

Each entry in `steps` is **one** UI action — no nested `plan`:

```json theme={null}
{
  "description": "**Profile: Customize and Save**",
  "steps": [
    { "command": "/profile" },
    { "press": "✨ Customize" },
    { "modal": "About Me", "values": ["hello status"] },
    { "select": 1, "values": "RED" },
    { "select": 2, "values": ["FPS", "MOBA"] },
    { "press": "Save" }
  ]
}
```

Supported keys per step: `command`, `press`, `select`, `modal`, `values`, `focusChannel`, `focusThread`, `type`, `key`, `wait`, `payload`.

* **`select`**: 1-based index of Discord select triggers under the scoped message/list. `values` is a string (single) or string array (multi); each entry is a contains-match on the option Name. Options are clicked, not walked with arrow keys, so nothing depends on scroll position, and a multi-select is toggled until exactly the listed values are on — anything the menu had pre-selected is clicked off. An unmatched value fails with the full option list.
* **`press` / `modal`**: by accessible Name, narrowest scope first (`modal` / Submit stay outside the messages list).
* **`focusChannel`**: click that channel in the guild sidebar. Value is an alias (`form-channel`, `test-logs`), a display name, or a Snowflake string. If the target is a thread (e.g. an opened ticket), the harness opens its parent then the starter.
* **`focusChannel` + `focusThread`**: open the parent via the sidebar, then click the thread starter. `focusThread` alone is invalid. Aliases: `test-logs` (parent) + `test-thread` (harness thread).

## UI-ready gate

* `press` / `modal` steps wait for CustomForm `test` / `ui-ready` (labels from **live message button components only**) before acting.
* `ui-ready` is bot-side and fires before Chrome paints the edit, so the driver then polls up to 10s for the label inside its scope, re-resolving the row each attempt (Discord re-renders the list and detaches the old node). `modal` also fails with "Modal did not open" rather than "no editable fields" when the press lands on a dead component.
* Fixture labels may omit Discord's emoji prefix: `"press": "Customize"` matches `✨ Customize`, and `"modal": "About Me"` matches `📝 About Me`. The step then presses the full rendered label. A timeout logs the labels that were actually seen; if nothing was emitted (e.g. ticket welcome Claim/Close), the fixture label is pressed and the driver polls for it.
* **`select` values** may be `[1]` / `[n]` to pick by position only when the same case controls that menu; prefer named / `.env.json` values for guild picks.
* Non-last steps advance as soon as the UI action succeeds (bag stays bound for mid-form flows).
* The **last** step waits for the form terminal event (`ended` / `error` / `warn`).

### Tickets fixtures

Each file is independent (own seed + teardown). Guild prereqs live in [`tickets/.env.json`](../tests/e2e/tickets/.env.json).

| File                 | Covers                                                                    |
| -------------------- | ------------------------------------------------------------------------- |
| `tickets/admin.json` | Panel/embed create, list, get, edit; teardown deletes                     |
| `tickets/panel.json` | Create panel+embed → Send to `e2e-ticket-send` → Open → close + delete    |
| `tickets/ops.json`   | Create → Send → Open → Claim / unclaim / Close; slash claim+close; delete |

Run any alone: `npm test -- tickets:panel`. Folder run `npm test -- tickets` runs whatever files are discovered (no required order). Best-effort cleanup cases set `"optional": true`.

### QMod fixtures

Guild prereqs: [`mod/.env.json`](../tests/e2e/mod/.env.json) (`qmod`, alert channels, `Moderator` role, two members — Chrome staff user + mute target).

| File                   | Covers                                                       |
| ---------------------- | ------------------------------------------------------------ |
| `mod/staff-setup.json` | `/admin-mod staff setup` alerts + mute/kick/ban rules        |
| `mod/auto-setup.json`  | `/admin-mod auto setup` Everyone/Here + timeout              |
| `mod/mute-ops.json`    | Seed mute rule → `/mod mute` → unmute → log → alerts channel |
| `mod/password.json`    | Password-gated mute (wrong then right)                       |

No live `/mod kick` / `/mod ban` (destructive). No native AutoMod message-fire. Run: `npm test -- mod` or `npm test -- mod:mute-ops`.

### Suggestions fixtures

Guild prereqs: [`suggest/.env.json`](../tests/e2e/suggest/.env.json) (`suggest`, channel `e2e-suggest-logs`).

| File                       | Covers                                                                                  |
| -------------------------- | --------------------------------------------------------------------------------------- |
| `suggest/setup.json`       | `/admin-suggest setup` logs channel + Utility Buttons customize                         |
| `suggest/submit-vote.json` | Seed setup → `/suggest` → Upvote/Downvote → Discussion thread                           |
| `suggest/admin-ops.json`   | Seed + submit → approve → clear → reject → reset → delete (`name:[~e2e_suggest_admin]`) |

Run: `npm test -- suggest` or `npm test -- suggest:admin-ops#admin-approve`.

### Embed Creator fixtures

Guild prereqs: [`embed/.env.json`](../tests/e2e/embed/.env.json) (`embed`, channel `e2e-embed-send`).

| File                  | Covers                                                                                  |
| --------------------- | --------------------------------------------------------------------------------------- |
| `embed/create.json`   | `/embed create` Author + Body + Save → list → get (`name:[~e2e_embed_create]`) → Delete |
| `embed/send.json`     | Create → get → Send → select `e2e-embed-send` → Submit                                  |
| `embed/edit-ops.json` | Create → Edit Author + Body → Save → Delete                                             |

Out of v1: Fields tab, color menu, Footer + Images URLs. Run: `npm test -- embed` or `npm test -- embed:send`.

### Reaction Roles fixtures

Guild prereqs: [`reaction/.env.json`](../tests/e2e/reaction/.env.json) (`reaction`, channel `e2e-reaction-send`, role `e2e-react-role` below Quarky’s highest role).

| File                     | Covers                                                                                             |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| `reaction/create.json`   | `/reaction-roles create` Embed + Add Role + Button → list → get (`name:[~e2e_rr_create]`) → Delete |
| `reaction/send.json`     | Create → get → Send → select `e2e-reaction-send` → Submit                                          |
| `reaction/edit-ops.json` | Create → Edit Embed → Save → Delete                                                                |

Out of v1: member register toggle, custom emoji / Image URL, Remove Role / multi-role. Run: `npm test -- reaction` or `npm test -- reaction:send`.

### Custom Forms fixtures

Guild prereqs: [`form/.env.json`](../tests/e2e/form/.env.json) (`form`, channels `e2e-form-send` + `e2e-form-logs`; Chrome user must match private-thread name `e2e - <username>` — `members` lists `Apex`).

| File                    | Covers                                                                             |
| ----------------------- | ---------------------------------------------------------------------------------- |
| `form/admin.json`       | `/form panel` + `/form embed` create/list/get/edit; teardown deletes               |
| `form/send-submit.json` | Panel+embed → Send to `e2e-form-send` → Open E2E Form → private thread chat answer |

Out of v1: other question types, Remove/multi-Q/multi-panel, re-submit, Image/emoji validators, ticket attach-form. Run: `npm test -- form` or `npm test -- form:send-submit#submit-answer`.

### Poll fixtures

Guild prereqs: [`poll/.env.json`](../tests/e2e/poll/.env.json) (channel `e2e-poll-send`; no feature flag — poll is ungated).

| File                  | Covers                                                                                       |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `poll/create.json`    | `/poll create` single-choice + two choices → list → get (`name:[~e2e_poll_create]`) → Delete |
| `poll/send-vote.json` | Create → get → Send to `e2e-poll-send` → press `(0)` → Delete                                |

Out of v1: multiple/ranked, Details, images/color, global/whats-next. Run: `npm test -- poll` or `npm test -- poll:send-vote#vote-act`.

### Giveaway fixtures

Guild prereqs: [`giveaway/.env.json`](../tests/e2e/giveaway/.env.json) (`giveaway`, channel `e2e-giveaway-send`).

| File                       | Covers                                                                                    |
| -------------------------- | ----------------------------------------------------------------------------------------- |
| `giveaway/create.json`     | `/giveaway create` Options + button → list → get (`name:[~e2e_giveaway_create]`) → Delete |
| `giveaway/start-join.json` | Create → Start & Send to `e2e-giveaway-send` → Enter → End → Delete                       |

Out of v1: auto-end/schedule, winners/reroll, host Edit, color/image menus. Run: `npm test -- giveaway` or `npm test -- giveaway:start-join#join-act`.
