Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Документация

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

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"));
}

#Тесты на 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, height390, 844Вьюпорт в пунктах: в headless, окно на десктопах, страница в вебе. На iOS и Android приложение занимает весь экран устройства.
safeArea0Отступы безопасной области в пунктах: { top: 47, bottom: 34 }.
clipboardпустоТекст в буфере обмена в начале.
colorScheme"light"Внешний вид системы: "light" или "dark" (классы colorScheme(), dark:).
locale"en-US"Локаль устройства, тег BCP 47 (locale(), значение по умолчанию Intl).
reducedMotionfalseПользователь попросил уменьшить анимацию (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"]);
}
Поле ответаПо умолчаниюОписание
status200Код статуса.
headersнетИмена и значения заголовков.
bodyпустоСтрока или значение, отправляемое как JSON (с content-type: application/json, если headers не задают другой).
errorнетЗапрос вместо этого завершается ошибкой с этим сообщением, как при отсутствии сети на устройстве.
delay0Сколько времени занимает ответ, в миллисекундах; шаг его не ждёт, а 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"));
}

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, который пользователь не может прокрутить, отключённый пункт меню).
ETIMEDOUTwaitFor или waitUntil не уложились во время, или приложение ничего не отрисовало после open.
ERR_NSH_TEST_NO_BACKback() не нашёл, откуда возвращаться.
ERR_NSH_TEST_UNSUPPORTEDopen с --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"))
ПлатформаКуда попадает testID
iOSaccessibilityIdentifier представления (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").

#См. также