Files
upstream-zulip/.claude/skills/visual-test/SKILL.md
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

375 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: visual-test
description: "Visually verify UI changes using Puppeteer screenshots. Use when you need to check layout, colors, positioning, or other visual aspects of a UI change."
---
# Visual Test
Runs a real browser against the Zulip test server and takes
screenshots you can read as images to verify layout, colors,
positioning, text content, etc.
## Steps
### 1. Write the puppeteer test script
Create `web/e2e-tests/_claude_<feature>_test.test.ts` using this template:
```typescript
import type {Page} from "puppeteer";
import * as common from "./lib/common.ts";
async function visual_test(page: Page): Promise<void> {
await common.log_in(page);
await common.screenshot(page, "step-1-logged-in");
// Navigate, interact, and screenshot each significant state.
// See "Available helpers" below.
}
await common.run_test(visual_test);
```
Adapt the body to exercise whatever UI you need to verify. Take a
screenshot at every visually significant state using descriptive names
like `step-2-color-picker-open`, `step-3-color-selected`.
**Important patterns:**
These patterns are derived from the existing Puppeteer tests in
`web/e2e-tests/`. Follow them to write reliable, non-flaky tests.
#### Waiting: never use hardcoded timeouts
The existing test suite has essentially zero `setTimeout` calls
(the two in `common.ts` are explicitly commented workarounds for
specific animation flakes). Always wait for the specific condition
you expect instead. The three main waiting primitives, in order of
preference:
- **`waitForSelector`** — wait for an element to appear or disappear.
This is the most common pattern in the test suite (100+ uses):
```typescript
// Wait for element to be visible (most common)
await page.waitForSelector("#left-sidebar", {visible: true});
// Wait for element to disappear (e.g., overlay closed, row deleted)
await page.waitForSelector("#subscription_overlay", {hidden: true});
```
- **`waitForFunction`** — wait for a condition that can't be
expressed as a single selector (text content, element count,
attribute value, application state):
```typescript
// Wait for specific text content
await page.waitForFunction(
() => document.querySelector(".save-button")?.textContent?.trim() === "Save changes",
);
// Wait for element count after filtering
await page.waitForFunction(
() => document.querySelectorAll(".linkifier_row").length === 4,
);
// Wait for an input's value to update
await page.waitForFunction(
() => document.querySelector<HTMLInputElement>("#full_name")?.value === "New name",
);
// Wait for focus to land on a specific element
await page.waitForFunction(
() => document.activeElement?.classList?.contains("search") === true,
);
// Wait for internal app state via zulip_test
await page.waitForFunction(
(content) => {
const last_msg = zulip_test.current_msg_list?.last();
return last_msg !== undefined && last_msg.raw_content === content
&& !last_msg.locally_echoed;
},
{},
content,
);
```
- **`waitForNavigation`** — only for actual full-page navigations
(form submits, reloads). Wrap with `Promise.all` when the
navigation is triggered by an action:
```typescript
await Promise.all([
page.waitForNavigation(),
page.$eval("form#login_form", (form) => { form.submit(); }),
]);
```
#### Interacting with elements
- **`page.click(selector)`** is the standard for clicking. When it's
unreliable (overlapping elements, timing), fall back to clicking
via `evaluate` — several existing tests do this with a comment
explaining why:
```typescript
// When page.click() is unreliable, click via the DOM directly
await page.evaluate(() => {
document.querySelector<HTMLElement>(".dialog_submit_button")?.click();
});
```
- **`page.type(selector, text)`** for typing. Use `{delay: 100}`
when typing triggers a typeahead or filter that needs per-keystroke
updates:
```typescript
await page.type('[name="user_list_filter"]', "ot", {delay: 100});
```
- **`common.clear_and_type(page, selector, text)`** to replace
existing input content (triple-click + Delete + type).
- **`common.fill_form(page, selector, params)`** to fill multiple
form fields at once — handles text inputs, checkboxes (by
toggling), and `<select>` elements.
- **`common.select_item_via_typeahead(page, selector, str, item)`**
to type into a field and pick a typeahead suggestion.
- **`page.keyboard.press("KeyC")`** for Zulip keyboard shortcuts.
After pressing, wait for the resulting UI change:
```typescript
await page.keyboard.press("KeyC");
await page.waitForSelector("#compose-textarea", {visible: true});
```
- **Hover before clicking action buttons** that only appear on
hover (e.g., message action icons):
```typescript
const msg = (await page.$$(".message_row")).at(-1)!;
await msg.hover();
await page.waitForSelector(".message-actions-menu-button", {visible: true});
await page.click(".message-actions-menu-button");
```
#### Navigating within the app
- **Click sidebar items** for in-app navigation:
```typescript
await page.click(".narrow-filter[data-stream-id='...'] .stream-name");
await page.waitForSelector("#message_view_header .zulip-icon-hashtag", {visible: true});
```
- **`page.goto(url)`** for hash-route navigation:
```typescript
await page.goto(`http://zulip.zulipdev.com:9981/#channels/${stream_id}/Denmark`);
```
- **`common.manage_organization(page)`** to navigate to org settings,
**`common.open_personal_menu(page)`** to open the personal menu.
#### Reading state with page.evaluate
Use `page.evaluate()` to read internal application state or DOM
properties not accessible through selectors:
```typescript
// Read internal Zulip state via the zulip_test global
const stream_id = await page.evaluate(
() => zulip_test.get_sub("Verona")!.stream_id,
);
// Read DOM properties
const page_language = await page.evaluate(
() => document.documentElement.lang,
);
```
#### Changing user settings via the API
Use `page.evaluate` with `fetch()` and reload, rather than clicking
through the settings UI:
```typescript
await page.evaluate(async () => {
const csrfToken = document.querySelector<HTMLInputElement>(
'input[name="csrfmiddlewaretoken"]',
)?.value ?? "";
await fetch("/json/settings", {
method: "PATCH",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
body: "user_list_style=1",
});
});
await page.reload({waitUntil: "networkidle2"});
```
#### XPath selectors for text matching
When you need to match elements by text content, use XPath with
`common.has_class_x()`:
```typescript
await page.waitForSelector(
`xpath///*[${common.has_class_x("stream-name")} and normalize-space()="Verona"]`,
);
```
#### Assertions for visual tests
For visual test scripts, prefer a soft-assertion pattern that
reports all failures rather than aborting on the first:
```typescript
const results: string[] = [];
function check(name: string, ok: boolean): void {
results.push(`${ok ? "PASS" : "FAIL"}: ${name}`);
console.log(`${ok ? "PASS" : "FAIL"}: ${name}`);
}
// ... run checks ...
const failures = results.filter((r) => r.startsWith("FAIL"));
console.log(`\n${results.length - failures.length}/${results.length} tests passed`);
```
This keeps the test running through failures so you see all results,
unlike `assert` which aborts on the first failure.
#### Verifying alignment
When using Puppeteer to verify visual alignment, do not rely on
eyeballing screenshots — especially small full-page ones. Instead:
- Use `page.evaluate()` with `getBoundingClientRect()` to measure
actual pixel positions of the elements you need aligned, and print
them to the console. Compare the numbers.
- Always take **both** a full-page screenshot and a zoomed clip of
the area of interest.
- For zoomed clips, calculate the clip region from non-fixed elements;
fixed/sticky elements may report bounding-box positions that don't
match their visual location on the page.
- Be aware that CSS nesting can scope styles to a specific parent
(e.g., `.parent .my-class`) — reusing the same class name in a
different context may not pick up the expected styles.
- To verify keyboard-focus styles, use real keyboard navigation
(`page.keyboard.press`); programmatic `.focus()` doesn't reliably
trigger `:focus-visible` and may be overridden by view-level focus
management.
- Focus rings drawn as `::before` / `::after` pseudo-elements aren't
visible in `getComputedStyle` of the focused element — verify them
in a screenshot, not via computed styles.
- For visual changes, produce before/after screenshot pairs by writing
one test and running it twice with a `SCREENSHOT_SUFFIX` env var
(`-old` on `main`, `-updated` on your branch).
### 2. Run the test
```bash
./tools/test-js-with-puppeteer _claude_<feature>_test
```
The runner matches test file names by prefix, so you don't need the
full filename or `.test.ts` suffix. This starts a fresh test server
on port 9981, runs the script, and saves screenshots to
`var/puppeteer/`. The test database is reset between test files.
On **aarch64 (ARM) hosts**, you must set `PUPPETEER_EXECUTABLE_PATH`
(see "Environment details" below):
```bash
PUPPETEER_EXECUTABLE_PATH=$(echo ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome) \
./tools/test-js-with-puppeteer _claude_<feature>_test
```
To run all existing Puppeteer tests, omit the test name argument.
**Timeout:** Tests can take 1–3 minutes each. Use a 300000ms timeout
for the Bash tool.
### 3. Read the screenshots
Use the Read tool on each `var/puppeteer/step-*.png` file. Claude's
multimodal vision will show the rendered page.
### 4. Analyze and report
Describe what you see — layout, colors, text content, positioning,
any issues. Compare against what was expected.
Zulip displays any JS exceptions encountered as a pop-up, but you should
also be able to get them from the puppeteer output.
### 5. Iterate if needed
If something is wrong, fix the source code (or adjust the test
script), then re-run from step 2.
### 6. Clean up
Leave test files as untracked `_claude_*` files so you can reuse them
when rebasing or iterating on the pull request. The `_claude_` prefix
is a convention to distinguish these from Zulip's committed test
files. Do not commit them.
## Environment details
- **Architecture:** On x86_64, Puppeteer's bundled Chrome works. On
**aarch64**, it does NOT (fails with rosetta/ld-linux errors). Use
`uname -m` to check. For aarch64, install Playwright's Chromium
and point `PUPPETEER_EXECUTABLE_PATH` at it (shown in step 2).
- **If the Playwright Chromium is missing**, install it:
```bash
npx --yes playwright install chromium
```
- **Headless mode:** Tests run headless (`headless: true` in
`common.ts`). There is no display server.
## Test infrastructure facts
- **Server URL:** `http://zulip.zulipdev.com:9981/`
- **Login:** `common.log_in(page)` uses credentials from
`var/puppeteer/test_credentials.json` (auto-generated by the test
harness). Default user is Desdemona (realm owner).
- **Known users:** `common.fullname.cordelia`, `.othello`, `.hamlet`
- **Screenshots:** saved to `var/puppeteer/<name>.png`
- **Window size:** 1400 x 1024
- **Test data:** The test database includes channels like "Verona",
"Denmark", "Scotland" with topics and messages. Non-system user
group `hamletcharacters` (members: Cordelia, Hamlet).
- **`zulip_test` global:** Only a limited set of internal functions
are exposed — see `web/src/zulip_test.ts`. Functions like
`get_stream_id` and `get_user_id_from_name` are available, but
`user_groups` is not. Navigate to groups via URL hash routes
or by clicking list items instead.
- **Database reset:** The test runner calls
`reset_zulip_test_database()` and `POST /flush_caches` between
test files, so each test file starts with a clean state.
- **`common.run_test()`** handles browser lifecycle, console log
forwarding with source-map resolution, automatic failure
screenshots, and logout at the end.
## Available helpers (from `web/e2e-tests/lib/common.ts`)
| Helper | Purpose |
| --------------------------------------------- | ---------------------------------------- |
| `common.log_in(page)` | Log in as the default user (Desdemona) |
| `common.screenshot(page, "name")` | Save `var/puppeteer/name.png` |
| `common.clear_and_type(page, selector, text)` | Clear input and type |
| `common.fill_form(page, selector, params)` | Fill multiple form fields at once |
| `common.wait_for_micromodal_to_open(page)` | Wait for modal open animation |
| `common.wait_for_micromodal_to_close(page)` | Wait for modal close animation |
| `common.get_stream_id(page, name)` | Get a stream's ID |
| `common.get_user_id_from_name(page, name)` | Get a user's ID |
| `common.open_personal_menu(page)` | Open the personal menu |
| `common.manage_organization(page)` | Navigate to org settings |
| `common.send_message(page, type, params)` | Send a stream or DM message |
| `common.send_multiple_messages(page, msgs)` | Send several messages in sequence |
| `common.select_item_via_typeahead(page, ...)` | Type into a field and select a typeahead |