@talelk/newui-data 1.0.0 → 1.1.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.
@@ -0,0 +1,157 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ CONFIG_DOCUMENT_LIMIT,
4
+ type ConfigStorage,
5
+ createConfigStore,
6
+ } from './config-store'
7
+
8
+ /** A Map-backed storage that records what was written. */
9
+ function memoryStorage(): ConfigStorage & { map: Map<string, string> } {
10
+ const map = new Map<string, string>()
11
+ return {
12
+ map,
13
+ getItem: (key) => map.get(key) ?? null,
14
+ setItem: (key, value) => void map.set(key, value),
15
+ removeItem: (key) => void map.delete(key),
16
+ keys: () => [...map.keys()],
17
+ }
18
+ }
19
+
20
+ describe('createConfigStore', () => {
21
+ beforeEach(() => {
22
+ vi.useFakeTimers()
23
+ vi.setSystemTime(new Date('2026-03-01T10:00:00Z'))
24
+ })
25
+ afterEach(() => {
26
+ vi.useRealTimers()
27
+ vi.restoreAllMocks()
28
+ })
29
+
30
+ it('wraps saves in a versioned envelope and loads them back', () => {
31
+ const storage = memoryStorage()
32
+ const store = createConfigStore({ key: 'acme.views', storage })
33
+
34
+ expect(store.save('revenue', { columns: ['date', 'usd'] })).toBe(true)
35
+ const loaded = store.load<{ columns: string[] }>('revenue')
36
+ expect(loaded?.v).toBe(1)
37
+ expect(loaded?.name).toBe('revenue')
38
+ expect(loaded?.updatedAt).toBe('2026-03-01T10:00:00.000Z')
39
+ expect(loaded?.doc).toEqual({ columns: ['date', 'usd'] })
40
+
41
+ // The store namespaces its keys, so a sibling store cannot collide.
42
+ expect([...storage.map.keys()]).toEqual(['acme.views:revenue'])
43
+ })
44
+
45
+ it('carries the type discriminator through save and list', () => {
46
+ const store = createConfigStore({
47
+ key: 'acme',
48
+ storage: memoryStorage(),
49
+ })
50
+ store.save('week-only', { filters: [] }, { type: 'saved-view' })
51
+ store.save('layout', { widgets: [] }, { type: 'dashboard-config' })
52
+
53
+ const listed = store.list()
54
+ expect(listed).toHaveLength(2)
55
+ expect(listed.map((row) => row.type).sort()).toEqual([
56
+ 'dashboard-config',
57
+ 'saved-view',
58
+ ])
59
+ })
60
+
61
+ it('lists documents newest first and skips unreadable entries', () => {
62
+ const storage = memoryStorage()
63
+ const store = createConfigStore({ key: 'acme', storage })
64
+ store.save('old', { n: 1 })
65
+ vi.setSystemTime(new Date('2026-03-02T10:00:00Z'))
66
+ store.save('new', { n: 2 })
67
+ storage.map.set('acme:garbage', '{not json')
68
+
69
+ expect(store.list().map((row) => row.name)).toEqual(['new', 'old'])
70
+ expect(store.load('garbage')).toBeUndefined()
71
+ })
72
+
73
+ it('removes and renames, reporting whether the document existed', () => {
74
+ const storage = memoryStorage()
75
+ const store = createConfigStore({ key: 'acme', storage })
76
+ store.save('draft', { n: 1 })
77
+
78
+ expect(store.remove('draft')).toBe(true)
79
+ expect(store.remove('draft')).toBe(false)
80
+ expect(store.load('draft')).toBeUndefined()
81
+
82
+ store.save('draft', { n: 1 })
83
+ vi.setSystemTime(new Date('2026-03-03T10:00:00Z'))
84
+ expect(store.rename('draft', 'keeper')).toBe(true)
85
+ expect(store.rename('draft', 'ghost')).toBe(false)
86
+
87
+ const keeper = store.load<{ n: number }>('keeper')
88
+ expect(keeper?.name).toBe('keeper')
89
+ expect(keeper?.updatedAt).toBe('2026-03-03T10:00:00.000Z')
90
+ expect(keeper?.doc).toEqual({ n: 1 })
91
+ expect(storage.map.has('acme:draft')).toBe(false)
92
+ })
93
+
94
+ it('migrates older envelopes instead of reading them wrong', () => {
95
+ const storage = memoryStorage()
96
+ storage.map.set(
97
+ 'acme:old',
98
+ JSON.stringify({ v: 0, name: 'old', updatedAt: 'x', doc: { a: 1 } }),
99
+ )
100
+
101
+ const migrated = createConfigStore({
102
+ key: 'acme',
103
+ storage,
104
+ migrate: (doc) => ({ ...(doc as object), migrated: true }),
105
+ })
106
+ const loaded = migrated.load<{ a: number; migrated: boolean }>('old')
107
+ expect(loaded?.v).toBe(1)
108
+ expect(loaded?.doc).toEqual({ a: 1, migrated: true })
109
+
110
+ // Without a migrate, a version mismatch is unreadable, not corrupted.
111
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
112
+ const strict = createConfigStore({ key: 'acme', storage })
113
+ expect(strict.load('old')).toBeUndefined()
114
+ expect(warn).toHaveBeenCalled()
115
+ })
116
+
117
+ it('caps documents at 64KB with a loud dev warning', () => {
118
+ const storage = memoryStorage()
119
+ const store = createConfigStore({ key: 'acme', storage })
120
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
121
+
122
+ const huge = 'x'.repeat(CONFIG_DOCUMENT_LIMIT + 1)
123
+ expect(store.save('huge', { data: huge })).toBe(false)
124
+ expect(storage.map.has('acme:huge')).toBe(false)
125
+ expect(warn).toHaveBeenCalledTimes(1)
126
+
127
+ // Just under the cap still saves.
128
+ expect(store.save('big', { data: 'y' })).toBe(true)
129
+ expect(store.load('big')).toBeDefined()
130
+ })
131
+
132
+ it('drops writes when the backend fails, warning once per failure site', () => {
133
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
134
+ const failing: ConfigStorage = {
135
+ getItem: () => null,
136
+ setItem: () => {
137
+ throw new Error('QuotaExceeded')
138
+ },
139
+ removeItem: () => {},
140
+ keys: () => [],
141
+ }
142
+ const store = createConfigStore({ key: 'acme', storage: failing })
143
+ expect(store.save('one', { n: 1 })).toBe(false)
144
+ expect(warn).toHaveBeenCalled()
145
+ })
146
+
147
+ it('falls back to memory under SSR, where localStorage is absent', () => {
148
+ vi.stubGlobal('localStorage', undefined)
149
+ try {
150
+ const store = createConfigStore({ key: 'acme' })
151
+ expect(store.save('page', { n: 1 })).toBe(true)
152
+ expect(store.load<{ n: number }>('page')?.doc.n).toBe(1)
153
+ } finally {
154
+ vi.unstubAllGlobals()
155
+ }
156
+ })
157
+ })
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Versioned documents for user-authored views.
3
+ *
4
+ * `createConfigStore` is one storage abstraction with two consumers: saved
5
+ * filters (the "saved view" side) and dashboard config (the widget-layout
6
+ * side). Both are documents in the same store, distinguished by a `type`
7
+ * discriminator in the envelope — never by separate stores.
8
+ *
9
+ * Documents are versioned envelopes so a future shape migrates per-store, not
10
+ * globally, and the envelope carries `updatedAt` so conflict resolution stays
11
+ * a later decision rather than a later accident. Storage is a seam: the
12
+ * default is `localStorage`, and a server-backed adapter (Frappe `/resource`
13
+ * or any REST) drops in without touching consumers.
14
+ */
15
+
16
+ /** The current envelope shape. A breaking change bumps this and migrates. */
17
+ export const CONFIG_SCHEMA_VERSION = 1
18
+
19
+ /** The byte cap on one stored document — a widget grid that grows forever. */
20
+ export const CONFIG_DOCUMENT_LIMIT = 64 * 1024
21
+
22
+ /** One stored document, with its version and metadata around the payload. */
23
+ export interface ConfigDocument<T = unknown> {
24
+ /** Envelope schema version; see {@link CONFIG_SCHEMA_VERSION}. */
25
+ v: number
26
+ name: string
27
+ /** What kind of document this is (`'saved-view'`, `'dashboard-config'`, …). */
28
+ type?: string
29
+ /** ISO timestamp of the last {@link ConfigStore.save}. */
30
+ updatedAt: string
31
+ /** The document's payload, owned by the consumer. */
32
+ doc: T
33
+ }
34
+
35
+ /** The row {@link ConfigStore.list} returns per stored document. */
36
+ export interface ConfigSummary {
37
+ name: string
38
+ type?: string
39
+ updatedAt: string
40
+ }
41
+
42
+ /**
43
+ * The storage seam. Keys are globally unique; the store namespaces them with
44
+ * its `key` prefix, so `keys()` must enumerate every key the store may read.
45
+ */
46
+ export interface ConfigStorage {
47
+ getItem(key: string): string | null
48
+ setItem(key: string, value: string): void
49
+ removeItem(key: string): void
50
+ /** Every key the storage holds, for the store's namespace scan. */
51
+ keys(): string[]
52
+ }
53
+
54
+ /** Options for {@link createConfigStore}. */
55
+ export interface ConfigStoreOptions {
56
+ /** Namespace prefix, so several stores share one storage. */
57
+ key: string
58
+ /** Backend. Defaults to `localStorage` (in-memory under SSR). */
59
+ storage?: ConfigStorage
60
+ /**
61
+ * Upgrade a document written against an older envelope version. Return the
62
+ * document in the current shape; return `undefined` to reject it. Without a
63
+ * `migrate`, a version mismatch is unreadable rather than silently wrong.
64
+ */
65
+ migrate?: (doc: unknown, from: number) => unknown
66
+ }
67
+
68
+ /** The document API {@link createConfigStore} returns. */
69
+ export interface ConfigStore {
70
+ /** Every stored document, newest first. Unreadable entries are skipped. */
71
+ list(): ConfigSummary[]
72
+ /** Load one document through `migrate`; `undefined` when absent or stale. */
73
+ load<T>(name: string): ConfigDocument<T> | undefined
74
+ /** Store a document in a fresh envelope. False when over the 64KB cap. */
75
+ save<T>(name: string, doc: T, options?: { type?: string }): boolean
76
+ /** Delete one document. Returns whether it existed. */
77
+ remove(name: string): boolean
78
+ /** Move a document to a new name. False when the source is absent. */
79
+ rename(from: string, to: string): boolean
80
+ }
81
+
82
+ function defaultStorage(): ConfigStorage {
83
+ const memory = new Map<string, string>()
84
+ let backend: Storage | null = null
85
+ try {
86
+ backend = typeof localStorage === 'undefined' ? null : localStorage
87
+ } catch {
88
+ // A sandboxed or disabled `localStorage` throws on access, not on use.
89
+ backend = null
90
+ }
91
+ if (!backend) {
92
+ return {
93
+ getItem: (key) => memory.get(key) ?? null,
94
+ setItem: (key, value) => void memory.set(key, value),
95
+ removeItem: (key) => void memory.delete(key),
96
+ keys: () => [...memory.keys()],
97
+ }
98
+ }
99
+ return {
100
+ getItem(key) {
101
+ try {
102
+ return backend.getItem(key)
103
+ } catch {
104
+ return null
105
+ }
106
+ },
107
+ setItem(key, value) {
108
+ // Failures surface to the caller's `save`, which warns and no-ops.
109
+ backend.setItem(key, value)
110
+ },
111
+ removeItem(key) {
112
+ try {
113
+ backend.removeItem(key)
114
+ } catch {
115
+ // The entry is already unreachable.
116
+ }
117
+ },
118
+ keys() {
119
+ const all: string[] = []
120
+ try {
121
+ for (let index = 0; index < backend.length; index++) {
122
+ const key = backend.key(index)
123
+ if (key !== null) all.push(key)
124
+ }
125
+ } catch {
126
+ return all
127
+ }
128
+ return all
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * True outside a production build. Bundlers replace `process.env.NODE_ENV`,
135
+ * and this package is type-checked against Node's types, not `vite/client`,
136
+ * so the read goes through `globalThis` with a guard for non-Node hosts.
137
+ */
138
+ function isProduction(): boolean {
139
+ return (
140
+ (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env
141
+ ?.NODE_ENV === 'production'
142
+ )
143
+ }
144
+
145
+ function warnStorageFailure(): void {
146
+ if (!isProduction()) {
147
+ console.warn(
148
+ '[newui-data] createConfigStore: the storage backend rejected a write (quota or disabled storage). The document is dropped, not fatal.',
149
+ )
150
+ }
151
+ }
152
+
153
+ function warnOverLimit(name: string, bytes: number): void {
154
+ if (!isProduction()) {
155
+ console.warn(
156
+ `[newui-data] createConfigStore: document "${name}" is ${bytes} bytes, over the ${CONFIG_DOCUMENT_LIMIT}-byte cap. It was not saved — check what grew (a widget grid that appends forever is the usual suspect).`,
157
+ )
158
+ }
159
+ }
160
+
161
+ function byteLength(text: string): number {
162
+ return new TextEncoder().encode(text).length
163
+ }
164
+
165
+ function isEnvelope(value: unknown): value is ConfigDocument {
166
+ if (value === null || typeof value !== 'object') return false
167
+ const candidate = value as Partial<ConfigDocument>
168
+ return (
169
+ typeof candidate.v === 'number' &&
170
+ typeof candidate.name === 'string' &&
171
+ typeof candidate.updatedAt === 'string' &&
172
+ 'doc' in candidate
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Create a store of named, versioned documents.
178
+ *
179
+ * `save` wraps the payload in a {@link ConfigDocument} envelope; `load` reads
180
+ * it back and runs `migrate` when the stored version is older than the
181
+ * current one. Documents are capped at 64KB with a loud dev warning, so an
182
+ * unbounded document is caught in development instead of in a quota error.
183
+ */
184
+ export function createConfigStore(options: ConfigStoreOptions): ConfigStore {
185
+ const storage = options.storage ?? defaultStorage()
186
+ const namespace = `${options.key}:`
187
+ const scoped = (name: string) => `${namespace}${name}`
188
+ let warned = false
189
+ const warn = (message: string) => {
190
+ if (warned) return
191
+ warned = true
192
+ console.warn(message)
193
+ }
194
+
195
+ /** A backend that throws drops the write and says so, once. */
196
+ const write = (name: string, raw: string): boolean => {
197
+ try {
198
+ storage.setItem(scoped(name), raw)
199
+ return true
200
+ } catch {
201
+ warnStorageFailure()
202
+ return false
203
+ }
204
+ }
205
+
206
+ const read = <T>(name: string): ConfigDocument<T> | undefined => {
207
+ const raw = storage.getItem(scoped(name))
208
+ if (raw === null) return undefined
209
+ let value: unknown
210
+ try {
211
+ value = JSON.parse(raw)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ if (!isEnvelope(value)) return undefined
216
+ const envelope = value as ConfigDocument<T>
217
+ if (envelope.v === CONFIG_SCHEMA_VERSION) return envelope
218
+ if (!options.migrate) {
219
+ warn(
220
+ `[newui-data] createConfigStore: "${name}" is v${envelope.v}, the store reads v${CONFIG_SCHEMA_VERSION}, and no migrate was supplied. It is unreadable, not corrupted.`,
221
+ )
222
+ return undefined
223
+ }
224
+ const migrated = options.migrate(envelope.doc, envelope.v)
225
+ if (migrated === undefined) return undefined
226
+ return { ...envelope, v: CONFIG_SCHEMA_VERSION, doc: migrated as T }
227
+ }
228
+
229
+ return {
230
+ list() {
231
+ const summaries: ConfigSummary[] = []
232
+ for (const key of storage.keys()) {
233
+ if (!key.startsWith(namespace)) continue
234
+ const name = key.slice(namespace.length)
235
+ const envelope = read(name)
236
+ if (!envelope) continue
237
+ const summary: ConfigSummary = {
238
+ name,
239
+ updatedAt: envelope.updatedAt,
240
+ }
241
+ if (envelope.type !== undefined) summary.type = envelope.type
242
+ summaries.push(summary)
243
+ }
244
+ return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
245
+ },
246
+ load<T>(name: string) {
247
+ return read<T>(name)
248
+ },
249
+ save<T>(name: string, doc: T, saveOptions: { type?: string } = {}) {
250
+ const envelope: ConfigDocument<T> = {
251
+ v: CONFIG_SCHEMA_VERSION,
252
+ name,
253
+ ...(saveOptions.type !== undefined ? { type: saveOptions.type } : {}),
254
+ updatedAt: new Date().toISOString(),
255
+ doc,
256
+ }
257
+ const raw = JSON.stringify(envelope)
258
+ const bytes = byteLength(raw)
259
+ if (bytes > CONFIG_DOCUMENT_LIMIT) {
260
+ warnOverLimit(name, bytes)
261
+ return false
262
+ }
263
+ return write(name, raw)
264
+ },
265
+ remove(name: string) {
266
+ const existed = storage.getItem(scoped(name)) !== null
267
+ try {
268
+ storage.removeItem(scoped(name))
269
+ } catch {
270
+ // The entry is unreachable to the store either way.
271
+ }
272
+ return existed
273
+ },
274
+ rename(from: string, to: string) {
275
+ const envelope = read(from)
276
+ if (!envelope) return false
277
+ const raw = JSON.stringify({
278
+ ...envelope,
279
+ name: to,
280
+ updatedAt: new Date().toISOString(),
281
+ } satisfies ConfigDocument)
282
+ if (byteLength(raw) > CONFIG_DOCUMENT_LIMIT) {
283
+ warnOverLimit(to, byteLength(raw))
284
+ return false
285
+ }
286
+ if (!write(to, raw)) return false
287
+ try {
288
+ storage.removeItem(scoped(from))
289
+ } catch {
290
+ // The renamed copy is saved; the source is already gone for us.
291
+ }
292
+ return true
293
+ },
294
+ }
295
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ type CsvExportTable,
4
+ type CsvExportTableRow,
5
+ exportCsv,
6
+ } from './csv-export'
7
+
8
+ interface FakeColumn {
9
+ id: string
10
+ visible?: boolean
11
+ meta?: Record<string, unknown>
12
+ }
13
+
14
+ function row(values: Record<string, unknown>): CsvExportTableRow {
15
+ return { getValue: (columnId) => values[columnId] }
16
+ }
17
+
18
+ function fakeTable(options: {
19
+ columns: FakeColumn[]
20
+ /** The raw data — what the core model returns. */
21
+ rows: Record<string, unknown>[]
22
+ /** The filtered, sorted view — what the filtered model returns. */
23
+ filtered?: Record<string, unknown>[]
24
+ pageIndex?: number
25
+ pageSize?: number
26
+ }): CsvExportTable {
27
+ const leaf = (visibleOnly: boolean) =>
28
+ options.columns
29
+ .filter((column) => !visibleOnly || column.visible !== false)
30
+ .map((column) => ({
31
+ id: column.id,
32
+ columnDef: column.meta ? { meta: column.meta } : {},
33
+ }))
34
+ return {
35
+ getAllLeafColumns: () => leaf(false),
36
+ getVisibleLeafColumns: () => leaf(true),
37
+ getCoreRowModel: () => ({ rows: options.rows.map(row) }),
38
+ getFilteredRowModel: () => ({
39
+ rows: (options.filtered ?? options.rows).map(row),
40
+ }),
41
+ getState: () => ({
42
+ pagination: {
43
+ pageIndex: options.pageIndex ?? 0,
44
+ pageSize: options.pageSize ?? 10,
45
+ },
46
+ }),
47
+ }
48
+ }
49
+
50
+ async function csv(blob: Blob): Promise<string> {
51
+ return blob.text()
52
+ }
53
+
54
+ const five = [
55
+ { id: 1, name: 'a' },
56
+ { id: 2, name: 'b' },
57
+ { id: 3, name: 'c' },
58
+ { id: 4, name: 'd' },
59
+ { id: 5, name: 'e' },
60
+ ]
61
+
62
+ describe('exportCsv', () => {
63
+ it('exports the filtered view with visible columns by default', async () => {
64
+ const table = fakeTable({
65
+ columns: [{ id: 'id' }, { id: 'name' }, { id: 'secret', visible: false }],
66
+ rows: five,
67
+ filtered: five.slice(0, 2),
68
+ })
69
+ const blob = await exportCsv(table)
70
+ expect(blob.type).toBe('text/csv;charset=utf-8')
71
+ expect(await csv(blob)).toBe('"id","name"\r\n"1","a"\r\n"2","b"')
72
+ })
73
+
74
+ it('scope page takes the current page slice', async () => {
75
+ const table = fakeTable({
76
+ columns: [{ id: 'id' }, { id: 'name' }],
77
+ rows: five,
78
+ pageIndex: 1,
79
+ pageSize: 2,
80
+ })
81
+ expect(await csv(await exportCsv(table, { scope: 'page' }))).toBe(
82
+ '"id","name"\r\n"3","c"\r\n"4","d"',
83
+ )
84
+ })
85
+
86
+ it('scope all ignores the filtered model', async () => {
87
+ const table = fakeTable({
88
+ columns: [{ id: 'id' }],
89
+ rows: five,
90
+ filtered: five.slice(0, 1),
91
+ })
92
+ const text = await csv(await exportCsv(table, { scope: 'all' }))
93
+ expect(text.split('\r\n')).toHaveLength(6)
94
+ expect(text).toContain('"5"')
95
+ })
96
+
97
+ it('columns all ships hidden columns too', async () => {
98
+ const table = fakeTable({
99
+ columns: [{ id: 'id' }, { id: 'secret', visible: false }],
100
+ rows: [{ id: 1, secret: 'x' }],
101
+ })
102
+ expect(await csv(await exportCsv(table, { columns: 'all' }))).toBe(
103
+ '"id","secret"\r\n"1","x"',
104
+ )
105
+ })
106
+
107
+ it('honours the export meta keys: name, format, exportable', async () => {
108
+ const table = fakeTable({
109
+ columns: [
110
+ { id: 'name', meta: { exportName: 'Full name' } },
111
+ {
112
+ id: 'amount',
113
+ meta: { format: (value: unknown) => `$${Number(value).toFixed(2)}` },
114
+ },
115
+ { id: 'internal', meta: { exportable: false } },
116
+ ],
117
+ rows: [{ name: 'ada', amount: 12.5, internal: 'keep out' }],
118
+ })
119
+ expect(await csv(await exportCsv(table))).toBe(
120
+ '"Full name","amount"\r\n"ada","$12.50"',
121
+ )
122
+ })
123
+
124
+ it('renders null and undefined as empty cells', async () => {
125
+ const table = fakeTable({
126
+ columns: [{ id: 'a' }, { id: 'b' }],
127
+ rows: [{ a: null, b: undefined }],
128
+ })
129
+ expect(await csv(await exportCsv(table))).toBe('"a","b"\r\n"",""')
130
+ })
131
+
132
+ it('quotes and escapes through csvText, including inner quotes', async () => {
133
+ const table = fakeTable({
134
+ columns: [{ id: 'quote' }],
135
+ rows: [{ quote: 'said "hi", loudly' }],
136
+ })
137
+ expect(await csv(await exportCsv(table))).toBe(
138
+ '"quote"\r\n"said ""hi"", loudly"',
139
+ )
140
+ })
141
+
142
+ it('falls back to String for values that are neither text nor numbers', async () => {
143
+ const table = fakeTable({
144
+ columns: [{ id: 'flag' }],
145
+ rows: [{ flag: true }],
146
+ })
147
+ expect(await csv(await exportCsv(table))).toBe('"flag"\r\n"true"')
148
+ })
149
+
150
+ it('is a promise — the call site never blocks', async () => {
151
+ const table = fakeTable({ columns: [{ id: 'id' }], rows: five })
152
+ const pending = exportCsv(table)
153
+ expect(pending).toBeInstanceOf(Promise)
154
+ await pending
155
+ })
156
+ })