sygnal 6.6.0 → 7.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.
- package/CHANGELOG.md +258 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +32 -9
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +32 -9
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +32 -43
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +32 -43
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +154 -6
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +153 -7
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +3 -1
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/resources.md +2 -0
- package/dist/guide/timers.md +3 -3
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +954 -388
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +300 -46
- package/dist/index.esm.js +953 -389
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +19 -9
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +19 -9
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +23 -22
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +2 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +66 -17
- package/src/core/runtime.ts +5 -1
- package/src/core/statics.ts +5 -5
- package/src/extra/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +10 -2
- package/src/extra/behaviors.ts +24 -3
- package/src/extra/diagnostics/checks/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +1 -0
- package/src/extra/diagnostics/checks/next.ts +49 -3
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/checks/public.d.ts +12 -0
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +24 -0
- package/src/extra/fetchDriver.ts +115 -7
- package/src/extra/fetchEventSource.ts +117 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +60 -9
- package/src/extra/ssr.ts +30 -41
- package/src/extra/testing.ts +100 -18
- package/src/extra/timers.ts +5 -2
- package/src/index.d.ts +299 -45
- package/src/index.ts +3 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- package/src/vite/graph.ts +11 -3
- package/src/vite/plugin.d.ts +7 -0
- package/src/vite/plugin.ts +10 -5
package/src/extra/lens.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* is gone); set: the array with that element replaced (the state unchanged when it is gone).
|
|
6
6
|
* Side-effect free.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
import {BINDING} from '../shared'
|
|
9
|
+
export const byId = (field: string, id: any, idKey = 'id') => Object.defineProperty({
|
|
9
10
|
get: (s: any) => (Array.isArray(s?.[field]) ? s[field].find((x: any) => x?.[idKey] === id) : undefined),
|
|
10
11
|
set: (s: any, v: any) => {
|
|
11
12
|
const a = s?.[field]
|
|
12
13
|
if (!Array.isArray(a) || !a.some((x: any) => x?.[idKey] === id)) return s
|
|
13
14
|
return {...s, [field]: a.map((x: any) => (x?.[idKey] === id ? v : x))}
|
|
14
15
|
},
|
|
15
|
-
})
|
|
16
|
+
}, BINDING, {value: [field, id, idKey]})
|
package/src/extra/queryCache.ts
CHANGED
|
@@ -43,9 +43,10 @@ export function queryCache(o: any = {}): any {
|
|
|
43
43
|
// the key of a cacheable request (else undefined); `res`: a resource's
|
|
44
44
|
const key = (q: any, res?: any) => {
|
|
45
45
|
if (typeof q == 'string') q = {url: q};
|
|
46
|
-
const {method, json, body, parse, query} = q;
|
|
47
|
-
const m = (method || (json !== undefined || body !== undefined ? 'POST' : 'GET')).toUpperCase();
|
|
48
|
-
|
|
46
|
+
const {method, json, body, parse, query, multipart} = q;
|
|
47
|
+
const m = (method || (json !== undefined || body !== undefined || multipart !== undefined ? 'POST' : 'GET')).toUpperCase();
|
|
48
|
+
// PLAN-11 U-5: a multipart request is never cached (its body isn't a string)
|
|
49
|
+
const b = json !== undefined ? JSON.stringify(json) : multipart !== undefined ? multipart : body ?? '';
|
|
49
50
|
if (q.cache === false || typeof b != 'string' || typeof parse == 'function' || parse == 'response' ||
|
|
50
51
|
!(q.cache || q.staleTime != null || (res && IDEMPOTENT.test(m)))) return;
|
|
51
52
|
return [m, withQuery(q.url ?? '', query, 1), b, parse].join(' ').trim();
|
|
@@ -2,6 +2,7 @@ import xs, {Stream} from 'xstream';
|
|
|
2
2
|
import {senderOf, keepSender, allowed, makeReplies} from './replies';
|
|
3
3
|
import {error as logError} from './diagnostics/legacy';
|
|
4
4
|
import {backoff} from './backoff';
|
|
5
|
+
import {FetchEventSource} from './fetchEventSource';
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* makeSocketDriver(options?) (PLAN-3 §1.3): WebSocket and server-sent events, declared per
|
|
@@ -11,7 +12,7 @@ import {backoff} from './backoff';
|
|
|
11
12
|
* - Sink values:
|
|
12
13
|
* - `{ connections: { [name]: spec | falsy } }`: the sender's whole set of connections. Diffed
|
|
13
14
|
* per (sender, name): a new name opens, a removed or falsy one closes, a changed URL /
|
|
14
|
-
* protocols / withCredentials / share closes and reopens, anything else (action names,
|
|
15
|
+
* protocols / withCredentials / headers / share closes and reopens, anything else (action names,
|
|
15
16
|
* reconnect) only rebinds.
|
|
16
17
|
* - `{ to: name, json? | text? | binary? }`: send on the sender's connection `name` (json is
|
|
17
18
|
* stringified). Queued while (re)connecting, bounded (`queueLimit`, oldest dropped). A send
|
|
@@ -36,6 +37,16 @@ import {backoff} from './backoff';
|
|
|
36
37
|
* - A disposed sender's connections close (and their retries stop); app dispose closes all.
|
|
37
38
|
* - No WebSocket / EventSource (SSR): nothing opens, nothing is reported.
|
|
38
39
|
* - `WebSocket` / `EventSource` are read at connect time: options.WebSocket ?? globalThis.WebSocket.
|
|
40
|
+
* - PLAN-11 E-1: an `sse` connection with `headers` (data: a token in it comes from state, and a
|
|
41
|
+
* changed header reconnects) is read with FetchEventSource (fetch, `options.fetch` if given),
|
|
42
|
+
* since EventSource can't send headers. An `options.EventSource` (a polyfill, the test fake)
|
|
43
|
+
* still wins and gets the headers in its init.
|
|
44
|
+
* - PLAN-13 S-1: SSE `meta: true`: message and `events` actions get `{ data, id, event }`. `resume:
|
|
45
|
+
* true | 'param'`: the transport keeps the last event id, and a reconnect the driver makes (after
|
|
46
|
+
* EventSource gave up) asks to resume from it: `?lastEventId=` (or `?param=`) on the URL, or
|
|
47
|
+
* `Last-Event-ID` on the fetch path (`init.lastEventId`). `staleAfterMs` (SSE and WebSocket): no
|
|
48
|
+
* event for that long (over fetch: no bytes, heartbeat comments count) closes the connection as a
|
|
49
|
+
* drop (`close` gets `stale: true`), so the reconnect policy applies.
|
|
39
50
|
*/
|
|
40
51
|
|
|
41
52
|
const SCOPE = '__sygnalScope';
|
|
@@ -91,19 +102,40 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
91
102
|
const out = (t: any, data: any, e: any) => {
|
|
92
103
|
try { t.s.send(data); } catch (error) { deliver(e, 'error', {error}); }
|
|
93
104
|
};
|
|
105
|
+
// S-1: an SSE event as a subscriber gets it (`meta`: with its id and type); the transport
|
|
106
|
+
// keeps the last id (resume) and counts it as life (staleAfterMs)
|
|
107
|
+
const got = (t: any, m: any, e: any) => e.sse && e.spec.meta ? {data: parse(m.data), id: m.lastEventId ?? '', event: m.type || 'message'} : parse(m.data);
|
|
108
|
+
const seen = (t: any, m: any) => { if (m.lastEventId) t.id = m.lastEventId; alive(t); };
|
|
94
109
|
// SSE named events: one listener per name across the subscribers
|
|
95
110
|
const listen = (t: any) => t.s && t.sse && each(t, e => Object.keys(e.spec.events || {}).forEach(name => {
|
|
96
111
|
if (t.on.has(name)) return;
|
|
97
112
|
t.on.add(name);
|
|
98
113
|
const s = t.s;
|
|
99
|
-
s.addEventListener(name, (m: any) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
s.addEventListener(name, (m: any) => {
|
|
115
|
+
if (t.s !== s) return;
|
|
116
|
+
seen(t, m);
|
|
117
|
+
each(t, x => {
|
|
118
|
+
const a = x.spec.events?.[name];
|
|
119
|
+
if (typeof a == 'string' && x.sender !== undefined) reply(x.sender, a, got(t, m, x));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
103
122
|
}));
|
|
123
|
+
// S-1: staleAfterMs (the smallest of the subscribers'): re-armed by every event; when it fires
|
|
124
|
+
// the connection is dropped (as if the server had gone) and the reconnect policy applies
|
|
125
|
+
const alive = (t: any) => {
|
|
126
|
+
clearTimeout(t.st);
|
|
127
|
+
const ms = Math.min(...[...t.subs].map(e => e.spec.staleAfterMs).filter(x => x > 0));
|
|
128
|
+
const s = t.s;
|
|
129
|
+
if (s && ms < 1 / 0) t.st = setTimeout(() => {
|
|
130
|
+
if (t.s !== s) return;
|
|
131
|
+
try { s.close(); } catch (_) {}
|
|
132
|
+
gone(t, t.sse ? {stale: true} : {code: 1006, reason: 'stale', stale: true});
|
|
133
|
+
}, ms);
|
|
134
|
+
};
|
|
104
135
|
|
|
105
136
|
// the socket closed (or failed to open) without the driver closing it
|
|
106
137
|
const gone = (t: any, info: any) => {
|
|
138
|
+
clearTimeout(t.st);
|
|
107
139
|
t.s = null;
|
|
108
140
|
t.open = false;
|
|
109
141
|
let again: any;
|
|
@@ -118,11 +150,22 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
118
150
|
|
|
119
151
|
const connect = (t: any) => {
|
|
120
152
|
t.timer = 0;
|
|
121
|
-
const
|
|
153
|
+
const hdr = t.sse && t.spec.headers && typeof t.spec.headers == 'object' ? t.spec.headers : undefined;
|
|
154
|
+
const C = t.sse ? options.EventSource ?? (hdr && typeof g.fetch == 'function' ? FetchEventSource : g.EventSource) : options.WebSocket ?? g.WebSocket;
|
|
122
155
|
if (typeof C != 'function') return;
|
|
156
|
+
// S-1: resume from the last event id: Last-Event-ID on the fetch path (init.lastEventId, which
|
|
157
|
+
// a polyfill or the test fake gets too), else a URL parameter
|
|
158
|
+
// (a parameter the URL already has is replaced, not added again)
|
|
159
|
+
const r = t.sse && t.id && [...t.subs].map(e => e.spec.resume).find(x => x);
|
|
160
|
+
let url = t.url;
|
|
161
|
+
if (r && !hdr) {
|
|
162
|
+
const p = encodeURIComponent(r === true ? 'lastEventId' : r) + '=', v = p + encodeURIComponent(t.id);
|
|
163
|
+
const re = new RegExp('([?&])' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^&#]*');
|
|
164
|
+
url = re.test(url) ? url.replace(re, '$1' + v) : url + (url.includes('?') ? '&' : '?') + v;
|
|
165
|
+
}
|
|
123
166
|
let s: any;
|
|
124
167
|
try {
|
|
125
|
-
s = t.sse ? new C(
|
|
168
|
+
s = t.sse ? new C(url, {withCredentials: !!t.spec.withCredentials, ...(hdr && {headers: hdr, fetch: options.fetch}), ...(r && {lastEventId: t.id})}) : new C(t.url, t.spec.protocols);
|
|
126
169
|
} catch (error) {
|
|
127
170
|
// a URL the constructor rejects never opens: no retry
|
|
128
171
|
each(t, e => deliver(e, 'error', {error}));
|
|
@@ -135,12 +178,19 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
135
178
|
s.onopen = mine(() => {
|
|
136
179
|
t.n = 0;
|
|
137
180
|
t.open = true;
|
|
181
|
+
alive(t);
|
|
138
182
|
each(t, e => { deliver(e, 'open', {reconnected: !!e.opened}); e.opened = true; });
|
|
139
183
|
const q = t.queue;
|
|
140
184
|
t.queue = [];
|
|
141
185
|
q.forEach((x: any) => t.s === s && out(t, x.data, x.e));
|
|
142
186
|
});
|
|
143
|
-
s.onmessage = mine((m: any) => {
|
|
187
|
+
s.onmessage = mine((m: any) => {
|
|
188
|
+
if (t.sse) seen(t, m); else alive(t);
|
|
189
|
+
const d = parse(m.data);
|
|
190
|
+
each(t, e => deliver(e, 'message', e.spec.meta && t.sse ? got(t, m, e) : d));
|
|
191
|
+
});
|
|
192
|
+
// S-1: FetchEventSource's bytes (heartbeat comments) count as life
|
|
193
|
+
s.onbeat = mine(() => alive(t));
|
|
144
194
|
s.onerror = mine((error: any) => {
|
|
145
195
|
each(t, e => deliver(e, 'error', {error}));
|
|
146
196
|
if (t.sse) {
|
|
@@ -156,6 +206,7 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
156
206
|
// closes the driver makes: no `close` action, late events ignored, retry cancelled
|
|
157
207
|
const end = (t: any) => {
|
|
158
208
|
clearTimeout(t.timer);
|
|
209
|
+
clearTimeout(t.st);
|
|
159
210
|
transports.delete(t.key);
|
|
160
211
|
const s = t.s;
|
|
161
212
|
t.s = null;
|
|
@@ -201,7 +252,7 @@ export function makeSocketDriver(options: any = {}) {
|
|
|
201
252
|
const sse = typeof spec.sse == 'string';
|
|
202
253
|
const url = resolve(sse ? spec.sse : spec.socket, sse);
|
|
203
254
|
// the connection's identity: a change reconnects, anything else only rebinds
|
|
204
|
-
const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials : 's' + JSON.stringify(spec.protocols)) + url;
|
|
255
|
+
const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials + (spec.headers ? JSON.stringify(spec.headers) : '') : 's' + JSON.stringify(spec.protocols)) + url;
|
|
205
256
|
if (old && old.key === key) {
|
|
206
257
|
old.spec = spec;
|
|
207
258
|
if (old.t) listen(old.t);
|
package/src/extra/ssr.ts
CHANGED
|
@@ -147,7 +147,7 @@ function withUses(def: any, state: any): any {
|
|
|
147
147
|
* state reads from the cache ('success' with the cached data), else 'loading' ('idle' for a
|
|
148
148
|
* falsy request), as the client's first paint does with the same (hydrated) cache
|
|
149
149
|
*/
|
|
150
|
-
function withResources(def: any, state: any): any {
|
|
150
|
+
function withResources(def: any, state: any, ctx?: any): any {
|
|
151
151
|
state = withUses(def, state)
|
|
152
152
|
const res = def && def.resources
|
|
153
153
|
if (!res || !state || typeof state !== 'object' || Array.isArray(state)) return state
|
|
@@ -155,7 +155,7 @@ function withResources(def: any, state: any): any {
|
|
|
155
155
|
for (const n of Object.keys(res)) {
|
|
156
156
|
if (out[n] !== undefined) continue
|
|
157
157
|
let q: any
|
|
158
|
-
try { q = typeof res[n] === 'function' ? res[n](state) : res[n] } catch (_) {}
|
|
158
|
+
try { q = typeof res[n] === 'function' ? res[n](state, ctx) : res[n] } catch (_) {}
|
|
159
159
|
const E = q && ssrCache ? ssrCache.get(ssrCache.key(q, 1)) : undefined
|
|
160
160
|
out[n] = E && E.at > 0 ? {status: 'success', data: E.v} : {status: q ? 'loading' : 'idle'}
|
|
161
161
|
}
|
|
@@ -198,10 +198,26 @@ function viewFailed(def: any, err: any, name: string): any {
|
|
|
198
198
|
return vnode
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
function collectHead(def: any, state: any): void {
|
|
201
|
+
function collectHead(def: any, state: any, ctx?: any): void {
|
|
202
202
|
const h = def && def.head
|
|
203
203
|
if (!heads || !h) return
|
|
204
|
-
try { heads.push(typeof h === 'function' ? h(state) : h) } catch (_) {}
|
|
204
|
+
try { heads.push(typeof h === 'function' ? h(state, ctx) : h) } catch (_) {}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* PLAN-13 F-1: the context a component sees (its parent's, plus its own entries computed on
|
|
209
|
+
* `state`). The statics get it as on the client; for `resources` it is computed on the state
|
|
210
|
+
* before the resources are filled in (a context entry can't read the component's own resource)
|
|
211
|
+
*/
|
|
212
|
+
function contextOf(def: any, state: any, parent: Record<string, any>): Record<string, any> {
|
|
213
|
+
const own = def.context || {}, out: Record<string, any> = {...parent}
|
|
214
|
+
for (const key of Object.keys(own)) {
|
|
215
|
+
const f = own[key]
|
|
216
|
+
if (typeof f === 'function' && state != null) {
|
|
217
|
+
try { out[key] = f(state) } catch (_) {}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
/**
|
|
@@ -245,20 +261,11 @@ function renderRoot(componentDef: any, options: RenderToStringOptions): string {
|
|
|
245
261
|
|
|
246
262
|
const ownState = state !== undefined ? state : componentDef.initialState
|
|
247
263
|
// 5-5: the view sees its resources; the hydration script keeps the state as it was given
|
|
248
|
-
const resolvedState = withResources(componentDef, ownState)
|
|
249
|
-
collectHead(componentDef, resolvedState)
|
|
264
|
+
const resolvedState = withResources(componentDef, ownState, contextOf(componentDef, ownState, context))
|
|
250
265
|
|
|
251
266
|
// Build context: merge parent context with component's own context definitions
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
for (const key of Object.keys(componentContext)) {
|
|
255
|
-
const contextFn = componentContext[key]
|
|
256
|
-
if (typeof contextFn === 'function' && resolvedState != null) {
|
|
257
|
-
try {
|
|
258
|
-
mergedContext[key] = contextFn(resolvedState)
|
|
259
|
-
} catch (_) {}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
267
|
+
const mergedContext = contextOf(componentDef, resolvedState, context)
|
|
268
|
+
collectHead(componentDef, resolvedState, mergedContext)
|
|
262
269
|
|
|
263
270
|
// Call the view function
|
|
264
271
|
let vnode: any
|
|
@@ -474,19 +481,10 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
|
|
|
474
481
|
// State passed directly
|
|
475
482
|
childState = stateProp
|
|
476
483
|
}
|
|
477
|
-
childState = withResources(componentDef, childState)
|
|
484
|
+
childState = withResources(componentDef, childState, contextOf(componentDef, childState, context))
|
|
478
485
|
|
|
479
486
|
// Build child context
|
|
480
|
-
const childContext
|
|
481
|
-
const componentContext = componentDef.context || {}
|
|
482
|
-
for (const key of Object.keys(componentContext)) {
|
|
483
|
-
const contextFn = componentContext[key]
|
|
484
|
-
if (typeof contextFn === 'function' && childState != null) {
|
|
485
|
-
try {
|
|
486
|
-
childContext[key] = contextFn(childState)
|
|
487
|
-
} catch (_) {}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
487
|
+
const childContext = contextOf(componentDef, childState, context)
|
|
490
488
|
|
|
491
489
|
// Extract slots from children
|
|
492
490
|
const slots: Record<string, any[]> = {}
|
|
@@ -507,7 +505,7 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
|
|
|
507
505
|
slots.default.push(...defaultSlotChildren)
|
|
508
506
|
}
|
|
509
507
|
|
|
510
|
-
collectHead(componentDef, childState)
|
|
508
|
+
collectHead(componentDef, childState, childContext)
|
|
511
509
|
|
|
512
510
|
// Call the view function
|
|
513
511
|
let result: any
|
|
@@ -713,20 +711,11 @@ function renderSwitchable(vnode: any, context: Record<string, any>, parentState:
|
|
|
713
711
|
* Internal helper: render a component def to a VNode (not HTML string).
|
|
714
712
|
*/
|
|
715
713
|
function renderToStringInternal(componentDef: any, state: any, context: Record<string, any>, uid: string): any {
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
const mergedContext: Record<string, any> = {...context}
|
|
720
|
-
for (const key of Object.keys(componentContext)) {
|
|
721
|
-
const contextFn = componentContext[key]
|
|
722
|
-
if (typeof contextFn === 'function' && resolvedState != null) {
|
|
723
|
-
try {
|
|
724
|
-
mergedContext[key] = contextFn(resolvedState)
|
|
725
|
-
} catch (_) {}
|
|
726
|
-
}
|
|
727
|
-
}
|
|
714
|
+
const own = state !== undefined ? state : componentDef.initialState
|
|
715
|
+
const resolvedState = withResources(componentDef, own, contextOf(componentDef, own, context))
|
|
716
|
+
const mergedContext = contextOf(componentDef, resolvedState, context)
|
|
728
717
|
|
|
729
|
-
collectHead(componentDef, resolvedState)
|
|
718
|
+
collectHead(componentDef, resolvedState, mergedContext)
|
|
730
719
|
let vnode: any
|
|
731
720
|
try {
|
|
732
721
|
vnode = componentDef({
|
package/src/extra/testing.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {enrichEventStream} from '../cycle/dom/enrichEventStream';
|
|
|
5
5
|
import eventBusDriver from './eventDriver';
|
|
6
6
|
import logDriver from './logDriver';
|
|
7
7
|
import {ownedCopy} from './owned';
|
|
8
|
+
import {isAbort} from '../shared';
|
|
8
9
|
import {renderToInnerHtml} from './ssr';
|
|
9
10
|
import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, report} from './diagnostics/index';
|
|
10
11
|
import xs from './xstreamCompat';
|
|
@@ -252,6 +253,13 @@ export interface RenderOptions {
|
|
|
252
253
|
* Default: unchanged (off unless configureStrict(true) was called).
|
|
253
254
|
*/
|
|
254
255
|
strict?: boolean;
|
|
256
|
+
/**
|
|
257
|
+
* 7.0 timing audit (SYG257, info) while the component is rendered; restored on dispose():
|
|
258
|
+
* each output sink that reads a top-level state field its action's STATE changed is reported
|
|
259
|
+
* (it gets the new value since 7.0; 6.x gave it the old one). Requires 'sygnal/diagnostics'.
|
|
260
|
+
* Default: unchanged (off unless configureTimingAudit(true) was called).
|
|
261
|
+
*/
|
|
262
|
+
timingAudit?: boolean;
|
|
255
263
|
/**
|
|
256
264
|
* settle()'s quiet window in ms (default 20): settle() resolves once nothing in the tree has
|
|
257
265
|
* rendered, reduced or changed state for this long. A model `next('X', data, ms)` with a
|
|
@@ -454,6 +462,11 @@ export interface RenderResult {
|
|
|
454
462
|
* `status`/`body`), or an Error / message (a network failure: the fetch rejects with it).
|
|
455
463
|
*/
|
|
456
464
|
fail: (sinkName: string, error: any, target?: FakeReplyTarget) => Promise<void>;
|
|
465
|
+
/**
|
|
466
|
+
* PLAN-11 U-2: one progress step `{ loaded, total?, phase? }` of a pending request sent with
|
|
467
|
+
* `progress` (chosen as in respond()); it stays pending until respond()/fail().
|
|
468
|
+
*/
|
|
469
|
+
progress: (sinkName: string, step: {loaded: number; total?: number; phase?: 'upload' | 'download'}, target?: FakeReplyTarget) => Promise<void>;
|
|
457
470
|
/**
|
|
458
471
|
* PLAN-6 L-4: stream chunks into a pending request of the LLM fake (the sink named by `llmSink`,
|
|
459
472
|
* default 'LLM': the real chat driver over an in-memory transport). Chunks: strings (text),
|
|
@@ -484,9 +497,10 @@ export interface RenderResult {
|
|
|
484
497
|
open: (sinkName: string, target?: FakeConnectionTarget) => Promise<void>;
|
|
485
498
|
/**
|
|
486
499
|
* PLAN-3 2-C: the server sends `data` (objects as JSON text) on the open connection(s): `message`
|
|
487
|
-
* fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event
|
|
500
|
+
* fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event;
|
|
501
|
+
* `{ id }` (PLAN-13 S-1): its SSE event id (`meta`, `resume`).
|
|
488
502
|
*/
|
|
489
|
-
push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; connection?: FakeConnectionTarget}) => Promise<void>;
|
|
503
|
+
push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; id?: string | number; connection?: FakeConnectionTarget}) => Promise<void>;
|
|
490
504
|
/**
|
|
491
505
|
* PLAN-3 2-C: the connection(s) close without the app closing them (a connecting one fails to
|
|
492
506
|
* open: `error` first): `close` fires with `{ code, reason, willReconnect }` and the fake
|
|
@@ -550,9 +564,9 @@ export interface RenderResult {
|
|
|
550
564
|
* render (await t.ready() first). 4-A1: after `await` of any wait, it shows the state the wait
|
|
551
565
|
* resolved with (a later render is held back until the next macrotask).
|
|
552
566
|
*/
|
|
553
|
-
query: (selector: string) =>
|
|
567
|
+
query: <E extends Element = Element>(selector: string) => E | null;
|
|
554
568
|
/** Every element matching a selector in the rendered tree (Portals included), as query() */
|
|
555
|
-
queryAll: (selector: string) =>
|
|
569
|
+
queryAll: <E extends Element = Element>(selector: string) => E[];
|
|
556
570
|
/**
|
|
557
571
|
* PLAN-5 W-1: a widget's host by selector or control: `.props` (what the view passed it),
|
|
558
572
|
* `.instance` (`dom: 'real'`: what mount returned) and `.dispatch(name, detail)` (the event
|
|
@@ -1197,6 +1211,13 @@ const validMs = (v: any) => typeof v == 'number' && Number.isFinite(v) && v >= 0
|
|
|
1197
1211
|
// too, so its time is the clock's (Date may be left real by `toFake`), and its waits drive it.
|
|
1198
1212
|
const fakeClock = (): any => (setTimeout as any).clock;
|
|
1199
1213
|
const clockNow = (): number => { const c = fakeClock(); return c ? c.now : Date.now(); };
|
|
1214
|
+
/** PLAN-11 Q-3: ms until the next pending fake timer (0: none, or real timers) */
|
|
1215
|
+
const nextFakeTimer = (): number => {
|
|
1216
|
+
const c = fakeClock();
|
|
1217
|
+
let due = Infinity;
|
|
1218
|
+
try { Object.values(c?.timers || {}).forEach((t: any) => { if (t && t.callAt > c.now && t.callAt < due) due = t.callAt; }); } catch (_) {}
|
|
1219
|
+
return due < Infinity ? due - c.now : 0;
|
|
1220
|
+
};
|
|
1200
1221
|
/**
|
|
1201
1222
|
* Under fake timers, advance the clock timer by timer (nextAsync flushes promises around each
|
|
1202
1223
|
* one) until `p` settles. Every pending wait has a timer (its timeout, a quiet-window tick), so
|
|
@@ -1254,17 +1275,27 @@ const sockKey = (sse: boolean, url: string, arg: any) => (sse ? 'e' + !!(arg &&
|
|
|
1254
1275
|
* PLAN-3 2-C: the in-memory WebSocket / EventSource the fake's makeSocketDriver opens. The
|
|
1255
1276
|
* harness drives it (t.open / t.push / t.drop); the driver's own close() is the app's close.
|
|
1256
1277
|
*/
|
|
1278
|
+
/** `url` without its query parameter `name` (PLAN-13 S-1: a resumed reconnect's id parameter) */
|
|
1279
|
+
const withoutParam = (url: string, name: string) =>
|
|
1280
|
+
url.replace(new RegExp('([?&])' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=[^&#]*&?'), '$1').replace(/[?&](#|$)/, '$1');
|
|
1257
1281
|
const fakeSocketClass = (sse: boolean, made: (s: any) => void) => class {
|
|
1258
1282
|
readyState = 0;
|
|
1259
1283
|
sse = sse;
|
|
1260
1284
|
byApp = false;
|
|
1261
1285
|
k: string;
|
|
1286
|
+
rn?: string;
|
|
1262
1287
|
url: string;
|
|
1263
1288
|
ls: Record<string, any[]> = {};
|
|
1264
1289
|
onopen: any; onmessage: any; onerror: any; onclose: any;
|
|
1265
1290
|
constructor(url: string, arg?: any) {
|
|
1266
1291
|
this.url = String(url);
|
|
1267
|
-
|
|
1292
|
+
// PLAN-13 S-1: a resumed SSE reconnect's URL has the id parameter (added, or replacing the
|
|
1293
|
+
// declared one): its key leaves that parameter out (`rn`: its name; made() compares the
|
|
1294
|
+
// connections' URLs without it too)
|
|
1295
|
+
const id = sse && arg?.lastEventId;
|
|
1296
|
+
const m = id && new RegExp('[?&]([^?&=]+)=' + encodeURIComponent(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=&|#|$)').exec(this.url);
|
|
1297
|
+
if (m) this.rn = m[1];
|
|
1298
|
+
this.k = sockKey(sse, m ? withoutParam(this.url, m[1]) : this.url, arg);
|
|
1268
1299
|
made(this);
|
|
1269
1300
|
}
|
|
1270
1301
|
send() {}
|
|
@@ -1369,6 +1400,7 @@ const headFake = () => {
|
|
|
1369
1400
|
};
|
|
1370
1401
|
let savedConfig: ReturnType<typeof _getDiagnosticsConfig>;
|
|
1371
1402
|
let savedStrict: any;
|
|
1403
|
+
let savedAudit: any;
|
|
1372
1404
|
|
|
1373
1405
|
/*
|
|
1374
1406
|
* PLAN-4 GS-2, mock DOM: could the element a command targets have this method? The core runs any
|
|
@@ -1468,7 +1500,7 @@ export function renderComponent(
|
|
|
1468
1500
|
componentDef: any,
|
|
1469
1501
|
options: RenderOptions = {}
|
|
1470
1502
|
): RenderResult {
|
|
1471
|
-
const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
|
|
1503
|
+
const {initialState, mockConfig = {}, drivers = {}, diagnostics, strict, timingAudit, dom = 'mock', autoConnect = true, socketSink = 'WS', resourceSink = 'HTTP', llmSink = 'LLM', http: httpOptions, context: ancestors} = options;
|
|
1472
1504
|
const {intent, model = {}} = componentDef;
|
|
1473
1505
|
// E4: real DOM mode
|
|
1474
1506
|
const real = dom == 'real';
|
|
@@ -1511,6 +1543,7 @@ export function renderComponent(
|
|
|
1511
1543
|
if (!active++) {
|
|
1512
1544
|
savedConfig = _getDiagnosticsConfig();
|
|
1513
1545
|
savedStrict = core.strict;
|
|
1546
|
+
savedAudit = core.timingAudit;
|
|
1514
1547
|
// G-051: forget the checks' report dedupe (once()), so a finding from an earlier test is
|
|
1515
1548
|
// reported again. Only that set: resetChecks() would also drop inspect()'s records of
|
|
1516
1549
|
// other live instances / apps.
|
|
@@ -1518,6 +1551,8 @@ export function renderComponent(
|
|
|
1518
1551
|
}
|
|
1519
1552
|
configureDiagnostics({mode: diagnostics || (prevMode == 'off' ? 'collect' : prevMode)});
|
|
1520
1553
|
if (strict !== undefined) core.strict = strict;
|
|
1554
|
+
// 7.0 timing audit (SYG257, 'sygnal/diagnostics'): same bridge flag as configureTimingAudit()
|
|
1555
|
+
if (timingAudit !== undefined) core.timingAudit = timingAudit;
|
|
1521
1556
|
// PLAN-4 GS-2: element commands run on the real DOM (jsdom gets the missing methods), and their
|
|
1522
1557
|
// SYG640/SYG641 are reported without the dev entry too
|
|
1523
1558
|
if (real && !domFakes) domFakes = fakeElementMethods(document.defaultView || globalThis);
|
|
@@ -1683,6 +1718,7 @@ export function renderComponent(
|
|
|
1683
1718
|
if (!--active) {
|
|
1684
1719
|
configureDiagnostics(savedConfig);
|
|
1685
1720
|
core.strict = savedStrict;
|
|
1721
|
+
core.timingAudit = savedAudit;
|
|
1686
1722
|
domFakes?.();
|
|
1687
1723
|
domFakes = undefined;
|
|
1688
1724
|
}
|
|
@@ -1716,7 +1752,8 @@ export function renderComponent(
|
|
|
1716
1752
|
// value: what t.requests lists (normalised); req: the request the driver got; res: the resource;
|
|
1717
1753
|
// pf: a { prefetch } fetch (5-5: answered into the cache, no reply)
|
|
1718
1754
|
// llm: the in-memory stream handle of an LLM fake request (PLAN-6 L-4)
|
|
1719
|
-
|
|
1755
|
+
// step: PLAN-11 U-2, the driver's progress callback of a request sent with `progress` (t.progress)
|
|
1756
|
+
type Pending = {value: any; req: any; category: any; res?: string; pf?: any; live: boolean; settle: (ok: boolean, v: any) => void; llm?: any; step?: (phase: string, loaded: number, total: number) => void};
|
|
1720
1757
|
type Fake = {select: any; errors: any; subs: Set<FakeSub>; at: (ns: any[]) => any; pending: Pending[]; in$: any; http: any; ws: Sock; llm?: any};
|
|
1721
1758
|
const fakes = new Map<string, Fake>();
|
|
1722
1759
|
// 5-3: the fake drivers' focus / online listeners (t.focus, t.online)
|
|
@@ -1749,14 +1786,22 @@ export function renderComponent(
|
|
|
1749
1786
|
const lin$ = xs.create();
|
|
1750
1787
|
llm = {in$: lin$, src: makeChatDriver({transport: tr, _frame: (f: () => void) => { frames.push(f); }})(lin$)};
|
|
1751
1788
|
}
|
|
1789
|
+
let memFetch: any;
|
|
1752
1790
|
const http = makeFetchDriver({
|
|
1753
1791
|
...httpOptions,
|
|
1792
|
+
// PLAN-11 U-2: a progress request is answered like any other (t.respond / t.fail); its
|
|
1793
|
+
// pending entry keeps the driver's progress callback for t.progress
|
|
1794
|
+
_progress: (url: string, init: any, step: any) => {
|
|
1795
|
+
const r = memFetch(url, init);
|
|
1796
|
+
pending[pending.length - 1].step = step;
|
|
1797
|
+
return r;
|
|
1798
|
+
},
|
|
1754
1799
|
// 5-3: focus / online come from t.focus() / t.online() only
|
|
1755
1800
|
_on: (f: any) => { signals.add(f); return () => signals.delete(f); },
|
|
1756
1801
|
_tap: (req: any, res?: string, pf?: any) => { tapped = [req, res, pf]; },
|
|
1757
1802
|
// D472: a batch / split request (a large decide()) is one fetch here, answered as one
|
|
1758
1803
|
_whole: true,
|
|
1759
|
-
fetch: (url: string, init: any) => new Promise((resolve, reject) => {
|
|
1804
|
+
fetch: memFetch = (url: string, init: any) => new Promise((resolve, reject) => {
|
|
1760
1805
|
const [req, res, pf] = tapped || [{url}];
|
|
1761
1806
|
tapped = undefined;
|
|
1762
1807
|
// G-171(1): a resource fetch is listed as { url, ...request, resource: name }; 5-5: a
|
|
@@ -1788,6 +1833,7 @@ export function renderComponent(
|
|
|
1788
1833
|
return {
|
|
1789
1834
|
select: (sel?: any) => xs.merge(hs.select(sel), own(false)(sel), sock.select(sel)),
|
|
1790
1835
|
errors: (sel?: any) => xs.merge(hs.errors(sel), own(true)(sel)),
|
|
1836
|
+
progress: (sel?: any) => hs.progress(sel),
|
|
1791
1837
|
subs, at, pending, in$, http, ws, llm,
|
|
1792
1838
|
// 5-3: the driver's matcher / inspection (the dev checks' SYG632, t.cache)
|
|
1793
1839
|
__matches: http.__matches, __inspect: http.__inspect,
|
|
@@ -1817,7 +1863,7 @@ export function renderComponent(
|
|
|
1817
1863
|
const w: Sock = {src: null, in$: xs.create(), conns: new Map(), sockets: [], sent: [], declaring: null};
|
|
1818
1864
|
const made = (s: any) => {
|
|
1819
1865
|
w.sockets.push(s);
|
|
1820
|
-
const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => c.k == s.k);
|
|
1866
|
+
const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => (s.rn ? sockKey(true, withoutParam(c.url, s.rn), c.spec) : c.k) == s.k);
|
|
1821
1867
|
if (w.declaring) {
|
|
1822
1868
|
// a declaration opened it: the first new connection with its key (and, shared, the
|
|
1823
1869
|
// other new shared ones)
|
|
@@ -1918,7 +1964,7 @@ export function renderComponent(
|
|
|
1918
1964
|
for (const k in uses) for (const a in uses[k]?.model || {}) names.push(k + '.' + a);
|
|
1919
1965
|
// G-275: the caller's initialState is owned (the dev statics freeze leaves it alone). G-289: a
|
|
1920
1966
|
// copy is marked, not the caller's object
|
|
1921
|
-
|
|
1967
|
+
let init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
|
|
1922
1968
|
// G-028: with no intent, model or initialState nothing would ever emit state, so the view
|
|
1923
1969
|
// never renders. Leave intent/model unset so the component falls back to the same no-op
|
|
1924
1970
|
// model run() uses, and renders.
|
|
@@ -2179,6 +2225,17 @@ export function renderComponent(
|
|
|
2179
2225
|
},
|
|
2180
2226
|
};
|
|
2181
2227
|
};
|
|
2228
|
+
// PLAN-13 F-2: a root that declares `route` starts from the test URL's route: its initialState's
|
|
2229
|
+
// router.current() ran at module load, before the test's URL existed. The route entry's STATE
|
|
2230
|
+
// reducer is applied to the initial state (its other sinks, a guard's redirect, run once when the
|
|
2231
|
+
// router answers the declaration), so the first render and the statics see the page the test
|
|
2232
|
+
// starts on: no render of '/' and no request for it
|
|
2233
|
+
const re = rt && typeof componentDef.route == 'string' && model[componentDef.route]
|
|
2234
|
+
const seed = typeof re == 'function' ? re : re && typeof re == 'object' && typeof re.STATE == 'function' && re.STATE
|
|
2235
|
+
if (seed && init !== undefined) {
|
|
2236
|
+
const v = seed(init, rt!.r.current(), noop, {state: init, previousState: init})
|
|
2237
|
+
if (v !== undefined && !isAbort(v)) init = v
|
|
2238
|
+
}
|
|
2182
2239
|
let sources: any, sinks: any;
|
|
2183
2240
|
try {
|
|
2184
2241
|
// the root runs with the test intent, model, initial state and name, and the drivers above.
|
|
@@ -2473,7 +2530,7 @@ export function renderComponent(
|
|
|
2473
2530
|
if (tg === undefined) return {match: cat, desc: catDesc, o};
|
|
2474
2531
|
if (typeof tg == 'string') {
|
|
2475
2532
|
// an ok/error action name, key, category, resource name or URL
|
|
2476
|
-
return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
|
|
2533
|
+
return {match: r => { const v = r.value || {}; return cat(r) && [v.ok, v.error, v.progress, v.key, v.category, r.res, v.url].includes(tg); }, desc: ` matching '${tg}'${catDesc}`, o};
|
|
2477
2534
|
}
|
|
2478
2535
|
if (typeof tg == 'function') {
|
|
2479
2536
|
return {match: r => { try { return cat(r) && !!tg(r.value); } catch (_) { return false; } }, desc: ` matching the predicate${catDesc}`, o};
|
|
@@ -2706,6 +2763,23 @@ export function renderComponent(
|
|
|
2706
2763
|
return {error: err, category, request: e?.req, status: status ?? x?.status, body};
|
|
2707
2764
|
});
|
|
2708
2765
|
|
|
2766
|
+
// PLAN-11 U-2: t.progress. One progress step of a pending request sent with `progress` (its
|
|
2767
|
+
// reply action, or progress() for `progress: true`); the request stays pending. Targets as
|
|
2768
|
+
// t.respond's; the phase defaults to 'upload' for a request with a body, else 'download'
|
|
2769
|
+
const progress = (name: string, step: any, opts?: FakeReplyTarget) => {
|
|
2770
|
+
throwFailure();
|
|
2771
|
+
if (drivers[name]) throw new Error(`[Sygnal] t.progress('${name}'): ${name} has a real driver (passed in drivers), so there is nothing to script`);
|
|
2772
|
+
if (!step || typeof step != 'object' || typeof step.loaded != 'number') throw new Error(`[Sygnal] t.progress('${name}', step): step is { loaded, total?, phase? } (got ${brief(step)})`);
|
|
2773
|
+
const tg = targetOf(opts);
|
|
2774
|
+
const what = `t.progress('${name}'${typeof opts == 'string' ? `, …, '${opts}'` : ''})`;
|
|
2775
|
+
const now = isReady && !inputs.length ? pick(name, tg) : undefined;
|
|
2776
|
+
if (now && !now.step) throw new Error(`[Sygnal] ${what}: the request ${brief(now.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
|
|
2777
|
+
return scripted(() => pick(name, tg), w => noPending(what, name, tg, w), (e: Pending) => {
|
|
2778
|
+
if (!e.step) return new Error(`[Sygnal] ${what}: the request ${brief(e.value)} has no progress: send it with progress: 'UPLOADING' (a reply action) or progress: true`);
|
|
2779
|
+
const r = e.req || {};
|
|
2780
|
+
e.step(step.phase || (r.body !== undefined || r.json !== undefined || r.multipart !== undefined ? 'upload' : 'download'), step.loaded, step.total ?? 0);
|
|
2781
|
+
}, sendDue);
|
|
2782
|
+
};
|
|
2709
2783
|
// PLAN-6 L-4: t.stream. Targets as t.respond's (an action name or key, a partial request, a
|
|
2710
2784
|
// predicate, { request, nth }); `end: false` (the last argument, or in the target options)
|
|
2711
2785
|
// leaves the stream open. Chunks become ChatEvents pushed into the in-memory stream; the fake's
|
|
@@ -2797,11 +2871,13 @@ export function renderComponent(
|
|
|
2797
2871
|
};
|
|
2798
2872
|
const open = (name: string, target?: FakeConnectionTarget) => sockCall('open', name, target, [0], sockOpen);
|
|
2799
2873
|
const push = (name: string, data: any, target?: any) => {
|
|
2800
|
-
const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection') ? target : {connection: target};
|
|
2874
|
+
const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection' || k == 'id') ? target : {connection: target};
|
|
2801
2875
|
const raw = typeof data == 'string' || (data && typeof data == 'object' && (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || (typeof Blob != 'undefined' && data instanceof Blob))) ? data : JSON.stringify(data);
|
|
2802
2876
|
const ev = o.event;
|
|
2803
2877
|
return sockCall('push', name, o.connection, [1], s => {
|
|
2804
|
-
|
|
2878
|
+
// PLAN-13 S-1: `id` sets the event id; as in EventSource, lastEventId keeps the last one
|
|
2879
|
+
if (s.sse && o.id != null) s.lastId = String(o.id);
|
|
2880
|
+
const m: any = {type: ev || 'message', data: raw, ...(s.sse && {lastEventId: s.lastId ?? ''})};
|
|
2805
2881
|
if (!ev || ev == 'message') sockFire(s, 'message', m);
|
|
2806
2882
|
if (s.sse) (s.ls[ev || 'message'] || []).forEach((f: any) => { try { f(m); } catch (e) { console.error(e); } });
|
|
2807
2883
|
});
|
|
@@ -2904,11 +2980,11 @@ export function renderComponent(
|
|
|
2904
2980
|
};
|
|
2905
2981
|
// 6-B (G-185): on the mock DOM, MockElement snapshots of the latest rendered tree
|
|
2906
2982
|
const mockAll = (s: string): any[] => findAll(vtree, parse(norm(s)), [], []).map(c => mockOf(c, htmlOf));
|
|
2907
|
-
const queryAll = (s: string):
|
|
2983
|
+
const queryAll = <E extends Element = Element>(s: string): E[] => {
|
|
2908
2984
|
s = selOf(s);
|
|
2909
2985
|
notYet(`t.queryAll('${s}')`);
|
|
2910
2986
|
if (!real) return mockAll(s);
|
|
2911
|
-
return roots().flatMap(r => Array.from(r.querySelectorAll(s)));
|
|
2987
|
+
return roots().flatMap(r => Array.from(r.querySelectorAll<E>(s)));
|
|
2912
2988
|
};
|
|
2913
2989
|
const queryIn = (s: string): Element | null => {
|
|
2914
2990
|
for (const r of roots()) {
|
|
@@ -2917,11 +2993,11 @@ export function renderComponent(
|
|
|
2917
2993
|
}
|
|
2918
2994
|
return null;
|
|
2919
2995
|
};
|
|
2920
|
-
const query = (s: string):
|
|
2996
|
+
const query = <E extends Element = Element>(s: string): E | null => {
|
|
2921
2997
|
s = selOf(s);
|
|
2922
2998
|
notYet(`t.query('${s}')`);
|
|
2923
2999
|
if (!real) return mockAll(s)[0] ?? null;
|
|
2924
|
-
return queryIn(s);
|
|
3000
|
+
return queryIn(s) as E | null;
|
|
2925
3001
|
};
|
|
2926
3002
|
|
|
2927
3003
|
// PLAN-5 W-1: a widget host (selector or control): the props it was rendered with (the mock
|
|
@@ -3134,7 +3210,12 @@ export function renderComponent(
|
|
|
3134
3210
|
let msg = `${name} timed out after ${timeoutMs}ms.`;
|
|
3135
3211
|
const [why, pending] = explainNext(start);
|
|
3136
3212
|
msg += why;
|
|
3137
|
-
if (
|
|
3213
|
+
if (inputs.length) msg += ` ${inputs.length} simulated input(s) still pending.`;
|
|
3214
|
+
// PLAN-11 Q-3: on the fake clock a state a timer (TIMER driver, periodic, delay) brings
|
|
3215
|
+
// later than the timeout can't arrive in time: name the next pending timer
|
|
3216
|
+
const later = pending ? 0 : nextFakeTimer();
|
|
3217
|
+
if (later) msg += ` A timer is still pending on the fake clock (it fires ${later}ms after this timeout): if the state comes from it, this wait ended first.`;
|
|
3218
|
+
if (pending || later) msg += ` Wait longer: t.${name}(pred, ms), or the renderComponent timeoutMs option (default for every wait).`;
|
|
3138
3219
|
// G-065: a state from before the call matched; next() only looks at new ones
|
|
3139
3220
|
for (let i = from - 1; i >= 0; i--) {
|
|
3140
3221
|
if (test(i)) {
|
|
@@ -3317,6 +3398,7 @@ export function renderComponent(
|
|
|
3317
3398
|
requests,
|
|
3318
3399
|
respond,
|
|
3319
3400
|
fail,
|
|
3401
|
+
progress,
|
|
3320
3402
|
stream,
|
|
3321
3403
|
connections,
|
|
3322
3404
|
cache,
|
package/src/extra/timers.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {makeReplies} from './replies';
|
|
|
13
13
|
* falsy one stops, a changed spec restarts (an equal one keeps running).
|
|
14
14
|
* - `every`: drift-free; tick n is due at start + n * every (each delay is computed from the
|
|
15
15
|
* start, not chained). A late tick (a throttled background tab) coalesces the missed ones:
|
|
16
|
-
* n jumps.
|
|
16
|
+
* n jumps. A delay is at most `every`, so a frozen Date.now() (a test clock that fixes the time
|
|
17
|
+
* while timers run) ticks once per interval. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
|
|
17
18
|
* - `after`: fires once, data `{ t }`. It doesn't fire again while the same spec stays declared.
|
|
18
19
|
* - `frame`: every animation frame (requestAnimationFrame; setTimeout 16 ms without it), data
|
|
19
20
|
* `{ t, dt }`: Date.now() and the ms since the previous frame (0 on the first).
|
|
@@ -47,7 +48,9 @@ const start = (s: any, send: (type: string, data: any) => void, e: any): (() =>
|
|
|
47
48
|
last = ts;
|
|
48
49
|
} else if (every) {
|
|
49
50
|
n = Math.max(n + 1, Math.floor((t - t0) / every));
|
|
50
|
-
|
|
51
|
+
// never more than one interval: under a frozen Date.now() (Playwright's setFixedTime: Date is
|
|
52
|
+
// fixed, timers run) the drift-free delay grew by one interval per tick
|
|
53
|
+
later(Math.min(every, t0 + (n + 1) * every - t));
|
|
51
54
|
send(s.action, {n, t});
|
|
52
55
|
} else {
|
|
53
56
|
e.d = 1;
|