nanoeffects 0.31.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{action-creator-6rlBTmH9.d.ts → action-creator-DprJV-pN.d.ts} +6 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-DFlmNJ6h.js → bus-Cmk4U1hQ.js} +1 -1
- package/dist/caller-DULg_ZYV.js +1 -0
- package/dist/context-Dp_QRXzb.js +1 -0
- package/dist/{context-types-DKNc5mkS.d.ts → context-types-BiTMp_lW.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +2 -2
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/guard.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/match.js +1 -1
- package/dist/{module-G39MaVOt.d.ts → module-DPKjzfRk.d.ts} +2 -2
- package/dist/notrace/{action-creator-BrMSO3RP.d.ts → action-creator-BWd9FWt6.d.ts} +2 -2
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-7GUL2EW5.d.ts → plugin-DmAYT3Xk.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BWNP23hr.d.ts → plugin-2d3uuW7o.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +2 -2
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/testing.d.ts +80 -0
- package/dist/testing.js +1 -0
- package/dist/trace.d.ts +227 -44
- package/dist/trace.js +3 -3
- package/dist/tracer-v7m-Qah_.js +2 -0
- package/dist/{types-N_cdRr1r.d.ts → types-DxPZDzZG.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-CeM1rtES.js → with-policy-BmL0eRU3.js} +1 -1
- package/dist/{with-policy-CthiBp_z.d.ts → with-policy-Y12d-yx6.d.ts} +1 -1
- package/docs/debugging-cases.md +122 -0
- package/docs/debugging-loops.md +79 -0
- package/docs/migrating-from-rxjs.md +1 -1
- package/docs/package.md +2 -1
- package/docs/recipes.md +35 -0
- package/docs/ru/debugging-cases.md +122 -0
- package/docs/ru/debugging-loops.md +79 -0
- package/docs/ru/migrating-from-rxjs.md +1 -1
- package/docs/ru/package.md +2 -1
- package/docs/ru/recipes.md +35 -0
- package/docs/ru/testing.md +92 -0
- package/docs/ru/tracing.md +189 -3
- package/docs/testing.md +92 -0
- package/docs/tracing.md +189 -3
- package/llms-full.txt +528 -5
- package/llms.txt +4 -2
- package/package.json +12 -3
- package/dist/context-B5Z2RpJt.js +0 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Тесты времени диаграммами
|
|
2
|
+
|
|
3
|
+
> **[Уровень B](README.md#виды-проектов)**: SPA с async UI. Сначала прочитайте: [Политики](policies.md).
|
|
4
|
+
|
|
5
|
+
Тест `latest`, `debounce`, `queue`, `retry` или `timeout` — это тест времени. С поддельными таймерами время прячется в числах (`advanceTimersByTime(30)`), а список вызовов вы проверяете вручную. Эта страница показывает, как записать расписание и ожидание двумя короткими диаграммами, как это делают marble-тесты RxJS.
|
|
6
|
+
|
|
7
|
+
Нужен тест-раннер с поддельными таймерами (Vitest или Jest). Сам хелпер его не импортирует.
|
|
8
|
+
|
|
9
|
+
## Тест политики диаграммой
|
|
10
|
+
|
|
11
|
+
**Проблема.** Нужен тест, который говорит: второй поиск отменяет первый, и завершается только второй.
|
|
12
|
+
|
|
13
|
+
**Когда.** Политика или эффект решает, что и когда выполняется, и ответ зависит от времени между событиями.
|
|
14
|
+
|
|
15
|
+
1. Создайте шину через `createMarbleBus`. Она записывает трассировку своих задач и даёт `run`.
|
|
16
|
+
2. Зарегистрируйте обработчик на `marbles.bus`, как в приложении.
|
|
17
|
+
3. Вызовите `run` с диаграммой отправок. Один символ — один кадр (по умолчанию 10 мс).
|
|
18
|
+
4. Вызовите `expect` с диаграммой фаз, которую ждёте.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// search.test.js
|
|
22
|
+
import { it, vi } from "vitest";
|
|
23
|
+
import { latest } from "nanoeffects/policies";
|
|
24
|
+
import { createMarbleBus, fakeTimersClock } from "nanoeffects/testing";
|
|
25
|
+
|
|
26
|
+
// поиск, который занимает 50 мс
|
|
27
|
+
function search(query) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, 50));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it("latest cancels the search that is running", async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
|
|
34
|
+
// createMarbleBus: шина, которая записывает трассировку, и run, который двигает время по кадрам
|
|
35
|
+
const marbles = createMarbleBus({ clock: fakeTimersClock(vi) });
|
|
36
|
+
|
|
37
|
+
// latest: новый поиск отменяет выполняющийся
|
|
38
|
+
marbles.bus.on("search/changed", latest(search), { traceId: "search" });
|
|
39
|
+
|
|
40
|
+
// "a-b": отправить "x" в кадре 0 и "xy" в кадре 2
|
|
41
|
+
const result = await marbles.run({
|
|
42
|
+
emits: { "search/changed": { marble: "a-b", values: { a: "x", b: "xy" } } },
|
|
43
|
+
frames: 12,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// s старт, c отмена, d готово: в кадре 2 второй поиск отменяет первый, и завершается в кадре 7
|
|
47
|
+
result.expect({ "search/changed:latest": "s-(cs)----d----" });
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Когда строка не совпала, `expect` бросает ошибку с двумя диаграммами одна под другой:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
marble row "search/changed:latest" differs
|
|
55
|
+
expected s-(cs)----d----
|
|
56
|
+
received s-(cs)---d-----
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Такой же тест для `queue` и `debounce` лежит в [`examples/testing`](../../examples/testing). Запустите его командой `pnpm vitest run examples/testing`.
|
|
60
|
+
|
|
61
|
+
## Чтение диаграмм
|
|
62
|
+
|
|
63
|
+
В диаграмме один символ или одна группа в скобках на кадр.
|
|
64
|
+
|
|
65
|
+
| Символ | В `emits` | В результате |
|
|
66
|
+
| ------ | --------- | ------------ |
|
|
67
|
+
| `-` | ничего не происходит | ничего не происходит |
|
|
68
|
+
| буква или цифра | отправляет событие со значением `values[символ]` (без `values` — сам символ) | `s` старт, `d` готово, `e` ошибка, `c` отмена, `x` отброшено |
|
|
69
|
+
| `(ab)` | две отправки в одном кадре, в таком порядке | две фазы в одном кадре, в порядке, в котором они произошли |
|
|
70
|
+
|
|
71
|
+
Другие символы (`|`, пробелы) вызывают ошибку с названием диаграммы и позицией.
|
|
72
|
+
|
|
73
|
+
У задачи в результате есть строка `type:effect`: `search/changed:latest` — задача политики `latest`, `search:run` — обработчик с именем `search`. Вызовите `result.rows()`, чтобы напечатать все строки, и скопируйте нужные в `expect`. В `expect` можно написать один эффект (`latest`), если он есть в одной строке. Если он есть в двух, ошибка попросит полный ключ.
|
|
74
|
+
|
|
75
|
+
## Кадр и число кадров
|
|
76
|
+
|
|
77
|
+
Кадр — 10 мс. Чтобы изменить его, передайте `frame` в `createMarbleBus`. Кадр события — его время, делённое на длину кадра, с округлением вниз: таймер на 100 мс, начатый в кадре 0, заканчивается в кадре 10, поэтому для него нужно `frames` не меньше 11.
|
|
78
|
+
|
|
79
|
+
`frames` — число кадров для запуска. Без него хелпер выполняет столько, сколько в самой длинной диаграмме `emits`. Работа, которая дольше диаграммы, обрезается, поэтому задавайте `frames`, когда обработчик занимает время.
|
|
80
|
+
|
|
81
|
+
## Другие часы
|
|
82
|
+
|
|
83
|
+
`fakeTimersClock(vi)` двигает поддельные таймеры и читает `Date.now()`. Тест включает поддельные таймеры сам (`vi.useFakeTimers()`) и оставляет `Date` поддельным, как по умолчанию. Jest работает так же: `fakeTimersClock(jest)`.
|
|
84
|
+
|
|
85
|
+
Часами служит любой объект с `advance(ms)` и `now()`. `advance` может вернуть промис.
|
|
86
|
+
|
|
87
|
+
## Чего нет
|
|
88
|
+
|
|
89
|
+
- **Нужен трассировщик.** Хелпер читает трассировку шины, поэтому у `nanoeffects/testing` нет сборки `notrace`.
|
|
90
|
+
- **Одна строка на все вызовы.** Два вызова одного обработчика делят строку, поэтому `(sd)` показывает порядок фаз, а не то, к какому вызову они относятся.
|
|
91
|
+
- **Нет данных в диаграммах.** Диаграмма показывает, когда, а не что. Значения проверяйте тест-раннером как обычно.
|
|
92
|
+
- **Не алфавит RxJS.** Нет горячих и холодных источников и нет `|` для конца потока.
|
package/docs/ru/tracing.md
CHANGED
|
@@ -13,15 +13,20 @@
|
|
|
13
13
|
- [Какая подписка начала цепочку](#какая-подписка-начала-цепочку)
|
|
14
14
|
- [Один поток через traceGroupId](#один-поток-через-tracegroupid)
|
|
15
15
|
- [Имя обработчика](#имя-обработчика)
|
|
16
|
+
- [Список подписчиков события](#список-подписчиков-события)
|
|
17
|
+
- [Строка кода обработчика](#строка-кода-обработчика)
|
|
16
18
|
- [Сохранение цепочки: `ctx.emit` вместо `bus.emit`](#сохранение-цепочки-ctxemit-вместо-busemit)
|
|
17
19
|
- [Отключение имён](#отключение-имён)
|
|
18
20
|
- [Встроенные логгеры](#встроенные-логгеры)
|
|
19
21
|
- [Сокращение объёма в production](#сокращение-объёма-в-production)
|
|
20
22
|
- [Последние события на случай инцидента](#последние-события-на-случай-инцидента)
|
|
23
|
+
- [Время и незавершённые задачи](#время-и-незавершённые-задачи)
|
|
24
|
+
- [Поиск причины ошибки](#поиск-причины-ошибки)
|
|
21
25
|
- [Настройка Redux DevTools](#настройка-redux-devtools)
|
|
22
26
|
- [Поиск циклов через `graphLogger`](#поиск-циклов-через-graphlogger)
|
|
23
27
|
- [Безопасные данные для инструмента](#безопасные-данные-для-инструмента)
|
|
24
28
|
- [Собственный логгер](#собственный-логгер)
|
|
29
|
+
- [Остановка на событии](#остановка-на-событии)
|
|
25
30
|
- [Удаление трассировки из сборки](#удаление-трассировки-из-сборки)
|
|
26
31
|
|
|
27
32
|
## Подключение трассировщика
|
|
@@ -186,7 +191,7 @@ const pay = createAction("checkout/pay", async () => {}, {
|
|
|
186
191
|
|
|
187
192
|
**Когда.** У события больше одного обработчика, и вы читаете трассировку, чтобы увидеть, кто сделал работу.
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
Обработчик получает имя своей функции, если регистрируете именованную функцию (`function saveName() {}`), поэтому лог приложения, которое никто не размечал, уже читается. Стрелка без имени остаётся как есть. Чтобы выбрать имя или назвать стрелку, дайте обработчику `traceId` при регистрации: он главнее имени функции. Плагин делает каждый вызов именованного обработчика задачей с этим именем, а то, что он отправляет через `ctx.emit`, становится дочерним. Минификатор может сократить имена функций в production-сборке: используйте `traceId`, где имя должно быть стабильным, и для обработчика, который возвращает политика (`latest(fn)`): у него нет собственного имени. `off` работает как раньше. Это работает и в модуле (`m.on(event, handler, { traceId })`), и на экземпляре, созданном через `extendBus`.
|
|
190
195
|
|
|
191
196
|
```js
|
|
192
197
|
import { createBus } from "nanoeffects";
|
|
@@ -209,6 +214,88 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
|
|
|
209
214
|
[nanoeffects] saveNotifier · run done
|
|
210
215
|
```
|
|
211
216
|
|
|
217
|
+
## Список подписчиков события
|
|
218
|
+
|
|
219
|
+
**Проблема.** Чтобы узнать, что запустит emit, приходится искать в коде каждый `bus.on` этого события.
|
|
220
|
+
|
|
221
|
+
**Когда.** Вы читаете чужое приложение или тест должен охранять подключения шины.
|
|
222
|
+
|
|
223
|
+
`describeBus(bus)` возвращает обработчики каждого события на данный момент: по именам и в порядке добавления. Имя — это `traceId`, иначе имя функции, иначе `"<anonymous>"`. Функция читает то, что хранит `tracePlugin`, поэтому на шине без плагина или без трассировщика, а также в сборке `notrace` она возвращает `{}`. Она ничего не запускает и не меняет шину.
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
import { createBus } from "nanoeffects";
|
|
227
|
+
import { consoleLogger, createTracer, describeBus, tracePlugin } from "nanoeffects/trace";
|
|
228
|
+
|
|
229
|
+
function saveName(name) {
|
|
230
|
+
console.log("save", name);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function audit(name) {
|
|
234
|
+
console.log("audit", name);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const trace = createTracer(consoleLogger());
|
|
238
|
+
|
|
239
|
+
const bus = createBus({ trace })
|
|
240
|
+
.use(tracePlugin());
|
|
241
|
+
|
|
242
|
+
bus.on("name/changed", saveName);
|
|
243
|
+
bus.on("name/changed", audit);
|
|
244
|
+
bus.on("saved", () => {}, { traceId: "refreshView" });
|
|
245
|
+
|
|
246
|
+
// describeBus: карта событий и имён обработчиков
|
|
247
|
+
console.log(describeBus(bus));
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
{ "name/changed": ["saveName", "audit"], saved: ["refreshView"] }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Строка кода обработчика
|
|
255
|
+
|
|
256
|
+
**Проблема.** Лог говорит, что выполнился `saveNotifier`, и приходится искать в коде `bus.on`, который его зарегистрировал. У стрелки без имени нет имени вообще, поэтому её нет и в логе.
|
|
257
|
+
|
|
258
|
+
**Когда.** Вы отлаживаете в разработке и хотите перейти от строки лога к коду.
|
|
259
|
+
|
|
260
|
+
Передайте `source: true` в `tracePlugin`. Он читает место каждого вызова `bus.on` и `bus.once` один раз, при регистрации, и никогда при `emit`. События обработчика получают `source` (`file:line:column`), `consoleLogger({ source: true })` печатает его, а `describeBus(bus, { source: true })` его перечисляет. Обработчик без имени тоже попадает в трассировку, под типом своего события и номером: `name/changed#1`, `name/changed#2`, в порядке регистрации. Два встроенных обработчика одного события — это две записи, а место остаётся в `source`. Добавление или перестановка вызовов `bus.on` меняет номера, поэтому дайте `traceId` обработчику, чьё имя должно остаться прежним между сборками.
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { createBus } from "nanoeffects";
|
|
264
|
+
import { consoleLogger, createTracer, describeBus, tracePlugin } from "nanoeffects/trace";
|
|
265
|
+
|
|
266
|
+
// source: печатать, где зарегистрирован обработчик
|
|
267
|
+
const trace = createTracer(consoleLogger({ source: true }));
|
|
268
|
+
|
|
269
|
+
// source: читать место каждого bus.on один раз, при регистрации
|
|
270
|
+
const bus = createBus({ trace })
|
|
271
|
+
.use(tracePlugin({ source: true }));
|
|
272
|
+
|
|
273
|
+
function saveName(name) {
|
|
274
|
+
console.log("save", name);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
bus.on("name/changed", saveName);
|
|
278
|
+
bus.on("name/changed", (name) => console.log("audit", name));
|
|
279
|
+
|
|
280
|
+
bus.emit("name/changed", "Eva");
|
|
281
|
+
|
|
282
|
+
// describeBus: те же места, ничего не запуская
|
|
283
|
+
console.log(describeBus(bus, { source: true }));
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
[nanoeffects] saveName · run start (file:///app/src/user.js:13:5)
|
|
288
|
+
[nanoeffects] name/changed#1 · run start (file:///app/src/user.js:14:5)
|
|
289
|
+
{ "name/changed": ["saveName (file:///app/src/user.js:13:5)", "<anonymous> (file:///app/src/user.js:14:5)"] }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Ограничения:
|
|
293
|
+
|
|
294
|
+
- **Минифицированный или собранный код** даёт строку сборки. Включите source maps (`node --enable-source-maps` или source maps браузера), чтобы увидеть исходную строку.
|
|
295
|
+
- **`ctx.on`, вызванный вручную** внутри обработчика, показывает строку в библиотеке. `on` модуля и экземпляра, созданного через `extendBus`, а также `on` у `matchPlugin`, `signalPlugin` и `devPlugin` показывают вашу строку при любом порядке плагинов и в бандле.
|
|
296
|
+
- **Нет стека**, например при `Error.stackTraceLimit = 0`: `source` не ставится, обработчик работает как обычно.
|
|
297
|
+
- **По умолчанию выключено.** Чтение стека стоит немного на каждую регистрацию, поэтому включайте его в разработке.
|
|
298
|
+
|
|
212
299
|
## Сохранение цепочки: `ctx.emit` вместо `bus.emit`
|
|
213
300
|
|
|
214
301
|
Внутри обработчика отправляйте через `ctx.emit`. Он сохраняет цепочку: запущенные им задачи становятся дочерними для обработчика, а у его событий есть `rootId` цепочки. Обычный `bus.emit` внутри обработчика запускает новый корень без `parentId` и без `rootId`, так что трассировка теряет связь. Шина не может об этом предупредить, потому что не знает, какой обработчик сейчас выполняется.
|
|
@@ -250,7 +337,7 @@ const bus = createBus({
|
|
|
250
337
|
| `reduxDevtools()` | Отправляет emit в Redux DevTools, а с `policies` — стадии обработчиков как `type/effect/phase`; state — `{ running }` |
|
|
251
338
|
| `consoleLogger()` | Печатает по одной строке на фазу, с отступом по глубине задачи |
|
|
252
339
|
| `performanceLogger()` | Измерения User Timing для панели Performance браузера |
|
|
253
|
-
| `graphLogger()` | Строит карту того, какое событие к какому ведёт, находит циклы во время работы приложения и записывает карту как Mermaid, Graphviz или JSON ([см. ниже](#поиск-циклов-через-graphlogger)) |
|
|
340
|
+
| `graphLogger()` | Строит карту того, какое событие к какому ведёт, находит циклы во время работы приложения и записывает карту как Mermaid, Graphviz или JSON ([см. ниже](#поиск-циклов-через-graphlogger)); [`mergeGraphs` и `findOrphans`](debugging-loops.md#подписки-которые-не-запускались) объединяют запуски и находят подписки, которые не запускались |
|
|
254
341
|
| `createTraceRecorder()` | Хранит события в `recorder.events`: все — для тестов, либо с `{ capacity }` последние N ([см. ниже](#последние-события-на-случай-инцидента)) |
|
|
255
342
|
|
|
256
343
|
### Сокращение объёма в production
|
|
@@ -311,12 +398,91 @@ window.addEventListener("error", () => {
|
|
|
311
398
|
Что нужно знать, прежде чем отправлять события:
|
|
312
399
|
|
|
313
400
|
- **`payload` и `error` могут содержать приватные данные**: то, что ввёл пользователь, токены, личный текст. Рекордер хранит их как есть и не вычищает. Выбирайте, что отправлять, как это делает `withoutPayload`.
|
|
314
|
-
- **У события нет времени.** Вы получаете порядок, а не длительность.
|
|
401
|
+
- **У события нет времени.** Вы получаете порядок, а не длительность. Время считает [`timingLogger`](#время-и-незавершённые-задачи).
|
|
315
402
|
- **Старый `parentId` может указывать на событие, которое вытеснено.** Когда вы строите дерево из событий, считайте неизвестный `parentId` корнем.
|
|
316
403
|
- **`recorder.events` с `capacity` — снимок.** Каждое чтение строит новый массив, поэтому читайте его один раз в нужный момент. Без `capacity` это один живой массив.
|
|
317
404
|
- **Низкий `sample` разрежает буфер.** `createTracer(recorder, { sample: 0.05 })` записывает 5% корневых задач, поэтому к моменту инцидента в буфере может быть мало. См. [Сокращение объёма в production](#сокращение-объёма-в-production).
|
|
318
405
|
- **`capacity`, не являющийся положительным целым** (`0`, отрицательное число, `NaN`), означает «без ограничения».
|
|
319
406
|
|
|
407
|
+
### Время и незавершённые задачи
|
|
408
|
+
|
|
409
|
+
У события нет времени, и по рекордеру нельзя узнать, какой обработчик медленный, сколько жил запрос до `cancel` и осталось ли что-то после размонтирования. `timingLogger` проставляет время на каждое событие при получении и связывает `start` с первой завершающей фазой (`done`, `error`, `cancel` или `drop`) той же задачи.
|
|
410
|
+
|
|
411
|
+
Подайте его в трассировщик вместе с рекордером: `createTracer` принимает массив логгеров.
|
|
412
|
+
|
|
413
|
+
<!-- docs-check: new -->
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
// bus.js
|
|
417
|
+
import { createBus } from "nanoeffects";
|
|
418
|
+
import { createTraceRecorder, createTracer, timingLogger } from "nanoeffects/trace";
|
|
419
|
+
|
|
420
|
+
// createTraceRecorder: события в порядке прихода
|
|
421
|
+
export const recorder = createTraceRecorder();
|
|
422
|
+
|
|
423
|
+
// timingLogger: время каждого события и длительность задач
|
|
424
|
+
export const timing = timingLogger();
|
|
425
|
+
|
|
426
|
+
// createTracer: каждое событие получают оба логгера
|
|
427
|
+
const trace = createTracer([recorder, timing]);
|
|
428
|
+
|
|
429
|
+
export const bus = createBus({ trace });
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
// check.js
|
|
434
|
+
import { timing } from "./bus";
|
|
435
|
+
|
|
436
|
+
// durations: задачи, которые завершились, с фазой конца и длительностью в мс
|
|
437
|
+
const slow = timing.durations.filter((task) => task.ms > 500);
|
|
438
|
+
console.log(slow);
|
|
439
|
+
|
|
440
|
+
// pending: задачи, которые начались и не закончились
|
|
441
|
+
const leftovers = timing.pending();
|
|
442
|
+
if (leftovers.length > 0) {
|
|
443
|
+
throw new Error(`${leftovers.length} tasks are still open`);
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Что нужно знать:
|
|
448
|
+
|
|
449
|
+
- **Часы.** Время берётся из `performance.now()`, а где его нет, из `Date.now()`. В тесте передайте свои часы: `timingLogger({ now })`.
|
|
450
|
+
- **`capacity` режет только `durations`.** `timingLogger({ capacity: 200 })` хранит последние 200 завершённых задач. `pending()` не обрезается: утечка видна как растущий список.
|
|
451
|
+
- **`clear()`** очищает оба списка. Вызывайте его между тестами.
|
|
452
|
+
- **Логгер видит то, что пропустил трассировщик.** Задача, чей `start` отфильтрован или не попал в `sample`, не получит пару, и её завершение игнорируется.
|
|
453
|
+
|
|
454
|
+
### Поиск причины ошибки
|
|
455
|
+
|
|
456
|
+
Ошибка приходит одним событием, и не видно, что к ней привело. `chainOf` строит путь от корня до задачи по `parentId`. Передайте ему рекордер: тогда в путь попадут и подписчики, которые не шлют событий (обычный обработчик, который вызывает `ctx.emit`).
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
// why.js
|
|
460
|
+
import { chainOf } from "nanoeffects/trace";
|
|
461
|
+
import { recorder } from "./bus";
|
|
462
|
+
|
|
463
|
+
const failed = recorder.events.find((event) => event.phase === "error");
|
|
464
|
+
|
|
465
|
+
// chainOf: путь от корня до упавшей задачи
|
|
466
|
+
const path = chainOf(recorder, failed.id);
|
|
467
|
+
|
|
468
|
+
const text = path.map((step) => step.type).join(" → ");
|
|
469
|
+
console.log(text); // queryChanged → searchLoaded
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
У шага есть `type`, а также `effect` и `phase` (у подписчика без событий их нет), `source` и `traceGroupId`, когда они известны. Если передать вместо рекордера массив событий (снимок или файл), в пути будут только задачи с событиями.
|
|
473
|
+
|
|
474
|
+
Чтобы видеть путь сразу в консоли, включите `chain` в `consoleLogger`:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
import { consoleLogger, createTracer } from "nanoeffects/trace";
|
|
478
|
+
|
|
479
|
+
// consoleLogger: строка ошибки заканчивается путём от корня
|
|
480
|
+
const trace = createTracer(consoleLogger({ chain: true }));
|
|
481
|
+
// [nanoeffects] render · run error (queryChanged → render)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`chain` помнит последние 1000 задач. По умолчанию он выключен.
|
|
485
|
+
|
|
320
486
|
### Настройка Redux DevTools
|
|
321
487
|
|
|
322
488
|
<!-- docs-check: new -->
|
|
@@ -448,6 +614,26 @@ const errorsOnly = (): TraceLogger => (event) => {
|
|
|
448
614
|
};
|
|
449
615
|
```
|
|
450
616
|
|
|
617
|
+
### Остановка на событии
|
|
618
|
+
|
|
619
|
+
Нужно остановиться в отладчике, когда приходит определённое событие. Для этого не нужен API: это пять строк в собственном логгере.
|
|
620
|
+
|
|
621
|
+
```js
|
|
622
|
+
import { createTracer } from "nanoeffects/trace";
|
|
623
|
+
|
|
624
|
+
// Логгер: останавливается в отладчике на начале задачи с этим type
|
|
625
|
+
const stopOn = (type) => (event) => {
|
|
626
|
+
if (event.type === type && event.phase === "start") {
|
|
627
|
+
debugger; // браузер остановится здесь, если открыты DevTools
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// createTracer: подайте логгер, как любой другой
|
|
632
|
+
const trace = createTracer(stopOn("search"));
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
В отладчике посмотрите `event.payload` и поднимитесь по стеку: синхронная часть цепочки видна в нём целиком.
|
|
636
|
+
|
|
451
637
|
## Удаление трассировки из сборки
|
|
452
638
|
|
|
453
639
|
Без трассировщика хуки почти ничего не стоят. `nanoeffects/trace` попадает в бандл, только если его импортируют.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Test time with diagrams
|
|
2
|
+
|
|
3
|
+
> **Level B.** Read first: [Policies](policies.md).
|
|
4
|
+
|
|
5
|
+
A test of `latest`, `debounce`, `queue`, `retry` or `timeout` is a test of time. With fake timers the time hides in numbers (`advanceTimersByTime(30)`), and you check a list of calls by hand. This page shows how to write the schedule and the expectation as two short diagrams, as RxJS marble tests do.
|
|
6
|
+
|
|
7
|
+
You need a test runner with fake timers (Vitest or Jest). The helper does not import one.
|
|
8
|
+
|
|
9
|
+
## Test a policy with a diagram
|
|
10
|
+
|
|
11
|
+
**Problem.** You want a test that says: a second search cancels the first one, and only the second finishes.
|
|
12
|
+
|
|
13
|
+
**When.** A policy or an effect decides what runs when, and the answer depends on the time between events.
|
|
14
|
+
|
|
15
|
+
1. Create a bus with `createMarbleBus`. It records the trace of its tasks and gives you `run`.
|
|
16
|
+
2. Register the handler on `marbles.bus`, as you do in your app.
|
|
17
|
+
3. Call `run` with a diagram of the emits. One character is one frame (10 ms by default).
|
|
18
|
+
4. Call `expect` with a diagram of the phases that you expect.
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// search.test.js
|
|
22
|
+
import { it, vi } from "vitest";
|
|
23
|
+
import { latest } from "nanoeffects/policies";
|
|
24
|
+
import { createMarbleBus, fakeTimersClock } from "nanoeffects/testing";
|
|
25
|
+
|
|
26
|
+
// a search that takes 50 ms
|
|
27
|
+
function search(query) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, 50));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it("latest cancels the search that is running", async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
|
|
34
|
+
// createMarbleBus: a bus that records its trace, and a run that moves time frame by frame
|
|
35
|
+
const marbles = createMarbleBus({ clock: fakeTimersClock(vi) });
|
|
36
|
+
|
|
37
|
+
// latest: a new search cancels the one that is running
|
|
38
|
+
marbles.bus.on("search/changed", latest(search), { traceId: "search" });
|
|
39
|
+
|
|
40
|
+
// "a-b": emit "x" at frame 0 and "xy" at frame 2
|
|
41
|
+
const result = await marbles.run({
|
|
42
|
+
emits: { "search/changed": { marble: "a-b", values: { a: "x", b: "xy" } } },
|
|
43
|
+
frames: 12,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// s start, c cancel, d done: at frame 2 the second search cancels the first, and it is done at frame 7
|
|
47
|
+
result.expect({ "search/changed:latest": "s-(cs)----d----" });
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
When a row differs, `expect` throws an error with both diagrams one under the other:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
marble row "search/changed:latest" differs
|
|
55
|
+
expected s-(cs)----d----
|
|
56
|
+
received s-(cs)---d-----
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The same test for `queue` and `debounce` is in [`examples/testing`](../examples/testing). Run it with `pnpm vitest run examples/testing`.
|
|
60
|
+
|
|
61
|
+
## Read the diagrams
|
|
62
|
+
|
|
63
|
+
A diagram has one character, or one group in parentheses, for each frame.
|
|
64
|
+
|
|
65
|
+
| Character | In `emits` | In the result |
|
|
66
|
+
| --------- | ---------- | ------------- |
|
|
67
|
+
| `-` | nothing happens | nothing happens |
|
|
68
|
+
| a letter or digit | emits the event with `values[character]` (the character itself without `values`) | `s` start, `d` done, `e` error, `c` cancel, `x` drop |
|
|
69
|
+
| `(ab)` | two emits in one frame, in this order | two phases in one frame, in the order they happened |
|
|
70
|
+
|
|
71
|
+
Other characters (`|`, spaces) throw an error that names the diagram and the position.
|
|
72
|
+
|
|
73
|
+
A task has a row in the result named `type:effect`: `search/changed:latest` is the task of the `latest` policy, `search:run` is the listener named `search`. Call `result.rows()` to print every row, then copy the ones you want into `expect`. In `expect` you can write the effect alone (`latest`) when one row has it. If two rows have it, the error asks for the full key.
|
|
74
|
+
|
|
75
|
+
## Set the frame and the number of frames
|
|
76
|
+
|
|
77
|
+
A frame is 10 ms. Pass `frame` to `createMarbleBus` to change it. The frame of an event is its time divided by the frame, rounded down: a timer of 100 ms that starts at frame 0 ends in frame 10, so `frames` must be 11 or more to show it.
|
|
78
|
+
|
|
79
|
+
`frames` is the number of frames to run. Without it the helper runs as many as the longest `emits` diagram has. Work that outlives the diagram is cut, so set `frames` when a handler takes time.
|
|
80
|
+
|
|
81
|
+
## Use another clock
|
|
82
|
+
|
|
83
|
+
`fakeTimersClock(vi)` moves fake timers and reads `Date.now()`. A test turns fake timers on itself (`vi.useFakeTimers()`) and leaves `Date` faked, which is the default. Jest works the same way: `fakeTimersClock(jest)`.
|
|
84
|
+
|
|
85
|
+
Any object with `advance(ms)` and `now()` is a clock. `advance` may return a promise.
|
|
86
|
+
|
|
87
|
+
## Know what is not there
|
|
88
|
+
|
|
89
|
+
- **It needs a tracer.** The helper reads the trace of the bus, so there is no `notrace` build of `nanoeffects/testing`.
|
|
90
|
+
- **One row for all calls.** Two calls of one listener share a row, so `(sd)` shows the order in which phases happened, not which call they belong to.
|
|
91
|
+
- **No payloads in diagrams.** The diagram shows when, not what. Check the values with your test runner as usual.
|
|
92
|
+
- **Not the RxJS alphabet.** There are no hot or cold sources and no `|` for the end of a stream.
|