tova 0.4.6 → 0.4.7
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/package.json +14 -2
- package/src/analyzer/analyzer.js +10 -5
- package/src/analyzer/type-registry.js +22 -3
- package/src/codegen/base-codegen.js +15 -4
- package/src/codegen/client-codegen.js +9 -6
- package/src/codegen/codegen.js +3 -2
- package/src/codegen/server-codegen.js +526 -81
- package/src/lsp/server.js +44 -25
- package/src/parser/server-ast.js +2 -1
- package/src/parser/server-parser.js +12 -1
- package/src/runtime/embedded.js +3 -3
- package/src/runtime/reactivity.js +405 -23
- package/src/runtime/router.js +215 -25
- package/src/runtime/rpc.js +152 -17
- package/src/runtime/ssr.js +66 -10
- package/src/runtime/testing.js +241 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Testing utilities for Tova applications.
|
|
2
|
+
// Provides helpers to render components, fire events, and wait for reactive updates.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { renderForTest, fireEvent, waitForEffect, cleanup } from './runtime/testing.js';
|
|
6
|
+
//
|
|
7
|
+
// test('counter increments', async () => {
|
|
8
|
+
// const { container, getByText } = renderForTest(Counter);
|
|
9
|
+
// fireEvent.click(getByText('Increment'));
|
|
10
|
+
// await waitForEffect();
|
|
11
|
+
// expect(getByText('1')).toBeTruthy();
|
|
12
|
+
// cleanup();
|
|
13
|
+
// });
|
|
14
|
+
|
|
15
|
+
import { createRoot, render, mount, tova_el, batch } from './reactivity.js';
|
|
16
|
+
|
|
17
|
+
// Track all mounted roots for cleanup
|
|
18
|
+
const _activeRoots = [];
|
|
19
|
+
|
|
20
|
+
// ─── Minimal DOM (for non-browser environments) ──────────
|
|
21
|
+
function ensureDOM() {
|
|
22
|
+
if (typeof document !== 'undefined' && document.createElement) return;
|
|
23
|
+
throw new Error('Tova testing: DOM environment required. Use a test runner with DOM support (bun:test, jsdom, happy-dom).');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── renderForTest ────────────────────────────────────────
|
|
27
|
+
// Renders a component into a detached container and returns query helpers.
|
|
28
|
+
// The component is mounted inside a reactive root for proper cleanup.
|
|
29
|
+
|
|
30
|
+
export function renderForTest(component, { props = {}, container: userContainer } = {}) {
|
|
31
|
+
ensureDOM();
|
|
32
|
+
|
|
33
|
+
const container = userContainer || document.createElement('div');
|
|
34
|
+
let disposeFn = null;
|
|
35
|
+
|
|
36
|
+
createRoot((dispose) => {
|
|
37
|
+
disposeFn = dispose;
|
|
38
|
+
const vnode = typeof component === 'function' ? component(props) : component;
|
|
39
|
+
if (typeof container.replaceChildren === 'function') {
|
|
40
|
+
container.replaceChildren();
|
|
41
|
+
} else {
|
|
42
|
+
while (container.firstChild) container.removeChild(container.firstChild);
|
|
43
|
+
}
|
|
44
|
+
container.appendChild(render(vnode));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
_activeRoots.push({ dispose: disposeFn, container });
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
container,
|
|
51
|
+
dispose: disposeFn,
|
|
52
|
+
// Query helpers
|
|
53
|
+
getByText: (text) => findByText(container, text),
|
|
54
|
+
getByTestId: (id) => container.querySelector(`[data-testid="${id}"]`),
|
|
55
|
+
getByRole: (role) => container.querySelector(`[role="${role}"]`),
|
|
56
|
+
querySelector: (sel) => container.querySelector(sel),
|
|
57
|
+
querySelectorAll: (sel) => container.querySelectorAll(sel),
|
|
58
|
+
// Debug helper
|
|
59
|
+
debug: () => {
|
|
60
|
+
if (container.innerHTML !== undefined) {
|
|
61
|
+
console.log(container.innerHTML);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(serializeNode(container));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── fireEvent ────────────────────────────────────────────
|
|
70
|
+
// Dispatches DOM events on elements. Works with both real DOM and mock DOM.
|
|
71
|
+
|
|
72
|
+
export const fireEvent = {
|
|
73
|
+
click(el, options = {}) {
|
|
74
|
+
return _dispatchEvent(el, 'click', options);
|
|
75
|
+
},
|
|
76
|
+
input(el, options = {}) {
|
|
77
|
+
if (options.value !== undefined && el) {
|
|
78
|
+
el.value = options.value;
|
|
79
|
+
}
|
|
80
|
+
return _dispatchEvent(el, 'input', options);
|
|
81
|
+
},
|
|
82
|
+
change(el, options = {}) {
|
|
83
|
+
if (options.value !== undefined && el) {
|
|
84
|
+
el.value = options.value;
|
|
85
|
+
}
|
|
86
|
+
if (options.checked !== undefined && el) {
|
|
87
|
+
el.checked = options.checked;
|
|
88
|
+
}
|
|
89
|
+
return _dispatchEvent(el, 'change', options);
|
|
90
|
+
},
|
|
91
|
+
submit(el, options = {}) {
|
|
92
|
+
return _dispatchEvent(el, 'submit', options);
|
|
93
|
+
},
|
|
94
|
+
focus(el, options = {}) {
|
|
95
|
+
return _dispatchEvent(el, 'focus', options);
|
|
96
|
+
},
|
|
97
|
+
blur(el, options = {}) {
|
|
98
|
+
return _dispatchEvent(el, 'blur', options);
|
|
99
|
+
},
|
|
100
|
+
keyDown(el, options = {}) {
|
|
101
|
+
return _dispatchEvent(el, 'keydown', options);
|
|
102
|
+
},
|
|
103
|
+
keyUp(el, options = {}) {
|
|
104
|
+
return _dispatchEvent(el, 'keyup', options);
|
|
105
|
+
},
|
|
106
|
+
mouseEnter(el, options = {}) {
|
|
107
|
+
return _dispatchEvent(el, 'mouseenter', options);
|
|
108
|
+
},
|
|
109
|
+
mouseLeave(el, options = {}) {
|
|
110
|
+
return _dispatchEvent(el, 'mouseleave', options);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function _dispatchEvent(el, eventName, options = {}) {
|
|
115
|
+
if (!el) throw new Error(`Tova testing: Cannot fire "${eventName}" on null/undefined element`);
|
|
116
|
+
|
|
117
|
+
// Mock DOM path: call event listeners directly
|
|
118
|
+
if (el.eventListeners && el.eventListeners[eventName]) {
|
|
119
|
+
const event = {
|
|
120
|
+
type: eventName,
|
|
121
|
+
target: el,
|
|
122
|
+
currentTarget: el,
|
|
123
|
+
preventDefault: () => {},
|
|
124
|
+
stopPropagation: () => {},
|
|
125
|
+
...options,
|
|
126
|
+
};
|
|
127
|
+
for (const handler of el.eventListeners[eventName]) {
|
|
128
|
+
handler(event);
|
|
129
|
+
}
|
|
130
|
+
return event;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Real DOM: use __handlers (Tova's internal handler map)
|
|
134
|
+
if (el.__handlers && el.__handlers[eventName]) {
|
|
135
|
+
const event = {
|
|
136
|
+
type: eventName,
|
|
137
|
+
target: el,
|
|
138
|
+
currentTarget: el,
|
|
139
|
+
preventDefault: () => {},
|
|
140
|
+
stopPropagation: () => {},
|
|
141
|
+
...options,
|
|
142
|
+
};
|
|
143
|
+
el.__handlers[eventName](event);
|
|
144
|
+
return event;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fall back to dispatchEvent for real DOM elements
|
|
148
|
+
if (typeof el.dispatchEvent === 'function') {
|
|
149
|
+
const EventClass = typeof Event !== 'undefined' ? Event : function(type) { this.type = type; };
|
|
150
|
+
const event = new EventClass(eventName, { bubbles: true, cancelable: true, ...options });
|
|
151
|
+
el.dispatchEvent(event);
|
|
152
|
+
return event;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
throw new Error(`Tova testing: Element has no event listeners for "${eventName}"`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── waitForEffect ────────────────────────────────────────
|
|
159
|
+
// Returns a promise that resolves after all pending effects and microtasks flush.
|
|
160
|
+
// Usage: await waitForEffect();
|
|
161
|
+
|
|
162
|
+
export function waitForEffect(ms = 0) {
|
|
163
|
+
return new Promise(resolve => {
|
|
164
|
+
if (ms > 0) {
|
|
165
|
+
setTimeout(resolve, ms);
|
|
166
|
+
} else {
|
|
167
|
+
// Flush microtasks (queueMicrotask + Promise)
|
|
168
|
+
queueMicrotask(() => queueMicrotask(resolve));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── cleanup ──────────────────────────────────────────────
|
|
174
|
+
// Disposes all mounted test roots and removes containers.
|
|
175
|
+
// Call this in afterEach() or at end of test.
|
|
176
|
+
|
|
177
|
+
export function cleanup() {
|
|
178
|
+
for (const root of _activeRoots) {
|
|
179
|
+
if (root.dispose) root.dispose();
|
|
180
|
+
if (root.container && root.container.parentNode) {
|
|
181
|
+
root.container.parentNode.removeChild(root.container);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
_activeRoots.length = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Query Helpers ────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function findByText(container, text) {
|
|
190
|
+
// Search through child nodes for text content match
|
|
191
|
+
const walker = walkNodes(container);
|
|
192
|
+
for (const node of walker) {
|
|
193
|
+
if (node.nodeType === 1) {
|
|
194
|
+
// Element node — check direct text content
|
|
195
|
+
const directText = getDirectText(node);
|
|
196
|
+
if (directText.includes(text)) return node;
|
|
197
|
+
}
|
|
198
|
+
if (node.nodeType === 3 && node.textContent && node.textContent.includes(text)) {
|
|
199
|
+
return node.parentNode;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function* walkNodes(node) {
|
|
206
|
+
if (!node) return;
|
|
207
|
+
const children = node.childNodes || node.children || [];
|
|
208
|
+
for (let i = 0; i < children.length; i++) {
|
|
209
|
+
yield children[i];
|
|
210
|
+
yield* walkNodes(children[i]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getDirectText(el) {
|
|
215
|
+
let text = '';
|
|
216
|
+
const children = el.childNodes || el.children || [];
|
|
217
|
+
for (let i = 0; i < children.length; i++) {
|
|
218
|
+
if (children[i].nodeType === 3) {
|
|
219
|
+
text += children[i].textContent || '';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return text;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Serialize a DOM node to a readable string (for debug output)
|
|
226
|
+
function serializeNode(node, depth = 0) {
|
|
227
|
+
if (!node) return '';
|
|
228
|
+
const indent = ' '.repeat(depth);
|
|
229
|
+
if (node.nodeType === 3) return `${indent}${JSON.stringify(node.textContent)}\n`;
|
|
230
|
+
if (node.nodeType === 8) return `${indent}<!--${node.data}-->\n`;
|
|
231
|
+
const tag = (node.tagName || 'unknown').toLowerCase();
|
|
232
|
+
let result = `${indent}<${tag}`;
|
|
233
|
+
if (node.className) result += ` class="${node.className}"`;
|
|
234
|
+
result += '>\n';
|
|
235
|
+
const children = node.childNodes || node.children || [];
|
|
236
|
+
for (let i = 0; i < children.length; i++) {
|
|
237
|
+
result += serializeNode(children[i], depth + 1);
|
|
238
|
+
}
|
|
239
|
+
result += `${indent}</${tag}>\n`;
|
|
240
|
+
return result;
|
|
241
|
+
}
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-runtime.js — do not edit
|
|
2
|
-
export const VERSION = "0.4.
|
|
2
|
+
export const VERSION = "0.4.7";
|