pulse-js-framework 1.7.8 → 1.7.10

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,403 @@
1
+ /**
2
+ * Pulse DevTools - Diagnostics Module
3
+ * @module pulse-js-framework/runtime/devtools/diagnostics
4
+ *
5
+ * Reactive dependency graph inspection, performance monitoring,
6
+ * and pulse/effect tracking.
7
+ */
8
+
9
+ import { pulse, effect, context } from '../pulse.js';
10
+ import { createLogger } from '../logger.js';
11
+
12
+ // Lazy import to avoid circular dependency
13
+ let getSnapshotCountFn = null;
14
+ export function _setSnapshotCountFn(fn) {
15
+ getSnapshotCountFn = fn;
16
+ }
17
+
18
+ const log = createLogger('DevTools');
19
+
20
+ // =============================================================================
21
+ // CONFIGURATION
22
+ // =============================================================================
23
+
24
+ /**
25
+ * Diagnostics configuration
26
+ */
27
+ export const config = {
28
+ enabled: false,
29
+ logUpdates: false,
30
+ logEffects: false,
31
+ warnOnSlowEffects: true,
32
+ slowEffectThreshold: 16 // ms (one frame at 60fps)
33
+ };
34
+
35
+ // =============================================================================
36
+ // REGISTRIES
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Registry of all tracked pulses
41
+ * @type {Map<string, {pulse: Pulse, name: string, createdAt: number}>}
42
+ */
43
+ export const pulseRegistry = new Map();
44
+
45
+ /**
46
+ * Registry of all tracked effects
47
+ * @type {Map<string, {effect: Object, name: string, createdAt: number, runCount: number, totalTime: number}>}
48
+ */
49
+ export const effectRegistry = new Map();
50
+
51
+ let pulseIdCounter = 0;
52
+ let trackedEffectIdCounter = 0;
53
+
54
+ // =============================================================================
55
+ // DIAGNOSTICS API
56
+ // =============================================================================
57
+
58
+ /**
59
+ * @typedef {Object} DiagnosticsStats
60
+ * @property {number} pulseCount - Total number of active pulses
61
+ * @property {number} effectCount - Total number of active effects
62
+ * @property {number} totalEffectRuns - Total effect executions
63
+ * @property {number} avgEffectTime - Average effect execution time (ms)
64
+ * @property {number} pendingEffects - Effects waiting to run
65
+ * @property {number} batchDepth - Current batch nesting depth
66
+ * @property {Object} memoryEstimate - Estimated memory usage
67
+ */
68
+
69
+ /**
70
+ * Get current diagnostics statistics
71
+ * @returns {DiagnosticsStats}
72
+ */
73
+ export function getDiagnostics() {
74
+ let totalRuns = 0;
75
+ let totalTime = 0;
76
+
77
+ for (const entry of effectRegistry.values()) {
78
+ totalRuns += entry.runCount;
79
+ totalTime += entry.totalTime;
80
+ }
81
+
82
+ const snapshotCount = getSnapshotCountFn ? getSnapshotCountFn() : 0;
83
+
84
+ return {
85
+ pulseCount: pulseRegistry.size,
86
+ effectCount: effectRegistry.size,
87
+ totalEffectRuns: totalRuns,
88
+ avgEffectTime: totalRuns > 0 ? totalTime / totalRuns : 0,
89
+ pendingEffects: context.pendingEffects.size,
90
+ batchDepth: context.batchDepth,
91
+ snapshotCount,
92
+ memoryEstimate: {
93
+ pulses: pulseRegistry.size * 100,
94
+ effects: effectRegistry.size * 200,
95
+ history: snapshotCount * 500
96
+ }
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Get detailed effect statistics
102
+ * @returns {Array<{id: string, name: string, runCount: number, avgTime: number, lastRun: number}>}
103
+ */
104
+ export function getEffectStats() {
105
+ return [...effectRegistry.entries()].map(([id, entry]) => ({
106
+ id,
107
+ name: entry.name,
108
+ runCount: entry.runCount,
109
+ avgTime: entry.runCount > 0 ? entry.totalTime / entry.runCount : 0,
110
+ totalTime: entry.totalTime,
111
+ createdAt: entry.createdAt
112
+ }));
113
+ }
114
+
115
+ /**
116
+ * Get list of all tracked pulses
117
+ * @returns {Array<{id: string, name: string, value: any, subscriberCount: number}>}
118
+ */
119
+ export function getPulseList() {
120
+ return [...pulseRegistry.entries()].map(([id, entry]) => ({
121
+ id,
122
+ name: entry.name,
123
+ value: entry.pulse.peek(),
124
+ subscriberCount: entry.pulse._subscribers?.size || 0,
125
+ createdAt: entry.createdAt
126
+ }));
127
+ }
128
+
129
+ // =============================================================================
130
+ // REACTIVE GRAPH INSPECTION
131
+ // =============================================================================
132
+
133
+ /**
134
+ * @typedef {Object} DependencyNode
135
+ * @property {string} id - Node identifier
136
+ * @property {string} type - 'pulse' | 'effect' | 'computed'
137
+ * @property {string} name - Display name
138
+ * @property {any} value - Current value (for pulses)
139
+ * @property {string[]} dependencies - IDs of nodes this depends on
140
+ * @property {string[]} dependents - IDs of nodes that depend on this
141
+ */
142
+
143
+ /**
144
+ * Find pulse ID from pulse instance
145
+ * @private
146
+ */
147
+ function findPulseId(pulseInstance) {
148
+ for (const [id, entry] of pulseRegistry) {
149
+ if (entry.pulse === pulseInstance) {
150
+ return id;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Build the reactive dependency graph
158
+ * @returns {{nodes: DependencyNode[], edges: Array<{from: string, to: string}>}}
159
+ */
160
+ export function getDependencyGraph() {
161
+ const nodes = [];
162
+ const edges = [];
163
+ const nodeMap = new Map();
164
+
165
+ // Add pulse nodes
166
+ for (const [id, entry] of pulseRegistry) {
167
+ const node = {
168
+ id,
169
+ type: 'pulse',
170
+ name: entry.name,
171
+ value: entry.pulse.peek(),
172
+ dependencies: [],
173
+ dependents: []
174
+ };
175
+ nodes.push(node);
176
+ nodeMap.set(id, node);
177
+ }
178
+
179
+ // Add effect nodes and build edges
180
+ for (const [id, entry] of effectRegistry) {
181
+ const node = {
182
+ id,
183
+ type: 'effect',
184
+ name: entry.name,
185
+ value: null,
186
+ dependencies: [],
187
+ dependents: []
188
+ };
189
+ nodes.push(node);
190
+ nodeMap.set(id, node);
191
+
192
+ // Get effect's dependencies
193
+ if (entry.effect?.dependencies) {
194
+ for (const dep of entry.effect.dependencies) {
195
+ const depId = findPulseId(dep);
196
+ if (depId) {
197
+ node.dependencies.push(depId);
198
+ edges.push({ from: depId, to: id });
199
+
200
+ const depNode = nodeMap.get(depId);
201
+ if (depNode) {
202
+ depNode.dependents.push(id);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { nodes, edges };
210
+ }
211
+
212
+ /**
213
+ * Export graph in DOT format for Graphviz visualization
214
+ * @returns {string} DOT format graph
215
+ */
216
+ export function exportGraphAsDot() {
217
+ const { nodes, edges } = getDependencyGraph();
218
+
219
+ let dot = 'digraph ReactiveGraph {\n';
220
+ dot += ' rankdir=LR;\n';
221
+ dot += ' node [shape=box];\n\n';
222
+
223
+ // Add nodes with styling
224
+ for (const node of nodes) {
225
+ const color = node.type === 'pulse' ? 'lightblue' : 'lightgreen';
226
+ const label = `${node.name}\\n${node.type}`;
227
+ dot += ` "${node.id}" [label="${label}" fillcolor="${color}" style="filled"];\n`;
228
+ }
229
+
230
+ dot += '\n';
231
+
232
+ // Add edges
233
+ for (const edge of edges) {
234
+ dot += ` "${edge.from}" -> "${edge.to}";\n`;
235
+ }
236
+
237
+ dot += '}\n';
238
+ return dot;
239
+ }
240
+
241
+ // =============================================================================
242
+ // PULSE & EFFECT TRACKING
243
+ // =============================================================================
244
+
245
+ /**
246
+ * Create a tracked pulse (for dev tools)
247
+ * @param {any} initialValue - Initial value
248
+ * @param {string} [name] - Display name for debugging
249
+ * @param {Object} [options] - Additional options
250
+ * @param {function} [options.onSnapshot] - Callback when snapshot should be taken
251
+ * @returns {Pulse} Tracked pulse
252
+ */
253
+ export function trackedPulse(initialValue, name, options = {}) {
254
+ const p = pulse(initialValue);
255
+ const id = `pulse_${++pulseIdCounter}`;
256
+
257
+ pulseRegistry.set(id, {
258
+ pulse: p,
259
+ name: name || id,
260
+ createdAt: Date.now()
261
+ });
262
+
263
+ // Wrap set to record snapshots
264
+ const originalSet = p.set.bind(p);
265
+ p.set = (value) => {
266
+ const result = originalSet(value);
267
+ if (config.enabled && config.logUpdates) {
268
+ log.info(`${name || id} updated:`, value);
269
+ }
270
+ if (config.enabled && options.onSnapshot) {
271
+ options.onSnapshot(`${name || id} = ${JSON.stringify(value)}`);
272
+ }
273
+ return result;
274
+ };
275
+
276
+ // Add dispose method
277
+ p.dispose = () => {
278
+ pulseRegistry.delete(id);
279
+ };
280
+
281
+ return p;
282
+ }
283
+
284
+ /**
285
+ * Create a tracked effect (for dev tools)
286
+ * @param {function} fn - Effect function
287
+ * @param {string} [name] - Display name for debugging
288
+ * @returns {function} Dispose function
289
+ */
290
+ export function trackedEffect(fn, name) {
291
+ const id = `effect_${++trackedEffectIdCounter}`;
292
+ const startTime = Date.now();
293
+
294
+ const entry = {
295
+ effect: null,
296
+ name: name || id,
297
+ createdAt: startTime,
298
+ runCount: 0,
299
+ totalTime: 0
300
+ };
301
+
302
+ effectRegistry.set(id, entry);
303
+
304
+ const wrappedFn = () => {
305
+ const runStart = performance.now();
306
+
307
+ if (config.enabled && config.logEffects) {
308
+ log.info(`${name || id} running...`);
309
+ }
310
+
311
+ const result = fn();
312
+
313
+ const runTime = performance.now() - runStart;
314
+ entry.runCount++;
315
+ entry.totalTime += runTime;
316
+
317
+ if (config.enabled && config.warnOnSlowEffects && runTime > config.slowEffectThreshold) {
318
+ log.warn(`${name || id} took ${runTime.toFixed(2)}ms (slow)`);
319
+ }
320
+
321
+ return result;
322
+ };
323
+
324
+ const dispose = effect(wrappedFn, { id });
325
+
326
+ // Store reference to effect for graph building
327
+ entry.effect = context.currentEffect;
328
+
329
+ return () => {
330
+ dispose();
331
+ effectRegistry.delete(id);
332
+ };
333
+ }
334
+
335
+ // =============================================================================
336
+ // PERFORMANCE PROFILING
337
+ // =============================================================================
338
+
339
+ /**
340
+ * Profile a section of code
341
+ * @param {string} name - Profile name
342
+ * @param {function} fn - Function to profile
343
+ * @returns {any} Result of fn
344
+ */
345
+ export function profile(name, fn) {
346
+ const start = performance.now();
347
+
348
+ try {
349
+ return fn();
350
+ } finally {
351
+ const duration = performance.now() - start;
352
+ log.info(`[Profile] ${name}: ${duration.toFixed(2)}ms`);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Create a performance marker
358
+ * @param {string} name - Marker name
359
+ * @returns {{end: function(): number}} Marker with end method
360
+ */
361
+ export function mark(name) {
362
+ const start = performance.now();
363
+
364
+ return {
365
+ end() {
366
+ const duration = performance.now() - start;
367
+ if (config.enabled) {
368
+ log.info(`[Mark] ${name}: ${duration.toFixed(2)}ms`);
369
+ }
370
+ return duration;
371
+ }
372
+ };
373
+ }
374
+
375
+ // =============================================================================
376
+ // RESET
377
+ // =============================================================================
378
+
379
+ /**
380
+ * Reset all diagnostics data
381
+ */
382
+ export function resetDiagnostics() {
383
+ pulseRegistry.clear();
384
+ effectRegistry.clear();
385
+ pulseIdCounter = 0;
386
+ trackedEffectIdCounter = 0;
387
+ }
388
+
389
+ export default {
390
+ config,
391
+ getDiagnostics,
392
+ getEffectStats,
393
+ getPulseList,
394
+ getDependencyGraph,
395
+ exportGraphAsDot,
396
+ trackedPulse,
397
+ trackedEffect,
398
+ profile,
399
+ mark,
400
+ resetDiagnostics,
401
+ pulseRegistry,
402
+ effectRegistry
403
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pulse DevTools - Module Index
3
+ * @module pulse-js-framework/runtime/devtools
4
+ *
5
+ * Re-exports all devtools functionality from sub-modules:
6
+ * - diagnostics: Reactive graph inspection, performance monitoring
7
+ * - time-travel: State snapshots and time-travel debugging
8
+ * - a11y-audit: Accessibility validation and reporting
9
+ */
10
+
11
+ // Diagnostics
12
+ export {
13
+ config,
14
+ getDiagnostics,
15
+ getEffectStats,
16
+ getPulseList,
17
+ getDependencyGraph,
18
+ exportGraphAsDot,
19
+ trackedPulse,
20
+ trackedEffect,
21
+ profile,
22
+ mark,
23
+ resetDiagnostics,
24
+ pulseRegistry,
25
+ effectRegistry
26
+ } from './diagnostics.js';
27
+
28
+ // Time-travel
29
+ export {
30
+ timeTravelConfig,
31
+ getIsTimeTraveling,
32
+ takeSnapshot,
33
+ getHistory,
34
+ getHistoryIndex,
35
+ getSnapshotCount,
36
+ travelTo,
37
+ back,
38
+ forward,
39
+ clearHistory
40
+ } from './time-travel.js';
41
+
42
+ // A11y Audit
43
+ export {
44
+ a11yAuditConfig,
45
+ runA11yAudit,
46
+ getA11yIssues,
47
+ getA11yStats,
48
+ enableA11yAudit,
49
+ disableA11yAudit,
50
+ toggleA11yHighlights,
51
+ exportA11yReport,
52
+ resetA11yAudit
53
+ } from './a11y-audit.js';
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Pulse DevTools - Time Travel Module
3
+ * @module pulse-js-framework/runtime/devtools/time-travel
4
+ *
5
+ * Time-travel debugging with state snapshots.
6
+ */
7
+
8
+ import { batch } from '../pulse.js';
9
+ import { createLogger } from '../logger.js';
10
+ import { pulseRegistry, config } from './diagnostics.js';
11
+
12
+ const log = createLogger('DevTools:TimeTravel');
13
+
14
+ // =============================================================================
15
+ // TIME-TRAVEL STATE
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Time-travel configuration
20
+ */
21
+ export const timeTravelConfig = {
22
+ maxSnapshots: 50
23
+ };
24
+
25
+ /**
26
+ * Time-travel state history
27
+ * @type {Array<{timestamp: number, state: Object, action: string}>}
28
+ */
29
+ const stateHistory = [];
30
+
31
+ /**
32
+ * Current position in history (for time-travel)
33
+ */
34
+ let historyIndex = -1;
35
+
36
+ /**
37
+ * Flag to prevent recording during time-travel
38
+ */
39
+ let isTimeTraveling = false;
40
+
41
+ // =============================================================================
42
+ // TIME-TRAVEL API
43
+ // =============================================================================
44
+
45
+ /**
46
+ * @typedef {Object} StateSnapshot
47
+ * @property {number} timestamp - When snapshot was taken
48
+ * @property {Object} state - Serialized state
49
+ * @property {string} action - Description of what caused the snapshot
50
+ * @property {number} index - Position in history
51
+ */
52
+
53
+ /**
54
+ * Check if currently time-traveling
55
+ * @returns {boolean}
56
+ */
57
+ export function getIsTimeTraveling() {
58
+ return isTimeTraveling;
59
+ }
60
+
61
+ /**
62
+ * Take a snapshot of current state
63
+ * @param {string} [action='manual'] - Description of the action
64
+ * @returns {StateSnapshot|null}
65
+ */
66
+ export function takeSnapshot(action = 'manual') {
67
+ if (isTimeTraveling) return null;
68
+
69
+ const state = {};
70
+ for (const [id, entry] of pulseRegistry) {
71
+ try {
72
+ // Deep clone to prevent mutation
73
+ state[id] = JSON.parse(JSON.stringify(entry.pulse.peek()));
74
+ } catch {
75
+ // Non-serializable value
76
+ state[id] = '[Non-serializable]';
77
+ }
78
+ }
79
+
80
+ const snapshot = {
81
+ timestamp: Date.now(),
82
+ state,
83
+ action,
84
+ index: stateHistory.length
85
+ };
86
+
87
+ // Trim history if too long
88
+ if (stateHistory.length >= timeTravelConfig.maxSnapshots) {
89
+ stateHistory.shift();
90
+ }
91
+
92
+ stateHistory.push(snapshot);
93
+ historyIndex = stateHistory.length - 1;
94
+
95
+ return snapshot;
96
+ }
97
+
98
+ /**
99
+ * Get state history
100
+ * @returns {StateSnapshot[]}
101
+ */
102
+ export function getHistory() {
103
+ return [...stateHistory];
104
+ }
105
+
106
+ /**
107
+ * Get current history position
108
+ * @returns {number}
109
+ */
110
+ export function getHistoryIndex() {
111
+ return historyIndex;
112
+ }
113
+
114
+ /**
115
+ * Get snapshot count
116
+ * @returns {number}
117
+ */
118
+ export function getSnapshotCount() {
119
+ return stateHistory.length;
120
+ }
121
+
122
+ /**
123
+ * Travel to a specific point in history
124
+ * @param {number} index - History index to travel to
125
+ * @returns {boolean} Success
126
+ */
127
+ export function travelTo(index) {
128
+ if (index < 0 || index >= stateHistory.length) {
129
+ return false;
130
+ }
131
+
132
+ const snapshot = stateHistory[index];
133
+ isTimeTraveling = true;
134
+
135
+ batch(() => {
136
+ for (const [id, value] of Object.entries(snapshot.state)) {
137
+ const entry = pulseRegistry.get(id);
138
+ if (entry && value !== '[Non-serializable]') {
139
+ entry.pulse.set(value);
140
+ }
141
+ }
142
+ });
143
+
144
+ historyIndex = index;
145
+ isTimeTraveling = false;
146
+
147
+ if (config.enabled) {
148
+ log.info(`Traveled to snapshot ${index}: ${snapshot.action}`);
149
+ }
150
+
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Go back one step in history
156
+ * @returns {boolean} Success
157
+ */
158
+ export function back() {
159
+ return travelTo(historyIndex - 1);
160
+ }
161
+
162
+ /**
163
+ * Go forward one step in history
164
+ * @returns {boolean} Success
165
+ */
166
+ export function forward() {
167
+ return travelTo(historyIndex + 1);
168
+ }
169
+
170
+ /**
171
+ * Clear all history
172
+ */
173
+ export function clearHistory() {
174
+ stateHistory.length = 0;
175
+ historyIndex = -1;
176
+ }
177
+
178
+ export default {
179
+ timeTravelConfig,
180
+ getIsTimeTraveling,
181
+ takeSnapshot,
182
+ getHistory,
183
+ getHistoryIndex,
184
+ getSnapshotCount,
185
+ travelTo,
186
+ back,
187
+ forward,
188
+ clearHistory
189
+ };