文档
测试
NSH 以 无界面(headless) 方式运行应用:使用真实的引擎(与设备上相同的响应式图、渲染器、组件、布局和列表虚拟化, TSX 还有 QuickJS),以 60 Hz 提供显示帧,但不需要模拟器、设备或窗口。只要 Cargo 或 CLI 能运行的地方它都能运行,包括 Linux CI 机器。它做两件事:
- 运行测试:每个测试打开应用,像用户一样操作(点击、输入、滚动),并检查屏幕上的内容。Rust 应用的测试用 Rust 编写,由
cargo test配合nsh-testingcrate 运行;用 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");
});#用 cargo test 编写 Rust 测试
Rust 应用的测试是它的 crate 的集成测试,以 nsh-engine 和 nsh-testing(默认 feature,不含 QuickJS)作为 dev-dependencies:
[dev-dependencies]
nsh-engine = "0.1"
nsh-testing = "0.1"
cargo test # crate 的所有测试
cargo test adds_a_todo # 名称包含 "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)(feature bundle)以同样的方式打开应用的 TSX 入口,基于 NSH 的和 crate 的 UI 集合构建
(用 Rust 开发)。examples/gallery-rs 就这样测试它的 Rust 根组件,每个演示一个模块;examples/rust-exports
从 Rust 测试它的 TSX 入口。
nsh test 也会运行这些测试:传入带有 Rust crate 的应用(nsh.json "rust")的目录时,它会在测试文件之前为其中每个 crate
运行 cargo test,并把 Rust 测试与 TypeScript 测试一起显示和计数(✓ src/lib.rs › tests::adds),计入汇总和 --junit
报告。所以无论是 Rust 应用、带 crate 的 TSX 应用还是 TSX 应用,nsh test 都是同一个命令;nsh check 同样会先对 crate 运行
cargo check --all-targets,再对 TypeScript 做类型检查。
#用 nsh test 编写测试
测试文件是放在代码旁边的 *.test.tsx 或 *.test.ts 文件,它导入 nsh/test,就像上面的 App.test.tsx:
nsh test # 当前目录中应用的所有测试文件
nsh test App.test.tsx # 一个文件
nsh test --filter "adds" # 名称包含 "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 在另一个全新的引擎上启动应用,因此测试之间从不
共享状态;测试结束时,它打开的应用会被关闭。测试在抛出异常、返回的 promise 被 reject 或超时时失败:超时默认 30 秒,
也可以用 test 的第三个参数按毫秒指定(test("syncs", body, 60_000))。带有自己
Rust crate 的应用(nsh.json 的 "rust")以同样的方式,在 NSH 用这些 crate 构建的无界面运行器中运行测试;在 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?) | 在所在块(文件或某个 describe)的每个测试之前 / 之后运行 fn:beforeEach 外层块的先运行,afterEach 内层块的先运行。beforeEach 失败时该测试失败;无论如何 afterEach 都会运行。 |
beforeAll(fn, timeout?)、afterAll(fn, timeout?) | 当所在块有测试要运行时,在第一个测试之前、最后一个测试之后各运行一次 fn。beforeAll 失败时,该块的测试都失败;afterAll 失败时报告为一个失败的 <块> › afterAll。beforeAll 打开的应用在该块的测试期间保持打开,在其 afterAll 之后关闭。 |
test.skip(name, body, timeout?)、describe.skip(name, body) | 声明不运行的测试或块:它们报告为已跳过。 |
test.only(name, body, timeout?)、describe.only(name, body) | 测试文件中有它们时,只运行这些测试和块;文件中的其他测试报告为已跳过。 |
test.todo(name) | 还没写的测试,报告为 todo。 |
钩子可以是 async 函数;timeout 默认 30 秒。测试和钩子在文件加载时声明:在测试或钩子里声明测试会抛出异常。
#打开应用
open(entry, options?) 构建 entry 处的应用(路径相对于测试文件;每次运行中每个入口只构建一次),以 390×844 的视口
启动它,并在它完成渲染且空闲后 resolve。使用 --target 时,应用按测试给出的每个选项运行;目标平台无法满足的选项会让
open 以 ERR_NSH_TEST_UNSUPPORTED reject,并在消息中指出该选项(见在真实平台上运行)。
| 选项 | 默认值 | 说明 |
|---|---|---|
storage | 空 | Storage 初始保存的内容:{ todos: "[]" }。 |
network | 无 | 应用的请求按路由得到的结果,见模拟网络。"live":请求、socket 和 WebSocket 都使用真实网络。 |
width、height | 390、844 | 视口大小,单位为点:headless、桌面平台的窗口、web 的页面。在 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 可用于任何桌面目标和 web,且总以桌面形态运行(在浏览器中无论宽度都是桌面形态,platform.os 仍是 "web"),"ios" 和 "android" 只用于各自的目标。 |
clock | 系统时钟 | 手动时钟,它的时间只随测试推进:{},或者用 { time } 从某个 Date(或自 Unix 纪元起的毫秒数)开始。见控制时间。 |
host | 无 | 由测试扮演的宿主应用:{ params, handlers }。见扮演宿主应用。 |
#扮演宿主应用
嵌入在原生应用中的应用通过 host 与宿主应用通信。在测试里由测试扮演宿主应用:host.params 是应用启动时读取的启动参数,host.handlers 按名称回答应用的 host.call、接收它的 host.post。处理函数可以是一个值,用它回答每次调用;也可以是一个函数,接收调用的数据并返回回答(或回答的 promise)。函数抛出的错误会让调用失败,与宿主应用出错时一样。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" } }]);
// 应用的 `host.on("conversation.open", …)` 会收到它。
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,以及 links="open" 的 TextView 中的链接)会被记录下来,并以“已打开”回报给应用,本机上不会真正打开任何东西。app.openedUrls() 列出它们:
await app.press("Contact us");
expect(app.openedUrls()).toEqual(["mailto:help@example.com"]);
#模拟网络
network 按 "<METHOD> <URL>" 列出路由(只写 URL 表示 GET;* 匹配任意文字)。没有路由匹配的请求会失败,因此测试不会在
未经允许时访问网络。路由是一个响应,或者一个接收请求、返回响应(或响应的 Promise)的函数;在 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 发送的值(headers 未指定时带上 content-type: application/json)。 |
error | 无 | 请求改为以这条消息失败,就像设备离线时那样。 |
delay | 0 | 响应需要的时间,单位为毫秒;步骤不会等它,waitFor 会。在手动时钟上,时钟走过这段时间后响应才到达。 |
路由的函数接收请求(method、url、headers,以及文字形式的 body),在测试文件中运行,所以可以统计请求次数,或者根据
测试准备好的数据作答。函数抛出异常时,请求失败,发出该请求的步骤也随之失败。app.requests() 以同样的形式按顺序列出应用发出的请求。
连接同样留在测试之内:除非 network 为 "live",应用的 socket(net)和 WebSocket(全局 WebSocket 和 websocket 模块)
不会建立连接。在应用自身的能力检查通过之后,net.connect 和 websocket 模块的 connect 以 code 为 ERR_NSH_TEST_NETWORK 的错误
reject,全局 WebSocket 先触发 error,再触发 close(1006,非正常关闭),与网络拒绝连接时一样。使用 "live" 时,它们和应用的请求一起
连接真实网络。在 Rust 中,EngineConfig::refuse_connections 对 Headless 应用做同样的事:它是一个函数,接收应用要连接的目标
(WebSocket 的 URL、socket 的 host:port),返回这次调用失败时的错误。
#控制时间
在系统时钟上,应用的定时器和原生动画与在设备上耗时相同,等待它们的测试也一样慢。使用手动时钟(Rust 中的
EngineConfig::clock,TypeScript 中 open 的 clock)时,应用改在手动时钟上运行,就像 Jest 的假定时器或 Playwright 的
page.clock:时间只随测试推进,并且立即推进。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(); // 十秒后的自动保存
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); // 十秒后的自动保存
expect(app.texts()).toContain("Saved at 09:00:10");响应带 delay 的 StubNetwork 用 .with_clock(clock.clone()) 跟随手动时钟。下面是 nsh test 的步骤;Headless 都有对应的方法
(run_for、fast_forward、set_system_time)。
| 步骤 | 说明 |
|---|---|
await app.clock.runFor(ms) | 把时钟推进 ms 毫秒,每次一个显示帧:定时器、requestAnimationFrame、原生动画和延迟的响应在到期时按顺序执行,各自在自己的时刻;应用空闲后 resolve。 |
await app.clock.fastForward(ms) | 一次把时钟推进 ms 毫秒,就像设备从睡眠中醒来:期间到期的定时器各执行一次。 |
app.clock.setSystemTime(time) | 把墙上时间(Date.now()、new Date())设为某个 Date 或毫秒数,不触发任何定时器。 |
Date、performance.now() 和 Intl 的默认日期都跟随这个时钟。步骤不再等待正在进行的内容:只要应用在请求帧或有原生
动画在运行,帧就一帧接一帧地立即到来,每帧在时钟上相隔一帧的时间(1/60 s);300 ms 的转场只需几毫秒,期间到期的
定时器也会执行。无限循环的动画(加载指示器)随时钟运行:测试推进时钟时它跟着转动。app.wait(ms) 和长按会把时钟推进它们各自的时长。更晚到期的定时器只有在测试把时钟推进到那里时才会执行。
waitFor 和 waitUntil 在等待时推进时钟,每次一个显示帧,每帧之后检查一次,所以定时器显示的内容会在等待中很快出现:
waitFor("Saved", { timeout: 15_000 }) 最多把时钟推进十五秒。它们的超时同时按两个时钟计算:等待最多把应用的时钟推进这么久,
并且在真实时间也过去这么久之后才 reject,所以来自应用外部的内容(路由的函数、网络)和在系统时钟上一样有足够的时间到达。gallery 的测试就是这样运行的
(examples/gallery/tests):其中十二个要等动画的测试用 6 s,而不是 28 s。
#操作步骤
步骤用 选择器 指定视图,写法和 Playwright 一致:
| 选择器 | 匹配 |
|---|---|
id:<testID> | 设置了该 testID 的视图。 |
label:<label> | 设置了该 accessibilityLabel 的视图。 |
text:<text> | 内容为该文字的文本(忽略首尾空格)、文本中可以按的片段(嵌套的 <Text onPress>、TextView 的链接),或者内容为该文字、空着时占位文字为该文字的输入框。 |
role:<role>、role:<role>[<name>] | 设置了该 role 的视图(role:button),并且名称为 <name>:它的标签,没有标签时取它内部的文字(role:button[Save])。 |
view:<id> | --trace 输出中该 id 的视图。 |
| 其他写法 | 依次按 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 crate 的语法(与 JavaScript 相同的字符类、
分组、锚点和量词,以线性时间匹配;标志 i、m 和 s)。>> 在前一部分匹配到的视图内部继续查找
(id:cart >> role:button[Remove]),nth=<n> 只保留第 n 个匹配,从 0 开始(text:Delete >> nth=1)。选择器找到的是用户
最先会看到的视图:先在最上层的模态窗口中找,再在屏幕上找;对于导航栈只看最上面的页面。它只匹配用户看得见的视图:有面积、不在
visibility: hidden 之内、位于窗口和裁剪它的视图(overflow: hidden)之内,或者位于用户可以滚动的滚动视图内容中的任何位置(滚动就能
把它带进来);opacity 和 transform 不影响,与 Playwright 一致。作用于视图的步骤(press、type、
clear、submit、scroll、longPress)要求恰好匹配一个视图:匹配到多个时步骤失败,错误信息会说明怎样缩小范围。
| 步骤 | 说明 |
|---|---|
await app.press(selector) | 按下该视图,或者它外层最近的可按压视图(文字外面的按钮):依次触发 onPressDown、onPressUp、onPress,期间手指按下再抬起,外层监听触摸的视图都能收到(例如 Overlay 的 onPressOutside)。文本中的片段按在它的文字上(await app.press("Privacy policy")),headless 以及 iOS、Android 和桌面平台都支持。500 ms 内再次按下同一视图算作第二次点击(clicks: 2,即双击),与各平台的计数方式一致。视图被禁用时 reject。 |
await app.longPress(selector) | 按住它(或它外层最近的可按压或监听触摸的视图),持续它的 delayLongPress(500 ms):依次触发 onPressDown、onLongPress、onPressUp,之后不再触发 onPress。触摸处理函数能收到这根手指(ContextMenu、Tooltip 会打开)。 |
await app.clear(selector) | 清空输入框,相当于全选后删除。 |
await app.type(selector, text) | 在输入框(或该视图内的第一个输入框;text: 也匹配输入框的内容或占位文字)已有内容的末尾逐键输入:输入框先获得焦点,然后每个字符触发一次 onKeyPress 和一次 onChange,不超过 maxLength。单行输入框中的 \n 相当于回车键。 |
await app.submit(selector) | 在输入框中按回车键,行为由它的 submitBehavior 决定:触发 onSubmit(单行输入框随后失去焦点),或者在多行输入框中换行。 |
await app.scroll(selector, distance) | 沿滚动视图或列表(该视图、它外层的滚动视图,或它内部的第一个)的方向拖动 distance 点,每帧 10 点,不超出内容范围,前提是用户可以滚动它(scrollable)。负数表示往回滚。 |
await app.hover(selector) | 把鼠标移到视图中央:鼠标离开的视图收到 onMouseLeave,移入的视图收到 onMouseEnter 并应用 hover: 样式,监听的视图收到带位置、没有按下按键的 onMouseMove。用于桌面和任意宽度的 web,包括手机形态的浏览器(headless 时使用桌面或 "web" platform);iOS 和 Android 手机没有鼠标,在那里会 reject。 |
await app.rightClick(selector) | 在视图中央按下鼠标右键:该视图或其外层最近的监听视图收到 onContextMenu。用于桌面和 web(headless 时使用桌面 platform);手机没有鼠标,在那里会 reject。 |
await app.paste(selector, { text?, html?, files?, uris? }) | 像剪贴板上有这些内容时用户粘贴一样粘贴到输入框:输入框获得焦点并收到 onPaste(html 是带格式的同一内容,files 是文件路径,其字节以 File 交给它,uris 是 file: URL),然后按 pasteBehavior 插入文本。支持 headless、macOS、iOS 和 web;本机剪贴板保持不变。 |
app.menu() | 应用显示的上下文菜单(Menu.popup)中的操作:label(子菜单中的项写在子菜单名之后,如 "Share > Mail")、disabled,可勾选项还有 checked;没有打开的菜单时为 null。 |
await app.chooseMenuItem(label) | 选择已打开上下文菜单中的操作,如同点击它:应用收到 onMenu(其 onSelect 运行),然后菜单关闭。该项被禁用或不存在时 reject。 |
await app.closeMenu() | 关闭已打开的上下文菜单,不选择任何项,如同按下 Escape。 |
await app.keyboard.press(keys) | 按下一个带修饰键的按键,写法同 keymap("mod+k"、"shift+/"、"escape";mod 是平台的命令键),如同硬件键盘,在所有平台上都可用(手机和平板也可以接硬件键盘):由应用的对应快捷键接收(keymap.bind、onKeyCommand),否则在 macOS 和 Web 上由 Tab 和 Shift+Tab 在输入框和可按压的视图之间移动键盘焦点(onFocus、onBlur、focus: 样式),Return 或空格按下焦点所在的视图,否则由获得焦点的输入框接收字符、Backspace 或回车,否则 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() 在当前位置抬起:其间的步骤可以查看拖动中的画面(拖动预览、放置线)。目前在 headless 和 Linux 上可用。 |
await app.dropFiles(selector, uris) | 把本地文件(file: URL,如同从文件管理器拖来)放到视图中央:它或外层最近一个监听的视图收到 onFileDrop。桌面平台,目前在 headless 和 Linux 上可用。 |
await app.drag(selector, { x, y }) | 手指落在视图中央,停留 100 ms,再移动 x、y 点(每帧 10 点)后抬起,由平台的输入系统处理:pan 跟随手指(pan={{ x, y }}、onPanStart,以及带松手速度的 onPanEnd),监听触摸的视图会收到触摸事件(onTouchStart、PanResponder),滚动视图随手指滚动,拉过顶部足够远时触发刷新(onRefresh),手指移开后其下的按压会被取消。在桌面和 web 上由鼠标完成。 |
await app.wait(ms) | 让应用运行 ms 毫秒:定时器、动画、请求。在手动时钟上,把时钟推进 ms(app.clock.runFor)。 |
await app.waitFor(selector, options?) | 有视图匹配时 resolve;传 { state: "gone" } 时,等到没有视图匹配才 resolve;timeout 毫秒(5000)后 reject。在手动时钟上,等待时每次把时钟推进一个显示帧,最多推进 timeout。 |
await app.waitUntil(condition, options?) | 应用每发生一次变化就重新检查,condition() 返回 true 时 resolve:用于选择器表达不了的条件(() => app.storage().note === "Milk")。在手动时钟上,两次检查之间把时钟推进一个显示帧,最多推进 timeout。 |
app.shows(selector) | 当前是否有视图匹配。 |
app.count(selector) | 当前匹配的视图个数。 |
app.enabled(selector) | 视图是否接受输入:它本身和外层视图都没有被禁用,输入框还要求不是 readonly。 |
app.texts() | 当前屏幕上的文字,按顺序排列。 |
app.value(selector) | 输入框的内容,没有匹配时为 null。 |
app.spanStyle(selector) | 返回匹配到的第一个字符的解析样式(无界面测试和各个 --target 都支持):color、可选的 backgroundColor、fontSize、fontWeight、italic、underline 和 lineThrough。颜色为 #rrggbbaa,字号单位为 pt。 |
app.storage() | 应用存储当前保存的内容。 |
await app.back() | 像用户那样返回(导航栏的返回按钮、下滑关闭弹出页):最新的模态窗口里(没有模态窗口时就在整个屏幕上)最内层的导航栈弹出栈顶页面(onDismiss),栈顶页面设置了 preventBack 时改为询问它(onBackRequest);没有可返回的栈时,最新的模态窗口请求关闭(onRequestClose)。没有可返回的内容时 reject。 |
app.onDialog(handler) | 回答应用之后弹出的对话框(Alert.alert、操作表):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 则取消。没有 handler 时选取都会被取消。仅限无界面运行。 |
app.picks() | 应用发起过的选取,按顺序排列。 |
app.hear(text) | 用户对正在运行的语音会话(Speech.start)说出 text:它的 result 把 text 报告为到目前为止的完整转写。仅限无界面运行。 |
app.endSpeech(error?) | 结束正在运行的语音会话:带 error("no-speech"、"network" 等)时会话失败,否则先到达最后一个最终结果再到达 end,什么都没听到时为 no-speech。仅限无界面运行。 |
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() | open 的 host.handlers 收到的应用调用和投递,按顺序排列:name、data。 |
app.openedUrls() | 应用打开的 URL(openUrl),按顺序排列:只记录,不打开。 |
await app.setViewport(width, height) | 改变视口大小,就像旋转设备或调整窗口大小那样(windowSize())。 |
await app.setColorScheme(scheme) | 像用户那样把系统外观切换为 "light" 或 "dark"。 |
await app.setLifecycle(state) | 把应用移到 "background"、"inactive" 或 "active",就像用户离开应用、切换到另一个应用或窗口、再回来时系统所做的那样(lifecycle())。仅限 headless 运行。 |
await app.screenshot(path?) | 应用截图(Uint8Array):图形平台返回应用窗口的 PNG,tui 返回 UTF-8 Cell JSON;给出 path 时同时保存到本机的这个路径(相对当前目录)。只有真实平台会绘制应用:见在真实平台上运行。 |
步骤会等到应用连续 100 ms 没有渲染、并且没有正在运行的、会自行结束的原生动画(对一直在渲染的应用最多等 2 s;在手动时钟上,同时最多等应用时间的 2 s)后才 resolve,所以下一行
代码运行时,这一步带来的变化已经在屏幕上;之后才出现的内容(响应、定时器)用 waitFor 等待。在手动时钟上,步骤不等这
100 ms:应用一旦跑完这一步和当前时钟时间带来的全部工作(此刻到期的定时器、promise、立即应答的桩响应),且不再需要帧,步骤
就 resolve(仍有请求在途时除外),所以手动时钟上的测试在步骤之间没有停顿。由其他线程应答的结果(nsh/host 调用、
文件读取、Rust 任务在线程上做的工作)在步骤 resolve 之后才到,用 waitFor 等待它显示的内容。无限循环的动画(比如加载
指示器)在后台持续运行,步骤和 waitFor 照常进行,就像浏览器测试中的无限 CSS 动画一样。选择器没有匹配到任何视图
(错误信息会列出屏幕上的文字)、应用在此期间抛出了异常,或者应用请求了只有真实平台才能回答的内容时,步骤会 reject,
测试随之失败。
测试可能要预期的失败都带有一个 code,它保持不变;错误消息是给人看的,可能会变,所以测试匹配 code:
code | 步骤或查询 |
|---|---|
ERR_NSH_TEST_NOT_FOUND | 屏幕上没有视图匹配选择器,或者打开的菜单没有这一项。 |
ERR_NSH_TEST_DISABLED | 选择器指向的视图已禁用(用户不能滚动的滚动视图、已禁用的菜单项)。 |
ETIMEDOUT | waitFor 或 waitUntil 超时,或者应用在 open 之后没有渲染任何内容。 |
ERR_NSH_TEST_NO_BACK | back() 没有可以返回的地方。 |
ERR_NSH_TEST_UNSUPPORTED | 使用 --target 时,open 收到了目标平台无法满足的选项:iOS 和 Android 上的 width 或 height、桌面或 web 上的手机 platform、iOS 或 Android 上的桌面平台或另一种手机的 platform、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 选择重复匹配中的一个。
没有匹配、匹配不唯一或 span 为空时会报错。更新 span 的 testID 会复用文本排版,保持 onTextLayout 的回调次数。
#无界面运行替平台做了什么
无界面运行有真实的运行时,但没有平台。它只扮演 NSH 协议规定的平台角色,不模拟只有真实平台才能做的事:
- 原生动画会运行,逐帧跑到结束:
start(callback)的回调会执行,等待动画结束的逻辑也会继续。没有终点的循环一直运行,直到应用停止它。 - 返回会像平台那样弹出页面、关闭模态窗口(
app.back())。 - 对话框会被回答:由测试的
onDialog回答,否则按它的取消按钮,否则在可以关闭时直接关闭;只能用其他按钮回答的对话框, 会让弹出它的步骤失败,直到测试指定要按哪个按钮。 - 剪贴板保存文字:应用复制的内容,粘贴时可以读回来(用
clipboard设定初始内容)。 - 页面的宿主应答
host:应用的nsh.json在web.host中指定了宿主时(页面的宿主)。 - 应用在它的能力下运行:
nsh.json的capabilities与设备上一样生效,没有声明的都会被拒绝。 - URL 只被记录:应用打开的 URL 以“已打开”回报,
app.openedUrls()列出它们。 - 选取会被回答:用测试给的文件(
onPick),否则视为取消,Picker.pickImagesresolve 为[]。 - 语音由测试决定:
Speech.available()resolve 为true,会话立即开始,听到hear说的内容。 - 模态窗口立即出现:弹出时就会触发
onShow。 - 图片不会加载:不会有
onLoad或onError,Image.getSize和Image.prefetch会 reject。 - 只有真实平台才能回答的请求会让步骤失败:剪贴板里的图片或文件。
#页面的宿主
发布到 Web 的应用从页面得到宿主:nsh.json 的 web.host,一个导出 createHost(player) 的模块
(Web)。无界面运行时,测试每打开一个应用,都会在应用启动前,在独立的 JavaScript 上下文中加载
这个模块(有定时器和 console,没有浏览器的 API,不能 import):它的 call 应答应用的
host.call,它的 player.hostEvent 送到 host.on,host.available() 为 true。
call 抛出异常、Promise 被拒绝或返回值不是 JSON 时,应用的调用以它的消息被拒绝,与页面上一致;应用启动前发送的事件
会在应用启动后送到它顶层注册的监听器。--web-host <module> 指定另一个模块。应用调用的函数必须列在它的 capabilities.host 中,
与页面上一致。调用的超时和宿主自己的定时器都按应用的时间计算,手动时钟(app.clock.runFor)会推进它们。在浏览器中(--target web),开发页面像发布的页面一样为每个应用运行 nsh.json 的 web.host;在其他平台上(--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 检查调用它抛出的异常;给出 promise 或返回 promise
的函数时,rejects 和 resolves 检查它的结果,与 Jest 相同:
await expect(save()).rejects.toMatchObject({ code: "ERR_ACCESS_DENIED" })。
| 检查 | 成立条件 |
|---|---|
toBe(expected) | 按 Object.is 的规则,值就是 expected:相同的原始值(NaN 等于 NaN,0 不等于 -0)或同一个对象。 |
toEqual(expected) | 与 Jest 的 toEqual 相同,值逐个成员地等于 expected:对象比较自身的可枚举属性(值为 undefined 的属性视为不存在),数组逐项比较,日期比较时间,正则表达式比较源码和标志,Map 和 Set 比较条目(不计顺序),ArrayBuffer 和类型化数组比较字节,错误比较名称和消息;NaN 等于 NaN。类的实例等于具有相同属性的普通对象。 |
toMatchObject(subset) | 值具有 subset 的每个属性,且各自按 toEqual 的规则相等,其中的对象也依次匹配;不看其他属性,数组逐项匹配,项数相同。 |
toContain(item) | 数组、Set、类型化数组或其他可迭代对象包含 item(===),或者字符串包含它。 |
toContainEqual(item) | 数组、Set 或其他可迭代对象包含一项按 toEqual 的规则等于 item。 |
toBeTruthy()、toBeFalsy() | 按 JS 的规则,值为真 / 为假。 |
toMatch(pattern) | 字符串包含 pattern(字符串),或者匹配它(RegExp,由 JS 引擎执行:后行断言、反向引用和命名分组都与 JavaScript 相同)。 |
toHaveLength(n) | 值的 length 为 n:字符串的 UTF-16 单元数,数组或类型化数组的项数。 |
toBeNull() | 值为 null。 |
toBeUndefined()、toBeDefined() | 值为 undefined / 不是 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):promise 会 reject,异常按 toThrow 的规则匹配 expected。 |
rejects.toMatchObject(subset) | expect(promise):promise 会 reject,reject 的值按 toMatchObject 的规则匹配 subset:错误自身的属性(code、permission 等),加上它的 name 和 message。 |
resolves.toBe(expected)、resolves.toEqual(expected)、resolves.toMatchObject(subset) | expect(promise):promise resolve 的值满足相应的检查。 |
检查不成立时,消息会写出得到的值和期望的值,写法与 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、tui、web、ios、android)在平台的开发 host 上运行同样的测试,而不是 headless:每次 open 都在一个新窗口里启动应用,并使用全新的
数据目录;步骤用平台自己的输入作用在选择器指向的视图上,就像 Playwright 在浏览器里那样:
- 按压是在视图中心点击,长按是按住不放,
type先点击输入框再逐键输入,scroll是在滚动视图上的触控板手势,back是用户的操作(macOS 和 Linux 上按 Escape,Windows 和 GPUI 里按鼠标的后退键,web 上是浏览器的后退按钮,iOS 上是把 sheet 往下滑,菜单、弹出框或Sheet打开时是 VoiceOver 的退出手势,否则点标题栏的返回按钮,Android 上是返回键); - 不在屏幕上的视图会先被滚动到可见,每次一个手势;在触摸屏上,手指像用户那样落在页面本身接收拖动的地方,避开跟随拖动的视图(滑块、绘图区域);
- 选择器、等待和查询看到的应用模型与 headless 相同,由 host 绘制的内容建立,所以找到的是同样的视图;
- 对话框是平台自己的:
app.dialogs()读取它们,回答对话框(onDialog,或测试没有回答时按它的取消按钮)会按下对应的按钮; app.screenshot(path)截取窗口。
nsh test --target macos # 在 macOS 窗口里运行应用的测试
nsh test --target linux # 在 GTK 窗口里运行(没有显示器的机器上用 xvfb-run -a …)
nsh test --target windows # 在 Win32 窗口里运行
nsh test --target ios # 在 iOS 模拟器里运行
nsh test --target android # 在 adb 列出的第一个 Android 设备或模拟器上运行
nsh test --target gpui # 在 GPUI 窗口里运行(macOS、Windows、Linux)
nsh test --target web # 在 headless 的 Chrome、Edge 或 Chromium 里运行
nsh test --target tui # 在内存终端里运行
nsh test tests/form.test.tsx --target macos # 只运行一个文件
窗口按视口大小打开(除非 open 指定,否则为 390 × 844),并且不会超出屏幕;在 iOS 和 Android 上,应用以设备的尺寸占满屏幕。应用以目标平台运行(platform.os)。
手动 clock 和 headless 时一样:app.clock.runFor、
fastForward 和 setSystemTime 立即推进应用的时间(计时器、每隔一帧的 requestAnimationFrame、Date),app.wait(ms)、waitFor 和 waitUntil 也会推进它;
平台每显示一帧,时钟前进一帧(1/60 秒),所以应用在动画时时间照常流逝。平台自身的动画和路由的 delay 按真实时间进行。应用的请求和 headless
时一样交给测试:由 network 路由应答,没有路由匹配的请求会失败,"live" 则放行到 host 的真实网络;app.requests() 列出全部请求。
使用 "live" 时,socket 和 WebSocket 在 host 的网络上连接,否则和 headless 时一样被拒绝。
storage 在应用启动时就已写入应用存储,app.storage() 从应用中读取。open 给出的 locale、reducedMotion 和 safeArea(包括取默认值时)不随平台上报的值变化,给出的 colorScheme 会切换系统外观("light" 也一样),没有给出的则沿用平台自己的;
应用的剪贴板调用由测试自己的剪贴板应答(clipboard 是它的初始文本,app.clipboard() 读取它),机器的剪贴板保持不变;文本输入框自身的复制和
粘贴使用系统剪贴板。host.params 会传给应用,app.host.send 发送事件,应用打开的 URL 被记录下来供 app.openedUrls() 读取,而不会真正打开。
host.handlers 在 headless 运行,由测试回答应用的调用。open 会按目标检查它的选项:iOS 和 Android 上的 width 与
height、属于另一类设备的 platform(桌面或 web 上的手机平台,iOS 或 Android 上的桌面平台或另一种手机)以及 host.handlers 会以
ERR_NSH_TEST_UNSUPPORTED reject,这样测试要么按写的那样运行,要么说明它需要什么。桌面平台的 platform(例如用 "macos" 获得桌面的鼠标和按键)
可以在任何桌面目标和 web 上运行,应用以该目标的平台运行,且总是桌面形态:在浏览器中页面无论宽度都以桌面形态启动;
不给 platform(或给 "web")时,页面像浏览器一样自己判断形态(宽度至少 600 点时为桌面)。带有自己 Rust crate 的应用运行在 NSH 为该平台用这些 crate 构建的开发 host 中;
Rust 应用的 host 运行它的 root,所以 open(".") 在那里启动它,和 headless 时一样。
nsh test --target tui 在内存终端中使用终端渲染器运行测试,适合 CI。每次 open 使用独立的 surface 和数据目录。
用 open(entry, { width, height }) 指定 point 尺寸;尺寸按应用 nsh.json 的 tui.pointsPerCell 向上取整为完整 Cell,
默认每个 Cell 为 8 × 16 points。app.screenshot("cells.json") 返回并保存 UTF-8 JSON:format: "nsh-tui-cells"、
version: 1、width、height,以及 rows[y][x] 中每个 Cell 的 symbol、fg、bg、underline 和 modifiers。
颜色使用 reset、小写 ratatui 颜色名、#rrggbb 或 indexed:N;修饰符是已启用样式名的数组,例如 bold、italic、
underlined。用 new TextDecoder().decode(bytes) 解码返回的字节,再解析 JSON,即可断言 Cell 快照。
在 web 上,应用运行在 headless 的 Chrome、Edge 或 Chromium 打开的开发 host 页面里:使用本机安装的浏览器,或
--browser <PATH> 指定的可执行文件(需要有浏览器:找不到时 nsh test 会列出它找过的位置)。整个运行共用一个浏览器,
每次 open 得到一个拥有独立存储的页面。输入是浏览器的可信输入,与用户的输入一样:鼠标点击、滚轮滚动和按键。
在 iOS 上,应用运行在 iOS 模拟器里的开发 host 中:使用已启动的模拟器,否则 nsh test 会启动最新的 iPhone。步骤是手指的触摸和软键盘的输入,
UIKit 收到它们的方式与在设备上相同。setColorScheme 切换模拟器的外观,nsh test 会在应用结束后把它恢复原样;屏幕是所模拟设备的尺寸。
在 Android 上,应用运行在 adb devices 列出的第一个设备或模拟器上的开发 host 中,并拥有独立的存储。步骤是手指的触摸和键盘的按键,
应用的视图收到它们的方式与在设备上相同;back 是系统的返回。setColorScheme 切换设备的深色主题,nsh test 会在应用结束后把它恢复原样。
#从标准输入给出步骤
如果传入的是应用的入口而不是测试,nsh test 会在真实网络下打开应用,让它运行 1.2 s,然后执行标准输入给出的步骤,
其中 app 就是这个应用:
echo 'await app.press("text:+1"); expect(app.texts()).toContain("1");' | nsh test App.tsx
这 1.2 s 让引擎的空闲垃圾回收在第一步之前运行,这样,如果应用丢掉了它仍在使用的对象,问题就会暴露出来:它的处理函数 不再生效。
示例中有可以参考的测试:Rust 应用见 examples/gallery-rs(cargo test,每个演示一个模块,以及 App.test.tsx),带 Rust crate
的应用见 examples/rust-exports,TSX 应用见 examples/counter 和 examples/gallery/tests(存储、输入框、长列表、模拟请求、
深色模式、手动时钟)。
#用 —trace 跟踪
--trace 会打印测试所打开应用的每个挂载批次,标注它所属的步骤,以及每次提交的耗时。在 Rust 中,测试也能打印同样的内容:
observe 把每个批次连同它的步骤交给测试,EngineConfig::trace 通过 log crate 记录 perf 行(target nsh::perf;在测试中
安装一个 logger,例如 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 # 首帧,然后空闲 1.2 s
echo 'await app.press("Add")' | nsh test App.tsx --trace # 再加一次点击
echo 'await app.press("Add")' | nsh test App.tsx --trace --target ios # 在 iOS 模拟器中做同样的事
加上 --target 时,跟踪来自真实平台:host 应用的批次标注在带来它们的步骤下,并附带 host 自己在设备硬件上测得的每次 commit
和事件的耗时。
它的用途:
- 检查应用(或某个 demo)能否无错误地构建、执行和渲染;
- 准确查看一次改动创建、移动或更新了哪些原生视图,以及数量;
- 测量首帧、一次点击以及每一帧滚动的开销;
- 在 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)
#批次与 mount item
每个 ── step: N items 块是一个 mount 批次(mount batch),标注它所属的步骤:平台在一帧内应用到原生视图上的全部内容,一次性切换到
主线程完成。其中的每一行是一个 mount item:
| Item | 含义 |
|---|---|
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 item;视图不会收到默认样式,也不会收到空的事件掩码;
未变化的 frame 不会发送。一帧滚动如果没有露出新行,根本不会产生批次。
#perf 日志行
| 行 | 含义 |
|---|---|
✓ bundled App.tsx (3255 B in 14.3 ms) | 打包耗时和 bundle 大小(开发配置)。 |
commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes | 一次提交:JS 排队的操作数、核心花费的时间(渲染组件、布局、diff)、绑定列表行花费的 JS 时间、产生的 mount item 数,以及存活的节点数。 |
turn: 2640µs (timers and tasks 1964µs) | 一次带提交的轮次:总时间,以及提交前 JS 定时器和 Rust 组件任务花费的时间(Rust 组件在提交中渲染)。 |
first frame: eval 271µs, total 1235µs | 执行 bundle 的时间,以及从启动到第一个批次的时间。 |
event Press on view 20 / event js: 196µs | 分发了一个事件,以及它的处理函数(及其引起的一切)在 JS 中花费的时间。 |
单次运行的耗时很大程度上取决于 CPU 的频率状态(刚从空闲中唤醒的机器会比较慢)。请在同一台机器上比较多次运行,取多次 运行中的最小值,并且更多地相信操作数和 item 数,而不是微秒数。
#滚动
app.scroll("id:photos", px(3000.0)).unwrap();
echo 'await app.scroll("id:photos", 3000)' | nsh test examples/gallery/Bench.tsx --trace
scroll 以每秒 60 帧、每帧 10 点的速度滚动列表,并打印每一帧的批次:
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 }
...
对于列表,这能展示虚拟化的实际效果:哪些帧绑定了新行、一行需要多少操作,以及没有露出新行的帧不产生任何
内容。要得到有意义的耗时,请使用 release 构建(cargo test --release,或源码检出中的 ./target/release/nsh)。
#在 CI 中
无界面测试不需要设备或显示器,因此可以在 Linux runner 上运行:Rust 应用的测试用 cargo test,nsh test 则只需要 CLI。只要有测试
失败,两者都以非零退出码退出:测试本身失败、应用无法构建或什么都没渲染、应用在某一步中抛出异常,都算失败。
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo test # Rust 应用的测试
- run: nsh test --junit test-results.xml # 它的 TypeScript 测试,或 TSX 应用的测试
(在 runner 上安装 nsh CLI 的方式与本地安装相同;参见安装。)
--junit <FILE> 还会把结果写成 JUnit XML 报告,CI 服务(GitHub Actions 的测试报告、GitLab、Jenkins、Azure Pipelines)
会按测试展示它:每个测试文件一个 testsuite,每个测试一个 testcase,并附上失败的内容、位置和屏幕上的文字。
要守住性能,可以记录几个交互的 item 数(--trace 输出中的 ── … : N items 行以及 ops / mount items 的数字),在它们
增长时让 CI 失败。这些计数是确定的;共享 CI 机器上的耗时则不是。
#用于 UI 测试的 testID
无界面测试检查的是运行时渲染出的内容,它们的 id: 选择器按测试 id 查找视图。如果要用平台的测试工具(XCUITest、Espresso / UI Automator、Appium、Maestro)
驱动真实的原生 UI,请给视图设置测试 id:Rust 中用 .test_id(…),TSX 中用 testID:
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(在 XCUITest 中为 app.otherElements["add-to-cart"])。 |
| Android | 无障碍节点的 view id 资源名(在 UI Automator 中为 By.res("add-to-cart"))。 |
在 Rust 中测试 id 是每个元素的方法,组件的元素也不例外(button().test_id("save") 为组件的根视图命名);testID 是每个元素以及每个 Rust 组件(Button、Dialog 等)都有的 prop。它不会改变屏幕阅读器读出的内容;那应该用
accessibilityLabel(参见无障碍)。在 --trace 的输出中,它出现在视图的 ViewProps item 里,
形如 test_id: Some("add-to-cart")。