Документация
Тестирование
NSH запускает приложения в режиме headless: настоящий движок (тот же реактивный граф, рендерер, компоненты, раскладка и виртуализация списков, что и на устройстве, и QuickJS для TSX) с кадрами отображения на 60 Гц, но без симулятора, устройства и окна. Он работает везде, где работают Cargo или CLI, включая Linux-машины CI, и решает две задачи:
- запускает тесты: каждый открывает приложение, выполняет шаги пользователя (нажатие, ввод, прокрутка) и проверяет, что показано на экране. Тесты Rust-приложения пишутся на Rust и запускаются с помощью
cargo testс крейтомnsh-testing; тесты на TypeScript запускаются с помощьюnsh test, как для TSX-приложений, так и для Rust-приложений; - с
--traceвыводит каждую операцию, которую рантайм отправляет нативным представлениям, с таймингами: сколько стоят первый кадр, нажатие или кадр прокрутки.
#Написание тестов
Один и тот же тест на Rust и на TypeScript: он открывает приложение с пустым списком дел в хранилище, вводит текст, нажимает кнопки и проверяет, что показывает экран.
// 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"));
}// 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");
});#Тесты на Rust с cargo test
Тесты Rust-приложения — это интеграционные тесты его крейта, с nsh-engine и nsh-testing (его функции по умолчанию, без QuickJS) в качестве dev-зависимостей:
[dev-dependencies]
nsh-engine = "0.1"
nsh-testing = "0.1"
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). 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.
#Тесты с nsh test
Файл теста — это файл *.test.tsx или *.test.ts рядом с вашим кодом. Он импортирует nsh/test, как это делает App.test.tsx выше:
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"
✓ 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) open(".") запускает его корневой компонент.
#Группировка тестов и их настройка
describe, хуки и варианты test работают как в Jest:
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 секундам, если не задан иначе. Тесты и хуки объявляются при загрузке файла: тест, объявленный внутри теста или хука, выбрасывает исключение.
#Открытие приложения
open(entry, options?) собирает приложение из entry (относительно файла теста; каждая точка входа собирается один раз за запуск), запускает его с вьюпортом 390×844 и завершается, когда приложение отрисовано и находится в покое. С --target приложение запускается с опциями, как их задаёт тест: если платформа цели не может выполнить одну из них, open отклоняется с ERR_NSH_TEST_UNSUPPORTED, называя её (см. На реальной платформе).
| Опция | По умолчанию | Описание |
|---|---|---|
storage | пусто | Что содержит Storage в начале: { todos: "[]" }. |
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). См. Управление временем. |
host | нет | Роль встраивающего приложения, которую играет тест: { params, handlers }. См. Игра за хост-приложение. |
#Игра за хост-приложение
Приложение, встроенное в нативное приложение, общается с ним через host. В тесте хост-приложение играет сам тест: host.params задаёт параметры запуска, которые приложение читает при старте, а host.handlers отвечает на host.call приложения и слышит его host.post по имени. Обработчик — это значение, которое отвечает на каждый вызов, или функция от данных вызова, возвращающая ответ (или промис с ним). То, что выбрасывает функция, отклоняет вызов, как ошибка хост-приложения. Вызов имени, которого нет в списке handlers, завершается ошибкой, как в хост-приложении, не обрабатывающем ничего.
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, ссылки TextView с links="open"), записываются и сообщаются приложению как открытые, при этом на этой машине ничего не открывается. app.openedUrls() перечисляет их:
await app.press("Contact us");
expect(app.openedUrls()).toEqual(["mailto:help@example.com"]);
#Подмена сети
network перечисляет маршруты по "<METHOD> <URL>" (голый URL — это GET; * соответствует любому тексту). Запрос, которому не соответствует ни один маршрут, завершается ошибкой, поэтому тесты никогда не выходят в сеть без запроса. Маршрут — это ответ или функция от запроса, возвращающая его (или промис с ним); в Rust StubNetwork::new(routes) принимает те же маршруты, каждый — StubResponse или StubRoute::Handler:
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"]);
}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 сокета), возвращающая ошибку, которой завершается его вызов.
#Управление временем
На системных часах таймеры и нативные анимации приложения занимают столько же времени, сколько на устройстве, как и тесты, которые их ждут. С ручными часами (EngineConfig::clock в Rust, clock у open в TypeScript) приложение работает на часах, время которых движется только тогда, когда его двигает тест, и сразу, как с фейковыми таймерами Jest или page.clock в Playwright. app.clock — это эти часы, а null, когда приложение работает на системных:
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"));
}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 с.
#Шаги
Шаги указывают представления с помощью селекторов, записанных так же, как в 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), затем ждёт, пока всё затихнет. См. Работа с хост-приложением. |
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 на этой машине, если путь указан (относительно текущего каталога). Приложение рисует только реальная платформа: см. На реальной платформе. |
Шаг завершается, когда приложение ничего не отрисовывало 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. Сообщение называет её. |
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.
#Что headless-запуски берут на себя от платформы
В headless-запуске есть настоящая среда выполнения, но нет платформы. Он исполняет роль платформы, которую определяет протокол NSH, и ничего из того, что под силу только настоящей платформе:
- Нативные анимации выполняются кадр за кадром до конца: колбэки
start(callback)вызываются, как и всё, что их ждёт. Бесконечные циклы работают, пока приложение их не остановит. - Возврат назад снимает экраны и закрывает модальные окна, как это делает платформа (
app.back()). - На диалоги отвечают: ответом из
onDialogтеста, иначе кнопкой отмены, иначе диалоги закрываются, если это возможно; диалог, на который отвечают только другие его кнопки, завершает ошибкой шаг, который его показал, пока тест не укажет, какую кнопку нажать. - Буфер обмена хранит текст: то, что приложение копирует, читается обратно при вставке (
clipboard, чтобы начать с некоторым текстом). - Хост страницы отвечает на
host, еслиnsh.jsonприложения указывает его вweb.host(Хост страницы). - Приложение работает в рамках своих capabilities: то, что его
nsh.jsonобъявляет вcapabilities, применяется как на устройстве, поэтому всё необъявленное отклоняется. - URL записываются: то, что открывает приложение, считается открытым, и
app.openedUrls()перечисляет это. - На выбор файлов отвечают: файлами теста (
onPick), иначе отменой, поэтомуPicker.pickImagesвыполняется с[]. - Речь задаёт тест:
Speech.available()выполняется сtrue, сессия стартует сразу и слышит то, что говоритhear. - Модальные окна показываются сразу:
onShowприходит, когда окно показано. - Изображения не загружаются:
onLoadиonErrorне приходят, аImage.getSizeиImage.prefetchотклоняются. - То, на что отвечает только настоящая платформа, завершает шаг ошибкой: изображение или файлы в буфере обмена.
#Хост страницы
Приложение, опубликованное в вебе, получает свой хост от страницы: nsh.json web.host, модуль, экспортирующий createHost(player) (Web). Headless-запуски загружают этот модуль для каждого приложения, которое открывает тест, до старта приложения, в отдельном контексте JavaScript (таймеры и console, ничего от браузера, без импортов): его call отвечает на host.call приложения, его 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.
// 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 содержит приложение, его хост и их тесты.
#Ожидания
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).
#На настоящей платформе
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)делает снимок окна.
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 возвращает после приложения.
#Шаги из стандартного ввода
Если вместо тестов передана точка входа приложения, nsh test открывает приложение в реальной сети, даёт ему поработать 1,2 с, затем выполняет шаги, которые подаёт стандартный ввод, с app приложением:
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-приложений.
#Трассировка с —trace
--trace печатает каждый пакет монтирования приложений, которые открывают тесты, под шагом, в котором он пришёл, и время каждого коммита. В Rust тест печатает то же самое: observe передаёт ему каждый пакет вместе с шагом, а EngineConfig::trace записывает строки perf через крейт log (target nsh::perf; установите в тесте логгер, например env_logger):
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 для файла теста или для точки входа приложения с шагами в стандартном вводе:
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.
#Чтение вывода
Вот пример счётчика с одним нажатием (echo 'await app.press("+1")' | nsh test examples/counter/App.tsx --trace) в сокращённом виде:
✓ 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)
#Батчи и элементы монтирования
Каждый блок ── 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; у представлений нет стиля по умолчанию и пустой маски событий; неизменившиеся кадры не отправляются. Кадр прокрутки, не открывающий ни одной новой строки, не создаёт батча вообще.
#Строки 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. |
Время одного запуска сильно зависит от состояния тактовой частоты процессора (машина, только что вышедшая из простоя, работает медленно). Сравнивайте запуски на одной машине, смотрите на минимум из нескольких запусков и больше доверяйте числу операций и элементов, чем микросекундам.
#Прокрутка
app.scroll("id:photos", px(3000.0)).unwrap();
echo 'await app.scroll("id:photos", 3000)' | nsh test examples/gallery/Bench.tsx --trace
scroll сдвигает список на 10 пунктов за кадр, 60 кадров в секунду, и печатается батч каждого кадра:
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 }
...
Для списка это показывает работу виртуализации: в каких кадрах привязываются новые строки, сколько операций стоит строка и что кадры, не открывающие новых строк, ничего не создают. Для осмысленных замеров времени используйте релизную сборку (cargo test --release или ./target/release/nsh в локальной копии репозитория).
#В CI
Headless-тестам не нужны ни устройство, ни дисплей, поэтому они работают на Linux-раннерах: cargo test для тестов Rust-приложения и nsh test, которому нужен только CLI. Любой упавший тест приводит к завершению любой из команд с ненулевым кодом: тест, который не проходит, приложение, которое не собирается или ничего не отрисовывает, исключение, выброшенное приложением во время шага.
# .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 на раннер так же, как вы устанавливаете его локально; см. Установка.)
--junit <FILE> также записывает результаты в отчёт JUnit XML, который CI-сервисы (репортеры тестов GitHub Actions, GitLab, Jenkins, Azure Pipelines) показывают по каждому тесту: testsuite на каждый файл с тестами, testcase на каждый тест, с указанием того, что упало, где и какие тексты были на экране.
Чтобы следить за производительностью, записывайте число элементов для нескольких взаимодействий (строки ── … : N items и числа ops / mount items из --trace) и считайте тест неуспешным, когда они растут. Счётчики детерминированы; время на общих CI-машинах — нет.
#testID для UI-тестов
Headless-тесты проверяют то, что отрисовывает рантайм, а их селекторы id: находят представления по test id. Чтобы управлять настоящим нативным UI с помощью тестовых инструментов платформы (XCUITest, Espresso / UI Automator, Appium, Maestro), задайте представлениям test id: .test_id(…) в Rust, testID в TSX:
view()
.test_id("add-to-cart")
.p_3()
.on_press(add)
.child(text(move || format!("{} items", count.get())).test_id("cart-count"))<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 (см. Доступность). В выводе --trace он появляется в элементе ViewProps представления как test_id: Some("add-to-cart").
#См. также
- Производительность: на что обращать внимание в этих числах
- Сервер разработки и отладка
- Архитектура: операции, коммиты и батчи монтирования