@vertz/ui 0.2.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,202 @@
1
+ import {
2
+ computed,
3
+ effect,
4
+ setReadValueCallback,
5
+ signal,
6
+ untrack
7
+ } from "./chunk-pgymxpn1.js";
8
+
9
+ // src/query/cache.ts
10
+ class MemoryCache {
11
+ _store = new Map;
12
+ get(key) {
13
+ return this._store.get(key);
14
+ }
15
+ set(key, value) {
16
+ this._store.set(key, value);
17
+ }
18
+ delete(key) {
19
+ this._store.delete(key);
20
+ }
21
+ }
22
+
23
+ // src/query/key-derivation.ts
24
+ function deriveKey(thunk) {
25
+ return `__q:${hashString(thunk.toString())}`;
26
+ }
27
+ function hashString(str) {
28
+ let hash = 5381;
29
+ for (let i = 0;i < str.length; i++) {
30
+ hash = hash * 33 ^ str.charCodeAt(i);
31
+ }
32
+ return (hash >>> 0).toString(36);
33
+ }
34
+
35
+ // src/query/query.ts
36
+ var defaultCache = new MemoryCache;
37
+ var inflight = new Map;
38
+ function query(thunk, options = {}) {
39
+ const {
40
+ initialData,
41
+ debounce: debounceMs,
42
+ enabled = true,
43
+ key: customKey,
44
+ cache = defaultCache
45
+ } = options;
46
+ const baseKey = deriveKey(thunk);
47
+ const depHashSignal = signal("");
48
+ const cacheKeyComputed = computed(() => {
49
+ const dh = depHashSignal.value;
50
+ return customKey ?? (dh ? `${baseKey}:${dh}` : `${baseKey}:init`);
51
+ });
52
+ function getCacheKey() {
53
+ return cacheKeyComputed.value;
54
+ }
55
+ function callThunkWithCapture() {
56
+ const captured = [];
57
+ const prevCb = setReadValueCallback((v) => captured.push(v));
58
+ let promise;
59
+ try {
60
+ promise = thunk();
61
+ } finally {
62
+ setReadValueCallback(prevCb);
63
+ }
64
+ const serialized = captured.map((v) => JSON.stringify(v)).join("|");
65
+ untrack(() => {
66
+ depHashSignal.value = hashString(serialized);
67
+ });
68
+ return promise;
69
+ }
70
+ const data = signal(initialData);
71
+ const loading = signal(initialData === undefined && enabled);
72
+ const error = signal(undefined);
73
+ if (initialData !== undefined) {
74
+ cache.set(getCacheKey(), initialData);
75
+ }
76
+ let fetchId = 0;
77
+ let debounceTimer;
78
+ const inflightKeys = new Set;
79
+ const refetchTrigger = signal(0);
80
+ function handleFetchPromise(promise, id, key) {
81
+ promise.then((result) => {
82
+ inflight.delete(key);
83
+ inflightKeys.delete(key);
84
+ if (id !== fetchId)
85
+ return;
86
+ cache.set(key, result);
87
+ data.value = result;
88
+ loading.value = false;
89
+ }, (err) => {
90
+ inflight.delete(key);
91
+ inflightKeys.delete(key);
92
+ if (id !== fetchId)
93
+ return;
94
+ error.value = err;
95
+ loading.value = false;
96
+ });
97
+ }
98
+ function startFetch(fetchPromise, key) {
99
+ const id = ++fetchId;
100
+ untrack(() => {
101
+ loading.value = true;
102
+ error.value = undefined;
103
+ });
104
+ const existing = inflight.get(key);
105
+ if (existing) {
106
+ handleFetchPromise(existing, id, key);
107
+ return;
108
+ }
109
+ inflight.set(key, fetchPromise);
110
+ inflightKeys.add(key);
111
+ handleFetchPromise(fetchPromise, id, key);
112
+ }
113
+ function refetch() {
114
+ const key = getCacheKey();
115
+ cache.delete(key);
116
+ inflight.delete(key);
117
+ refetchTrigger.value = refetchTrigger.peek() + 1;
118
+ }
119
+ let disposeFn;
120
+ if (enabled) {
121
+ let isFirst = true;
122
+ disposeFn = effect(() => {
123
+ refetchTrigger.value;
124
+ if (customKey) {
125
+ const existing = untrack(() => inflight.get(customKey));
126
+ if (existing) {
127
+ const id = ++fetchId;
128
+ untrack(() => {
129
+ loading.value = true;
130
+ error.value = undefined;
131
+ });
132
+ handleFetchPromise(existing, id, customKey);
133
+ isFirst = false;
134
+ return;
135
+ }
136
+ }
137
+ const promise = callThunkWithCapture();
138
+ const key = untrack(() => getCacheKey());
139
+ if (!customKey) {
140
+ const existing = untrack(() => inflight.get(key));
141
+ if (existing) {
142
+ promise.catch(() => {});
143
+ const id = ++fetchId;
144
+ untrack(() => {
145
+ loading.value = true;
146
+ error.value = undefined;
147
+ });
148
+ handleFetchPromise(existing, id, key);
149
+ isFirst = false;
150
+ return;
151
+ }
152
+ }
153
+ if (!isFirst && !customKey) {
154
+ const cached = untrack(() => cache.get(key));
155
+ if (cached !== undefined) {
156
+ promise.catch(() => {});
157
+ untrack(() => {
158
+ data.value = cached;
159
+ loading.value = false;
160
+ error.value = undefined;
161
+ });
162
+ isFirst = false;
163
+ return;
164
+ }
165
+ }
166
+ if (isFirst && initialData !== undefined) {
167
+ promise.catch(() => {});
168
+ isFirst = false;
169
+ return;
170
+ }
171
+ isFirst = false;
172
+ if (debounceMs !== undefined && debounceMs > 0) {
173
+ clearTimeout(debounceTimer);
174
+ promise.catch(() => {});
175
+ debounceTimer = setTimeout(() => {
176
+ startFetch(promise, key);
177
+ }, debounceMs);
178
+ } else {
179
+ startFetch(promise, key);
180
+ }
181
+ });
182
+ }
183
+ function dispose() {
184
+ disposeFn?.();
185
+ clearTimeout(debounceTimer);
186
+ fetchId++;
187
+ for (const key of inflightKeys) {
188
+ inflight.delete(key);
189
+ }
190
+ inflightKeys.clear();
191
+ }
192
+ return {
193
+ data,
194
+ loading,
195
+ error,
196
+ refetch,
197
+ revalidate: refetch,
198
+ dispose
199
+ };
200
+ }
201
+
202
+ export { MemoryCache, deriveKey, query };
@@ -0,0 +1,268 @@
1
+ /**
2
+ * User interaction simulation utilities for testing Vertz UI components.
3
+ *
4
+ * Dispatches real DOM events so that event listeners set up with `__on`
5
+ * (or plain addEventListener) fire as they would in a browser.
6
+ */
7
+ /**
8
+ * Simulate a mouse click on the given element.
9
+ *
10
+ * Dispatches a `click` MouseEvent that bubbles and is cancelable,
11
+ * matching browser behavior.
12
+ */
13
+ declare function click(el: Element): Promise<void>;
14
+ /**
15
+ * Simulate a keyboard key press.
16
+ *
17
+ * Dispatches `keydown` followed by `keyup` on the currently active
18
+ * element (or `document.body` if nothing is focused).
19
+ */
20
+ declare function press(key: string): Promise<void>;
21
+ /**
22
+ * Fill form fields by name with the provided values.
23
+ *
24
+ * Looks up each key in `data` as a form element `[name="<key>"]` inside
25
+ * the given `<form>` and sets its value. Dispatches `input` and `change`
26
+ * events on each field so reactive bindings pick up the new values.
27
+ *
28
+ * Supported element types:
29
+ * - `<input>` (text, email, password, etc.) — sets `.value`
30
+ * - `<input type="checkbox">` — sets `.checked` (`"true"` / `"false"`)
31
+ * - `<input type="radio">` — checks the radio whose `.value` matches
32
+ * - `<textarea>` — sets `.value`
33
+ * - `<select>` — sets `.value`
34
+ *
35
+ * @throws {TypeError} If `formEl` is not an `HTMLFormElement`.
36
+ * @throws {TypeError} If a named field in `data` does not exist in the form.
37
+ */
38
+ declare function fillForm(formEl: HTMLFormElement, data: Record<string, string>): Promise<void>;
39
+ /**
40
+ * Trigger form submission by dispatching a `submit` event.
41
+ *
42
+ * The event bubbles and is cancelable, matching browser behavior.
43
+ * Test handlers can call `event.preventDefault()` to prevent default
44
+ * form navigation.
45
+ *
46
+ * @throws {TypeError} If `formEl` is not an `HTMLFormElement`.
47
+ */
48
+ declare function submitForm(formEl: HTMLFormElement): Promise<void>;
49
+ /**
50
+ * DOM query utilities for testing Vertz UI components.
51
+ *
52
+ * Provides helpers to locate elements by text content or test IDs,
53
+ * plus an async `waitFor` poller for assertions on async updates.
54
+ */
55
+ /**
56
+ * Find a descendant element whose text content matches `text`.
57
+ * Throws if no match is found.
58
+ */
59
+ declare function findByText2(container: Element, text: string): HTMLElement;
60
+ /**
61
+ * Find a descendant element whose text content matches `text`.
62
+ * Returns null if no match is found.
63
+ */
64
+ declare function queryByText2(container: Element, text: string): HTMLElement | null;
65
+ /**
66
+ * Find a descendant element with `data-testid="<id>"`.
67
+ * Throws if no match is found.
68
+ */
69
+ declare function findByTestId2(container: Element, id: string): HTMLElement;
70
+ /**
71
+ * Find a descendant element with `data-testid="<id>"`.
72
+ * Returns null if no match is found.
73
+ */
74
+ declare function queryByTestId2(container: Element, id: string): HTMLElement | null;
75
+ /** Options for `waitFor`. */
76
+ interface WaitForOptions {
77
+ /** Maximum time in ms to wait before throwing (default 1000). */
78
+ timeout?: number;
79
+ /** Polling interval in ms (default 50). */
80
+ interval?: number;
81
+ }
82
+ /**
83
+ * Poll an assertion function until it passes or times out.
84
+ *
85
+ * Useful for waiting on async DOM updates driven by signals or loaders.
86
+ */
87
+ declare function waitFor(assertion: () => void, opts?: WaitForOptions): Promise<void>;
88
+ /** The result returned by `renderTest()`. */
89
+ interface RenderTestResult {
90
+ /** The container element that wraps the rendered component. */
91
+ container: HTMLElement;
92
+ /** Find an element by its text content. Throws if not found. */
93
+ findByText: (text: string) => HTMLElement;
94
+ /** Find an element by its text content. Returns null if not found. */
95
+ queryByText: (text: string) => HTMLElement | null;
96
+ /** Find an element by `data-testid`. Throws if not found. */
97
+ findByTestId: (id: string) => HTMLElement;
98
+ /** Find an element by `data-testid`. Returns null if not found. */
99
+ queryByTestId: (id: string) => HTMLElement | null;
100
+ /** Simulate a click on the given element. */
101
+ click: (el: Element) => Promise<void>;
102
+ /** Simulate typing text into an input element. */
103
+ type: (el: Element, text: string) => Promise<void>;
104
+ /** Remove the container from the DOM and clean up. */
105
+ unmount: () => void;
106
+ }
107
+ /**
108
+ * Render a component (Element or DocumentFragment) into a fresh container
109
+ * attached to `document.body`.
110
+ *
111
+ * Returns scoped query and interaction helpers for the rendered tree.
112
+ */
113
+ declare function renderTest(component: Element | DocumentFragment): RenderTestResult;
114
+ /**
115
+ * Template literal type utility that extracts route parameter names from a path pattern.
116
+ *
117
+ * Examples:
118
+ * - `'/users/:id'` -> `{ id: string }`
119
+ * - `'/users/:id/posts/:postId'` -> `{ id: string; postId: string }`
120
+ * - `'/files/*'` -> `{ '*': string }`
121
+ * - `'/users'` -> `Record<string, never>`
122
+ */
123
+ /** Extract param names from a single segment. */
124
+ type ExtractSegmentParam<S extends string> = S extends `:${infer Param}` ? Param : never;
125
+ /** Recursively extract params from path segments separated by '/'. */
126
+ type ExtractParamsFromSegments<T extends string> = T extends `${infer Segment}/${infer Rest}` ? ExtractSegmentParam<Segment> | ExtractParamsFromSegments<Rest> : ExtractSegmentParam<T>;
127
+ /** Check if a path contains a wildcard '*' at the end. */
128
+ type HasWildcard<T extends string> = T extends `${string}*` ? true : false;
129
+ /** Remove trailing wildcard for param extraction. */
130
+ type WithoutWildcard<T extends string> = T extends `${infer Before}*` ? Before : T;
131
+ /**
132
+ * Extract typed params from a route path pattern.
133
+ * `:param` segments become `{ param: string }`.
134
+ * A trailing `*` becomes `{ '*': string }`.
135
+ */
136
+ type ExtractParams<T extends string> = [ExtractParamsFromSegments<WithoutWildcard<T>>] extends [never] ? HasWildcard<T> extends true ? {
137
+ "*": string;
138
+ } : Record<string, never> : HasWildcard<T> extends true ? { [K in ExtractParamsFromSegments<WithoutWildcard<T>>] : string } & {
139
+ "*": string;
140
+ } : { [K in ExtractParamsFromSegments<WithoutWildcard<T>>] : string };
141
+ /** Simple schema interface for search param parsing. */
142
+ interface SearchParamSchema<T> {
143
+ parse(data: unknown): T;
144
+ }
145
+ /** A route configuration for a single path. */
146
+ interface RouteConfig<
147
+ TPath extends string = string,
148
+ TLoaderData = unknown,
149
+ TSearch = unknown
150
+ > {
151
+ /** Component factory (lazy for code splitting). */
152
+ component: () => Node | Promise<{
153
+ default: () => Node;
154
+ }>;
155
+ /** Optional loader that runs before render. */
156
+ loader?: (ctx: {
157
+ params: ExtractParams<TPath>;
158
+ signal: AbortSignal;
159
+ }) => Promise<TLoaderData> | TLoaderData;
160
+ /** Optional error component rendered when loader throws. */
161
+ errorComponent?: (error: Error) => Node;
162
+ /** Optional search params schema for validation/coercion. */
163
+ searchParams?: SearchParamSchema<TSearch>;
164
+ /** Nested child routes. */
165
+ children?: RouteDefinitionMap;
166
+ }
167
+ /** A map of path patterns to route configs (user input format). */
168
+ interface RouteDefinitionMap {
169
+ [pattern: string]: RouteConfig;
170
+ }
171
+ /** Internal compiled route. */
172
+ interface CompiledRoute {
173
+ /** The original path pattern. */
174
+ pattern: string;
175
+ /** The route config. */
176
+ component: RouteConfig["component"];
177
+ loader?: (ctx: {
178
+ params: Record<string, string>;
179
+ signal: AbortSignal;
180
+ }) => Promise<unknown> | unknown;
181
+ errorComponent?: RouteConfig["errorComponent"];
182
+ searchParams?: RouteConfig["searchParams"];
183
+ /** Compiled children. */
184
+ children?: CompiledRoute[];
185
+ }
186
+ /** A single matched route entry in the matched chain. */
187
+ interface MatchedRoute {
188
+ route: CompiledRoute;
189
+ params: Record<string, string>;
190
+ }
191
+ /** Result of matching a URL against the route tree. */
192
+ interface RouteMatch {
193
+ /** All params extracted from the full URL path. */
194
+ params: Record<string, string>;
195
+ /** The leaf route config that matched. */
196
+ route: CompiledRoute;
197
+ /** The chain of matched routes from root to leaf (for nested layouts). */
198
+ matched: MatchedRoute[];
199
+ /** URLSearchParams from the URL. */
200
+ searchParams: URLSearchParams;
201
+ /** Parsed/coerced search params if schema is defined. */
202
+ search: Record<string, unknown>;
203
+ }
204
+ /**
205
+ * A reactive signal that holds a value and notifies subscribers on change.
206
+ */
207
+ interface Signal<T> {
208
+ /** Get the current value and subscribe to changes (when inside a tracking context). */
209
+ get value(): T;
210
+ /** Set the current value and notify subscribers if changed. */
211
+ set value(newValue: T);
212
+ /** Read the current value without subscribing (no tracking). */
213
+ peek(): T;
214
+ /** Manually notify all subscribers (useful after mutating the value in place). */
215
+ notify(): void;
216
+ }
217
+ /** Options for router.navigate(). */
218
+ interface NavigateOptions {
219
+ /** Use history.replaceState instead of pushState. */
220
+ replace?: boolean;
221
+ }
222
+ /** The router instance returned by createRouter. */
223
+ interface Router {
224
+ /** Current matched route (reactive signal). */
225
+ current: Signal<RouteMatch | null>;
226
+ /** Loader data from the current route's loaders (reactive signal). */
227
+ loaderData: Signal<unknown[]>;
228
+ /** Loader error if any loader threw (reactive signal). */
229
+ loaderError: Signal<Error | null>;
230
+ /** Parsed search params from the current route (reactive signal). */
231
+ searchParams: Signal<Record<string, unknown>>;
232
+ /** Navigate to a new URL path. */
233
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
234
+ /** Re-run all loaders for the current route. */
235
+ revalidate: () => Promise<void>;
236
+ /** Remove popstate listener and clean up the router. */
237
+ dispose: () => void;
238
+ }
239
+ /** Options for `createTestRouter`. */
240
+ interface TestRouterOptions {
241
+ /** The initial URL path to navigate to (default "/"). */
242
+ initialPath?: string;
243
+ }
244
+ /** Result returned by `createTestRouter`. */
245
+ interface TestRouterResult {
246
+ /** A container element that holds the route's rendered component. */
247
+ component: Element;
248
+ /** The underlying router instance. */
249
+ router: Router;
250
+ /** Navigate to a new path (wraps router.navigate). */
251
+ navigate: (path: string) => Promise<void>;
252
+ }
253
+ /**
254
+ * Create a test router with the given route definitions.
255
+ *
256
+ * The router is initialized at `initialPath` (default "/") and the
257
+ * matched route's component is resolved and returned in `component`.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * const { component, router, navigate } = await createTestRouter({
262
+ * '/': { component: () => { const el = document.createElement('div'); el.textContent = 'Home'; return el; } },
263
+ * '/about': { component: () => { const el = document.createElement('div'); el.textContent = 'About'; return el; } },
264
+ * });
265
+ * ```
266
+ */
267
+ declare function createTestRouter(routes: RouteDefinitionMap, opts?: TestRouterOptions): Promise<TestRouterResult>;
268
+ export { waitFor, submitForm, renderTest, queryByText2 as queryByText, queryByTestId2 as queryByTestId, press, findByText2 as findByText, findByTestId2 as findByTestId, fillForm, createTestRouter, click, WaitForOptions, TestRouterResult, TestRouterOptions, RenderTestResult };
@@ -0,0 +1,236 @@
1
+ import {
2
+ createRouter
3
+ } from "../shared/chunk-xd9d7q5p.js";
4
+ import {
5
+ defineRoutes
6
+ } from "../shared/chunk-j8vzvne3.js";
7
+ import"../shared/chunk-pgymxpn1.js";
8
+
9
+ // src/test/interactions.ts
10
+ async function click(el) {
11
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
12
+ await Promise.resolve();
13
+ }
14
+ async function type(el, text) {
15
+ const target = typeof el === "string" ? document.querySelector(el) : el;
16
+ if (!target) {
17
+ throw new TypeError(`type: element not found${typeof el === "string" ? ` for selector "${el}"` : ""}`);
18
+ }
19
+ if (!isInputLike(target)) {
20
+ throw new TypeError("type: element is not an <input> or <textarea>");
21
+ }
22
+ target.value = text;
23
+ target.dispatchEvent(new Event("input", { bubbles: true }));
24
+ target.dispatchEvent(new Event("change", { bubbles: true }));
25
+ await Promise.resolve();
26
+ }
27
+ async function press(key) {
28
+ const target = document.activeElement ?? document.body;
29
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
30
+ target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true, cancelable: true }));
31
+ await Promise.resolve();
32
+ }
33
+ async function fillForm(formEl, data) {
34
+ if (!(formEl instanceof HTMLFormElement)) {
35
+ throw new TypeError("fillForm: first argument must be an <form> element");
36
+ }
37
+ for (const [name, value] of Object.entries(data)) {
38
+ const elements = formEl.querySelectorAll(`[name="${name}"]`);
39
+ if (elements.length === 0) {
40
+ throw new TypeError(`fillForm: no element found with name "${name}" in the form`);
41
+ }
42
+ const first = elements[0];
43
+ if (first instanceof HTMLInputElement && first.type === "radio") {
44
+ let matched = false;
45
+ for (const el of elements) {
46
+ if (el instanceof HTMLInputElement && el.type === "radio") {
47
+ const shouldCheck = el.value === value;
48
+ el.checked = shouldCheck;
49
+ if (shouldCheck) {
50
+ el.dispatchEvent(new Event("input", { bubbles: true }));
51
+ el.dispatchEvent(new Event("change", { bubbles: true }));
52
+ matched = true;
53
+ }
54
+ }
55
+ }
56
+ if (!matched) {
57
+ throw new TypeError(`fillForm: no radio button with name "${name}" has value "${value}"`);
58
+ }
59
+ continue;
60
+ }
61
+ if (first instanceof HTMLInputElement && first.type === "checkbox") {
62
+ first.checked = value === "true";
63
+ first.dispatchEvent(new Event("input", { bubbles: true }));
64
+ first.dispatchEvent(new Event("change", { bubbles: true }));
65
+ continue;
66
+ }
67
+ if (first instanceof HTMLSelectElement) {
68
+ first.value = value;
69
+ first.dispatchEvent(new Event("input", { bubbles: true }));
70
+ first.dispatchEvent(new Event("change", { bubbles: true }));
71
+ continue;
72
+ }
73
+ if (first instanceof HTMLInputElement || first instanceof HTMLTextAreaElement) {
74
+ first.value = value;
75
+ first.dispatchEvent(new Event("input", { bubbles: true }));
76
+ first.dispatchEvent(new Event("change", { bubbles: true }));
77
+ continue;
78
+ }
79
+ throw new TypeError(`fillForm: element with name "${name}" is not a supported form field type`);
80
+ }
81
+ await Promise.resolve();
82
+ }
83
+ async function submitForm(formEl) {
84
+ if (!(formEl instanceof HTMLFormElement)) {
85
+ throw new TypeError("submitForm: argument must be a <form> element");
86
+ }
87
+ formEl.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
88
+ await Promise.resolve();
89
+ }
90
+ function isInputLike(el) {
91
+ const tag = el.tagName.toLowerCase();
92
+ return tag === "input" || tag === "textarea";
93
+ }
94
+ // src/test/queries.ts
95
+ function findByText(container, text) {
96
+ const el = queryByText(container, text);
97
+ if (!el) {
98
+ throw new TypeError(`findByText: no element found with text "${text}"`);
99
+ }
100
+ return el;
101
+ }
102
+ function queryByText(container, text) {
103
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
104
+ let node = walker.currentNode;
105
+ while (node) {
106
+ if (hasDirectTextMatch(node, text)) {
107
+ return node;
108
+ }
109
+ const next = walker.nextNode();
110
+ if (!next)
111
+ break;
112
+ node = next;
113
+ }
114
+ return queryByTextContent(container, text);
115
+ }
116
+ function hasDirectTextMatch(el, text) {
117
+ for (const child of el.childNodes) {
118
+ if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim() === text) {
119
+ return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+ function queryByTextContent(container, text) {
125
+ let best = null;
126
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
127
+ let node = walker.currentNode;
128
+ while (node) {
129
+ if (node.textContent?.trim() === text) {
130
+ best = node;
131
+ }
132
+ const next = walker.nextNode();
133
+ if (!next)
134
+ break;
135
+ node = next;
136
+ }
137
+ return best;
138
+ }
139
+ function findByTestId(container, id) {
140
+ const el = queryByTestId(container, id);
141
+ if (!el) {
142
+ throw new TypeError(`findByTestId: no element found with data-testid="${id}"`);
143
+ }
144
+ return el;
145
+ }
146
+ function queryByTestId(container, id) {
147
+ return container.querySelector(`[data-testid="${id}"]`);
148
+ }
149
+ async function waitFor(assertion, opts) {
150
+ const timeout = opts?.timeout ?? 1000;
151
+ const interval = opts?.interval ?? 50;
152
+ const deadline = Date.now() + timeout;
153
+ let lastError;
154
+ while (Date.now() < deadline) {
155
+ try {
156
+ assertion();
157
+ return;
158
+ } catch (err) {
159
+ lastError = err;
160
+ }
161
+ await sleep(interval);
162
+ }
163
+ try {
164
+ assertion();
165
+ } catch {
166
+ throw lastError instanceof Error ? lastError : new TypeError(`waitFor timed out after ${timeout}ms`);
167
+ }
168
+ }
169
+ function sleep(ms) {
170
+ return new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
172
+ // src/test/render-test.ts
173
+ function renderTest(component) {
174
+ const container = document.createElement("div");
175
+ container.setAttribute("data-testid", "render-test-container");
176
+ container.appendChild(component);
177
+ document.body.appendChild(container);
178
+ return {
179
+ click,
180
+ container,
181
+ findByTestId: (id) => findByTestId(container, id),
182
+ findByText: (text) => findByText(container, text),
183
+ queryByTestId: (id) => queryByTestId(container, id),
184
+ queryByText: (text) => queryByText(container, text),
185
+ type,
186
+ unmount() {
187
+ container.remove();
188
+ }
189
+ };
190
+ }
191
+ // src/test/test-router.ts
192
+ async function createTestRouter(routes, opts) {
193
+ const initialPath = opts?.initialPath ?? "/";
194
+ window.history.replaceState(null, "", initialPath);
195
+ const compiled = defineRoutes(routes);
196
+ const router = createRouter(compiled, initialPath);
197
+ const container = document.createElement("div");
198
+ container.setAttribute("data-testid", "test-router-container");
199
+ await renderCurrentRoute(router, container);
200
+ async function navigate(path) {
201
+ await router.navigate(path);
202
+ await renderCurrentRoute(router, container);
203
+ }
204
+ return { component: container, navigate, router };
205
+ }
206
+ async function renderCurrentRoute(router, container) {
207
+ while (container.firstChild) {
208
+ container.removeChild(container.firstChild);
209
+ }
210
+ const match = router.current.value;
211
+ if (!match)
212
+ return;
213
+ const componentResult = match.route.component();
214
+ let node;
215
+ if (componentResult instanceof Promise) {
216
+ const mod = await componentResult;
217
+ node = mod.default();
218
+ } else {
219
+ node = componentResult;
220
+ }
221
+ container.appendChild(node);
222
+ }
223
+ export {
224
+ waitFor,
225
+ type,
226
+ submitForm,
227
+ renderTest,
228
+ queryByText,
229
+ queryByTestId,
230
+ press,
231
+ findByText,
232
+ findByTestId,
233
+ fillForm,
234
+ createTestRouter,
235
+ click
236
+ };