@fictjs/testing-library 0.2.2 → 0.3.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/index.cjs +502 -0
- package/dist/index.d.cts +491 -1
- package/dist/index.d.ts +491 -1
- package/dist/index.js +472 -0
- package/package.json +26 -3
package/dist/index.js
CHANGED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
render as fictRender,
|
|
4
|
+
createRoot,
|
|
5
|
+
createElement,
|
|
6
|
+
ErrorBoundary,
|
|
7
|
+
Suspense,
|
|
8
|
+
createSuspenseToken
|
|
9
|
+
} from "@fictjs/runtime";
|
|
10
|
+
import { registerErrorHandler } from "@fictjs/runtime/advanced";
|
|
11
|
+
import { __fictPushContext, __fictPopContext } from "@fictjs/runtime/internal";
|
|
12
|
+
import { getQueriesForElement, prettyDOM, queries } from "@testing-library/dom";
|
|
13
|
+
export * from "@testing-library/dom";
|
|
14
|
+
var mountedContainers = /* @__PURE__ */ new Set();
|
|
15
|
+
var mountedHookRoots = /* @__PURE__ */ new Set();
|
|
16
|
+
if (typeof process === "undefined" || !process.env?.FICT_TL_SKIP_AUTO_CLEANUP) {
|
|
17
|
+
const globalAfterEach = globalThis.afterEach;
|
|
18
|
+
if (typeof globalAfterEach === "function") {
|
|
19
|
+
globalAfterEach(() => {
|
|
20
|
+
cleanup();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function render(view, options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
container: providedContainer,
|
|
27
|
+
baseElement: providedBaseElement,
|
|
28
|
+
queries: customQueries,
|
|
29
|
+
wrapper
|
|
30
|
+
} = options;
|
|
31
|
+
let container = providedContainer;
|
|
32
|
+
let baseElement = providedBaseElement;
|
|
33
|
+
let ownedContainer = false;
|
|
34
|
+
if (!baseElement) {
|
|
35
|
+
baseElement = container ?? document.body;
|
|
36
|
+
}
|
|
37
|
+
if (!container) {
|
|
38
|
+
container = baseElement.appendChild(document.createElement("div"));
|
|
39
|
+
ownedContainer = true;
|
|
40
|
+
}
|
|
41
|
+
let wrappedView = view;
|
|
42
|
+
if (wrapper) {
|
|
43
|
+
const Wrapper = wrapper;
|
|
44
|
+
wrappedView = () => {
|
|
45
|
+
const children = view();
|
|
46
|
+
return createElement({ type: Wrapper, props: { children } });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let currentTeardown = null;
|
|
50
|
+
const teardown = fictRender(wrappedView, container);
|
|
51
|
+
currentTeardown = teardown;
|
|
52
|
+
const ref = { container, baseElement, ownedContainer, teardown };
|
|
53
|
+
mountedContainers.add(ref);
|
|
54
|
+
const queryHelpers = getQueriesForElement(container, customQueries);
|
|
55
|
+
const debug = (el = baseElement, maxLength, debugOptions) => {
|
|
56
|
+
if (Array.isArray(el)) {
|
|
57
|
+
el.forEach((e) => console.log(prettyDOM(e, maxLength, debugOptions)));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(prettyDOM(el, maxLength, debugOptions));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const unmount = () => {
|
|
63
|
+
if (currentTeardown) {
|
|
64
|
+
currentTeardown();
|
|
65
|
+
currentTeardown = null;
|
|
66
|
+
}
|
|
67
|
+
if (ownedContainer && container?.parentNode) {
|
|
68
|
+
container.parentNode.removeChild(container);
|
|
69
|
+
}
|
|
70
|
+
mountedContainers.delete(ref);
|
|
71
|
+
};
|
|
72
|
+
const rerender = (newView) => {
|
|
73
|
+
if (currentTeardown) {
|
|
74
|
+
currentTeardown();
|
|
75
|
+
}
|
|
76
|
+
let wrappedNewView = newView;
|
|
77
|
+
if (wrapper) {
|
|
78
|
+
const Wrapper = wrapper;
|
|
79
|
+
wrappedNewView = () => {
|
|
80
|
+
const children = newView();
|
|
81
|
+
return createElement({ type: Wrapper, props: { children } });
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
currentTeardown = fictRender(wrappedNewView, container);
|
|
85
|
+
ref.teardown = currentTeardown;
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
asFragment: () => container?.innerHTML ?? "",
|
|
89
|
+
container,
|
|
90
|
+
baseElement,
|
|
91
|
+
debug,
|
|
92
|
+
unmount,
|
|
93
|
+
rerender,
|
|
94
|
+
...queryHelpers
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function cleanupAtContainer(ref) {
|
|
98
|
+
const { container, teardown, ownedContainer } = ref;
|
|
99
|
+
if (typeof teardown === "function") {
|
|
100
|
+
teardown();
|
|
101
|
+
} else if (typeof teardown !== "undefined") {
|
|
102
|
+
console.warn(
|
|
103
|
+
"[@fictjs/testing-library] Expected teardown to be a function. This might indicate a version mismatch between @fictjs/runtime and @fictjs/testing-library."
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (ownedContainer && container?.parentNode) {
|
|
107
|
+
container.parentNode.removeChild(container);
|
|
108
|
+
}
|
|
109
|
+
mountedContainers.delete(ref);
|
|
110
|
+
}
|
|
111
|
+
function cleanup() {
|
|
112
|
+
mountedContainers.forEach(cleanupAtContainer);
|
|
113
|
+
mountedHookRoots.forEach((dispose) => {
|
|
114
|
+
try {
|
|
115
|
+
dispose();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error("[fict/testing-library] Error during hook cleanup:", err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
mountedHookRoots.clear();
|
|
121
|
+
}
|
|
122
|
+
function renderHook(hookFn, options = {}) {
|
|
123
|
+
let initialProps;
|
|
124
|
+
let wrapper;
|
|
125
|
+
if (Array.isArray(options)) {
|
|
126
|
+
initialProps = options;
|
|
127
|
+
} else {
|
|
128
|
+
initialProps = options.initialProps;
|
|
129
|
+
wrapper = options.wrapper;
|
|
130
|
+
}
|
|
131
|
+
const resultContainer = { current: void 0 };
|
|
132
|
+
let currentProps = initialProps ?? [];
|
|
133
|
+
let disposeRoot = null;
|
|
134
|
+
let registeredDispose = null;
|
|
135
|
+
const registerDispose = (dispose) => {
|
|
136
|
+
if (registeredDispose) {
|
|
137
|
+
mountedHookRoots.delete(registeredDispose);
|
|
138
|
+
}
|
|
139
|
+
registeredDispose = dispose;
|
|
140
|
+
mountedHookRoots.add(dispose);
|
|
141
|
+
};
|
|
142
|
+
const executeHook = () => {
|
|
143
|
+
const { dispose, value } = createRoot(() => {
|
|
144
|
+
let hookResult;
|
|
145
|
+
if (wrapper) {
|
|
146
|
+
const Wrapper = wrapper;
|
|
147
|
+
createElement({
|
|
148
|
+
type: Wrapper,
|
|
149
|
+
props: {
|
|
150
|
+
children: (() => {
|
|
151
|
+
__fictPushContext();
|
|
152
|
+
try {
|
|
153
|
+
hookResult = hookFn(...currentProps);
|
|
154
|
+
} finally {
|
|
155
|
+
__fictPopContext();
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
})()
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
__fictPushContext();
|
|
163
|
+
try {
|
|
164
|
+
hookResult = hookFn(...currentProps);
|
|
165
|
+
} finally {
|
|
166
|
+
__fictPopContext();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return hookResult;
|
|
170
|
+
});
|
|
171
|
+
disposeRoot = dispose;
|
|
172
|
+
registerDispose(dispose);
|
|
173
|
+
resultContainer.current = value;
|
|
174
|
+
return value;
|
|
175
|
+
};
|
|
176
|
+
executeHook();
|
|
177
|
+
const rerender = (newProps) => {
|
|
178
|
+
if (disposeRoot) {
|
|
179
|
+
disposeRoot();
|
|
180
|
+
}
|
|
181
|
+
if (newProps !== void 0) {
|
|
182
|
+
currentProps = newProps;
|
|
183
|
+
}
|
|
184
|
+
executeHook();
|
|
185
|
+
};
|
|
186
|
+
const cleanupHook = () => {
|
|
187
|
+
if (disposeRoot) {
|
|
188
|
+
disposeRoot();
|
|
189
|
+
disposeRoot = null;
|
|
190
|
+
}
|
|
191
|
+
if (registeredDispose) {
|
|
192
|
+
mountedHookRoots.delete(registeredDispose);
|
|
193
|
+
registeredDispose = null;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
result: resultContainer,
|
|
198
|
+
rerender,
|
|
199
|
+
cleanup: cleanupHook,
|
|
200
|
+
unmount: cleanupHook
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function testEffect(fn) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
let settled = false;
|
|
206
|
+
let resolveScheduled = false;
|
|
207
|
+
let disposed = false;
|
|
208
|
+
const disposeRef = { current: null };
|
|
209
|
+
const scheduleDispose = () => {
|
|
210
|
+
if (disposed) return;
|
|
211
|
+
queueMicrotask(() => {
|
|
212
|
+
if (disposed) return;
|
|
213
|
+
disposed = true;
|
|
214
|
+
disposeRef.current?.();
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
const reportError = (err) => {
|
|
218
|
+
if (settled) {
|
|
219
|
+
queueMicrotask(() => {
|
|
220
|
+
throw err;
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
settled = true;
|
|
225
|
+
reject(err);
|
|
226
|
+
scheduleDispose();
|
|
227
|
+
};
|
|
228
|
+
const scheduleResolve = (result) => {
|
|
229
|
+
if (settled || resolveScheduled) return;
|
|
230
|
+
resolveScheduled = true;
|
|
231
|
+
queueMicrotask(() => {
|
|
232
|
+
if (settled) return;
|
|
233
|
+
settled = true;
|
|
234
|
+
resolve(result);
|
|
235
|
+
scheduleDispose();
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
const root = createRoot(() => {
|
|
239
|
+
registerErrorHandler((err) => {
|
|
240
|
+
reportError(err);
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
fn((result) => {
|
|
245
|
+
scheduleResolve(result);
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
reportError(err);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
disposeRef.current = root.dispose;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function waitForCondition(condition, options = {}) {
|
|
255
|
+
const { timeout = 1e3, interval = 50 } = options;
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
const check = () => {
|
|
259
|
+
if (condition()) {
|
|
260
|
+
resolve();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (Date.now() - startTime >= timeout) {
|
|
264
|
+
reject(new Error(`waitForCondition timed out after ${timeout}ms`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
setTimeout(check, interval);
|
|
268
|
+
};
|
|
269
|
+
check();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function flush() {
|
|
273
|
+
return new Promise((resolve) => queueMicrotask(resolve));
|
|
274
|
+
}
|
|
275
|
+
async function act(fn) {
|
|
276
|
+
try {
|
|
277
|
+
const result = await fn();
|
|
278
|
+
await flush();
|
|
279
|
+
await flush();
|
|
280
|
+
return result;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
await flush();
|
|
283
|
+
await flush();
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function renderWithErrorBoundary(view, options = {}) {
|
|
288
|
+
const {
|
|
289
|
+
fallback = createElement({
|
|
290
|
+
type: "div",
|
|
291
|
+
props: { "data-testid": "error-fallback", children: "Error occurred" }
|
|
292
|
+
}),
|
|
293
|
+
onError,
|
|
294
|
+
resetKeys,
|
|
295
|
+
...renderOptions
|
|
296
|
+
} = options;
|
|
297
|
+
let hasError = false;
|
|
298
|
+
let resetFn;
|
|
299
|
+
let _currentError = null;
|
|
300
|
+
const wrappedFallback = (err, reset) => {
|
|
301
|
+
hasError = true;
|
|
302
|
+
_currentError = err;
|
|
303
|
+
resetFn = reset;
|
|
304
|
+
if (typeof fallback === "function") {
|
|
305
|
+
return fallback(err, reset);
|
|
306
|
+
}
|
|
307
|
+
return fallback;
|
|
308
|
+
};
|
|
309
|
+
const ErrorBoundaryComponent = ErrorBoundary;
|
|
310
|
+
const wrapView = (nextView) => {
|
|
311
|
+
return () => {
|
|
312
|
+
const ViewComponent = () => nextView();
|
|
313
|
+
return createElement({
|
|
314
|
+
type: ErrorBoundaryComponent,
|
|
315
|
+
props: {
|
|
316
|
+
fallback: wrappedFallback,
|
|
317
|
+
onError: (err) => {
|
|
318
|
+
hasError = true;
|
|
319
|
+
_currentError = err;
|
|
320
|
+
onError?.(err);
|
|
321
|
+
},
|
|
322
|
+
resetKeys,
|
|
323
|
+
children: {
|
|
324
|
+
type: ViewComponent,
|
|
325
|
+
props: {},
|
|
326
|
+
key: void 0
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const result = render(wrapView(view), renderOptions);
|
|
333
|
+
return {
|
|
334
|
+
...result,
|
|
335
|
+
triggerError: (error) => {
|
|
336
|
+
hasError = true;
|
|
337
|
+
_currentError = error;
|
|
338
|
+
const Thrower = () => {
|
|
339
|
+
throw error;
|
|
340
|
+
};
|
|
341
|
+
result.rerender(
|
|
342
|
+
wrapView(() => ({
|
|
343
|
+
type: Thrower,
|
|
344
|
+
props: {},
|
|
345
|
+
key: void 0
|
|
346
|
+
}))
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
resetErrorBoundary: () => {
|
|
350
|
+
hasError = false;
|
|
351
|
+
_currentError = null;
|
|
352
|
+
if (resetFn) {
|
|
353
|
+
try {
|
|
354
|
+
resetFn();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
onError?.(err);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
isShowingFallback: () => hasError,
|
|
361
|
+
rerender: (nextView) => {
|
|
362
|
+
hasError = false;
|
|
363
|
+
_currentError = null;
|
|
364
|
+
resetFn = void 0;
|
|
365
|
+
result.rerender(wrapView(nextView));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function createTestSuspenseToken() {
|
|
370
|
+
const handle = createSuspenseToken();
|
|
371
|
+
return {
|
|
372
|
+
// Cast to match the TestSuspenseHandle type
|
|
373
|
+
token: handle.token,
|
|
374
|
+
resolve: handle.resolve,
|
|
375
|
+
reject: handle.reject
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function renderWithSuspense(view, options = {}) {
|
|
379
|
+
const {
|
|
380
|
+
fallback = createElement({
|
|
381
|
+
type: "div",
|
|
382
|
+
props: { "data-testid": "suspense-fallback", children: "Loading..." }
|
|
383
|
+
}),
|
|
384
|
+
onResolve,
|
|
385
|
+
onReject,
|
|
386
|
+
resetKeys,
|
|
387
|
+
...renderOptions
|
|
388
|
+
} = options;
|
|
389
|
+
let isSuspended = false;
|
|
390
|
+
let isResolved = true;
|
|
391
|
+
let resolveWaiters = [];
|
|
392
|
+
const wrappedFallback = (err) => {
|
|
393
|
+
isSuspended = true;
|
|
394
|
+
isResolved = false;
|
|
395
|
+
if (typeof fallback === "function") {
|
|
396
|
+
return fallback(err);
|
|
397
|
+
}
|
|
398
|
+
return fallback;
|
|
399
|
+
};
|
|
400
|
+
const SuspenseComponent = Suspense;
|
|
401
|
+
const wrapView = (nextView) => {
|
|
402
|
+
return () => {
|
|
403
|
+
if (onReject) {
|
|
404
|
+
registerErrorHandler(() => true);
|
|
405
|
+
}
|
|
406
|
+
const ViewComponent = () => nextView();
|
|
407
|
+
return createElement({
|
|
408
|
+
type: SuspenseComponent,
|
|
409
|
+
props: {
|
|
410
|
+
fallback: wrappedFallback,
|
|
411
|
+
onResolve: () => {
|
|
412
|
+
isSuspended = false;
|
|
413
|
+
isResolved = true;
|
|
414
|
+
onResolve?.();
|
|
415
|
+
resolveWaiters.forEach((waiter) => waiter());
|
|
416
|
+
resolveWaiters = [];
|
|
417
|
+
},
|
|
418
|
+
onReject: (err) => {
|
|
419
|
+
isSuspended = false;
|
|
420
|
+
onReject?.(err);
|
|
421
|
+
},
|
|
422
|
+
resetKeys,
|
|
423
|
+
children: {
|
|
424
|
+
type: ViewComponent,
|
|
425
|
+
props: {},
|
|
426
|
+
key: void 0
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
const result = render(wrapView(view), renderOptions);
|
|
433
|
+
return {
|
|
434
|
+
...result,
|
|
435
|
+
isShowingFallback: () => {
|
|
436
|
+
const fallbackEl = result.container.querySelector('[data-testid="suspense-fallback"]');
|
|
437
|
+
return !!fallbackEl || isSuspended;
|
|
438
|
+
},
|
|
439
|
+
waitForResolution: (waitOptions) => {
|
|
440
|
+
const { timeout = 5e3 } = waitOptions ?? {};
|
|
441
|
+
if (isResolved && !isSuspended) {
|
|
442
|
+
return Promise.resolve();
|
|
443
|
+
}
|
|
444
|
+
return new Promise((resolve, reject) => {
|
|
445
|
+
const timeoutId = setTimeout(() => {
|
|
446
|
+
reject(new Error(`Suspense did not resolve within ${timeout}ms`));
|
|
447
|
+
}, timeout);
|
|
448
|
+
resolveWaiters.push(() => {
|
|
449
|
+
clearTimeout(timeoutId);
|
|
450
|
+
resolve();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
rerender: (nextView) => {
|
|
455
|
+
result.rerender(wrapView(nextView));
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
export {
|
|
460
|
+
act,
|
|
461
|
+
cleanup,
|
|
462
|
+
createTestSuspenseToken,
|
|
463
|
+
flush,
|
|
464
|
+
prettyDOM,
|
|
465
|
+
queries,
|
|
466
|
+
render,
|
|
467
|
+
renderHook,
|
|
468
|
+
renderWithErrorBoundary,
|
|
469
|
+
renderWithSuspense,
|
|
470
|
+
testEffect,
|
|
471
|
+
waitForCondition
|
|
472
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fictjs/testing-library",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Fict testing
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Testing utilities for Fict components, built on @testing-library/dom",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"provenance": true
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"url": "https://github.com/fictjs/fict.git",
|
|
12
12
|
"directory": "packages/testing-library"
|
|
13
13
|
},
|
|
14
|
+
"license": "MIT",
|
|
14
15
|
"type": "module",
|
|
15
16
|
"main": "dist/index.cjs",
|
|
16
17
|
"module": "dist/index.js",
|
|
@@ -25,9 +26,31 @@
|
|
|
25
26
|
"files": [
|
|
26
27
|
"dist"
|
|
27
28
|
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@testing-library/dom": "^10.4.1"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@fictjs/runtime": "0.3.0"
|
|
34
|
+
},
|
|
28
35
|
"devDependencies": {
|
|
29
|
-
"
|
|
36
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
37
|
+
"@types/node": "^22.14.1",
|
|
38
|
+
"jsdom": "^26.1.0",
|
|
39
|
+
"tsup": "^8.5.1",
|
|
40
|
+
"vitest": "^4.0.18",
|
|
41
|
+
"@fictjs/compiler": "0.3.0",
|
|
42
|
+
"@fictjs/runtime": "0.3.0",
|
|
43
|
+
"@fictjs/vite-plugin": "0.3.0"
|
|
30
44
|
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"fict",
|
|
47
|
+
"testing",
|
|
48
|
+
"testing-library",
|
|
49
|
+
"test",
|
|
50
|
+
"unit-test",
|
|
51
|
+
"ui",
|
|
52
|
+
"dom"
|
|
53
|
+
],
|
|
31
54
|
"scripts": {
|
|
32
55
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
33
56
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|