@blotch/angular-lynx-testing-library

中文文档
22.2.18发布于 2026/11/08TypeScriptES Module构建来源已验证近 30 天下载 3489

Testing utilities for AngularLynx apps

原始使用文档
文档目录25 节
  1. Installation
  2. Setup
  3. Without Angular CLI
  4. API
  5. render(component, options?)
  6. cleanup()
  7. waitForUpdate()
  8. fireEvent
  9. eventMap
  10. *ByAccessibilityLabel
  11. *ByRole
  12. screen , within() and getQueriesForElement()
  13. queries
  14. Usage
  15. Rendering a component
  16. Injecting providers
  17. Reactive signals
  18. Handling events
  19. Control flow — @if
  20. Control flow — @for
  21. Architecture notes
  22. Single-thread model
  23. The render pipeline
  24. Event dispatch
  25. Comparison with React/Vue Lynx testing libraries

@blotch/angular-lynx-testing-library

Testing utilities for AngularLynx apps. Renders Angular standalone components through the Lynx PAPI pipeline into a JSDOM tree, then exposes the result via @testing-library/dom queries, which also read Lynx's accessibility-* attributes.

Installation

ng generate @blotch/angular-lynx:add-testing

It installs this package (and Vitest and jsdom, if the project doesn't have them; ng new adds both) and makes sure ng test runs through AngularLynx's unit-test builder. To install it by hand: npm install --save-dev @blotch/angular-lynx-testing-library.

Setup

None. Tests run with ng test, whose builder (@blotch/angular-lynx-build:unit-test, which ng add sets up) loads this package's setup before the tests whenever the project has it. There is no Vitest config or setup file to write.

The setup:

  • Enables Angular JIT compilation (@angular/compiler), for components compiled at runtime
  • Installs LynxTestingEnv on the jsdom window
  • Switches to the Lynx main thread (where PAPI functions live)
  • Polyfills PAPI functions the testing environment doesn't implement (__GetParent, __QuerySelector, etc.)
  • Makes lynx.reportError return for a warning, as Lynx does, instead of throwing it, so an @boundary shows its fallback with the default error handler
  • Installs @testing-library/jest-dom matchers

ng test compiles the tests ahead of time, like the app, and checks templates strictly, as the Angular Language Service does: a component that uses Lynx elements imports LYNX_ELEMENTS from @blotch/angular-lynx. The examples below do.

Without Angular CLI

Under plain Vitest, load the setup yourself and define the compile-time flags the AngularLynx runtime reads (the rsbuild plugin defines them in the app, and ng test sets them too):

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  define: {
    __DEV__: JSON.stringify(true),
    __MAIN_THREAD__: JSON.stringify(true),
    __WEB__: JSON.stringify(false),
    __PROFILE__: JSON.stringify(false),
    __ENABLE_SSR__: JSON.stringify(false),
  },
  // Angular's JIT compiler reads the metadata of legacy decorators.
  oxc: { decorator: { legacy: true } },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['@blotch/angular-lynx-testing-library/setup'],
  },
});

API

render(component, options?)

Bootstraps a standalone Angular component and returns a JSDOM container with @testing-library/dom queries bound to it.

import { render } from '@blotch/angular-lynx-testing-library';

const { container, getByText, queryByRole } = await render(MyComponent);

render is async — it awaits Angular's createApplication, then bootstraps the component. The initial change detection cycle runs before the promise resolves, so the container is ready to query immediately.

Options:

OptionTypeDescription
providers(Provider | EnvironmentProviders)[]Additional providers injected into the app (services, tokens, mocks).
queriesQueriesCustom queries to bind to container, next to the library's.
inputsRenderInputs<C>Values for the component's inputs, keyed by the name a template binds (an aliased input's alias). Set before the first change detection.
const { getByText, rerender } = await render(Greeting, {
  inputs: { name: 'Lynx' },
});

// Keeps the component and changes only the inputs passed.
await rerender({ inputs: { name: 'Angular' } });

rerender({ inputs }) can change only inputs render() set: Angular sets them through inputBinding(), and a component with input bindings refuses other inputs (and componentRef.setInput()). Give render() the starting value of any input a test changes later.

provideZonelessChangeDetection() and provideRenderer() are always included — you do not need to provide them.

Return value:

PropertyTypeDescription
containerElementThe JSDOM <page> root element. All rendered children are descendants.
unmount() => voidDestroys the Angular app.
rerender({ inputs }) => Promise<RenderResult> or (component, options?) => Promise<RenderResult>Changes inputs render() set, keeping the component; or renders another component in its place.
...queries—Every query in queries bound to container (getByText, getByRole, getByAccessibilityLabel, etc.).

cleanup()

Destroys the current Angular app and resets the Lynx environment. Called automatically after each test — you only need this for manual control.

import { cleanup } from '@blotch/angular-lynx-testing-library';

afterEach(cleanup);

Set ATL_SKIP_AUTO_CLEANUP=true to disable auto-cleanup.

waitForUpdate()

Waits for Angular's zoneless change detection scheduler to flush pending reactive updates, then forces a synchronous CD cycle.

import { waitForUpdate } from '@blotch/angular-lynx-testing-library';

const count = signal(0);
count.set(1);
await waitForUpdate();
// DOM now reflects count = 1

Call this after any signal mutation that happens outside an event handler (e.g. direct .set() in test code).

Under vi.useFakeTimers(), change detection waits for the fake clock as well, so nothing renders on the real clock. waitForUpdate() waits on a zero-delay setTimeout then: start it, move the clock (await vi.advanceTimersByTimeAsync(1)), and await it.

fireEvent

Fires Lynx events at JSDOM elements. Use the named helpers for standard Lynx events:

import { fireEvent } from '@blotch/angular-lynx-testing-library';

fireEvent.tap(element);
fireEvent.input(element, { value: 'hello' });
fireEvent.scroll(element, { scrollTop: 100 });

A named helper runs the handlers a device would, in the same order:

  • Touch events bubble. A tap, long press or touch runs the element's own (bindtap) or (catchtap), then its ancestors' on the way up to the page. So a tap on a row inside a pressable card reaches the card's (click). (capture-bind…) and (capture-catch…) handlers run before them, from the page down.
  • Mouse, wheel and key events bubble too, as Lynx sends them on desktop and web.
  • A catch handler stops the event where it runs: a (catchtap) panel keeps a tap from reaching the backdrop behind it.
  • Other events stay on their element. scroll, input, layoutchange and the rest run only the element's own handler.

init.bubbles overrides the event's own: fireEvent.tap(element, { bubbles: false }) runs only the element's handler.

init.eventType fires at one kind of handler on the element and nothing else: fireEvent.tap(element, { eventType: 'catchEvent' }) dispatches the DOM event "catchEvent:tap", which reaches only the element's (catchtap).

init.detail is the event's own data, as a device sends it: an <image>'s size once it loads, say.

fireEvent.load(image, { detail: { width: 240, height: 340 } });

To dispatch a raw DOM event directly:

fireEvent(element, new Event('bindEvent:tap'));

A CustomEvent's detail reaches the handler too: fireEvent(element, new CustomEvent('bindEvent:load', { detail })).

Available named helpers:

tap · longtap · touchstart · touchmove · touchcancel · touchend · longpress · keydown · keyup · mousedown · mouseup · mousemove · mouseclick · mousedblclick · mouselongpress · wheel · scroll · scrollend · scrolltoupper · scrolltolower · contentsizechanged · scrolltoupperedge · scrolltoloweredge · scrolltonormalstate · uiappear · uidisappear · focus · blur · input · confirm · layoutchange · load · error · bgload · bgerror · transitionstart · transitionend · transitioncancel · animationend

eventMap

The full Lynx event registry. Each entry has a defaultInit object: { bubbles: true } for the events Lynx sends up the response chain, {} for the rest.

import { eventMap } from '@blotch/angular-lynx-testing-library';

console.log(Object.keys(eventMap));
// ['tap', 'longtap', 'touchstart', ...]

*ByAccessibilityLabel

Find elements by their accessibility-label, the text a screen reader reads on Lynx. Testing Library's *ByLabelText reads <label> and aria-* attributes, which Lynx elements don't have.

const { getByAccessibilityLabel } = await render(Toolbar);

fireEvent.tap(getByAccessibilityLabel('Close'));

They come in Testing Library's six variants (getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy) and take its TextMatch (a string, RegExp or function) and MatcherOptions (exact, normalizer).

*ByRole

Testing Library's *ByRole, reading Lynx's accessibility attributes as a screen reader does. Elements Testing Library gives a role still match, such as an <input> as a textbox.

const { getByRole } = await render(Toolbar);

fireEvent.tap(getByRole('button', { name: 'Save' }));
expect(getByRole('button', { name: 'Week', selected: true })).toBeTruthy();
accessibility-traitsRole
buttonbutton
linklink
imageimg
headerheading
searchsearchbox
adjustableslider
tabbartablist
  • Role: from each trait in the table. An <image> is an img unless it sets accessibility-traits.
  • Reachable: a screen reader reaches <text>, <image>, and other elements with accessibility-element="true", outside accessibility-elements-hidden. A tabbar is read from the container around the tabs, so it needs no accessibility-element. Others match only with hidden: true.
  • name: the accessibility-label, or a <text>'s own text. A <view> gets no name from the text inside it, since iOS doesn't read it.
  • selected: the selected trait, as in accessibility-traits="button,selected", which an active routerLinkActive element gets, and which [lynxAccessibilityTraits]="{ selected: true }" adds. iOS reads a comma-separated list. Android reads one trait (button or image) and treats a list as none.
  • value: { text }: the accessibility-value.
  • Other ARIA states (checked, pressed, expanded, level, …) have no Lynx attribute, so Lynx elements never match them.

screen, within() and getQueriesForElement()

Testing Library's, with the queries above: screen binds every query to document.body, and within(element) binds them to element. getQueriesForElement is within.

queries

Every query, unbound (each takes the container first). Spread it into your own queries to keep them next to these. Each query is also exported on its own, such as getByAccessibilityLabel(container, 'Close').

Usage

Rendering a component

import { Component } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import { render } from '@blotch/angular-lynx-testing-library';

@Component({
  selector: 'greeting',
  imports: [LYNX_ELEMENTS],
  template: `<text>Hello, {{ name }}!</text>`,
})
class GreetingComponent {
  name = 'Lynx';
}

it('renders the greeting', async () => {
  const { container } = await render(GreetingComponent);
  expect(container.querySelector('text')!.textContent).toBe('Hello, Lynx!');
});

Injecting providers

import { Component, InjectionToken, inject } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import { render } from '@blotch/angular-lynx-testing-library';

const GREETING = new InjectionToken<string>('GREETING');

@Component({
  selector: 'test-greeting',
  imports: [LYNX_ELEMENTS],
  template: `<text>{{ greeting() }}</text>`,
})
class GreetingComponent {
  greeting = inject(GREETING);
}

it('uses the injected value', async () => {
  const { container } = await render(GreetingComponent, {
    providers: [{ provide: GREETING, useValue: 'Hola' }],
  });
  expect(container.querySelector('text')!.textContent).toBe('Hola');
});

Reactive signals

Define the signal outside the component so both the component and the test can access it:

import { Component, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';

const count = signal(0);

@Component({
  selector: 'test-counter',
  imports: [LYNX_ELEMENTS],
  template: `<text>{{ count() }}</text>`,
})
class CounterComponent {
  count = count;
}

it('updates when the signal changes', async () => {
  const { container } = await render(CounterComponent);
  expect(container.querySelector('text')!.textContent).toBe('0');

  count.set(42);
  await waitForUpdate();

  expect(container.querySelector('text')!.textContent).toBe('42');
});

Handling events

AngularLynx event bindings use Lynx prefixes: (bindtap), (catchtap), (bindinput), etc.

import { Component, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import {
  render,
  fireEvent,
  waitForUpdate,
} from '@blotch/angular-lynx-testing-library';

@Component({
  selector: 'test-button',
  imports: [LYNX_ELEMENTS],
  template: `
    <view (bindtap)="increment()">
      <text>{{ count() }}</text>
    </view>
  `,
})
class ButtonComponent {
  count = signal(0);
  increment() {
    this.count.update((n) => n + 1);
  }
}

it('increments on tap', async () => {
  const { container } = await render(ButtonComponent);

  fireEvent.tap(container.querySelector('view')!);
  await waitForUpdate();

  expect(container.querySelector('text')!.textContent).toBe('1');
});

Control flow — @if

it('shows content conditionally', async () => {
  const visible = signal(true);

  @Component({
    selector: 'test-if',
    imports: [LYNX_ELEMENTS],
    template: `@if (visible()) {
      <text>Hello</text>
    }`,
  })
  class IfComponent {
    visible = visible;
  }

  const { container } = await render(IfComponent);
  expect(container.querySelector('text')).not.toBeNull();

  visible.set(false);
  await waitForUpdate();
  expect(container.querySelector('text')).toBeNull();
});

Control flow — @for

it('renders a dynamic list', async () => {
  const items = signal(['A', 'B', 'C']);

  @Component({
    selector: 'test-for',
    imports: [LYNX_ELEMENTS],
    template: `@for (item of items(); track item) {
      <text>{{ item }}</text>
    }`,
  })
  class ListComponent {
    items = items;
  }

  const { container } = await render(ListComponent);
  expect(container.querySelectorAll('text').length).toBe(3);

  items.set(['A', 'B', 'C', 'D']);
  await waitForUpdate();
  expect(container.querySelectorAll('text').length).toBe(4);
});

Architecture notes

Single-thread model

AngularLynx runs entirely on the Lynx main thread (__MAIN_THREAD__ = true). This differs from React Lynx and Vue Lynx, which split rendering across two threads:

LibraryThread model
React LynxDual: background thread runs React, main thread applies PAPI ops
Vue LynxDual: background thread runs Vue, main thread applies PAPI ops
AngularLynxSingle: Angular renderer calls PAPI directly on main thread

In tests, this means Angular stays on the main thread, where PAPI functions (__CreatePage, __CreateView, etc.) are always available. The environment switches to the background thread only to answer a call AngularLynx sends there, such as LynxLocalStorage's, and switches straight back.

The render pipeline

render(Component)
  └─ bootstrapApplication(Component, {
       provideZonelessChangeDetection(),
       provideRenderer(),
       ...providers
     })
       └─ LynxDocument.createRootElement()
            └─ __CreatePage()  →  <page> appended to document.body
       └─ Angular renders component tree
            └─ __CreateView / __CreateText / …  →  JSDOM children
       └─ LynxRendererFactory2.end()
            └─ __FlushElementTree()  (no-op in tests)
  └─ appRef.tick()  →  force synchronous CD
  └─ return { container: elementTree.root, ...queries }

Event dispatch

AngularLynx registers event handlers via __AddEvent(element, eventType, eventName, { type: 'worklet', value: callback }). The testing environment stores this as a DOM event listener under the key "${eventType}:${eventName}" (e.g. "bindEvent:tap").

fireEvent.tap(el) walks Lynx's event response chain, from the page down to el and back up, and calls each element's listener for the kind of handler it has, stopping at a catch one. The listener calls runWorklet(callback, [event]) which invokes the Angular handler directly on the main thread. One bubbling "bindEvent:tap" DOM event wouldn't do: it would pass a (catchtap) ancestor's "catchEvent:tap" listener by and run the handlers above it.

Template binding → PAPI registration → DOM listener key:

TemplateListener key
(bindtap)bindEvent:tap
(catchtap)catchEvent:tap
(bindinput)bindEvent:input
(bindscroll)bindEvent:scroll

Comparison with React/Vue Lynx testing libraries

FeatureReact LynxVue LynxAngularLynx
render() returnSyncSyncAsync
Thread modelDual (BG+MT)Dual (BG+MT)Main only
Auto-cleanup env varPTL_SKIP_AUTO_CLEANUP—ATL_SKIP_AUTO_CLEANUP
renderHook()✓——
Snapshot serializer✓ (RefProxy)——
Vitest plugin✓—— (ng test sets up)
Named fireEvent helpers30+1637
Thread-aware fireEvent✓✓— (single thread)
Async state helperwaitSchedule()waitForUpdate()waitForUpdate()

版本与发布记录

@blotch/angular-lynx-testing-library 共发布 20 个版本,首次发布于 2026/10/12;过去 12 个月发布了 20 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
22.2.18latest2026/11/08今天
22.2.172026/11/08今天
22.2.162026/11/07今天
22.2.152026/11/071 天前
22.2.142026/11/071 天前
22.2.132026/11/062 天前

关于 @blotch/angular-lynx-testing-library 的常见问题

@blotch/angular-lynx-testing-library 是什么?

Testing utilities for AngularLynx apps

如何安装 @blotch/angular-lynx-testing-library?

在项目目录运行 npm install @blotch/angular-lynx-testing-library,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@angular/core(^22.2.0)、@angular/compiler(^22.2.0)、@blotch/angular-lynx(^22.2.0)、@angular/platform-browser(^22.2.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@blotch/angular-lynx-testing-library 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@blotch/angular-lynx-testing-library 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@blotch/angular-lynx-testing-library 可以商用吗?

@blotch/angular-lynx-testing-library 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@blotch/angular-lynx-testing-library 的下载量有多少?

根据 npm 官方下载统计,@blotch/angular-lynx-testing-library 在 2026/10/09 – 2026/11/07 这 30 天内被下载 3,489 次,日均约 116 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@blotch/angular-lynx-testing-library 还在维护吗?

最新版本 22.2.18 发布于 2026/11/08(今天);至今共发布 20 个版本,其中过去 12 个月发布了 20 个;首次发布于 2026/10/12。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。