---
url: https://nsh.dev/ru/docs/testing-and-trace
description: "Тестируйте приложения без графического интерфейса (headless): на Rust с помощью cargo test и nsh_testing или на TypeScript с помощью nsh test. Открывайте приложение, нажимайте, вводите текст, прокручивайте и ждите, как пользователь, подменяйте сеть и хранилище заглушками и читайте операции нативных представлений и тайминги, которые выводит --trace; testID для UI-тестов."
---

# Тестирование

NSH запускает приложения в режиме **headless**: настоящий движок (тот же реактивный граф, рендерер, компоненты, раскладка и виртуализация списков, что и на устройстве, и QuickJS для TSX) с кадрами отображения на 60 Гц, но без симулятора, устройства и окна. Он работает везде, где работают Cargo или CLI, включая Linux-машины CI, и решает две задачи:

- запускает тесты: каждый открывает приложение, выполняет шаги пользователя (нажатие, ввод, прокрутка) и проверяет, что показано на экране. Тесты Rust-приложения пишутся на Rust и запускаются с помощью `cargo test` с крейтом `nsh-testing`; тесты на TypeScript запускаются с помощью `nsh test`, как для TSX-приложений, так и для Rust-приложений;
- с `--trace` выводит каждую операцию, которую рантайм отправляет нативным представлениям, с таймингами: сколько стоят первый кадр, нажатие или кадр прокрутки.

<a id="writing-tests"></a>

## Написание тестов

Один и тот же тест на Rust и на TypeScript: он открывает приложение с пустым списком дел в хранилище, вводит текст, нажимает кнопки и проверяет, что показывает экран.

```rust
// tests/app/main.rs
use nsh_engine::{EngineConfig, Root};
use nsh_testing::{Headless, MemoryStorage};

#[test]
fn adds_a_todo() {
    let mut config = EngineConfig::new(Root::of::<my_app::App>());
    config.storage = Some(MemoryStorage::new([("todos".to_owned(), "[]".to_owned())]));
    let mut app = Headless::start(config).unwrap();
    app.type_text("id:new-todo", "Buy milk").unwrap();
    app.press("text:Add").unwrap();
    app.wait_for("text:Buy milk").unwrap();
    assert!(app.screen().texts().contains(&"1 item left"));
}
```

```tsx
// App.test.tsx
import { expect, open, test } from "nsh/test";

test("adds a todo", async () => {
  const app = await open("./App.tsx", { storage: { todos: "[]" } });
  await app.type("id:new-todo", "Buy milk");
  await app.press("text:Add");
  await app.waitFor("text:Buy milk");
  expect(app.texts()).toContain("1 item left");
});
```

<a id="tests-in-rust-with-cargo-test"></a>

## Тесты на Rust с cargo test

Тесты Rust-приложения — это интеграционные тесты его крейта, с `nsh-engine` и `nsh-testing` (его функции по умолчанию, без QuickJS) в качестве dev-зависимостей:

```toml
[dev-dependencies]
nsh-engine = "0.1"
nsh-testing = "0.1"
```

```sh
cargo test                    # every test of the crate
cargo test adds_a_todo        # the tests whose name contains "adds_a_todo"
```

`EngineConfig::new(root)` — это движок размером с телефон (390×844 пунктов), в котором больше ничего не задано; его поля задают то же, что задают опции `open` ниже: `storage` (`MemoryStorage::new(entries)`), `network` (`StubNetwork`, см. ниже), `viewport`, `environment` (внешний вид, локаль, уменьшение анимации), `clock` (`Clock::manual(time)`, см. ниже). `nsh_testing::Headless::start(config)` запускает приложение и хранит модель показываемых им представлений. Его шаги — это шаги `nsh test` ниже, с теми же селекторами, но на Rust: `press`, `long_press`, `clear`, `type_text`, `submit`, `scroll(selector, px(3000.0))`, `back`, `wait`, `wait_for`, `wait_for_gone`, `wait_until`, `run_for`, `fast_forward`, `set_system_time`, `set_viewport`, `set_clipboard` и `on_dialog`; `app.shows(text)` и `app.screen()` (`texts()`, `count(selector)`, `enabled(selector)`, `dialogs()`, `clipboard()`, `commands()`) читают то, что есть на экране. Если шаг невозможно выполнить, он возвращает ошибку с текстами, показанными на экране.

`nsh_testing::tsx(entry, sets)` (функция `bundle`) таким же образом открывает TSX-точку входа приложения, собранную с наборами UI из NSH и из крейта ([Сборка с Rust](https://nsh.dev/translations/ru/docs/rust.md#testing)). `examples/gallery-rs` так тестирует свой корень на Rust, по одному модулю на каждое демо; `examples/rust-exports` тестирует свою TSX-точку входа из Rust.

`nsh test` тоже запускает эти тесты: получив каталог приложения с Rust-крейтами (`nsh.json` `"rust"`), он перед файлами тестов запускает `cargo test` для каждого крейта в нём, а Rust-тесты показывает и учитывает вместе с TypeScript-тестами (`✓ src/lib.rs › tests::adds`) в своей сводке и в отчёте `--junit`. Поэтому `nsh test` — единая команда для Rust-приложения, для TSX-приложения с крейтом и для TSX-приложения без него, а `nsh check` аналогично запускает `cargo check --all-targets` для крейтов перед проверкой типов TypeScript.

<a id="tests-with-nsh-test"></a>

## Тесты с nsh test

Файл теста — это файл `*.test.tsx` или `*.test.ts` рядом с вашим кодом. Он импортирует `nsh/test`, как это делает `App.test.tsx` выше:

```sh
nsh test                      # every test file of the app in the current directory
nsh test App.test.tsx         # one file
nsh test --filter "adds"      # the tests whose name contains "adds"
```

```text
✓ App.test.tsx › adds a todo (84ms)
✗ App.test.tsx › keeps todos after a restart (312ms)
    expected ["Todos","0 items left"] to contain "1 item left"
    at <anonymous> (App.test.tsx:21:22)
    on screen: "Todos", "0 items left"
error 2 tests: 1 passed, 1 failed (0.9s)
```

Упавший тест показывает, что было выброшено, в каком месте файла теста, и тексты на экране открытого им приложения. Любое падение приводит к завершению `nsh test` с ненулевым кодом. `nsh init` создаёт `App.test.tsx` с приложением, а `nsh check` проверяет типы файлов тестов вместе с остальным кодом (`.nsh/types` объявляет `nsh/test`).

Каждый файл теста выполняется на собственном движке, а его тесты идут один за другим, по порядку. `open` запускает приложение на другом движке, каждый раз новом, поэтому тесты никогда не делят состояние; приложения, открытые тестом, закрываются, когда он заканчивается. Тест падает, когда выбрасывает исключение, когда возвращённый им промис отклоняется, и по истечении таймаута: 30 секунд или число миллисекунд, заданное третьим аргументом `test` (`test("syncs", body, 60_000)`). Приложение со своими Rust-крейтами (`nsh.json` `"rust"`) запускает тесты в headless-раннере, который NSH собирает вместе с ними, точно так же; в Rust-приложении (`nsh.json` `"root"`, [Сборка с Rust](https://nsh.dev/translations/ru/docs/rust.md)) `open(".")` запускает его корневой компонент.

<a id="grouping-tests-and-setting-them-up"></a>

### Группировка тестов и их настройка

`describe`, хуки и варианты `test` работают как в Jest:

```tsx
import { beforeEach, describe, expect, open, test } from "nsh/test";
import type { App } from "nsh/test";

describe("the cart", () => {
  let app: App;
  beforeEach(async () => {
    app = await open("./App.tsx", { storage: { cart: "[]" } });
    await app.press("Cart");
  });

  test("starts empty", () => {
    expect(app.texts()).toContain("No items");
  });

  test.todo("applies a coupon");
});
```

| Функция | Описание |
|---|---|
| `describe(name, body)` | Группирует тесты и блоки, которые объявляет `body` (сразу, при загрузке файла): их имена начинаются с `name` (`the cart › starts empty`), а объявленные в нём хуки выполняются только вокруг них. Блоки можно вкладывать. |
| `beforeEach(fn, timeout?)`, `afterEach(fn, timeout?)` | Выполняют `fn` перед каждым тестом и после него в блоке, где они объявлены (файл, `describe`): сначала `beforeEach` внешних блоков, сначала `afterEach` внутренних. Упавший `beforeEach` роняет тест; `afterEach` выполняется в любом случае. |
| `beforeAll(fn, timeout?)`, `afterAll(fn, timeout?)` | Выполняют `fn` один раз перед первым и после последнего теста своего блока, если запускается хотя бы один его тест. Упавший `beforeAll` роняет тесты блока; упавший `afterAll` отображается как упавший `<block> › afterAll`. Приложение, открытое в `beforeAll`, остаётся открытым для тестов блока и закрывается после его `afterAll`. |
| `test.skip(name, body, timeout?)`, `describe.skip(name, body)` | Объявляет тест или блок, тесты которого не выполняются: они отображаются как пропущенные. |
| `test.only(name, body, timeout?)`, `describe.only(name, body)` | Пока в файле теста есть такие объявления, выполняются только эти тесты и блоки; остальные тесты файла отображаются как пропущенные. |
| `test.todo(name)` | Тест, который ещё предстоит написать, отображается как таковой. |

Хук может быть `async`; его `timeout` равен 30 секундам, если не задан иначе. Тесты и хуки объявляются при загрузке файла: тест, объявленный внутри теста или хука, выбрасывает исключение.

<a id="opening-the-app"></a>

### Открытие приложения

`open(entry, options?)` собирает приложение из `entry` (относительно файла теста; каждая точка входа собирается один раз за запуск), запускает его с вьюпортом 390×844 и завершается, когда приложение отрисовано и находится в покое. С `--target` приложение запускается с опциями, как их задаёт тест: если платформа цели не может выполнить одну из них, `open` отклоняется с `ERR_NSH_TEST_UNSUPPORTED`, называя её (см. [На реальной платформе](#on-a-real-platform)).

| Опция | По умолчанию | Описание |
|---|---|---|
| `storage` | пусто | Что содержит `Storage` в начале: `{ todos: "[]" }`. |
| `network` | нет | Какие ответы получают запросы приложения, по маршрутам: см. [Подмена сети](#stubbing-the-network). `"live"`: настоящая сеть, для его запросов, сокетов и WebSocket. |
| `width`, `height` | 390, 844 | Вьюпорт в пунктах: в headless, окно на десктопах, страница в вебе. На iOS и Android приложение занимает весь экран устройства. |
| `safeArea` | 0 | Отступы безопасной области в пунктах: `{ top: 47, bottom: 34 }`. |
| `clipboard` | пусто | Текст в буфере обмена в начале. |
| `colorScheme` | `"light"` | Внешний вид системы: `"light"` или `"dark"` (классы `colorScheme()`, `dark:`). |
| `locale` | `"en-US"` | Локаль устройства, тег BCP 47 (`locale()`, значение по умолчанию `Intl`). |
| `reducedMotion` | `false` | Пользователь попросил уменьшить анимацию (`reduceMotion()`). |
| `platform` | `"ios"` | Платформа, как которая работает приложение в headless: `"ios"`, `"android"` или десктопная, `"macos"`, `"windows"`, `"linux"`, либо `"web"` в десктопной идиоме (`platform.os`, свойства `ios={{…}}` / `android={{…}}` / `desktop={{…}}`, клавиатурные сочетания). С `--target` приложение работает как платформа цели: десктопная `platform` на любом десктопе, а веб — всегда как десктоп (в браузере в десктопной идиоме при любой ширине, `platform.os` остаётся `"web"`), `"ios"` и `"android"` — каждая как сама по себе. |
| `clock` | системные | Ручные часы, время которых движется только тогда, когда его двигает тест: `{}` или `{ time }`, чтобы начать с момента `Date` (или с миллисекунд с эпохи Unix). См. [Управление временем](#controlling-time). |
| `host` | нет | Роль встраивающего приложения, которую играет тест: `{ params, handlers }`. См. [Игра за хост-приложение](#playing-the-host-app). |

<a id="playing-the-host-app"></a>

### Игра за хост-приложение

Приложение, встроенное в нативное приложение, общается с ним через [`host`](https://nsh.dev/translations/ru/modules/nsh.md#host). В тесте хост-приложение играет сам тест: `host.params` задаёт параметры запуска, которые приложение читает при старте, а `host.handlers` отвечает на `host.call` приложения и слышит его `host.post` по имени. Обработчик — это значение, которое отвечает на каждый вызов, или функция от данных вызова, возвращающая ответ (или промис с ним). То, что выбрасывает функция, отклоняет вызов, как ошибка хост-приложения. Вызов имени, которого нет в списке `handlers`, завершается ошибкой, как в хост-приложении, не обрабатывающем ничего.

```tsx
const app = await open("./App.tsx", {
  host: {
    params: { conversation: "c-42" },
    handlers: {
      "user.profile": async ({ id }) => ({ id, name: "Ada" }),
      "feature.flags": { search: true },
      analytics: null,
    },
  },
});
await app.press("Profile");
expect(app.host.calls()).toEqual([{ name: "user.profile", data: { id: 7 } }]);
expect(app.host.posts()).toEqual([{ name: "analytics", data: { screen: "profile" } }]);
// The app's `host.on("conversation.open", …)` hears it.
await app.host.send("conversation.open", { id: "c-43" });
expect(app.shows("Conversation c-43")).toBe(true);
```

`app.host.send(name, data)` отправляет событие, а затем ждёт, пока приложение не окажется в покое. `app.host.calls()` и `app.host.posts()` перечисляют то, что получили обработчики, по порядку: `name` и `data`.

URL, которые открывает приложение ([`openUrl`](https://nsh.dev/translations/ru/modules/nsh.md#openurl), ссылки `TextView` с `links="open"`), записываются и сообщаются приложению как открытые, при этом на этой машине ничего не открывается. `app.openedUrls()` перечисляет их:

```tsx
await app.press("Contact us");
expect(app.openedUrls()).toEqual(["mailto:help@example.com"]);
```

<a id="stubbing-the-network"></a>

### Подмена сети

`network` перечисляет маршруты по `"<METHOD> <URL>"` (голый URL — это `GET`; `*` соответствует любому тексту). Запрос, которому не соответствует ни один маршрут, завершается ошибкой, поэтому тесты никогда не выходят в сеть без запроса. Маршрут — это ответ или функция от запроса, возвращающая его (или промис с ним); в Rust `StubNetwork::new(routes)` принимает те же маршруты, каждый — `StubResponse` или `StubRoute::Handler`:

```rust
use std::sync::Arc;
use nsh_engine::{EngineConfig, Root};
use nsh_testing::{Headless, StubNetwork, StubResponse};

#[test]
fn adds_a_todo_on_the_server() {
    let mut todos = StubResponse::default();
    todos.status = 200;
    todos.headers = vec![("content-type".to_owned(), "application/json".to_owned())];
    todos.body = br#"[{ "id": 1, "title": "Walk" }]"#.to_vec();
    let mut created = StubResponse::default();
    created.status = 201;
    let mut offline = StubResponse::default();
    offline.error = Some("offline".to_owned());
    let network = Arc::new(StubNetwork::new([
        ("GET https://api.example.com/todos".to_owned(), todos),
        ("POST https://api.example.com/todos".to_owned(), created),
        ("GET https://api.example.com/sync".to_owned(), offline),
    ]));
    let mut config = EngineConfig::new(Root::of::<my_app::App>());
    config.network = Some(network.clone());
    let mut app = Headless::start(config).unwrap();
    app.press("Add").unwrap();
    let methods: Vec<String> = network.requests().iter().map(|r| r.method.clone()).collect();
    assert_eq!(methods, ["GET", "POST"]);
}
```

```tsx
const app = await open("./App.tsx", {
  network: {
    "GET https://api.example.com/todos": { body: [{ id: 1, title: "Walk" }] },
    "POST https://api.example.com/todos": (request) => ({ status: 201, body: JSON.parse(request.body!) }),
    "GET https://api.example.com/todos/*": { status: 404 },
    "GET https://api.example.com/sync": { error: "offline" },
    "GET https://api.example.com/slow": { body: "done", delay: 2000 },
  },
});
await app.press("Add");
expect(app.requests().map((r) => r.method)).toEqual(["GET", "POST"]);
```

| Поле ответа | По умолчанию | Описание |
|---|---|---|
| `status` | 200 | Код статуса. |
| `headers` | нет | Имена и значения заголовков. |
| `body` | пусто | Строка или значение, отправляемое как JSON (с `content-type: application/json`, если `headers` не задают другой). |
| `error` | нет | Запрос вместо этого завершается ошибкой с этим сообщением, как при отсутствии сети на устройстве. |
| `delay` | 0 | Сколько времени занимает ответ, в миллисекундах; шаг его не ждёт, а `waitFor` ждёт. На ручных часах ответ приходит, когда часы проходят это время. |

Функция маршрута получает запрос (`method`, `url`, `headers`, `body` как текст) и выполняется в файле теста, поэтому может считать запросы или отвечать исходя из того, что подготовил тест. Функция, которая выбрасывает исключение, роняет запрос и шаг, который его выполнил. `app.requests()` перечисляет запросы, сделанные приложением, по порядку, так же.

Соединения тоже остаются в тесте: сокеты приложения (`net`) и WebSocket (глобальный `WebSocket` и модуль `websocket`) ничего не открывают, если `network` не равен `"live"`. После проверок возможностей, которые проходит само приложение, `net.connect` и `connect` модуля `websocket` отклоняются с `code` `ERR_NSH_TEST_NETWORK`, а глобальный `WebSocket` вызывает `error`, затем `close` (1006, не чистое), как при соединении, которое отклонила сеть. С `"live"` они подключаются к настоящей сети наравне с запросами приложения. В Rust `EngineConfig::refuse_connections` делает то же для приложения `Headless`: это функция от того, к чему подключается приложение (URL WebSocket, `host:port` сокета), возвращающая ошибку, которой завершается его вызов.

<a id="controlling-time"></a>

### Управление временем

На системных часах таймеры и нативные анимации приложения занимают столько же времени, сколько на устройстве, как и тесты, которые их ждут. С ручными часами (`EngineConfig::clock` в Rust, `clock` у `open` в TypeScript) приложение работает на часах, время которых движется только тогда, когда его двигает тест, и сразу, как с фейковыми таймерами Jest или `page.clock` в Playwright. `app.clock` — это эти часы, а `null`, когда приложение работает на системных:

```rust
use std::time::{Duration, SystemTime};
use nsh_engine::{Clock, EngineConfig, Root};
use nsh_testing::Headless;

#[test]
fn autosaves() {
    let mut config = EngineConfig::new(Root::of::<my_app::App>());
    // 2026-01-01T09:00:00Z
    config.clock = Clock::manual(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_258_000));
    let mut app = Headless::start(config).unwrap();
    app.type_text("label:Note", "Milk").unwrap();
    app.run_for(Duration::from_secs(10)).unwrap(); // the autosave, ten seconds later
    assert!(app.shows("Saved at 09:00:10"));
}
```

```tsx
const app = await open("./App.tsx", { clock: { time: new Date("2026-01-01T09:00:00Z") } });
await app.type("label:Note", "Milk");
await app.clock!.runFor(10_000);           // the autosave, ten seconds later
expect(app.texts()).toContain("Saved at 09:00:10");
```

`StubNetwork`, у ответов которого есть `delay`, следует за ручными часами с `.with_clock(clock.clone())`. Шаги ниже принадлежат `nsh test`; у `Headless` есть каждый из них (`run_for`, `fast_forward`, `set_system_time`).

| Шаг | Описание |
|---|---|
| `await app.clock.runFor(ms)` | Двигает часы на `ms` миллисекунд, по одному кадру отображения. Таймеры, `requestAnimationFrame`, нативные анимации и отложенные ответы выполняются по мере наступления срока, по порядку, каждый в своё время. Завершается, когда приложение в покое. |
| `await app.clock.fastForward(ms)` | Двигает часы на `ms` миллисекунд сразу, как устройство, выходящее из сна: таймеры, срок которых наступил за это время, выполняются по одному разу. |
| `app.clock.setSystemTime(time)` | Устанавливает настенное время (`Date.now()`, `new Date()`) в `Date` или миллисекунды, не двигая ни один таймер. |

`Date`, `performance.now()` и дата по умолчанию у `Intl` следуют за часами. Шаги завершают то, что выполняется, не ожидая этого: пока приложение запрашивает кадры или идёт нативная анимация, кадры идут один за другим, каждый на время кадра (1/60 с) позже по часам. Переход в 300 мс занимает несколько миллисекунд, и таймер, срок которого наступает во время него, выполняется. Анимация, которая повторяется бесконечно (спиннер), идёт вместе с часами: она вращается по мере того, как тест двигает часы. `app.wait(ms)` и долгое нажатие двигают часы на своё время. Таймер, срок которого наступает позже, выполняется, только когда тест подвинет часы до него. `waitFor` и `waitUntil` двигают часы, пока ждут, по одному кадру отображения, проверяя после каждого кадра, поэтому то, что показывает таймер, появляется по мере ожидания, за мгновение: `waitFor("Saved", { timeout: 15_000 })` двигает часы максимум на пятнадцать секунд. Их таймаут считается по обоим часам: ожидание двигает часы приложения не более чем на него и отклоняется, когда прошло столько же реального времени, поэтому то, что приходит извне приложения (функция маршрута, сеть), имеет столько же времени, как на системных часах. Тесты галереи работают так (`examples/gallery/tests`): её двенадцать тестов, ожидающих анимации, занимают 6 с вместо 28 с.
<a id="steps"></a>

### Шаги

Шаги указывают представления с помощью **селекторов**, записанных так же, как в Playwright:

| Селектор | Совпадает с |
|---|---|
| `id:<testID>` | Представление с этим `testID`. |
| `label:<label>` | Представление с этим `accessibilityLabel`. |
| `text:<text>` | Текст, который читается так (пробелы вокруг не учитываются), фрагмент текста, принимающий нажатия (вложенный `<Text onPress>`, ссылка `TextView`), или поле ввода, содержащее его или, пока оно пусто, с таким плейсхолдером. |
| `role:<role>`, `role:<role>[<name>]` | Представление с этой ролью `role` (`role:button`) и этим именем: его метка, иначе тексты внутри него (`role:button[Save]`). |
| `view:<id>` | Представление с этим id в выводе `--trace`. |
| всё остальное | `testID`, иначе метка, иначе текст, как записано. |

Часть, которая начинается со слова в нижнем регистре и двоеточия, называет префикс (`id`, `label`, `role`, `text`, `view`), поэтому
`tapped: solo` — это ошибка шага со списком префиксов. Чтобы найти текст, метку или test id, которые начинаются так, используйте соответствующий префикс, который читает остальное как написано: `text:tapped: solo`, `label:note: draft`, `id:row:2`. Селекторы сохраняют своё значение
при появлении новых префиксов.

`label:`, `text:` и имя роли также принимают регулярное выражение: `text:/^Buy/i`, записанное в синтаксисе крейта Rust `regex`
(классы, группы, якоря и квантификаторы JavaScript, сопоставление за линейное время; флаги `i`, `m` и `s`). ` >> ` ищет
внутри того, что совпало в предыдущей части (`id:cart >> role:button[Remove]`), а `nth=<n>` оставляет одно из совпадений, начиная с 0
(`text:Delete >> nth=1`). Селектор находит представления, на которые пользователь посмотрел бы в первую очередь: в верхнем модальном окне, затем на экране,
а из навигационного стека — только верхний экран. Он совпадает с тем, что видит пользователь: представления с площадью, вне `visibility: hidden`, внутри окна и
представлений, которые их обрезают (`overflow: hidden`), или в любом месте содержимого scroll view, которое пользователь может прокрутить и которое
прокрутка выводит на экран (`opacity` и трансформации не учитываются, как в Playwright). Шаг, который действует на представление (`press`, `type`, `clear`, `submit`, `scroll`, `longPress`), требует ровно
одного: селектор, совпавший с несколькими, проваливает шаг, а ошибка подсказывает, как сузить выбор.

| Шаг | Описание |
|---|---|
| `await app.press(selector)` | Нажимает на представление или на ближайшее окружающее представление, которое слышит нажатия (кнопку вокруг текста): `onPressDown`, `onPressUp`, `onPress`, внутри — палец, который опускается и поднимается, и его слышат окружающие представления, слушающие касания (`onPressOutside` оверлея). Фрагмент текста нажимается по его символам (`await app.press("Privacy policy")`), в headless-режиме, на iOS, Android и десктопах. Второе нажатие на то же представление в течение 500 мс — это его второй клик (`clicks: 2`, двойное нажатие), как их считают платформы. Отклоняется, если представление отключено. |
| `await app.longPress(selector)` | Удерживает на нём палец (или на ближайшем окружающем представлении, которое слышит нажатия или касания) в течение его `delayLongPress` (500 мс): `onPressDown`, `onLongPress`, `onPressUp`; `onPress` не следует. Обработчики касаний слышат палец (открывается `ContextMenu` или `Tooltip`). |
| `await app.clear(selector)` | Очищает поле ввода, как выделение всего и удаление. |
| `await app.type(selector, text)` | Вводит текст в поле ввода (или в первое поле ввода внутри представления; `text:` также совпадает с текстом или плейсхолдером поля) в конец того, что в нём есть, клавиша за клавишей: поле получает фокус, затем каждый символ — это `onKeyPress` и `onChange`, в пределах `maxLength`. `\n` в однострочном поле — это клавиша Return. |
| `await app.submit(selector)` | Клавиша Return в поле ввода, как указано в его `submitBehavior`: `onSubmit` (и фокус покидает однострочное поле) или перевод строки в многострочном. |
| `await app.scroll(selector, distance)` | Перетаскивает scroll view или список (само представление, окружающее его или первое внутри него) на `distance` пунктов вдоль его оси, по 10 пунктов за кадр, в пределах его содержимого, как это может сделать пользователь (`scrollable`). Отрицательное значение прокручивает назад. |
| `await app.hover(selector)` | Перемещает мышь над серединой представления: представления под ней, которые она покинула, слышат `onMouseLeave`, те, над которыми она оказалась, — `onMouseEnter` и принимают свои стили `hover:`, а слушающие слышат `onMouseMove` с позицией мыши и без нажатой кнопки. Десктопы и веб при любой ширине, включая браузер в идиоме телефона (headless: десктоп или `"web"` `platform`); у iOS- или Android-телефона нет мыши: там шаг отклоняется. |
| `await app.rightClick(selector)` | Вторичная кнопка мыши над серединой представления: оно или ближайшее окружающее слушающее представление слышит `onContextMenu`. Десктопы и веб (headless: десктоп `platform`); у телефона нет мыши: там шаг отклоняется. |
| `await app.paste(selector, { text?, html?, files?, uris? })` | Вставляет в поле ввода, как это делает пользователь, то, что лежит в буфере обмена: поле получает фокус и слышит `onPaste` (`html` — то же содержимое с форматированием, `files` — пути, байты которых оно получает как `File`, `uris` `file:` — URL), затем его текст вставляется так, как сказано в `pasteBehavior`. Headless, на macOS, iOS и в вебе; буфер обмена машины остаётся прежним. |
| `app.menu()` | Действия контекстного меню, которое показывает приложение (`Menu.popup`): `label` (действия подменю — после его собственного, `"Share > Mail"`), `disabled` и `checked` для пункта с флажком; `null`, если ни одно меню не открыто. |
| `await app.chooseMenuItem(label)` | Выбирает действие открытого контекстного меню, как клик по нему: приложение слышит `onMenu` (выполняется его `onSelect`), затем меню закрывается. Отклоняется, если пункт отключён или отсутствует. |
| `await app.closeMenu()` | Закрывает открытое контекстное меню без выбора, как Escape. |
| `await app.keyboard.press(keys)` | Нажимает клавишу с модификаторами, как её называет `keymap` (`"mod+k"`, `"shift+/"`, `"escape"`; `mod` — клавиша Command платформы), на аппаратной клавиатуре, на каждой платформе (у телефонов и планшетов она тоже бывает): её слышит сочетание клавиш приложения (`keymap.bind`, `onKeyCommand`), иначе на macOS и в вебе Tab и Shift+Tab перемещают фокус клавиатуры между полями ввода и представлениями, принимающими нажатия (стили `onFocus`, `onBlur`, `focus:`), а Return или Space нажимают представление, на котором стоит фокус, иначе сфокусированное поле принимает символ, Backspace или клавишу Return, иначе Escape возвращает назад. |
| `await app.keyboard.down(keys)` / `up(keys)` | Удерживает клавиши-модификаторы (`"mod"`, `"shift"`, `"alt"`, `"ctrl"`, `"meta"`, соединяются через `+`) до `up`: нажатия, перетаскивания и наведения за это время несут их, как Mod-клик или Shift-перетаскивание. Пока только headless и на Linux. |
| `await app.dragAndHold(selector, by)` / `app.drop()` | Перетаскивает так же, как `drag`, и держит палец опущенным до `drop()`, который поднимает его там, где он находится: шаги между ними видят экран посреди перетаскивания (превью перетаскивания, линию drop). Пока только headless и на Linux. |
| `await app.dropFiles(selector, uris)` | Сбрасывает локальные файлы (URL `file:`, как их перетаскивает файловый менеджер) на середину представления: оно или ближайшее окружающее слушающее представление слышит `onFileDrop`. Десктопы, headless и пока только Linux. |
| `await app.drag(selector, { x, y })` | Палец касается середины представления, лежит 100 мс, смещается на `x`, `y` пунктов, по 10 за кадр, и поднимается, как это воспринимает ввод платформы: за ним следует pan (`pan={{ x, y }}`, `onPanStart`, `onPanEnd` со скоростью при отпускании), его слышат представления, слушающие касания (`onTouchStart`, `PanResponder`), scroll view прокручивается вместе с ним и, если потянуть достаточно далеко за верхний край, обновляется (`onRefresh`), а нажатие под ним отменяется, как только палец уходит в сторону. На десктопах и в вебе это мышь. |
| `await app.wait(ms)` | Даёт приложению поработать `ms` миллисекунд: таймеры, анимации, запросы. На ручных часах сдвигает их на `ms` (`app.clock.runFor`). |
| `await app.waitFor(selector, options?)` | Завершается, когда представление совпало, или с `{ state: "gone" }`, когда не совпало ни одно; отклоняется через `timeout` мс (5000). На ручных часах двигает их по одному кадру дисплея, пока ждёт, не более чем на `timeout`. |
| `await app.waitUntil(condition, options?)` | Завершается, когда `condition()` возвращает true, проверяя снова после каждого изменения приложения: для того, что не выражается селектором (`() => app.storage().note === "Milk"`). На ручных часах двигает их на один кадр дисплея между двумя проверками, не более чем на `timeout`. |
| `app.shows(selector)` | Совпадает ли сейчас представление. |
| `app.count(selector)` | Сколько представлений совпадает сейчас. |
| `app.enabled(selector)` | Принимает ли представление ввод: ни оно, ни окружающее его представление не отключены, а поле ввода не `readonly`. |
| `app.texts()` | Тексты на экране сейчас, по порядку. |
| `app.value(selector)` | Текст поля ввода, `null`, если ничего не совпало. |
| `app.spanStyle(selector)` | Вычисленный стиль первого совпавшего символа, в headless-режиме и на каждой `--target`: `color`, необязательный `backgroundColor`, `fontSize`, `fontWeight`, `italic`, `underline` и `lineThrough`. Цвета — `#rrggbbaa`; размеры в пунктах. |
| `app.storage()` | Что сейчас хранится в хранилище приложения. |
| `await app.back()` | Возвращается назад, как это делает пользователь (кнопка «назад» в заголовке, смахивание листа вниз): самый внутренний стек новейшего модального окна, иначе экрана, убирает свой верхний экран (`onDismiss`) или спрашивает его (`onBackRequest`), если есть `preventBack`; без него новейшее модальное окно просит закрыть себя (`onRequestClose`). Отклоняется, если возвращаться не откуда. |
| `app.onDialog(handler)` | Отвечает на диалоги, которые приложение показывает с этого момента (`Alert.alert`, action sheet): `handler` получает диалог (`title`, `message`, `buttons`, `cancelable`, `sheet`) и возвращает текст кнопки для нажатия или `null`, чтобы закрыть диалог без выбора. |
| `app.dialogs()` | Диалоги, которые показало приложение, по порядку. |
| `app.onPick(handler)` | Отвечает на запросы выбора, которые приложение делает с этого момента (`Picker.pickImages`): `handler` получает то, что запрашивает выбор (`multiple`, `limit`, `maxDimension`, `quality`, `format`), и возвращает файлы, выбранные пользователем (`{ name, type?, data: Uint8Array }`, передаются приложению как есть), или `null` для отмены. Без обработчика выбор отменяется. Headless-запуски. |
| `app.picks()` | Запросы выбора, которые сделало приложение, по порядку. |
| `app.hear(text)` | Пользователь произносит `text` в текущую сессию распознавания речи (`Speech.start`): её `result` сообщает `text` как весь транскрипт на данный момент. Headless-запуски. |
| `app.endSpeech(error?)` | Завершает текущую сессию распознавания речи: с `error` (`"no-speech"`, `"network"`, ...) она завершается ошибкой, иначе приходят последний финальный результат и `end`, `no-speech`, если ничего не было услышано. Headless-запуски. |
| `app.speechRequests()` | Сессии распознавания речи, которые запустило приложение, по порядку: `lang` (`null` для пользовательской) и `interim`. |
| `app.clipboard()` | Текст в буфере обмена сейчас, `null`, если там его нет. |
| `app.requests()` | Запросы, которые сделало приложение, по порядку: `method`, `url`, `headers`, `body`. |
| `await app.host.send(name, data?)` | Отправляет приложению событие хост-приложения (`host.on`), затем ждёт, пока всё затихнет. См. [Работа с хост-приложением](#playing-the-host-app). |
| `app.host.calls()`, `app.host.posts()` | Вызовы и сообщения приложения, которые получил `host.handlers` у `open`, по порядку: `name`, `data`. |
| `app.openedUrls()` | URL, которые открыло приложение (`openUrl`), по порядку: они записываются, но никогда не открываются. |
| `await app.setViewport(width, height)` | Изменяет размер viewport, как поворот устройства или изменение размера окна (`windowSize()`). |
| `await app.setColorScheme(scheme)` | Переключает внешний вид системы на `"light"` или `"dark"`, как это делает пользователь. |
| `await app.setLifecycle(state)` | Переводит приложение в `"background"`, `"inactive"` или `"active"`, как это делает система, когда пользователь уходит из приложения, переключается на другое приложение или окно и возвращается (`lifecycle()`). Только headless-запуски. |
| `await app.screenshot(path?)` | Снимок окна приложения в формате PNG (`Uint8Array`), который также сохраняется в `path` на этой машине, если путь указан (относительно текущего каталога). Приложение рисует только реальная платформа: см. [На реальной платформе](#on-a-real-platform). |

Шаг завершается, когда приложение ничего не отрисовывало 100 мс и не идёт нативная анимация, которая заканчивается сама (максимум 2 с, для приложений, которые рисуют постоянно; на ручных часах — также 2 с времени приложения), так что то, что он изменил, уже на экране, когда выполняется следующая строка; для того, что происходит позже (ответ, таймер), используйте `waitFor`. На ручных часах шаг не ждёт эти 100 мс: он завершается, как только приложение выполнило всё, что стало должно выполниться по шагу и времени часов (таймеры, срок которых наступил, промисы, подставленный ответ, отданный сразу), и не требует кадра, если только запрос ещё не в полёте, поэтому тесты на ручных часах идут без пауз между шагами. То, что отвечает другой поток (вызов `nsh/host`, чтение файла, работа задачи Rust в потоке), приходит после завершения шага: дождитесь через `waitFor` того, что он показывает. Анимация, которая повторяется бесконечно, например индикатор загрузки, продолжает идти в фоне, пока выполняются шаги и `waitFor`, как бесконечная CSS-анимация в тестах браузера. Шаг отклоняется, проваливая тест, когда ни одно представление не совпало с его селектором (ошибка перечисляет тексты на экране), когда приложение за это время выбросило исключение и когда приложение запросило то, на что отвечает только реальная платформа.

Сбои, которых тест может ожидать, несут `code`, который остаётся неизменным; их сообщения предназначены для людей и могут меняться, поэтому тест сопоставляет код:

| `code` | Шаг или запрос |
|---|---|
| `ERR_NSH_TEST_NOT_FOUND` | Ни одно представление на экране не совпадает с селектором, или в открытом меню нет такого пункта. |
| `ERR_NSH_TEST_DISABLED` | Представление, названное селектором, отключено (scroll view, который пользователь не может прокрутить, отключённый пункт меню). |
| `ETIMEDOUT` | `waitFor` или `waitUntil` не уложились во время, или приложение ничего не отрисовало после `open`. |
| `ERR_NSH_TEST_NO_BACK` | `back()` не нашёл, откуда возвращаться. |
| `ERR_NSH_TEST_UNSUPPORTED` | `open` с `--target` получил опцию, которую платформа цели не может выполнить: `width` или `height` на iOS и Android, `platform` телефона на десктопе или в вебе, опция десктопа или другого телефона на iOS или Android, `host.handlers`. Сообщение называет её. |

```tsx
await expect(app.press("Pay")).rejects.toMatchObject({ code: "ERR_NSH_TEST_DISABLED" });
```

`app.spanStyle("id:guide")` читает вложенный `<Text testID="guide">`; `text:word` читает первый символ
этого совпадения. Ограничивайте запросы через `id:note >> text:word` и выбирайте повторяющиеся совпадения через `>> nth=0`.
Запрос сообщает об ошибке для отсутствующего, неоднозначного или пустого фрагмента. Обновление `testID` фрагмента повторно использует раскладку его текста
и сохраняет счётчик вызовов колбэка `onTextLayout`.
<a id="what-headless-runs-play-of-the-platform"></a>

### Что headless-запуски берут на себя от платформы

В headless-запуске есть настоящая среда выполнения, но нет платформы. Он исполняет роль платформы, которую определяет протокол NSH, и ничего из того, что под силу только настоящей платформе:

- **Нативные анимации выполняются** кадр за кадром до конца: колбэки `start(callback)` вызываются, как и всё, что их ждёт. Бесконечные циклы работают, пока приложение их не остановит.
- **Возврат назад** снимает экраны и закрывает модальные окна, как это делает платформа (`app.back()`).
- **На диалоги отвечают**: ответом из `onDialog` теста, иначе кнопкой отмены, иначе диалоги закрываются, если это возможно; диалог, на который отвечают только другие его кнопки, завершает ошибкой шаг, который его показал, пока тест не укажет, какую кнопку нажать.
- **Буфер обмена хранит текст**: то, что приложение копирует, читается обратно при вставке (`clipboard`, чтобы начать с некоторым текстом).
- **Хост страницы отвечает на `host`**, если `nsh.json` приложения указывает его в `web.host` ([Хост страницы](#the-pages-host)).
- **Приложение работает в рамках своих capabilities**: то, что его `nsh.json` объявляет в `capabilities`, применяется как на устройстве, поэтому всё необъявленное отклоняется.
- **URL записываются**: то, что открывает приложение, считается открытым, и `app.openedUrls()` перечисляет это.
- **На выбор файлов отвечают**: файлами теста (`onPick`), иначе отменой, поэтому `Picker.pickImages` выполняется с `[]`.
- **Речь задаёт тест**: `Speech.available()` выполняется с `true`, сессия стартует сразу и слышит то, что говорит `hear`.
- **Модальные окна показываются сразу**: `onShow` приходит, когда окно показано.
- **Изображения не загружаются**: `onLoad` и `onError` не приходят, а `Image.getSize` и `Image.prefetch` отклоняются.
- **То, на что отвечает только настоящая платформа, завершает шаг ошибкой**: изображение или файлы в буфере обмена.

<a id="the-pages-host"></a>

### Хост страницы

Приложение, опубликованное в вебе, получает свой хост от страницы: `nsh.json` `web.host`, модуль, экспортирующий `createHost(player)` ([Web](https://nsh.dev/translations/ru/docs/web.md#the-hosts-functions)). Headless-запуски загружают этот модуль для каждого приложения, которое открывает тест, до старта приложения, в отдельном контексте JavaScript (таймеры и `console`, ничего от браузера, без импортов): его `call` отвечает на [`host.call`](https://nsh.dev/translations/ru/modules/nsh.md#host) приложения, его `player.hostEvent` доходит до `host.on`, а `host.available()` равно `true`. `call`, который выбрасывает ошибку, отклоняется или возвращает не JSON, отклоняет вызов приложения с его сообщением, как на странице, а события, отправленные до старта приложения, доходят до его слушателей верхнего уровня, как только оно стартует. `--web-host <module>` указывает другой модуль. `capabilities.host` приложения должен перечислять то, что оно вызывает, как на странице. Тайм-аут вызова и собственные таймеры хоста идут по времени приложения, поэтому их двигают ручные часы (`app.clock.runFor`). В браузере (`--target web`) страница разработки запускает `web.host` из `nsh.json` для каждого приложения, как опубликованная страница; на остальных платформах (`--target`) у приложений нет хоста: `host.available()` равно `false`.

```tsx
// nsh.json: { "capabilities": { "host": ["echo"] }, "web": { "host": "./host.js" } }
import { open, test } from "nsh/test";

test("calls reach the page's host", async () => {
  const app = await open("./App.tsx");
  await app.press("label:call");
  await app.waitFor('text:{"n":1}');
});
```

`examples/host-echo` содержит приложение, его хост и их тесты.

<a id="expectations"></a>

### Ожидания

`expect(value)` проверяет значения как Jest, в том виде, в каком их создал JS: `toBe` — это `Object.is`, `toEqual` сравнивает по членам, а `Date`, `Map` или `Uint8Array` сравниваются как то, чем они являются. Каждая проверка выбрасывает ошибку, если не выполняется. `.not` переворачивает их: `expect(app.texts()).not.toContain("Error")`. Если передана функция, `toThrow` проверяет, что выбрасывает её вызов; если передан промис или функция, которая его возвращает, `rejects` и `resolves` проверяют, чем он завершается, как в Jest: `await expect(save()).rejects.toMatchObject({ code: "ERR_ACCESS_DENIED" })`.

| Проверка | Выполняется, когда |
|---|---|
| `toBe(expected)` | Значение — `expected`, как говорит `Object.is`: тот же примитив (`NaN` — это `NaN`, `0` — не `-0`) или тот же объект. |
| `toEqual(expected)` | Значение равно `expected` по членам, как `toEqual` в Jest: объекты — по собственным перечислимым свойствам (свойство, равное `undefined`, считается отсутствующим), массивы — по элементам, даты — по времени, регулярные выражения — по исходному тексту и флагам, Map и Set — по записям в любом порядке, `ArrayBuffer` и типизированные массивы — по байтам, ошибки — по имени и сообщению; `NaN` равно `NaN`. Экземпляр класса равен простому объекту с теми же свойствами. |
| `toMatchObject(subset)` | У значения есть все свойства `subset`, каждое равно по правилам `toEqual`, а объекты внутри сопоставляются так же; остальные свойства не рассматриваются, а массивы сопоставляются поэлементно, целиком. |
| `toContain(item)` | Массив, `Set`, типизированный массив или другой итерируемый объект содержит `item` (`===`), либо строка содержит его. |
| `toContainEqual(item)` | Массив, `Set` или другой итерируемый объект содержит элемент, равный `item` по правилам `toEqual`. |
| `toBeTruthy()`, `toBeFalsy()` | Значение истинно / ложно с точки зрения JS. |
| `toMatch(pattern)` | Строка содержит `pattern` (строку) или соответствует ему (`RegExp`, выполняется движком JS: look-behind, обратные ссылки и именованные группы работают как в JavaScript). |
| `toHaveLength(n)` | `length` значения равна `n`: у строки — единицы UTF-16, у массива или типизированного массива — элементы. |
| `toBeNull()` | Значение — `null`. |
| `toBeUndefined()`, `toBeDefined()` | Значение — `undefined` / любое другое. |
| `toBeNaN()` | Значение — `NaN`. |
| `toBeGreaterThan(n)`, `toBeGreaterThanOrEqual(n)`, `toBeLessThan(n)`, `toBeLessThanOrEqual(n)` | Число соотносится так с `n`. |
| `toThrow(expected?)` | `expect(fn)`: вызов `fn` выбрасывает ошибку. Если задан `expected`, ошибка ему соответствует: её сообщение содержит строку или соответствует `RegExp`; класс ошибки (`TypeError`, класс приложения) — это `name` ошибки, а `Error` подходит любой ошибке; у ошибки то же сообщение. |
| `rejects.toThrow(expected?)` | `expect(promise)`: промис отклоняется с ошибкой, соответствующей `expected` по правилам `toThrow`. |
| `rejects.toMatchObject(subset)` | `expect(promise)`: промис отклоняется со значением, соответствующим `subset` по правилам `toMatchObject`: собственные свойства ошибки (`code`, `permission`, ...) вместе с её `name` и `message`. |
| `resolves.toBe(expected)`, `resolves.toEqual(expected)`, `resolves.toMatchObject(subset)` | `expect(promise)`: промис выполняется со значением, которое проходит проверку. |

Неудачная проверка сообщает, что получила и что ожидала, записывая это так, как записал бы JS: `expected Date(2024-01-02T00:00:00.000Z) to equal Date(2024-01-01T00:00:00.000Z)`.

У `rejects` и `resolves` тоже есть свой `.not`: `await expect(load()).resolves.not.toBe(null)`.

<a id="on-a-real-platform"></a>

### На настоящей платформе

`nsh test --target macos` (или `linux`, `windows`, `gpui`, `web`, `ios`, `android`) запускает те же тесты на хосте разработки платформы вместо headless: каждый `open` запускает приложение в отдельном окне со свежим каталогом данных, а шаги действуют штатным вводом платформы на views, которые называют их селекторы, как Playwright в браузере:

- нажатие — это клик в середину view, долгое нажатие — удерживаемый клик, `type` кликает по полю ввода и вводит его клавиши, `scroll` — жест трекпада над scroll view, `back` — то, что делает пользователь (Escape в macOS и Linux, боковая кнопка «назад» мыши в Windows и в GPUI, кнопка «назад» браузера в вебе, смахивание листа вниз, жест выхода VoiceOver, пока открыто меню, popover или `Sheet`, иначе кнопка «назад» в заголовке в iOS, кнопка «назад» в Android);
- view за пределами экрана сначала прокручивается в область видимости, по одному жесту за раз; на сенсорном экране палец попадает туда, где его принимает сама страница, рядом с views, которые следуют за перетаскиванием (слайдер, область рисования), как при прокрутке пользователем;
- селекторы, ожидания и запросы видят ту же модель приложения, что и headless-запуски, построенную из того, что рисует хост, поэтому находят те же views;
- диалоги — собственные диалоги платформы: `app.dialogs()` читает их, а ответ на диалог (`onDialog` или его кнопка отмены, когда тест ничего не говорит) нажимает его кнопку;
- `app.screenshot(path)` делает снимок окна.

```sh
nsh test --target macos                       # the app's tests in macOS windows
nsh test --target linux                       # in GTK windows (xvfb-run -a … on a machine without a display)
nsh test --target windows                     # in Win32 windows
nsh test --target ios                         # in an iOS simulator
nsh test --target android                     # on the Android device or emulator adb lists first
nsh test --target gpui                        # in GPUI windows (macOS, Windows, Linux)
nsh test --target web                         # in a headless Chrome, Edge or Chromium
nsh test tests/form.test.tsx --target macos   # one file
```

Окно открывается размером с viewport (390 × 844, если `open` не говорит иное) в пределах экрана; в iOS и Android приложение занимает весь экран устройства, в его размере. Приложение работает как платформа целевой системы (`platform.os`). Ручные `clock` работают так же, как в headless: `app.clock.runFor`, `fastForward` и `setSystemTime` сразу двигают время приложения (таймеры, `requestAnimationFrame` с интервалом в кадр, `Date`), `app.wait(ms)`, `waitFor` и `waitUntil` тоже двигают его, а каждый кадр, который показывает платформа, двигает его на кадр (1/60 с), поэтому время приложения идёт, пока оно анимируется. Собственные анимации платформы и `delay` маршрута занимают реальное время. Запросы приложения приходят к тесту так же, как в headless: на них отвечают маршруты `network`, запрос, которому не соответствует ни один маршрут, завершается ошибкой, а `"live"` пропускает их в сеть хоста; `app.requests()` перечисляет их все. Сокеты и WebSocket подключаются в сети хоста с `"live"`, а без него отклоняются, как в headless. `storage` лежит в хранилище приложения при его старте, а `app.storage()` читает его из приложения. `locale`, `reducedMotion` и `safeArea`, когда `open` их задаёт (включая значение по умолчанию), содержат то, что сообщает платформа, а заданный им `colorScheme` переключает внешний вид системы (`"light"` тоже); то, что он опускает, определяет платформа, а вызовы буфера обмена из приложения обслуживаются собственным буфером обмена теста (`clipboard` — сначала его текст, `app.clipboard()` читает его), поэтому буфер обмена машины остаётся прежним; копирование и вставка в самом текстовом поле используют системный буфер. `host.params` доходит до приложения, `app.host.send` отправляет события, а URL, которые открывает приложение, записываются для `app.openedUrls()` вместо того, чтобы открываться. `host.handlers` работает в headless, где на вызовы приложения отвечает тест. `open` проверяет свои параметры на соответствие целевой платформе: `width` и `height` в iOS и Android, `platform` устройства другого вида (телефона на десктопе или в вебе, десктопа или другого телефона в iOS или Android) и `host.handlers` отклоняются с `ERR_NSH_TEST_UNSUPPORTED`, поэтому тест либо работает как написан, либо говорит, что ему нужно. `platform` десктопа (`"macos"` для мыши и клавиш десктопа) работает на каждой десктопной платформе и в вебе, как платформа этой цели и всегда как десктоп: в браузере страница стартует в десктопной идиоме независимо от ширины, тогда как без `platform` (или с `"web"`) она определяет свою идиому так же, как браузер (десктоп начиная с 600 пунктов в ширину). Приложение с собственными Rust-крейтами работает в хосте разработки, собранном вместе с его крейтами, которые NSH собирает для платформы; хост Rust-приложения запускает его корень, поэтому `open(".")` стартует его там так же, как в headless.

В вебе приложения работают на странице хоста разработки в headless-Chrome, Edge или Chromium: том, что установлен на этой машине, или в исполняемом файле, который называет `--browser <PATH>` (браузер необходим: `nsh test` сообщает, где искал, если не находит ни одного). Запуск обслуживает один браузер, и каждый `open` получает страницу с собственным хранилищем. Ввод — доверенный ввод браузера, как у пользователя: клики мыши, прокрутка колесом и клавиши.

В iOS приложения работают в хосте разработки в симуляторе iOS: уже запущенном, иначе в новейшем iPhone, который `nsh test` запускает. Шаги — это касания пальцем и набор на программной клавиатуре, как их получает UIKit от устройства. `setColorScheme` переключает внешний вид симулятора, который `nsh test` возвращает после приложения, а экран имеет размер симулируемого устройства.

В Android приложения работают в хосте разработки на первом устройстве или эмуляторе из списка `adb devices`, с собственным хранилищем. Шаги — это касания пальцем и клавиши клавиатуры, как их получают views приложения от устройства; `back` — системная кнопка «назад». `setColorScheme` переключает тёмную тему устройства, которую `nsh test` возвращает после приложения.

<a id="steps-from-standard-input"></a>

### Шаги из стандартного ввода

Если вместо тестов передана точка входа приложения, `nsh test` открывает приложение в реальной сети, даёт ему поработать 1,2 с, затем выполняет шаги, которые подаёт стандартный ввод, с `app` приложением:

```sh
echo 'await app.press("text:+1"); expect(app.texts()).toContain("1");' | nsh test App.tsx
```

Эти 1,2 с позволяют сборке мусора движка в простое сработать до первого шага, поэтому приложение, которое отпускает то, чем ещё пользуется, это покажет: его обработчики перестанут работать.

У примеров есть тесты, от которых можно отталкиваться: `examples/gallery-rs` для Rust-приложения (`cargo test`, по одному модулю на демо, и `App.test.tsx`), `examples/rust-exports` для приложения с Rust-крейтом, `examples/counter` и `examples/gallery/tests` (хранилище, поля ввода, длинный список, подменённые запросы, тёмный режим, ручные часы) для TSX-приложений.

<a id="tracing-with---trace"></a>

## Трассировка с --trace

`--trace` печатает каждый пакет монтирования приложений, которые открывают тесты, под шагом, в котором он пришёл, и время каждого коммита. В Rust тест печатает то же самое: `observe` передаёт ему каждый пакет вместе с шагом, а `EngineConfig::trace` записывает строки `perf` через крейт `log` (target `nsh::perf`; установите в тесте логгер, например `env_logger`):

```rust
let mut config = EngineConfig::new(Root::of::<my_app::App>());
config.trace = true;
let mut app = Headless::start(config).unwrap();
app.observe(|step, batch| nsh_testing::print_batch(&mut std::io::stdout(), step, batch));
app.press("Add").unwrap();
```

`nsh test` принимает `--trace` для файла теста или для точки входа приложения с шагами в стандартном вводе:

```sh
nsh test App.test.tsx --trace
nsh test App.tsx --trace                                   # the first frame, then 1.2 s idle
echo 'await app.press("Add")' | nsh test App.tsx --trace    # and a press
echo 'await app.press("Add")' | nsh test App.tsx --trace --target ios   # the same in an iOS simulator
```

С `--target` трассировка относится к платформе: это пакеты, которые применил хост, под шагами, которые их вызвали, и собственные замеры хоста для каждого коммита и события на оборудовании устройства.

Используйте её, чтобы:

- проверить, что приложение (или демо) собирается, вычисляется и отрисовывается без ошибок;
- увидеть, какие именно нативные views создаёт, перемещает или обновляет изменение и сколько их;
- измерить стоимость первого кадра, нажатия и каждого кадра прокрутки;
- ловить регрессии в CI.
<a id="reading-the-output"></a>

### Чтение вывода

Вот пример счётчика с одним нажатием (`echo 'await app.press("+1")' | nsh test examples/counter/App.tsx
--trace`) в сокращённом виде:

```text
✓ bundled App.tsx (3255 B in 6.2 ms)
perf commit: 56 ops, 271µs (list js 5µs), 49 mount items, 21 nodes
perf first frame: eval 187µs, total 820µs
── open(App.tsx): 49 items
   LayoutDirection { rtl: false }
   Create { id: 2, kind: View }
   Create { id: 4, kind: Text }
   Insert { parent: 2, child: 4, before: None }
   ...
   Frame { id: 2, x: 0, y: 0, w: 390, h: 844 }
   Frame { id: 4, x: 24, y: 64, w: 342, h: 34 }
   ...
   Text { id: 4, text: "nsh counter", content: 342x34 @ 0,0 }
   Text { id: 14, text: "0", content: 36x77 @ 0,0 }
perf event Press on view 26
perf event js: 413µs
perf commit: 10 ops, 672µs (list js 7µs), 7 mount items, 24 nodes
── press(+1): 7 items
   Create { id: 42, kind: Text }
   Insert { parent: 32, child: 42, before: None }
   Frame { id: 32, x: 24, y: 366, w: 342, h: 43 }
   Frame { id: 42, x: 0, y: 23, w: 342, h: 20 }
   Text { id: 14, text: "1", content: 36x77 @ 0,0 }
   Text { id: 42, text: "• 1", content: 342x20 @ 0,0 }
   Text { id: 34, text: "Recent values: 1", content: 342x17 @ 0,0 }
✓ App.tsx › steps (1.4s)
```

<a id="batches-and-mount-items"></a>

#### Батчи и элементы монтирования

Каждый блок `── step: N items` — это один **батч монтирования** (mount batch) под шагом, в котором он пришёл: всё, что платформа применяет к нативным представлениям за один кадр, за один переход в главный поток. Строки в нём — это **элементы монтирования** (mount items):

| Элемент | Значение |
|---|---|
| `Create { id, kind }` | Создать нативное представление (`View`, `Text`, `Image`, `Scroll`, ...). |
| `Insert { parent, child, before }` | Поместить представление в родителя перед соседним элементом (`None`: в конец). |
| `Remove` / `Delete` | Отсоединить представление от родителя / освободить его. |
| `ViewStyle { id, style }` | Оформление представления: фон, границы, скругление, тень, прозрачность, трансформация, ... |
| `ViewProps { id, props }` | Настройки касаний и доступности, `testID`. |
| `Events { id, events }` | О каких событиях сообщает представление (`EventMask(1)` — нажатие). |
| `Frame { id, x, y, w, h }` | Его позиция и размер внутри родителя, в пунктах. |
| `Text { id, text, content }` | Разложенный текст для текстового представления. |
| `Image { id, source, ... }` | Изображение для загрузки. |

Отправляется только то, что изменилось: касание, меняющее одно число, отправляет один элемент `Text`; у представлений нет стиля по умолчанию и пустой маски событий; неизменившиеся кадры не отправляются. Кадр прокрутки, не открывающий ни одной новой строки, **не создаёт батча вообще**.

<a id="the-762-lines"></a>

#### Строки `perf`

| Строка | Значение |
|---|---|
| `✓ bundled App.tsx (3255 B in 14.3 ms)` | Время сборки и размер бандла (настройки разработки). |
| `commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes` | Один коммит: операции, поставленные в очередь JS, время, потраченное ядром (рендеринг компонентов, раскладка, сравнение), время JS на привязку строк списка, созданные элементы монтирования и число живых узлов. |
| `turn: 2640µs (timers and tasks 1964µs)` | Такт, завершившийся коммитом: его полное время и время, затраченное его JS-таймерами и задачами Rust-компонентов до коммита (Rust-компонент рендерится в коммите). |
| `first frame: eval 271µs, total 1235µs` | Время вычисления бандла и время от старта до первого батча. |
| `event Press on view 20` / `event js: 196µs` | Отправленное событие и время, которое заняла его обработка (и вызванное ею) в JS. |

> [!NOTE]
> Текст измеряется приблизительным текстовым движком в headless-режиме, поэтому размеры текста немного отличаются от устройства, где используется CoreText или текстовая раскладка Android. Логика раскладки, число представлений и число операций совпадают.

Время одного запуска сильно зависит от состояния тактовой частоты процессора (машина, только что вышедшая из простоя, работает медленно). Сравнивайте запуски на одной машине, смотрите на минимум из нескольких запусков и больше доверяйте числу операций и элементов, чем микросекундам.

<a id="scrolling"></a>

### Прокрутка

```rust
app.scroll("id:photos", px(3000.0)).unwrap();
```

```sh
echo 'await app.scroll("id:photos", 3000)' | nsh test examples/gallery/Bench.tsx --trace
```

`scroll` сдвигает список на 10 пунктов за кадр, 60 кадров в секунду, и печатается батч каждого кадра:

```text
perf commit: 55 ops, 1667µs (list js 771µs), 46 mount items, 414 nodes
── scroll(id:photos, 3000): 46 items
   Create { id: 792, kind: View }
   Insert { parent: 94, child: 792, before: None }
   ...
```

Для [списка](https://nsh.dev/translations/ru/docs/lists.md) это показывает работу виртуализации: в каких кадрах привязываются новые строки, сколько операций стоит строка и что кадры, не открывающие новых строк, ничего не создают. Для осмысленных замеров времени используйте релизную сборку (`cargo test --release` или `./target/release/nsh` в локальной копии репозитория).

<a id="in-ci"></a>

### В CI

Headless-тестам не нужны ни устройство, ни дисплей, поэтому они работают на Linux-раннерах: `cargo test` для тестов Rust-приложения и `nsh test`, которому нужен только CLI. Любой упавший тест приводит к завершению любой из команд с ненулевым кодом: тест, который не проходит, приложение, которое не собирается или ничего не отрисовывает, исключение, выброшенное приложением во время шага.

```yaml
# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo test                            # a Rust app's tests
      - run: nsh test --junit test-results.xml     # its TypeScript tests, or a TSX app's
```

(Установите CLI `nsh` на раннер так же, как вы устанавливаете его локально; см. [Установка](https://nsh.dev/translations/ru/docs/installation.md).)

`--junit <FILE>` также записывает результаты в отчёт JUnit XML, который CI-сервисы (репортеры тестов GitHub Actions, GitLab, Jenkins, Azure Pipelines) показывают по каждому тесту: `testsuite` на каждый файл с тестами, `testcase` на каждый тест, с указанием того, что упало, где и какие тексты были на экране.

Чтобы следить за производительностью, записывайте число элементов для нескольких взаимодействий (строки `── … : N items` и числа `ops` / `mount items` из `--trace`) и считайте тест неуспешным, когда они растут. Счётчики детерминированы; время на общих CI-машинах — нет.

> [!NOTE]
> Тесты выполняются с `capabilities`, которые объявляет `nsh.json` приложения, как и на устройстве: то, что приложение не объявляет, запрещено и в тесте.

<a id="784-for-ui-tests"></a>

## `testID` для UI-тестов

Headless-тесты проверяют то, что отрисовывает рантайм, а их селекторы `id:` находят представления по test id. Чтобы управлять настоящим нативным UI с помощью тестовых инструментов платформы (XCUITest, Espresso / UI Automator, Appium, Maestro), задайте представлениям test id: `.test_id(…)` в Rust, `testID` в TSX:

```rust
view()
    .test_id("add-to-cart")
    .p_3()
    .on_press(add)
    .child(text(move || format!("{} items", count.get())).test_id("cart-count"))
```

```tsx
<View testID="add-to-cart" onPress={add} style={{ padding: 12 }}>
  <Text testID="cart-count">{`${count()} items`}</Text>
</View>
```

| Платформа | Куда попадает `testID` |
|---|---|
| iOS | `accessibilityIdentifier` представления (`app.otherElements["add-to-cart"]` в XCUITest). |
| Android | Имя ресурса идентификатора представления в узле доступности (`By.res("add-to-cart")` в UI Automator). |

Test id — это метод каждого элемента в Rust, включая элементы компонентов (`button().test_id("save")`, который задаёт имя корню компонента), а `testID` — проп каждого элемента и каждого Rust-компонента (`Button`, `Dialog`, ...). Он не меняет то, что произносят скринридеры; для этого используйте `accessibilityLabel` (см. [Доступность](https://nsh.dev/translations/ru/docs/accessibility.md)). В выводе `--trace` он появляется в элементе `ViewProps` представления как `test_id: Some("add-to-cart")`.

<a id="see-also"></a>

## См. также

- [Производительность](https://nsh.dev/translations/ru/docs/performance.md): на что обращать внимание в этих числах
- [Сервер разработки и отладка](https://nsh.dev/translations/ru/docs/dev-server-and-debugging.md)
- [Архитектура](https://nsh.dev/translations/ru/docs/architecture.md): операции, коммиты и батчи монтирования
