zero-query 1.3.0 → 1.3.1
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/README.md +10 -8
- package/dist/API.md +22 -19
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +26 -8
- package/dist/zquery.min.js +5 -5
- package/package.json +5 -3
- package/src/diff.js +23 -5
- package/tests/coverage-targets.test.js +524 -0
- package/tests/diff.test.js +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-query",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Lightweight modern frontend library - jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"lint": "eslint .",
|
|
35
35
|
"lint:fix": "eslint . --fix",
|
|
36
36
|
"test": "vitest run",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
37
38
|
"test:watch": "vitest",
|
|
38
39
|
"test:ssr": "node tests/test-ssr.js",
|
|
39
40
|
"bench": "vitest bench --run"
|
|
@@ -56,10 +57,10 @@
|
|
|
56
57
|
"license": "MIT",
|
|
57
58
|
"repository": {
|
|
58
59
|
"type": "git",
|
|
59
|
-
"url": "git+https://github.com/
|
|
60
|
+
"url": "git+https://github.com/molexxxx/zero-query.git"
|
|
60
61
|
},
|
|
61
62
|
"bugs": {
|
|
62
|
-
"url": "https://github.com/
|
|
63
|
+
"url": "https://github.com/molexxxx/zero-query/issues"
|
|
63
64
|
},
|
|
64
65
|
"homepage": "https://z-query.com/docs",
|
|
65
66
|
"publishConfig": {
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
|
69
70
|
"@eslint/js": "^9.39.4",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
70
72
|
"@zero-server/sdk": "^0.9.8",
|
|
71
73
|
"esbuild": "^0.27.7",
|
|
72
74
|
"eslint": "^9.39.4",
|
package/src/diff.js
CHANGED
|
@@ -129,15 +129,33 @@ function _morphChildren(oldParent, newParent) {
|
|
|
129
129
|
if (hasKeys) {
|
|
130
130
|
oldKeyMap = new Map();
|
|
131
131
|
newKeyMap = new Map();
|
|
132
|
+
// Duplicate keys among siblings (common with auto-keys: several
|
|
133
|
+
// buttons sharing one data-id for event delegation) make keyed
|
|
134
|
+
// reconciliation ill-defined - it drops and duplicates nodes.
|
|
135
|
+
// Positional morphing is always correct for colliding sets, so
|
|
136
|
+
// bail out to the unkeyed path on the first duplicate.
|
|
137
|
+
let duplicate = false;
|
|
132
138
|
for (let i = 0; i < oldLen; i++) {
|
|
133
139
|
const key = _getKey(oldChildren[i]);
|
|
134
|
-
if (key != null)
|
|
140
|
+
if (key != null) {
|
|
141
|
+
if (oldKeyMap.has(key)) { duplicate = true; break; }
|
|
142
|
+
oldKeyMap.set(key, i);
|
|
143
|
+
}
|
|
135
144
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
if (!duplicate) {
|
|
146
|
+
for (let i = 0; i < newLen; i++) {
|
|
147
|
+
const key = _getKey(newChildren[i]);
|
|
148
|
+
if (key != null) {
|
|
149
|
+
if (newKeyMap.has(key)) { duplicate = true; break; }
|
|
150
|
+
newKeyMap.set(key, i);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (duplicate) {
|
|
155
|
+
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
156
|
+
} else {
|
|
157
|
+
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
139
158
|
}
|
|
140
|
-
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
141
159
|
} else {
|
|
142
160
|
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
143
161
|
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { query, ZQueryCollection } from '../src/core.js';
|
|
3
|
+
import { component, mount, prefetch, style } from '../src/component.js';
|
|
4
|
+
import { http } from '../src/http.js';
|
|
5
|
+
import { deepClone, groupBy, isEmpty } from '../src/utils.js';
|
|
6
|
+
import { Room } from '../src/webrtc/room.js';
|
|
7
|
+
import { useConnectionQuality, useDataChannel, usePeer, useRoom, useTracks } from '../src/webrtc/reactive.js';
|
|
8
|
+
import { WebRtcError } from '../src/webrtc/errors.js';
|
|
9
|
+
import { SignalingClient } from '../src/webrtc/signaling.js';
|
|
10
|
+
import {
|
|
11
|
+
FakeRTCPeerConnection,
|
|
12
|
+
FakeWebSocket,
|
|
13
|
+
fakeSockets,
|
|
14
|
+
resetFakePeerConnections,
|
|
15
|
+
resetFakeSockets,
|
|
16
|
+
} from './_helpers/webrtcFakes.js';
|
|
17
|
+
|
|
18
|
+
async function openSignaling(selfId = 'self_z') {
|
|
19
|
+
const client = new SignalingClient('ws://localhost/rtc', {
|
|
20
|
+
WebSocket: FakeWebSocket,
|
|
21
|
+
reconnect: false,
|
|
22
|
+
});
|
|
23
|
+
const connectPromise = client.connect();
|
|
24
|
+
fakeSockets[0].fakeOpen();
|
|
25
|
+
fakeSockets[0].fakeMessage({ type: 'hello', peerId: selfId });
|
|
26
|
+
await connectPromise;
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function makeRoom(selfId = 'self_z') {
|
|
31
|
+
const signaling = await openSignaling(selfId);
|
|
32
|
+
return new Room({
|
|
33
|
+
id: 'room1',
|
|
34
|
+
self: selfId,
|
|
35
|
+
signaling,
|
|
36
|
+
peerOptions: { RTCPeerConnection: FakeRTCPeerConnection },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fakeStream(tracks) {
|
|
41
|
+
return { id: 'stream_fake', getTracks: () => tracks.slice() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stubAnimationFrame() {
|
|
45
|
+
const original = globalThis.requestAnimationFrame;
|
|
46
|
+
globalThis.requestAnimationFrame = (callback) => {
|
|
47
|
+
callback(performance.now());
|
|
48
|
+
return 1;
|
|
49
|
+
};
|
|
50
|
+
return () => {
|
|
51
|
+
globalThis.requestAnimationFrame = original;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockHttpResponse({ body = {}, contentType = 'application/json', ok = true, status = 200, jsonThrows = false }) {
|
|
56
|
+
const responseText = typeof body === 'string' ? body : JSON.stringify(body);
|
|
57
|
+
globalThis.fetch = vi.fn(() => Promise.resolve({
|
|
58
|
+
ok,
|
|
59
|
+
status,
|
|
60
|
+
statusText: ok ? 'OK' : 'Error',
|
|
61
|
+
headers: {
|
|
62
|
+
get: (header) => header.toLowerCase() === 'content-type' ? contentType : null,
|
|
63
|
+
entries: () => [['content-type', contentType]],
|
|
64
|
+
},
|
|
65
|
+
json: () => jsonThrows ? Promise.reject(new Error('bad json')) : Promise.resolve(typeof body === 'object' ? body : JSON.parse(body)),
|
|
66
|
+
text: () => Promise.resolve(responseText),
|
|
67
|
+
blob: () => Promise.resolve(new Blob([responseText])),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('coverage target - utility fallback paths', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.restoreAllMocks();
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('deepClone fallback preserves richer built-ins when structuredClone is unavailable', () => {
|
|
78
|
+
const originalStructuredClone = globalThis.structuredClone;
|
|
79
|
+
Object.defineProperty(globalThis, 'structuredClone', { value: undefined, configurable: true, writable: true });
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const arrayBuffer = new Uint8Array([1, 2, 3]).buffer;
|
|
83
|
+
const source = {
|
|
84
|
+
date: new Date('2024-01-02T00:00:00.000Z'),
|
|
85
|
+
regex: /zq/gi,
|
|
86
|
+
map: new Map([[{ id: 1 }, { nested: true }]]),
|
|
87
|
+
set: new Set([{ label: 'a' }]),
|
|
88
|
+
typed: new Uint8Array([7, 8]),
|
|
89
|
+
buffer: arrayBuffer,
|
|
90
|
+
nested: { value: 1 },
|
|
91
|
+
};
|
|
92
|
+
source.self = source;
|
|
93
|
+
|
|
94
|
+
const clone = deepClone(source);
|
|
95
|
+
|
|
96
|
+
expect(clone).not.toBe(source);
|
|
97
|
+
expect(clone.self).toBe(clone);
|
|
98
|
+
expect(clone.date.getTime()).toBe(source.date.getTime());
|
|
99
|
+
expect(clone.regex.source).toBe('zq');
|
|
100
|
+
expect(clone.regex.flags).toBe('gi');
|
|
101
|
+
expect([...clone.map.values()][0]).toEqual({ nested: true });
|
|
102
|
+
expect([...clone.set][0]).toEqual({ label: 'a' });
|
|
103
|
+
expect([...clone.typed]).toEqual([7, 8]);
|
|
104
|
+
expect(clone.buffer).not.toBe(arrayBuffer);
|
|
105
|
+
clone.nested.value = 2;
|
|
106
|
+
expect(source.nested.value).toBe(1);
|
|
107
|
+
} finally {
|
|
108
|
+
Object.defineProperty(globalThis, 'structuredClone', { value: originalStructuredClone, configurable: true, writable: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('groupBy falls back when Object.groupBy is not present', () => {
|
|
113
|
+
const originalGroupBy = Object.groupBy;
|
|
114
|
+
Object.defineProperty(Object, 'groupBy', { value: undefined, configurable: true, writable: true });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
expect(groupBy(['ant', 'ape', 'bee'], (item) => item[0])).toEqual({ a: ['ant', 'ape'], b: ['bee'] });
|
|
118
|
+
} finally {
|
|
119
|
+
Object.defineProperty(Object, 'groupBy', { value: originalGroupBy, configurable: true, writable: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('isEmpty handles maps, sets, primitives, and populated objects', () => {
|
|
124
|
+
expect(isEmpty(new Map())).toBe(true);
|
|
125
|
+
expect(isEmpty(new Set([1]))).toBe(false);
|
|
126
|
+
expect(isEmpty(false)).toBe(false);
|
|
127
|
+
expect(isEmpty({ value: 1 })).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('coverage target - HTTP edge behavior', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
http.clearInterceptors();
|
|
134
|
+
http.configure({ baseURL: '', headers: { 'Content-Type': 'application/json' }, timeout: 30000 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
vi.useRealTimers();
|
|
139
|
+
vi.restoreAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects when a request interceptor blocks the request', async () => {
|
|
143
|
+
http.onRequest(() => false);
|
|
144
|
+
mockHttpResponse({ body: { ok: true } });
|
|
145
|
+
|
|
146
|
+
await expect(http.get('https://api.test.com/blocked')).rejects.toThrow('Request blocked by interceptor');
|
|
147
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('parses octet-stream responses as blobs', async () => {
|
|
151
|
+
mockHttpResponse({ body: 'binary', contentType: 'application/octet-stream' });
|
|
152
|
+
|
|
153
|
+
const result = await http.get('https://api.test.com/file.bin');
|
|
154
|
+
|
|
155
|
+
expect(result.data).toBeInstanceOf(Blob);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('tries JSON then falls back to text for unknown content types', async () => {
|
|
159
|
+
mockHttpResponse({ body: '{"ok":true}', contentType: 'application/custom+json' });
|
|
160
|
+
|
|
161
|
+
const result = await http.get('https://api.test.com/custom');
|
|
162
|
+
|
|
163
|
+
expect(result.data).toEqual({ ok: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns null when response body parsing fails', async () => {
|
|
167
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
168
|
+
mockHttpResponse({ body: '{bad', contentType: 'application/json', jsonThrows: true });
|
|
169
|
+
|
|
170
|
+
const result = await http.get('https://api.test.com/bad-json');
|
|
171
|
+
|
|
172
|
+
expect(result.data).toBeNull();
|
|
173
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('uses the AbortSignal.any fallback when native composition is unavailable', async () => {
|
|
177
|
+
const originalAny = AbortSignal.any;
|
|
178
|
+
Object.defineProperty(AbortSignal, 'any', { value: undefined, configurable: true, writable: true });
|
|
179
|
+
const abortController = new AbortController();
|
|
180
|
+
abortController.abort('stop');
|
|
181
|
+
globalThis.fetch = vi.fn((url, options) => {
|
|
182
|
+
expect(url).toBe('https://api.test.com/abort');
|
|
183
|
+
expect(options.signal.aborted).toBe(true);
|
|
184
|
+
return Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await expect(http.get('https://api.test.com/abort', null, { signal: abortController.signal })).rejects.toThrow('Request aborted');
|
|
189
|
+
} finally {
|
|
190
|
+
Object.defineProperty(AbortSignal, 'any', { value: originalAny, configurable: true, writable: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('reports timeout aborts distinctly', async () => {
|
|
195
|
+
vi.useFakeTimers();
|
|
196
|
+
globalThis.fetch = vi.fn((url, options) => new Promise((resolve, reject) => {
|
|
197
|
+
options.signal.addEventListener('abort', () => {
|
|
198
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
199
|
+
});
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
const requestPromise = http.get('https://api.test.com/slow', null, { timeout: 5 });
|
|
203
|
+
const rejection = expect(requestPromise).rejects.toThrow('Request timeout after 5ms');
|
|
204
|
+
await vi.advanceTimersByTimeAsync(5);
|
|
205
|
+
|
|
206
|
+
await rejection;
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('coverage target - collection edge behavior', () => {
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
document.body.innerHTML = '<div id="box" style="opacity:1"><span>box</span></div><div id="target"></div>';
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
vi.useRealTimers();
|
|
217
|
+
vi.restoreAllMocks();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('animate resolves on transitionend and clears transition style', async () => {
|
|
221
|
+
const restoreAnimationFrame = stubAnimationFrame();
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const collection = query('#box');
|
|
225
|
+
const animationPromise = collection.animate({ opacity: '0.5' }, 100);
|
|
226
|
+
const box = document.querySelector('#box');
|
|
227
|
+
box.dispatchEvent(new Event('transitionend'));
|
|
228
|
+
|
|
229
|
+
await expect(animationPromise).resolves.toBe(collection);
|
|
230
|
+
expect(box.style.transition).toBe('');
|
|
231
|
+
expect(box.style.opacity).toBe('0.5');
|
|
232
|
+
} finally {
|
|
233
|
+
restoreAnimationFrame();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('fadeOut hides the element after the animation completes', async () => {
|
|
238
|
+
const restoreAnimationFrame = stubAnimationFrame();
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const fadePromise = query('#box').fadeOut(100);
|
|
242
|
+
document.querySelector('#box').dispatchEvent(new Event('transitionend'));
|
|
243
|
+
await fadePromise;
|
|
244
|
+
expect(document.querySelector('#box').style.display).toBe('none');
|
|
245
|
+
} finally {
|
|
246
|
+
restoreAnimationFrame();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('slideToggle handles visible and hidden elements', () => {
|
|
251
|
+
vi.useFakeTimers();
|
|
252
|
+
const restoreAnimationFrame = stubAnimationFrame();
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const box = document.querySelector('#box');
|
|
256
|
+
query('#box').slideToggle(25);
|
|
257
|
+
vi.advanceTimersByTime(25);
|
|
258
|
+
expect(box.style.display).toBe('none');
|
|
259
|
+
|
|
260
|
+
query('#box').slideToggle(25);
|
|
261
|
+
expect(box.style.overflow).toBe('hidden');
|
|
262
|
+
vi.advanceTimersByTime(25);
|
|
263
|
+
expect(box.style.display).toBe('');
|
|
264
|
+
} finally {
|
|
265
|
+
restoreAnimationFrame();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('window scroll helpers read and write through window.scrollTo', () => {
|
|
270
|
+
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
271
|
+
Object.defineProperty(window, 'scrollX', { value: 12, configurable: true });
|
|
272
|
+
Object.defineProperty(window, 'scrollY', { value: 34, configurable: true });
|
|
273
|
+
const collection = new ZQueryCollection([window]);
|
|
274
|
+
|
|
275
|
+
expect(collection.scrollTop()).toBe(34);
|
|
276
|
+
expect(collection.scrollLeft()).toBe(12);
|
|
277
|
+
|
|
278
|
+
collection.scrollTop(99);
|
|
279
|
+
collection.scrollLeft(77);
|
|
280
|
+
|
|
281
|
+
expect(scrollSpy).toHaveBeenCalledWith(12, 99);
|
|
282
|
+
expect(scrollSpy).toHaveBeenCalledWith(77, 34);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('coverage target - component external resources and global styles', () => {
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
document.body.innerHTML = '';
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
delete window.__zqInline;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('prefetches inline template and style resources before mount', async () => {
|
|
296
|
+
window.__zqInline = {
|
|
297
|
+
'external-template.html': '<p class="external">{{ props.label }}</p>',
|
|
298
|
+
'external-style.css': '.external { color: red; }',
|
|
299
|
+
};
|
|
300
|
+
component('coverage-external', {
|
|
301
|
+
props: { label: String },
|
|
302
|
+
templateUrl: 'external-template.html',
|
|
303
|
+
styleUrl: 'external-style.css',
|
|
304
|
+
});
|
|
305
|
+
await prefetch('coverage-external');
|
|
306
|
+
|
|
307
|
+
document.body.innerHTML = '<coverage-external id="external" label="Loaded"></coverage-external>';
|
|
308
|
+
mount('#external', 'coverage-external');
|
|
309
|
+
|
|
310
|
+
expect(document.querySelector('.external').textContent).toBe('Loaded');
|
|
311
|
+
expect(document.querySelector('style[data-zq-component="coverage-external"]')).not.toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('style() deduplicates stylesheets and removes critical hiding style after load', async () => {
|
|
315
|
+
const handle = style(['coverage-style.css', 'coverage-style.css'], { bg: '#123456' });
|
|
316
|
+
const links = [...document.querySelectorAll('link[data-zq-style]')].filter((link) => link.href.includes('coverage-style.css'));
|
|
317
|
+
expect(links).toHaveLength(1);
|
|
318
|
+
expect(document.querySelector('style[data-zq-critical]')).not.toBeNull();
|
|
319
|
+
|
|
320
|
+
links[0].onload();
|
|
321
|
+
await handle.ready;
|
|
322
|
+
|
|
323
|
+
expect(document.querySelector('style[data-zq-critical]')).toBeNull();
|
|
324
|
+
handle.remove();
|
|
325
|
+
expect([...document.querySelectorAll('link[data-zq-style]')].some((link) => link.href.includes('coverage-style.css'))).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('style() resolves readiness even when a stylesheet errors', async () => {
|
|
329
|
+
const handle = style('coverage-error.css', { critical: false });
|
|
330
|
+
const link = [...document.querySelectorAll('link[data-zq-style]')].find((candidate) => candidate.href.includes('coverage-error.css'));
|
|
331
|
+
link.onerror();
|
|
332
|
+
|
|
333
|
+
await handle.ready;
|
|
334
|
+
handle.remove();
|
|
335
|
+
|
|
336
|
+
expect(link.isConnected).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('coverage target - WebRTC reactive guards', () => {
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
resetFakeSockets();
|
|
343
|
+
resetFakePeerConnections();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
afterEach(() => {
|
|
347
|
+
vi.useRealTimers();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('validates composable arguments', async () => {
|
|
351
|
+
const room = await makeRoom();
|
|
352
|
+
|
|
353
|
+
await expect(useRoom({})).rejects.toThrow(WebRtcError);
|
|
354
|
+
expect(() => usePeer({}, 'peer_a')).toThrow(WebRtcError);
|
|
355
|
+
expect(() => usePeer(room, '')).toThrow(WebRtcError);
|
|
356
|
+
expect(() => useTracks({})).toThrow(WebRtcError);
|
|
357
|
+
expect(() => useDataChannel({}, 'chat')).toThrow(WebRtcError);
|
|
358
|
+
expect(() => useDataChannel(room, '')).toThrow(WebRtcError);
|
|
359
|
+
expect(() => useConnectionQuality({})).toThrow(WebRtcError);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('useTracks tolerates streams without events and getTracks failures', () => {
|
|
363
|
+
const handle = useTracks({ stream: { getTracks: () => { throw new Error('track failure'); } } });
|
|
364
|
+
expect(handle.value).toEqual([]);
|
|
365
|
+
handle.refresh();
|
|
366
|
+
expect(handle.value).toEqual([]);
|
|
367
|
+
expect(() => handle.dispose()).not.toThrow();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('useDataChannel close and dispose tolerate wrapper failures', async () => {
|
|
371
|
+
const room = await makeRoom();
|
|
372
|
+
const off = vi.fn(() => { throw new Error('off failure'); });
|
|
373
|
+
const wrapper = {
|
|
374
|
+
on: vi.fn(() => off),
|
|
375
|
+
send: vi.fn(),
|
|
376
|
+
close: vi.fn(() => { throw new Error('close failure'); }),
|
|
377
|
+
};
|
|
378
|
+
room.dataChannel = vi.fn(() => wrapper);
|
|
379
|
+
|
|
380
|
+
const channel = useDataChannel(room, 'chat');
|
|
381
|
+
channel.send('hello');
|
|
382
|
+
|
|
383
|
+
expect(wrapper.send).toHaveBeenCalledWith('hello');
|
|
384
|
+
expect(() => channel.close()).not.toThrow();
|
|
385
|
+
expect(() => channel.dispose()).not.toThrow();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('useConnectionQuality accepts object reports and ignores sampler failures', async () => {
|
|
389
|
+
let reportShouldFail = false;
|
|
390
|
+
const handle = useConnectionQuality({ pc: {} }, {
|
|
391
|
+
intervalMs: 999999,
|
|
392
|
+
getStats: async () => {
|
|
393
|
+
if (reportShouldFail) throw new Error('stats unavailable');
|
|
394
|
+
return {
|
|
395
|
+
inbound: { type: 'inbound-rtp', packetsLost: 4, packetsReceived: 96 },
|
|
396
|
+
pair: { type: 'candidate-pair', nominated: true, currentRoundTripTime: 0.25 },
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await Promise.resolve();
|
|
402
|
+
await Promise.resolve();
|
|
403
|
+
expect(handle.value).toBe('fair');
|
|
404
|
+
|
|
405
|
+
reportShouldFail = true;
|
|
406
|
+
await handle.dispose();
|
|
407
|
+
expect(handle.value).toBe('fair');
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('coverage target - Room edge behavior', () => {
|
|
412
|
+
beforeEach(() => {
|
|
413
|
+
resetFakeSockets();
|
|
414
|
+
resetFakePeerConnections();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('does not add peers after the room is closed', async () => {
|
|
418
|
+
const room = await makeRoom();
|
|
419
|
+
room.closed = true;
|
|
420
|
+
room._addPeer('peer_a');
|
|
421
|
+
expect(room.peers.peek().size).toBe(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('room event listeners are removable and isolated from listener errors', async () => {
|
|
425
|
+
const room = await makeRoom();
|
|
426
|
+
const joined = [];
|
|
427
|
+
const noop = room.on('peer-joined', null);
|
|
428
|
+
const removeThrower = room.on('peer-joined', () => { throw new Error('listener failure'); });
|
|
429
|
+
room.on('peer-joined', (info) => joined.push(info.id));
|
|
430
|
+
|
|
431
|
+
expect(() => noop()).not.toThrow();
|
|
432
|
+
fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
|
|
433
|
+
removeThrower();
|
|
434
|
+
fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_b' });
|
|
435
|
+
|
|
436
|
+
expect(joined).toEqual(['peer_a', 'peer_b']);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('forwards mute and unmute signaling frames', async () => {
|
|
440
|
+
const room = await makeRoom();
|
|
441
|
+
const events = [];
|
|
442
|
+
room.on('mute', (message) => events.push(['mute', message.kind]));
|
|
443
|
+
room.on('unmute', (message) => events.push(['unmute', message.kind]));
|
|
444
|
+
|
|
445
|
+
fakeSockets[0].fakeMessage({ type: 'mute', id: 'peer_a', kind: 'audio' });
|
|
446
|
+
fakeSockets[0].fakeMessage({ type: 'unmute', id: 'peer_a', kind: 'video' });
|
|
447
|
+
|
|
448
|
+
expect(events).toEqual([['mute', 'audio'], ['unmute', 'video']]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('track and connection events update PeerInfo and emit failures', async () => {
|
|
452
|
+
const room = await makeRoom();
|
|
453
|
+
const errors = [];
|
|
454
|
+
room.on('error', (error) => errors.push(error));
|
|
455
|
+
fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
|
|
456
|
+
const info = room.peers.peek().get('peer_a');
|
|
457
|
+
|
|
458
|
+
info.pc.fakeTrack({ streams: [], track: { kind: 'video', id: 'video_1' } });
|
|
459
|
+
expect(room.peers.peek().get('peer_a').video).toBe(true);
|
|
460
|
+
expect(info.stream.getTracks().some((track) => track.id === 'video_1')).toBe(true);
|
|
461
|
+
|
|
462
|
+
const incomingStream = { id: 'incoming', getTracks: () => [] };
|
|
463
|
+
info.pc.fakeTrack({ streams: [incomingStream], track: { kind: 'audio', id: 'audio_1' } });
|
|
464
|
+
expect(room.peers.peek().get('peer_a').stream).toBe(incomingStream);
|
|
465
|
+
expect(room.peers.peek().get('peer_a').audio).toBe(true);
|
|
466
|
+
|
|
467
|
+
info.pc.fakeConnectionStateChange('failed');
|
|
468
|
+
expect(errors[0]).toBeInstanceOf(WebRtcError);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('publish and unpublish report per-peer sender errors', async () => {
|
|
472
|
+
const room = await makeRoom();
|
|
473
|
+
const errors = [];
|
|
474
|
+
room.on('error', (error) => errors.push(error));
|
|
475
|
+
fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
|
|
476
|
+
const info = room.peers.peek().get('peer_a');
|
|
477
|
+
|
|
478
|
+
info.peer.addTrack = () => { throw new Error('add failed'); };
|
|
479
|
+
await room.publish(fakeStream([{ kind: 'audio', id: 'track_fail' }]));
|
|
480
|
+
expect(errors.at(-1).message).toBe('add failed');
|
|
481
|
+
|
|
482
|
+
const track = { kind: 'video', id: 'track_remove' };
|
|
483
|
+
const stream = fakeStream([track]);
|
|
484
|
+
info.peer.addTrack = () => ({ track });
|
|
485
|
+
await room.publish(stream);
|
|
486
|
+
info.peer.removeTrack = () => { throw new Error('remove failed'); };
|
|
487
|
+
await room.unpublish(stream);
|
|
488
|
+
|
|
489
|
+
expect(errors.at(-1).message).toBe('remove failed');
|
|
490
|
+
await expect(room.unpublish(null)).rejects.toThrow(WebRtcError);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('adopts incoming data channels with EventTarget-style listeners', async () => {
|
|
494
|
+
const room = await makeRoom('self_z');
|
|
495
|
+
fakeSockets[0].fakeMessage({ type: 'peer-joined', id: 'peer_a' });
|
|
496
|
+
const wrapper = room.dataChannel('chat');
|
|
497
|
+
const info = room.peers.peek().get('peer_a');
|
|
498
|
+
const opened = [];
|
|
499
|
+
const messages = [];
|
|
500
|
+
const listeners = {};
|
|
501
|
+
const fakeChannel = {
|
|
502
|
+
label: 'chat',
|
|
503
|
+
addEventListener: (event, callback) => { listeners[event] = callback; },
|
|
504
|
+
close: vi.fn(),
|
|
505
|
+
send: vi.fn(() => { throw new Error('dead channel'); }),
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
wrapper.on('open', () => { throw new Error('open listener failure'); });
|
|
509
|
+
wrapper.on('open', (peerId) => opened.push(peerId));
|
|
510
|
+
wrapper.on('message', () => { throw new Error('message listener failure'); });
|
|
511
|
+
wrapper.on('message', (data, peerId) => messages.push({ data, peerId }));
|
|
512
|
+
|
|
513
|
+
info.pc.fakeDataChannel(fakeChannel);
|
|
514
|
+
listeners.open();
|
|
515
|
+
listeners.message({ data: 'hello' });
|
|
516
|
+
wrapper.send('ignored');
|
|
517
|
+
wrapper.close();
|
|
518
|
+
|
|
519
|
+
expect(opened).toEqual(['peer_a']);
|
|
520
|
+
expect(messages).toEqual([{ data: 'hello', peerId: 'peer_a' }]);
|
|
521
|
+
expect(fakeChannel.close).toHaveBeenCalledTimes(1);
|
|
522
|
+
expect(() => wrapper.send('after-close')).not.toThrow();
|
|
523
|
+
});
|
|
524
|
+
});
|