From aad55d84a20267243bf148b864ec44fb2092b4f9 Mon Sep 17 00:00:00 2001 From: merryman Date: Tue, 2 Jun 2026 16:48:51 +0200 Subject: [PATCH 01/14] Add lively.context inspector runtime --- lively.context/index.js | 1 + lively.context/lib/inspector-runtime.js | 426 ++++++++++++++++++ .../tests/inspector-runtime-test.js | 152 +++++++ 3 files changed, 579 insertions(+) create mode 100644 lively.context/lib/inspector-runtime.js create mode 100644 lively.context/tests/inspector-runtime-test.js diff --git a/lively.context/index.js b/lively.context/index.js index b39ddb1e8c..aec7539760 100644 --- a/lively.context/index.js +++ b/lively.context/index.js @@ -1,3 +1,4 @@ export * from './lib/rewriter.js'; +export * from './lib/inspector-runtime.js'; import './lib/interpreter.js'; import './lib/stackReification.js'; diff --git a/lively.context/lib/inspector-runtime.js b/lively.context/lib/inspector-runtime.js new file mode 100644 index 0000000000..671a14f188 --- /dev/null +++ b/lively.context/lib/inspector-runtime.js @@ -0,0 +1,426 @@ +/*global System*/ + +const DEFAULT_ENV_KEY = '@lively-env'; +const HALT_UNWIND_TAG = 'lively.context.inspector.halt'; + +let runtime; + +function globalObject () { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof window !== 'undefined') return window; + if (typeof global !== 'undefined') return global; + return {}; +} + +function systemObject () { + const Global = globalObject(); + if (Global.System) return Global.System; + try { + if (typeof System !== 'undefined') return System; + } catch (err) {} + return null; +} + +function getLivelyEnv () { + const Global = globalObject(); + const system = systemObject(); + let env; + + if (system && typeof system.get === 'function') { + try { env = system.get(DEFAULT_ENV_KEY); } catch (err) {} + } + + if (!env) env = Global.__livelyEnv || (Global.__livelyEnv = {}); + return env; +} + +function own (obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function installDesktopDebuggerBridge (runtime) { + const Global = globalObject(); + const desktop = Global.livelyDesktop || (Global.livelyDesktop = {}); + const existing = desktop.debugger || {}; + + desktop.debugger = { + ...existing, + isAvailable: existing.isAvailable || function () { return true; }, + deliverCapture: existing.deliverCapture || function (descriptor) { + return runtime.deliverCapture(descriptor); + } + }; + + return desktop.debugger; +} + +function frameIdsFromContext (context) { + return context.frameOrder.slice(); +} + +export class InspectorRegistry { + constructor ({ bridge } = {}) { + this.bridge = bridge || null; + this.contexts = {}; + this.captureCount = 0; + this.frameCount = 0; + this.scopeCount = 0; + this.refCount = 0; + } + + createCaptureId () { + this.captureCount++; + return 'capture-' + this.captureCount; + } + + createContext (options = {}) { + const { id, captureId, reason = 'debugger', exception, frames = [], metadata = {} } = options; + const contextId = id || captureId || this.createCaptureId(); + const context = { + id: contextId, + reason, + metadata, + frameOrder: [], + frames: {}, + scopes: {}, + refs: {}, + exceptionRef: null, + createdAt: Date.now() + }; + + this.contexts[contextId] = context; + + if (own(options, 'exception')) { + context.exceptionRef = this.storeValue(contextId, exception, 'exception'); + } + + frames.forEach(frame => this.storeFrame(contextId, frame)); + return context; + } + + releaseContext (contextId) { + return delete this.contexts[contextId]; + } + + getContext (contextId) { + return this.contexts[contextId]; + } + + hasContext (contextId) { + return !!this.contexts[contextId]; + } + + storeValue (contextId, value, hint = 'value') { + const context = this.getContext(contextId); + if (!context) throw new Error('Cannot store value for unknown debug context ' + contextId); + const refId = hint + '-' + (++this.refCount); + context.refs[refId] = value; + return refId; + } + + resolveRef (contextId, refId) { + const context = this.getContext(contextId); + return context && context.refs[refId]; + } + + storeFrame (contextId, frameSpec = {}) { + const context = this.getContext(contextId); + if (!context) throw new Error('Cannot store frame for unknown debug context ' + contextId); + + const frameId = frameSpec.frameId || frameSpec.id || 'frame-' + (++this.frameCount); + const frame = { + frameId, + contextId, + index: context.frameOrder.length, + functionName: frameSpec.functionName || frameSpec.name || '', + source: frameSpec.source || null, + location: frameSpec.location || null, + thisRef: null, + argumentsRef: null, + exceptionRef: null, + scopeRefs: [] + }; + + if (own(frameSpec, 'thisValue')) frame.thisRef = this.storeValue(contextId, frameSpec.thisValue, 'this'); + else if (own(frameSpec, 'this')) frame.thisRef = this.storeValue(contextId, frameSpec.this, 'this'); + if (own(frameSpec, 'arguments')) frame.argumentsRef = this.storeValue(contextId, frameSpec.arguments, 'arguments'); + if (own(frameSpec, 'exception')) frame.exceptionRef = this.storeValue(contextId, frameSpec.exception, 'exception'); + + context.frames[frameId] = frame; + if (!context.frameOrder.includes(frameId)) context.frameOrder.push(frameId); + + (frameSpec.scopes || []).forEach(scope => { + this.storeScope(contextId, frameId, scope); + }); + + return frame; + } + + storeScope (contextId, frameId, scopeSpec = {}) { + const context = this.getContext(contextId); + if (!context) throw new Error('Cannot store scope for unknown debug context ' + contextId); + const frame = context.frames[frameId]; + if (!frame) throw new Error('Cannot store scope for unknown frame ' + frameId); + + const scopeId = scopeSpec.scopeId || scopeSpec.id || 'scope-' + (++this.scopeCount); + const bindings = {}; + const sourceBindings = scopeSpec.bindings || {}; + Object.keys(sourceBindings).forEach(name => { + bindings[name] = sourceBindings[name]; + }); + + context.scopes[scopeId] = { + scopeId, + frameId, + contextId, + type: scopeSpec.type || 'local', + name: scopeSpec.name || '', + bindings + }; + + if (!frame.scopeRefs.includes(scopeId)) frame.scopeRefs.push(scopeId); + return scopeId; + } + + continuationFor (descriptor) { + if (!descriptor) throw new Error('Cannot create InspectorContinuation without a descriptor'); + const contextId = typeof descriptor === 'string' + ? descriptor + : descriptor.contextId || descriptor.id || descriptor.captureId; + return new InspectorContinuation(this, { ...descriptor, contextId }); + } + + deliverCapture (descriptor) { + if (!descriptor) throw new Error('Cannot deliver empty debugger capture'); + let contextId = descriptor.contextId || descriptor.id || descriptor.captureId; + if (!contextId) { + contextId = this.createContext(descriptor).id; + } else if (!this.hasContext(contextId)) { + this.createContext(descriptor); + } + return this.continuationFor({ ...descriptor, contextId }); + } +} + +export class InspectorContinuation { + constructor (registry, descriptor) { + this.registry = registry; + this.contextId = descriptor.contextId; + this.descriptor = descriptor; + } + + get id () { return this.contextId; } + + get reason () { + const context = this.context; + return context && context.reason; + } + + get context () { + return this.registry.getContext(this.contextId); + } + + get currentFrame () { + return this.frames()[0]; + } + + get exception () { + const context = this.context; + return context && context.exceptionRef + ? this.registry.resolveRef(this.contextId, context.exceptionRef) + : undefined; + } + + frames () { + const context = this.context; + if (!context) return []; + return frameIdsFromContext(context).map(frameId => + new InspectorFrame(this.registry, this.contextId, frameId)); + } + + release () { + return this.registry.releaseContext(this.contextId); + } + + close () { + return this.release(); + } +} + +export class InspectorFrame { + constructor (registry, contextId, frameId) { + this.registry = registry; + this.contextId = contextId; + this.frameId = frameId; + } + + get id () { return this.frameId; } + + get record () { + const context = this.registry.getContext(this.contextId); + return context && context.frames[this.frameId]; + } + + get functionName () { + const record = this.record; + return record && record.functionName; + } + + get source () { + const record = this.record; + return record && record.source; + } + + get location () { + const record = this.record; + return record && record.location; + } + + scopes () { + const record = this.record; + if (!record) return []; + return record.scopeRefs.map(scopeId => + new InspectorScope(this.registry, this.contextId, scopeId)); + } + + lookup (name) { + const scope = this.scopes().find(scope => scope.hasBinding(name)); + return scope ? scope.lookup(name) : undefined; + } + + getThis () { + const record = this.record; + return record && record.thisRef + ? this.registry.resolveRef(this.contextId, record.thisRef) + : undefined; + } + + getArguments () { + const record = this.record; + return record && record.argumentsRef + ? this.registry.resolveRef(this.contextId, record.argumentsRef) + : undefined; + } + + getException () { + const record = this.record; + return record && record.exceptionRef + ? this.registry.resolveRef(this.contextId, record.exceptionRef) + : undefined; + } +} + +export class InspectorScope { + constructor (registry, contextId, scopeId) { + this.registry = registry; + this.contextId = contextId; + this.scopeId = scopeId; + } + + get id () { return this.scopeId; } + + get record () { + const context = this.registry.getContext(this.contextId); + return context && context.scopes[this.scopeId]; + } + + get type () { + const record = this.record; + return record && record.type; + } + + get name () { + const record = this.record; + return record && record.name; + } + + bindingNames () { + const record = this.record; + return record ? Object.keys(record.bindings) : []; + } + + hasBinding (name) { + const record = this.record; + return !!record && own(record.bindings, name); + } + + lookup (name) { + const record = this.record; + return record && own(record.bindings, name) + ? record.bindings[name] + : undefined; + } + + get bindings () { + const record = this.record; + return record && record.bindings; + } +} + +export class InspectorHaltUnwind { + constructor (reason, captureId) { + this.reason = reason; + this.captureId = captureId; + this.tag = HALT_UNWIND_TAG; + } + + get isLivelyInspectorHaltUnwind () { return true; } + + toString () { + return '[LivelyInspectorHalt ' + this.reason + ']'; + } +} + +export function isInspectorHaltUnwind (err) { + return !!err && (err.isLivelyInspectorHaltUnwind || err.tag === HALT_UNWIND_TAG); +} + +export function installInspectorRuntime ({ bridge, env } = {}) { + const livelyEnv = env || getLivelyEnv(); + let registry = livelyEnv.debuggerContexts; + + if (!(registry instanceof InspectorRegistry)) { + registry = new InspectorRegistry({ bridge }); + livelyEnv.debuggerContexts = registry; + } else if (bridge) { + registry.bridge = bridge; + } + + runtime = { + registry, + bridge: bridge || registry.bridge || null, + deliverCapture (descriptor) { + return registry.deliverCapture(descriptor); + } + }; + + const desktopBridge = installDesktopDebuggerBridge(runtime); + if (!runtime.bridge && desktopBridge && typeof desktopBridge.armHalt === 'function') { + runtime.bridge = desktopBridge; + } + + return runtime; +} + +export function getInspectorRuntime () { + return runtime || installInspectorRuntime(); +} + +export function getInspectorRegistry () { + return getInspectorRuntime().registry; +} + +export function halt (reason = 'halt') { + const runtime = getInspectorRuntime(); + const captureId = runtime.registry.createCaptureId(); + const bridge = runtime.bridge; + + if (bridge && typeof bridge.armHalt === 'function') { + bridge.armHalt({ reason, captureId }); + } + + debugger; + throw new InspectorHaltUnwind(reason, captureId); +} + +export { HALT_UNWIND_TAG }; diff --git a/lively.context/tests/inspector-runtime-test.js b/lively.context/tests/inspector-runtime-test.js new file mode 100644 index 0000000000..382d13ea04 --- /dev/null +++ b/lively.context/tests/inspector-runtime-test.js @@ -0,0 +1,152 @@ +"format esm"; +/*global describe, it, beforeEach*/ +import { expect } from 'mocha-es6'; +import { + InspectorRegistry, + InspectorContinuation, + InspectorFrame, + InspectorScope, + halt, + installInspectorRuntime, + isInspectorHaltUnwind +} from '../lib/inspector-runtime.js'; + +describe('inspector runtime', function () { + let registry; + + beforeEach(function () { + registry = new InspectorRegistry(); + }); + + function createContext (spec = {}) { + return registry.createContext({ + id: 'capture-1', + reason: 'halt', + frames: [{ + frameId: 'frame-1', + functionName: 'inner', + thisValue: spec.thisValue, + arguments: spec.arguments, + scopes: spec.scopes || [] + }] + }); + } + + it('stores and releases debug contexts', function () { + createContext(); + expect(registry.hasContext('capture-1')).equals(true); + expect(registry.releaseContext('capture-1')).equals(true); + expect(registry.hasContext('capture-1')).equals(false); + }); + + it('preserves object identity for stored bindings, this, and arguments', function () { + const object = { value: 23 }; + const thisValue = { receiver: true }; + const args = [object]; + createContext({ + thisValue, + arguments: args, + scopes: [{ bindings: { object } }] + }); + + const frame = registry.continuationFor('capture-1').currentFrame; + expect(frame.lookup('object')).equals(object); + expect(frame.getThis()).equals(thisValue); + expect(frame.getArguments()).equals(args); + }); + + it('preserves shadowed names as separate scope records', function () { + createContext({ + scopes: [ + { scopeId: 'local', type: 'local', bindings: { value: 'inner' } }, + { scopeId: 'closure', type: 'closure', bindings: { value: 'outer' } } + ] + }); + + const frame = registry.continuationFor('capture-1').currentFrame; + const scopes = frame.scopes(); + expect(scopes).to.have.length(2); + expect(scopes[0].lookup('value')).equals('inner'); + expect(scopes[1].lookup('value')).equals('outer'); + }); + + it('looks up bindings in scope-chain order', function () { + createContext({ + scopes: [ + { type: 'local', bindings: { value: 'inner' } }, + { type: 'closure', bindings: { value: 'outer', other: 42 } } + ] + }); + + const frame = registry.continuationFor('capture-1').currentFrame; + expect(frame.lookup('value')).equals('inner'); + expect(frame.lookup('other')).equals(42); + expect(frame.lookup('missing')).equals(undefined); + }); + + it('wraps continuations, frames, and scopes over registry ids', function () { + createContext({ + scopes: [{ scopeId: 'scope-1', type: 'local', name: 'Local', bindings: { value: 3 } }] + }); + + const continuation = registry.continuationFor('capture-1'); + const frame = continuation.currentFrame; + const scope = frame.scopes()[0]; + + expect(continuation).to.be.instanceof(InspectorContinuation); + expect(frame).to.be.instanceof(InspectorFrame); + expect(scope).to.be.instanceof(InspectorScope); + expect(continuation.reason).equals('halt'); + expect(frame.functionName).equals('inner'); + expect(scope.type).equals('local'); + expect(scope.name).equals('Local'); + expect(scope.bindingNames()).eql(['value']); + }); + + it('delivers capture descriptors as ids, not serialized values', function () { + const object = { nested: { same: true } }; + const continuation = registry.deliverCapture({ + captureId: 'capture-2', + reason: 'exception', + exception: object, + frames: [{ + frameId: 'frame-2', + scopes: [{ scopeId: 'scope-2', bindings: { object } }] + }] + }); + + expect(continuation.id).equals('capture-2'); + expect(continuation.exception).equals(object); + expect(continuation.currentFrame.lookup('object')).equals(object); + expect(registry.getContext('capture-2').frames['frame-2'].scopeRefs).eql(['scope-2']); + }); + + it('installs the registry under the lively env debuggerContexts slot', function () { + const env = {}; + const runtime = installInspectorRuntime({ env }); + + expect(env.debuggerContexts).equals(runtime.registry); + expect(runtime.registry).to.be.instanceof(InspectorRegistry); + }); + + it('arms the bridge and throws a tagged halt unwind', function () { + const calls = []; + installInspectorRuntime({ + env: {}, + bridge: { armHalt: capture => calls.push(capture) } + }); + + try { + halt('test halt'); + } catch (err) { + expect(isInspectorHaltUnwind(err)).equals(true); + expect(err.reason).equals('test halt'); + expect(calls).to.have.length(1); + expect(calls[0].reason).equals('test halt'); + expect(calls[0].captureId).equals(err.captureId); + return; + } + + throw new Error('halt did not throw'); + }); +}); From 3a1cb94ac49180f9992cc86ebed96d250d049a3a Mon Sep 17 00:00:00 2001 From: merryman Date: Tue, 2 Jun 2026 17:00:13 +0200 Subject: [PATCH 02/14] Add NW inspector capture service --- lively.app/desktop/inject.js | 42 +- lively.app/desktop/inspector-service.cjs | 511 ++++++++++++++++++ lively.app/desktop/start-server.cjs | 14 + lively.app/tests/inspector-service-test.cjs | 167 ++++++ lively.context/lib/inspector-runtime.js | 20 +- .../tests/inspector-runtime-test.js | 32 ++ 6 files changed, 780 insertions(+), 6 deletions(-) create mode 100644 lively.app/desktop/inspector-service.cjs create mode 100644 lively.app/tests/inspector-service-test.cjs diff --git a/lively.app/desktop/inject.js b/lively.app/desktop/inject.js index 31b561bbdc..3e9425411b 100644 --- a/lively.app/desktop/inject.js +++ b/lively.app/desktop/inject.js @@ -47,11 +47,51 @@ return window.confirm([title, message].filter(Boolean).join('\n\n')); } + const debuggerState = window.__LIVELY_DESKTOP_DEBUGGER__ || (window.__LIVELY_DESKTOP_DEBUGGER__ = { + armedHalt: null, + captures: [] + }); + + function armHalt (capture) { + debuggerState.armedHalt = { + captureId: capture && capture.captureId, + reason: capture && capture.reason || 'halt', + armedAt: Date.now() + }; + return true; + } + + function consumeArmedHalt () { + const capture = debuggerState.armedHalt; + debuggerState.armedHalt = null; + return capture; + } + + function deliverCapture (descriptor) { + debuggerState.captures.push(descriptor); + debuggerState.lastCapture = descriptor; + try { + window.dispatchEvent(new CustomEvent('lively-desktop-debugger-capture', { detail: descriptor })); + } catch (_) {} + return true; + } + + const desktop = window.livelyDesktop || {}; + const desktopDebugger = desktop.debugger || {}; + window.livelyDesktop = { + ...desktop, navigateToDashboard: navigateToDashboard, showDevTools: showDevTools, showDesktopMessage: showDesktopMessage, - confirmDesktopAction: confirmDesktopAction + confirmDesktopAction: confirmDesktopAction, + debugger: { + ...desktopDebugger, + armHalt: desktopDebugger.armHalt || armHalt, + consumeArmedHalt: desktopDebugger.consumeArmedHalt || consumeArmedHalt, + isAvailable: desktopDebugger.isAvailable || function () { return true; }, + deliverCapture: desktopDebugger.deliverCapture || deliverCapture + } }; // Keyboard shortcut: Cmd/Ctrl + Shift + D → Dashboard. diff --git a/lively.app/desktop/inspector-service.cjs b/lively.app/desktop/inspector-service.cjs new file mode 100644 index 0000000000..ee56fbb158 --- /dev/null +++ b/lively.app/desktop/inspector-service.cjs @@ -0,0 +1,511 @@ +// CDP-backed inspector capture service for the NW.js desktop app. +// +// CDP is only used while V8 is paused. Actual values are stored by executing +// small functions in the renderer so the debugger UI can later inspect them +// in-process through lively.context's registry. + +const http = require('http'); +const https = require('https'); + +const DEFAULT_CDP_PORT = 9222; +const DEFAULT_TARGET_TIMEOUT = 30000; +const DEFAULT_TARGET_INTERVAL = 250; + +function noop () {} + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function jsonForExpression (value) { + return JSON.stringify(value).replace(/ { + const req = client.get(url, res => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`${url} returned HTTP ${res.statusCode}`)); + return; + } + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }); + req.on('error', reject); + req.setTimeout(5000, () => { + req.destroy(new Error(`${url} timed out`)); + }); + }); +} + +async function defaultFetchJson (url) { + if (typeof fetch === 'function') { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + return response.json(); + } + return fetchJsonWithHttp(url); +} + +function pageTarget (targets) { + return (targets || []).find(target => + target.type === 'page' && + target.webSocketDebuggerUrl && + !String(target.url || '').startsWith('devtools://')); +} + +function bindingNamesFromProperties (properties) { + return (properties || []) + .filter(prop => + prop && + prop.name && + prop.name !== '__proto__' && + prop.value && + !prop.get && + !prop.set) + .map(prop => prop.name); +} + +function sourceForFrame (frame) { + const location = frame.location || {}; + return { + url: frame.url || '', + scriptId: location.scriptId || '' + }; +} + +function locationForFrame (frame) { + const location = frame.location || {}; + return { + scriptId: location.scriptId || '', + lineNumber: location.lineNumber, + columnNumber: location.columnNumber + }; +} + +function remoteObjectLabel (remoteObject) { + if (!remoteObject) return ''; + return remoteObject.description || remoteObject.value || remoteObject.type || ''; +} + +const RENDERER_HELPERS = ` +function livelyInspectorRegistryForCapture(payload) { + const Global = typeof globalThis !== 'undefined' ? globalThis : window; + let env = null; + if (Global.System && typeof Global.System.get === 'function') { + try { env = Global.System.get('@lively-env'); } catch (err) {} + } + if (!env) env = Global.__livelyEnv; + const registry = env && env.debuggerContexts; + if (!registry) throw new Error('lively.context inspector runtime is not installed'); + if (!registry.hasContext(payload.captureId)) { + registry.createContext({ + id: payload.captureId, + reason: payload.reason, + metadata: payload.metadata || {} + }); + } + return registry; +} + +function livelyInspectorFrameSpec(payload) { + return { + frameId: payload.frameId, + functionName: payload.functionName || '', + source: payload.source || null, + location: payload.location || null + }; +} +`; + +const STORE_FRAME_FUNCTION = `function livelyInspectorStoreFrame(payload) { + ${RENDERER_HELPERS} + const registry = livelyInspectorRegistryForCapture(payload); + const frame = livelyInspectorFrameSpec(payload); + if (payload.storeThis) frame.thisValue = this; + else if (payload.hasThisByValue) frame.thisValue = payload.thisValue; + registry.storeFrame(payload.captureId, frame); + return { contextId: payload.captureId, frameId: payload.frameId }; +}`; + +const STORE_SCOPE_FUNCTION = `function livelyInspectorStoreScope(payload) { + ${RENDERER_HELPERS} + const registry = livelyInspectorRegistryForCapture(payload); + const context = registry.getContext(payload.captureId); + if (!context.frames[payload.frameId]) registry.storeFrame(payload.captureId, livelyInspectorFrameSpec(payload)); + const bindings = {}; + for (const name of payload.bindingNames || []) { + try { bindings[name] = this[name]; } catch (err) {} + } + registry.storeScope(payload.captureId, payload.frameId, { + scopeId: payload.scopeId, + type: payload.type || 'local', + name: payload.name || '', + bindings + }); + return { contextId: payload.captureId, frameId: payload.frameId, scopeId: payload.scopeId }; +}`; + +const STORE_EXCEPTION_FUNCTION = `function livelyInspectorStoreException(payload) { + ${RENDERER_HELPERS} + const registry = livelyInspectorRegistryForCapture(payload); + registry.storeException(payload.captureId, this, payload.frameId); + return { contextId: payload.captureId, exception: true }; +}`; + +const STORE_FRAME_BY_VALUE_FUNCTION = `function livelyInspectorStoreFrameByValue(payload) { + ${RENDERER_HELPERS} + const registry = livelyInspectorRegistryForCapture(payload); + const frame = livelyInspectorFrameSpec(payload); + if (payload.hasThisByValue) frame.thisValue = payload.thisValue; + registry.storeFrame(payload.captureId, frame); + return { contextId: payload.captureId, frameId: payload.frameId }; +}`; + +const CONSUME_ARMED_HALT_EXPRESSION = `(() => { + const debuggerBridge = globalThis.livelyDesktop && globalThis.livelyDesktop.debugger; + if (!debuggerBridge || typeof debuggerBridge.consumeArmedHalt !== 'function') return null; + return debuggerBridge.consumeArmedHalt(); +})()`; + +class CDPClient { + constructor (url, { WebSocketImpl = globalThis.WebSocket } = {}) { + if (!WebSocketImpl) throw new Error('No WebSocket implementation available for CDP'); + this.url = url; + this.WebSocketImpl = WebSocketImpl; + this.nextId = 1; + this.pending = new Map(); + this.eventHandler = null; + this.ws = null; + } + + async open () { + this.ws = new this.WebSocketImpl(this.url); + await new Promise((resolve, reject) => { + const onOpen = () => resolve(); + const onError = event => reject(new Error(event && event.message || 'CDP websocket error')); + this.ws.addEventListener('open', onOpen, { once: true }); + this.ws.addEventListener('error', onError, { once: true }); + }); + this.ws.addEventListener('message', event => this._onMessage(event.data)); + } + + onEvent (handler) { + this.eventHandler = handler; + } + + _onMessage (data) { + const message = JSON.parse(typeof data === 'string' ? data : Buffer.from(data).toString('utf8')); + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) reject(new Error(message.error.message || 'CDP error')); + else resolve(message.result || {}); + return; + } + if (message.method && this.eventHandler) this.eventHandler(message.method, message.params || {}); + } + + send (method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } + + close () { + try { this.ws && this.ws.close(); } catch (_) {} + } +} + +class InspectorService { + constructor ({ + cdpPort = DEFAULT_CDP_PORT, + targetTimeout = DEFAULT_TARGET_TIMEOUT, + targetInterval = DEFAULT_TARGET_INTERVAL, + fetchJson = defaultFetchJson, + createClient = null, + client = null, + WebSocketImpl = globalThis.WebSocket, + log = noop + } = {}) { + this.cdpPort = cdpPort; + this.targetTimeout = targetTimeout; + this.targetInterval = targetInterval; + this.fetchJson = fetchJson; + this.createClient = createClient || (url => new CDPClient(url, { WebSocketImpl })); + this.client = client; + this.log = log; + this.captureCount = 0; + this.started = false; + this.handlingPause = false; + } + + async start () { + if (this.started) return this; + if (!this.client) { + const target = await this.waitForPageTarget(); + this.client = this.createClient(target.webSocketDebuggerUrl); + await this.client.open(); + } + if (typeof this.client.onEvent === 'function') { + this.client.onEvent((method, params) => { + if (method === 'Debugger.paused') this.handlePaused(params); + }); + } + await this.client.send('Runtime.enable'); + await this.client.send('Debugger.enable'); + await this.client.send('Debugger.setPauseOnExceptions', { state: 'uncaught' }); + this.started = true; + this.log('inspector service attached to renderer target'); + return this; + } + + stop () { + this.started = false; + if (this.client && typeof this.client.close === 'function') this.client.close(); + } + + async waitForPageTarget () { + const start = Date.now(); + let lastError; + while (Date.now() - start < this.targetTimeout) { + try { + const targets = await this.fetchJson(`http://127.0.0.1:${this.cdpPort}/json/list`); + const target = pageTarget(targets); + if (target) return target; + } catch (err) { + lastError = err; + } + await sleep(this.targetInterval); + } + throw new Error('No NW.js page target found for inspector service' + + (lastError ? ': ' + (lastError.message || lastError) : '')); + } + + createCaptureId () { + this.captureCount++; + return 'desktop-capture-' + this.captureCount + '-' + Date.now().toString(36); + } + + async handlePaused (params) { + if (this.handlingPause) return; + this.handlingPause = true; + let descriptor = null; + try { + descriptor = await this.capturePaused(params); + } catch (err) { + this.log('inspector capture failed: ' + (err.stack || err)); + } + + try { + await this.client.send('Debugger.resume'); + } catch (err) { + this.log('inspector resume failed: ' + (err.stack || err)); + } + + if (descriptor) { + try { + await this.deliverCapture(descriptor); + } catch (err) { + this.log('inspector deliver failed: ' + (err.stack || err)); + } + } + this.handlingPause = false; + } + + async capturePaused (params) { + const callFrames = params.callFrames || []; + if (!callFrames.length) return null; + + const armed = await this.consumeArmedHalt(callFrames[0]); + if (!armed && params.reason !== 'exception') return null; + + const captureId = armed && armed.captureId || this.createCaptureId(); + const reason = armed && armed.reason || (params.reason === 'exception' ? 'exception' : params.reason || 'debugger'); + const descriptor = { + captureId, + contextId: captureId, + reason, + pauseReason: params.reason || '', + metadata: { + capturedAt: new Date().toISOString(), + hitBreakpoints: params.hitBreakpoints || [] + }, + frames: [] + }; + + const exceptionObjectId = params.reason === 'exception' && params.data && params.data.objectId; + + for (let i = 0; i < callFrames.length; i++) { + const frame = callFrames[i]; + const frameId = 'frame-' + i; + const framePayload = { + captureId, + reason, + metadata: descriptor.metadata, + frameId, + functionName: frame.functionName || '', + source: sourceForFrame(frame), + location: locationForFrame(frame) + }; + await this.storeFrame(frame, framePayload); + if (i === 0 && exceptionObjectId) { + await this.storeException(captureId, exceptionObjectId, reason, descriptor.metadata); + } + + const frameDescriptor = { + frameId, + functionName: framePayload.functionName, + source: framePayload.source, + location: framePayload.location, + thisLabel: remoteObjectLabel(frame.this), + scopes: [] + }; + + const scopes = frame.scopeChain || []; + for (let j = 0; j < scopes.length; j++) { + const scope = scopes[j]; + const objectId = scope.object && scope.object.objectId; + if (!objectId) continue; + + const properties = await this.client.send('Runtime.getProperties', { + objectId, + ownProperties: true, + accessorPropertiesOnly: false, + generatePreview: false + }); + const bindingNames = bindingNamesFromProperties(properties.result); + const scopeId = frameId + '-scope-' + j; + + await this.storeScope(objectId, { + ...framePayload, + scopeId, + type: scope.type || 'local', + name: scope.name || '', + bindingNames + }); + + frameDescriptor.scopes.push({ + scopeId, + type: scope.type || 'local', + name: scope.name || '', + bindingNames + }); + } + + descriptor.frames.push(frameDescriptor); + } + + return descriptor; + } + + async consumeArmedHalt (topFrame) { + if (!topFrame || !topFrame.callFrameId) return null; + try { + const result = await this.client.send('Debugger.evaluateOnCallFrame', { + callFrameId: topFrame.callFrameId, + expression: CONSUME_ARMED_HALT_EXPRESSION, + returnByValue: true, + silent: true + }); + return result && result.result && result.result.value || null; + } catch (err) { + this.log('inspector halt arm lookup failed: ' + (err.stack || err)); + return null; + } + } + + async storeFrame (frame, payload) { + const thisObject = frame && frame.this; + if (thisObject && thisObject.objectId) { + await this.client.send('Runtime.callFunctionOn', { + objectId: thisObject.objectId, + functionDeclaration: STORE_FRAME_FUNCTION, + arguments: [{ value: { ...payload, storeThis: true } }], + returnByValue: true, + silent: true + }); + return; + } + + const hasThisByValue = thisObject && Object.prototype.hasOwnProperty.call(thisObject, 'value'); + await this.client.send('Debugger.evaluateOnCallFrame', { + callFrameId: frame.callFrameId, + expression: `(${STORE_FRAME_BY_VALUE_FUNCTION})(${jsonForExpression({ + ...payload, + hasThisByValue, + thisValue: hasThisByValue ? thisObject.value : undefined + })})`, + returnByValue: true, + silent: true + }); + } + + async storeScope (objectId, payload) { + await this.client.send('Runtime.callFunctionOn', { + objectId, + functionDeclaration: STORE_SCOPE_FUNCTION, + arguments: [{ value: payload }], + returnByValue: true, + silent: true + }); + } + + async storeException (captureId, objectId, reason, metadata) { + await this.client.send('Runtime.callFunctionOn', { + objectId, + functionDeclaration: STORE_EXCEPTION_FUNCTION, + arguments: [{ + value: { + captureId, + reason, + metadata, + frameId: 'frame-0' + } + }], + returnByValue: true, + silent: true + }); + } + + async deliverCapture (descriptor) { + await this.client.send('Runtime.evaluate', { + expression: `(() => { + const descriptor = ${jsonForExpression(descriptor)}; + const debuggerBridge = globalThis.livelyDesktop && globalThis.livelyDesktop.debugger; + if (debuggerBridge && typeof debuggerBridge.deliverCapture === 'function') { + return debuggerBridge.deliverCapture(descriptor); + } + globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__ = + globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__ || []; + globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__.push(descriptor); + return false; + })()`, + returnByValue: true, + silent: true + }); + } +} + +function createInspectorService (options) { + return new InspectorService(options); +} + +module.exports = { + CDPClient, + InspectorService, + createInspectorService, + bindingNamesFromProperties, + pageTarget +}; diff --git a/lively.app/desktop/start-server.cjs b/lively.app/desktop/start-server.cjs index 2fdf436fd9..66a2391637 100644 --- a/lively.app/desktop/start-server.cjs +++ b/lively.app/desktop/start-server.cjs @@ -21,6 +21,7 @@ const { createHash } = require('crypto'); const { spawn, execSync } = require('child_process'); const { pathToFileURL } = require('url'); const { runVelopackStartup } = require('./updates.cjs'); +const { createInspectorService } = require('./inspector-service.cjs'); // --------------------------------------------------------------------------- // 0. Detect mode: dev (monorepo) vs bundled (standalone distribution) @@ -700,6 +701,7 @@ function setupFlatnEnv () { } const win = nw.Window.get(); + let inspectorService = null; const b = livelyBoot(); if (b && b.setDashboardUrl) b.setDashboardUrl(dashboardUrl); @@ -713,10 +715,22 @@ function setupFlatnEnv () { win.window.location.href = dashboardUrl; } + if (process.env.LIVELY_APP_INSPECTOR_SERVICE !== '0') { + const cdpPort = Number(process.env.LIVELY_APP_CDP_PORT || 9222); + inspectorService = createInspectorService({ + cdpPort: Number.isFinite(cdpPort) && cdpPort > 0 ? cdpPort : 9222, + log: msg => log('inspector: ' + msg) + }); + inspectorService.start().catch(err => { + log('inspector: service unavailable: ' + (err.stack || err)); + }); + } + win.on('close', function () { log('Window closing, killing server...'); closing = true; clearTimeout(restartTimer); + if (inspectorService) inspectorService.stop(); if (currentChild) currentChild.kill('SIGTERM'); setTimeout(() => this.close(true), 2000); }); diff --git a/lively.app/tests/inspector-service-test.cjs b/lively.app/tests/inspector-service-test.cjs new file mode 100644 index 0000000000..ad7b039593 --- /dev/null +++ b/lively.app/tests/inspector-service-test.cjs @@ -0,0 +1,167 @@ +const assert = require('assert'); +const { + InspectorService, + bindingNamesFromProperties, + pageTarget +} = require('../desktop/inspector-service.cjs'); + +class FakeCDPClient { + constructor ({ armed = { captureId: 'capture-test', reason: 'halt' }, properties = {} } = {}) { + this.armed = armed; + this.properties = properties; + this.calls = []; + } + + async send (method, params = {}) { + this.calls.push({ method, params }); + if (method === 'Debugger.evaluateOnCallFrame') { + if (String(params.expression).includes('consumeArmedHalt')) { + return { result: { value: this.armed } }; + } + return { result: { value: true } }; + } + if (method === 'Runtime.getProperties') { + return { result: this.properties[params.objectId] || [] }; + } + if (method === 'Runtime.callFunctionOn') { + return { result: { value: true } }; + } + if (method === 'Runtime.evaluate') { + return { result: { value: true } }; + } + return {}; + } +} + +function pausedPayload (overrides = {}) { + return { + reason: 'other', + callFrames: [{ + callFrameId: 'call-frame-0', + functionName: 'inner', + url: 'http://127.0.0.1:9011/foo.js', + location: { scriptId: 'script-1', lineNumber: 10, columnNumber: 4 }, + this: { type: 'object', objectId: 'this-0', description: 'Object' }, + scopeChain: [ + { type: 'local', name: 'Local', object: { objectId: 'scope-local' } }, + { type: 'closure', name: 'Closure', object: { objectId: 'scope-closure' } } + ] + }], + ...overrides + }; +} + +async function testBindingNameFiltering () { + const names = bindingNamesFromProperties([ + { name: 'object', value: { type: 'object', objectId: 'object-1' } }, + { name: 'token', value: { type: 'string', value: 'SHOULD_NOT_LEAVE_CDP' } }, + { name: '__proto__', value: { type: 'object' } }, + { name: 'getterOnly', get: { type: 'function' } } + ]); + + assert.deepStrictEqual(names, ['object', 'token']); +} + +async function testTargetSelection () { + const target = pageTarget([ + { type: 'page', url: 'devtools://devtools', webSocketDebuggerUrl: 'ignored' }, + { type: 'worker', url: 'http://example.test', webSocketDebuggerUrl: 'ignored' }, + { type: 'page', url: 'http://127.0.0.1:9011/dashboard/', webSocketDebuggerUrl: 'ws://target' } + ]); + + assert.strictEqual(target.webSocketDebuggerUrl, 'ws://target'); +} + +async function testCaptureStoresValuesInRenderer () { + const client = new FakeCDPClient({ + properties: { + 'scope-local': [ + { name: 'object', value: { type: 'object', objectId: 'object-1', description: 'Object' } }, + { name: 'token', value: { type: 'string', value: 'SHOULD_NOT_LEAVE_CDP' } } + ], + 'scope-closure': [ + { name: 'outer', value: { type: 'number', value: 23 } } + ] + } + }); + const service = new InspectorService({ client }); + const descriptor = await service.capturePaused(pausedPayload()); + + assert.strictEqual(descriptor.captureId, 'capture-test'); + assert.strictEqual(descriptor.reason, 'halt'); + assert.deepStrictEqual(descriptor.frames[0].scopes[0].bindingNames, ['object', 'token']); + assert.deepStrictEqual(descriptor.frames[0].scopes[1].bindingNames, ['outer']); + + const getPropertiesCalls = client.calls.filter(call => call.method === 'Runtime.getProperties'); + assert.strictEqual(getPropertiesCalls.length, 2); + + const storeCalls = client.calls.filter(call => call.method === 'Runtime.callFunctionOn'); + assert(storeCalls.some(call => call.params.objectId === 'this-0')); + assert(storeCalls.some(call => call.params.objectId === 'scope-local')); + assert(storeCalls.some(call => call.params.objectId === 'scope-closure')); + for (const call of storeCalls) { + assert.doesNotThrow(() => new Function('return (' + call.params.functionDeclaration + ')')()); + } + + const localStore = storeCalls.find(call => call.params.objectId === 'scope-local'); + assert.deepStrictEqual(localStore.params.arguments[0].value.bindingNames, ['object', 'token']); + assert(!JSON.stringify(localStore.params.arguments[0].value).includes('SHOULD_NOT_LEAVE_CDP')); + assert(!JSON.stringify(descriptor).includes('SHOULD_NOT_LEAVE_CDP')); +} + +async function testExceptionCaptureStoresExceptionObject () { + const client = new FakeCDPClient({ + armed: null, + properties: { + 'scope-local': [{ name: 'error', value: { type: 'object', objectId: 'error-binding' } }], + 'scope-closure': [] + } + }); + const service = new InspectorService({ client }); + const descriptor = await service.capturePaused(pausedPayload({ + reason: 'exception', + data: { type: 'object', objectId: 'exception-1', description: 'Error: boom' } + })); + + assert.strictEqual(descriptor.reason, 'exception'); + const frameStoreIndex = client.calls.findIndex(call => + call.method === 'Runtime.callFunctionOn' && + call.params.objectId === 'this-0'); + const exceptionStoreIndex = client.calls.findIndex(call => + call.method === 'Runtime.callFunctionOn' && + call.params.objectId === 'exception-1' && + call.params.functionDeclaration.includes('storeException')); + assert(frameStoreIndex > -1); + assert(exceptionStoreIndex > frameStoreIndex); +} + +async function testHandlePausedResumesAndDeliversDescriptor () { + const client = new FakeCDPClient({ + properties: { 'scope-local': [], 'scope-closure': [] } + }); + const service = new InspectorService({ client }); + await service.handlePaused(pausedPayload()); + + assert(client.calls.some(call => call.method === 'Debugger.resume')); + const deliver = client.calls.find(call => call.method === 'Runtime.evaluate'); + assert(deliver.params.expression.includes('capture-test')); + assert(deliver.params.expression.includes('deliverCapture')); +} + +async function run () { + await testBindingNameFiltering(); + await testTargetSelection(); + await testCaptureStoresValuesInRenderer(); + await testExceptionCaptureStoresExceptionObject(); + await testHandlePausedResumesAndDeliversDescriptor(); + console.log('inspector service tests ok'); +} + +if (require.main === module) { + run().catch(err => { + console.error(err && err.stack || err); + process.exit(1); + }); +} + +module.exports = { run }; diff --git a/lively.context/lib/inspector-runtime.js b/lively.context/lib/inspector-runtime.js index 671a14f188..5b5dd59516 100644 --- a/lively.context/lib/inspector-runtime.js +++ b/lively.context/lib/inspector-runtime.js @@ -46,7 +46,7 @@ function installDesktopDebuggerBridge (runtime) { desktop.debugger = { ...existing, isAvailable: existing.isAvailable || function () { return true; }, - deliverCapture: existing.deliverCapture || function (descriptor) { + deliverCapture: function (descriptor) { return runtime.deliverCapture(descriptor); } }; @@ -128,19 +128,20 @@ export class InspectorRegistry { if (!context) throw new Error('Cannot store frame for unknown debug context ' + contextId); const frameId = frameSpec.frameId || frameSpec.id || 'frame-' + (++this.frameCount); - const frame = { + const frame = context.frames[frameId] || { frameId, contextId, index: context.frameOrder.length, - functionName: frameSpec.functionName || frameSpec.name || '', - source: frameSpec.source || null, - location: frameSpec.location || null, thisRef: null, argumentsRef: null, exceptionRef: null, scopeRefs: [] }; + frame.functionName = frameSpec.functionName || frameSpec.name || frame.functionName || ''; + frame.source = frameSpec.source || frame.source || null; + frame.location = frameSpec.location || frame.location || null; + if (own(frameSpec, 'thisValue')) frame.thisRef = this.storeValue(contextId, frameSpec.thisValue, 'this'); else if (own(frameSpec, 'this')) frame.thisRef = this.storeValue(contextId, frameSpec.this, 'this'); if (own(frameSpec, 'arguments')) frame.argumentsRef = this.storeValue(contextId, frameSpec.arguments, 'arguments'); @@ -156,6 +157,15 @@ export class InspectorRegistry { return frame; } + storeException (contextId, exception, frameId = null) { + const context = this.getContext(contextId); + if (!context) throw new Error('Cannot store exception for unknown debug context ' + contextId); + const exceptionRef = this.storeValue(contextId, exception, 'exception'); + context.exceptionRef = exceptionRef; + if (frameId && context.frames[frameId]) context.frames[frameId].exceptionRef = exceptionRef; + return exceptionRef; + } + storeScope (contextId, frameId, scopeSpec = {}) { const context = this.getContext(contextId); if (!context) throw new Error('Cannot store scope for unknown debug context ' + contextId); diff --git a/lively.context/tests/inspector-runtime-test.js b/lively.context/tests/inspector-runtime-test.js index 382d13ea04..63c7ee0769 100644 --- a/lively.context/tests/inspector-runtime-test.js +++ b/lively.context/tests/inspector-runtime-test.js @@ -84,6 +84,38 @@ describe('inspector runtime', function () { expect(frame.lookup('missing')).equals(undefined); }); + it('updates a frame without dropping captured scopes', function () { + const receiver = { first: true }; + const nextReceiver = { second: true }; + createContext({ + thisValue: receiver, + scopes: [{ scopeId: 'local', bindings: { value: 3 } }] + }); + + registry.storeFrame('capture-1', { + frameId: 'frame-1', + functionName: 'inner renamed', + thisValue: nextReceiver + }); + + const frame = registry.continuationFor('capture-1').currentFrame; + expect(frame.functionName).equals('inner renamed'); + expect(frame.lookup('value')).equals(3); + expect(frame.getThis()).equals(nextReceiver); + }); + + it('stores exception references for contexts and frames', function () { + const exception = new Error('boom'); + createContext(); + + registry.storeException('capture-1', exception, 'frame-1'); + + const continuation = registry.continuationFor('capture-1'); + const frame = continuation.currentFrame; + expect(continuation.exception).equals(exception); + expect(frame.getException()).equals(exception); + }); + it('wraps continuations, frames, and scopes over registry ids', function () { createContext({ scopes: [{ scopeId: 'scope-1', type: 'local', name: 'Local', bindings: { value: 3 } }] From cf5a9058a92f5857bfc9e7fce811eb6690e5f02b Mon Sep 17 00:00:00 2001 From: merryman Date: Tue, 2 Jun 2026 17:11:43 +0200 Subject: [PATCH 03/14] Add Lively debugger UI --- lively.app/desktop/inspector-service.cjs | 25 ++ lively.app/tests/inspector-service-test.cjs | 22 ++ lively.context/lib/inspector-runtime.js | 87 ++++- .../tests/inspector-runtime-test.js | 57 ++- lively.ide/js/debugger/ui.cp.js | 353 ++++++++++++++++++ 5 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 lively.ide/js/debugger/ui.cp.js diff --git a/lively.app/desktop/inspector-service.cjs b/lively.app/desktop/inspector-service.cjs index ee56fbb158..b77473ab98 100644 --- a/lively.app/desktop/inspector-service.cjs +++ b/lively.app/desktop/inspector-service.cjs @@ -10,6 +10,7 @@ const https = require('https'); const DEFAULT_CDP_PORT = 9222; const DEFAULT_TARGET_TIMEOUT = 30000; const DEFAULT_TARGET_INTERVAL = 250; +const HALT_UNWIND_TAG = 'lively.context.inspector.halt'; function noop () {} @@ -177,6 +178,10 @@ const CONSUME_ARMED_HALT_EXPRESSION = `(() => { return debuggerBridge.consumeArmedHalt(); })()`; +const IS_HALT_UNWIND_FUNCTION = `function livelyInspectorIsHaltUnwind() { + return !!this && (this.isLivelyInspectorHaltUnwind || this.tag === '${HALT_UNWIND_TAG}'); +}`; + class CDPClient { constructor (url, { WebSocketImpl = globalThis.WebSocket } = {}) { if (!WebSocketImpl) throw new Error('No WebSocket implementation available for CDP'); @@ -328,6 +333,11 @@ class InspectorService { const callFrames = params.callFrames || []; if (!callFrames.length) return null; + if (params.reason === 'exception' && params.data && params.data.objectId && + await this.isHaltUnwindException(params.data.objectId)) { + return null; + } + const armed = await this.consumeArmedHalt(callFrames[0]); if (!armed && params.reason !== 'exception') return null; @@ -426,6 +436,21 @@ class InspectorService { } } + async isHaltUnwindException (objectId) { + try { + const result = await this.client.send('Runtime.callFunctionOn', { + objectId, + functionDeclaration: IS_HALT_UNWIND_FUNCTION, + returnByValue: true, + silent: true + }); + return !!(result && result.result && result.result.value); + } catch (err) { + this.log('inspector halt unwind check failed: ' + (err.stack || err)); + return false; + } + } + async storeFrame (frame, payload) { const thisObject = frame && frame.this; if (thisObject && thisObject.objectId) { diff --git a/lively.app/tests/inspector-service-test.cjs b/lively.app/tests/inspector-service-test.cjs index ad7b039593..2e0944827d 100644 --- a/lively.app/tests/inspector-service-test.cjs +++ b/lively.app/tests/inspector-service-test.cjs @@ -24,6 +24,9 @@ class FakeCDPClient { return { result: this.properties[params.objectId] || [] }; } if (method === 'Runtime.callFunctionOn') { + if (params.functionDeclaration.includes('IsHaltUnwind')) { + return { result: { value: params.objectId === 'halt-unwind' } }; + } return { result: { value: true } }; } if (method === 'Runtime.evaluate') { @@ -135,6 +138,24 @@ async function testExceptionCaptureStoresExceptionObject () { assert(exceptionStoreIndex > frameStoreIndex); } +async function testTaggedHaltUnwindIsIgnored () { + const client = new FakeCDPClient({ armed: null }); + const service = new InspectorService({ client }); + const descriptor = await service.capturePaused(pausedPayload({ + reason: 'exception', + data: { type: 'object', objectId: 'halt-unwind', description: '[LivelyInspectorHalt halt]' } + })); + + assert.strictEqual(descriptor, null); + const unwindCheck = client.calls.find(call => + call.method === 'Runtime.callFunctionOn' && + call.params.objectId === 'halt-unwind'); + assert.doesNotThrow(() => new Function('return (' + unwindCheck.params.functionDeclaration + ')')()); + assert(!client.calls.some(call => + call.method === 'Runtime.getProperties' || + (call.method === 'Runtime.callFunctionOn' && call.params.objectId === 'scope-local'))); +} + async function testHandlePausedResumesAndDeliversDescriptor () { const client = new FakeCDPClient({ properties: { 'scope-local': [], 'scope-closure': [] } @@ -153,6 +174,7 @@ async function run () { await testTargetSelection(); await testCaptureStoresValuesInRenderer(); await testExceptionCaptureStoresExceptionObject(); + await testTaggedHaltUnwindIsIgnored(); await testHandlePausedResumesAndDeliversDescriptor(); console.log('inspector service tests ok'); } diff --git a/lively.context/lib/inspector-runtime.js b/lively.context/lib/inspector-runtime.js index 5b5dd59516..58946eb11e 100644 --- a/lively.context/lib/inspector-runtime.js +++ b/lively.context/lib/inspector-runtime.js @@ -58,6 +58,81 @@ function frameIdsFromContext (context) { return context.frameOrder.slice(); } +function importDebuggerUI () { + const system = systemObject(); + if (system && typeof system.import === 'function') { + return system.import('lively.ide/js/debugger/ui.cp.js'); + } + return import('lively.ide/js/debugger/ui.cp.js'); +} + +function openContinuationForRuntime (runtime, continuation) { + if (!runtime.autoOpen || !continuation) return Promise.resolve(null); + if (runtime.openedCaptureIds.has(continuation.id)) return Promise.resolve(null); + runtime.openedCaptureIds.add(continuation.id); + + const open = runtime.openForContinuation || (async continuation => { + const mod = await importDebuggerUI(); + return mod.openForContinuation(continuation); + }); + + return Promise.resolve().then(() => open(continuation)).catch(err => { + runtime.openedCaptureIds.delete(continuation.id); + if (typeof console !== 'undefined' && console.warn) { + console.warn('Could not open lively.context debugger', err); + } + return null; + }); +} + +function installCaptureListener (runtime) { + const Global = globalObject(); + Global.__LIVELY_INSPECTOR_CAPTURE_RUNTIME__ = runtime; + if (Global.__LIVELY_INSPECTOR_CAPTURE_LISTENER__) { + drainPendingCaptures(runtime); + return; + } + runtime.captureListenerInstalled = true; + Global.__LIVELY_INSPECTOR_CAPTURE_LISTENER__ = true; + + if (typeof Global.addEventListener === 'function') { + Global.addEventListener('lively-desktop-debugger-capture', evt => { + const activeRuntime = Global.__LIVELY_INSPECTOR_CAPTURE_RUNTIME__; + if (activeRuntime && evt && evt.detail) activeRuntime.deliverCapture(evt.detail); + }); + } + + drainPendingCaptures(runtime); +} + +function installHaltUnwindSuppression () { + const Global = globalObject(); + if (Global.__LIVELY_INSPECTOR_HALT_SUPPRESSION__) return; + Global.__LIVELY_INSPECTOR_HALT_SUPPRESSION__ = true; + + if (typeof Global.addEventListener !== 'function') return; + + Global.addEventListener('error', evt => { + if (evt && isInspectorHaltUnwind(evt.error) && typeof evt.preventDefault === 'function') { + evt.preventDefault(); + } + }, true); + + Global.addEventListener('unhandledrejection', evt => { + if (evt && isInspectorHaltUnwind(evt.reason) && typeof evt.preventDefault === 'function') { + evt.preventDefault(); + } + }, true); +} + +function drainPendingCaptures (runtime) { + const Global = globalObject(); + const pending = Global.__LIVELY_PENDING_DEBUGGER_CAPTURES__; + if (Array.isArray(pending) && pending.length) { + pending.splice(0).forEach(descriptor => runtime.deliverCapture(descriptor)); + } +} + export class InspectorRegistry { constructor ({ bridge } = {}) { this.bridge = bridge || null; @@ -385,7 +460,7 @@ export function isInspectorHaltUnwind (err) { return !!err && (err.isLivelyInspectorHaltUnwind || err.tag === HALT_UNWIND_TAG); } -export function installInspectorRuntime ({ bridge, env } = {}) { +export function installInspectorRuntime ({ bridge, env, autoOpen = true, openForContinuation = null } = {}) { const livelyEnv = env || getLivelyEnv(); let registry = livelyEnv.debuggerContexts; @@ -399,8 +474,14 @@ export function installInspectorRuntime ({ bridge, env } = {}) { runtime = { registry, bridge: bridge || registry.bridge || null, + autoOpen, + openForContinuation, + openedCaptureIds: new Set(), + captureListenerInstalled: false, deliverCapture (descriptor) { - return registry.deliverCapture(descriptor); + const continuation = registry.deliverCapture(descriptor); + openContinuationForRuntime(this, continuation); + return continuation; } }; @@ -408,6 +489,8 @@ export function installInspectorRuntime ({ bridge, env } = {}) { if (!runtime.bridge && desktopBridge && typeof desktopBridge.armHalt === 'function') { runtime.bridge = desktopBridge; } + installCaptureListener(runtime); + installHaltUnwindSuppression(); return runtime; } diff --git a/lively.context/tests/inspector-runtime-test.js b/lively.context/tests/inspector-runtime-test.js index 63c7ee0769..30eca9f97a 100644 --- a/lively.context/tests/inspector-runtime-test.js +++ b/lively.context/tests/inspector-runtime-test.js @@ -1,5 +1,5 @@ "format esm"; -/*global describe, it, beforeEach*/ +/*global describe, it, beforeEach, afterEach, globalThis*/ import { expect } from 'mocha-es6'; import { InspectorRegistry, @@ -18,6 +18,10 @@ describe('inspector runtime', function () { registry = new InspectorRegistry(); }); + afterEach(function () { + delete globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__; + }); + function createContext (spec = {}) { return registry.createContext({ id: 'capture-1', @@ -161,6 +165,57 @@ describe('inspector runtime', function () { expect(runtime.registry).to.be.instanceof(InspectorRegistry); }); + it('auto-opens delivered captures once', async function () { + const opened = []; + const runtime = installInspectorRuntime({ + env: {}, + openForContinuation: continuation => opened.push(continuation) + }); + + runtime.deliverCapture({ captureId: 'capture-open', frames: [] }); + runtime.deliverCapture({ captureId: 'capture-open', frames: [] }); + await Promise.resolve(); + + expect(opened).to.have.length(1); + expect(opened[0].id).equals('capture-open'); + }); + + it('drains pending desktop captures when installed', async function () { + const opened = []; + globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__ = [ + { captureId: 'capture-pending', frames: [] } + ]; + + installInspectorRuntime({ + env: {}, + openForContinuation: continuation => opened.push(continuation) + }); + await Promise.resolve(); + + expect(opened).to.have.length(1); + expect(opened[0].id).equals('capture-pending'); + }); + + it('suppresses tagged halt unwind boundary events', function () { + const oldAddEventListener = globalThis.addEventListener; + const handlers = {}; + delete globalThis.__LIVELY_INSPECTOR_HALT_SUPPRESSION__; + globalThis.addEventListener = (type, handler) => { handlers[type] = handler; }; + + try { + installInspectorRuntime({ env: {}, autoOpen: false }); + let prevented = false; + handlers.error({ + error: { tag: 'lively.context.inspector.halt' }, + preventDefault () { prevented = true; } + }); + expect(prevented).equals(true); + } finally { + globalThis.addEventListener = oldAddEventListener; + delete globalThis.__LIVELY_INSPECTOR_HALT_SUPPRESSION__; + } + }); + it('arms the bridge and throws a tagged halt unwind', function () { const calls = []; installInspectorRuntime({ diff --git a/lively.ide/js/debugger/ui.cp.js b/lively.ide/js/debugger/ui.cp.js new file mode 100644 index 0000000000..1bb42e6ee9 --- /dev/null +++ b/lively.ide/js/debugger/ui.cp.js @@ -0,0 +1,353 @@ +import { GridLayout, TilingLayout, ViewModel, component, part, Label, Text, Icon, config } from 'lively.morphic'; +import { Color, pt, rect } from 'lively.graphics'; +import { DarkButton } from 'lively.components/buttons.cp.js'; +import { DarkList } from 'lively.components/list.cp.js'; +import { signal } from 'lively.bindings'; +import { InspectionTree, PropertyTree, printValue } from '../inspector/context.js'; + +function frameLabel (frame, index) { + const name = frame.functionName || ''; + const location = frame.location || {}; + const line = Number.isFinite(location.lineNumber) ? ':' + (location.lineNumber + 1) : ''; + return '#' + index + ' ' + name + line; +} + +function sourceSummary (frame) { + if (!frame) return ''; + const source = frame.source || {}; + const location = frame.location || {}; + const lines = [ + frame.functionName ? 'function ' + frame.functionName : '', + source.url || source.scriptId || '(no source url)', + Number.isFinite(location.lineNumber) + ? 'line ' + (location.lineNumber + 1) + ', column ' + ((location.columnNumber || 0) + 1) + : '' + ].filter(Boolean); + return lines.join('\n'); +} + +function scopeLabel (scope) { + const names = scope.bindingNames(); + const suffix = names.length ? ' (' + names.length + ')' : ''; + return (scope.name || scope.type || 'scope') + suffix; +} + +function valueTreeObjectForScope (scope) { + const bindings = scope && scope.bindings; + return bindings || {}; +} + +export class LivelyDebuggerModel extends ViewModel { + static get properties () { + return { + continuation: {}, + selectedFrame: {}, + selectedScope: {}, + + expose: { + get () { return ['continuation', 'onWindowClose', 'closeDebugger']; } + }, + + bindings: { + get () { + return [ + { target: 'stack list', signal: 'selection', handler: 'selectFrame' }, + { target: 'scope list', signal: 'selection', handler: 'selectScope' }, + { target: 'close button', signal: 'fire', handler: 'closeDebugger' }, + { target: 'proceed button', signal: 'fire', handler: 'disabledAction' }, + { target: 'retry button', signal: 'fire', handler: 'disabledAction' }, + { target: 'step into button', signal: 'fire', handler: 'disabledAction' }, + { target: 'step over button', signal: 'fire', handler: 'disabledAction' }, + { target: 'step out button', signal: 'fire', handler: 'disabledAction' }, + { target: 'restart frame button', signal: 'fire', handler: 'disabledAction' } + ]; + } + } + }; + } + + viewDidLoad () { + this.refreshFromContinuation(); + } + + renderDraggableTreeLabel (args) { + return args.value; + } + + renderPropertyControl ({ keyString, valueString }) { + return keyString + ': ' + valueString; + } + + refreshSelectedLine () {} + + refreshFromContinuation () { + const frames = this.continuation ? this.continuation.frames() : []; + this.ui.stackList.items = frames.map((frame, index) => ({ + isListItem: true, + string: frameLabel(frame, index), + value: frame + })); + this.ui.stackList.selection = frames[0] || null; + this.selectFrame(frames[0] || null); + this.updateStatus(); + this.disableFutureButtons(); + } + + updateStatus () { + const reason = this.continuation && this.continuation.reason || 'debugger'; + const exception = this.continuation && this.continuation.exception; + const exceptionText = exception ? ' ' + printValue(exception) : ''; + this.ui.status.textString = reason + exceptionText; + } + + disableFutureButtons () { + for (const name of [ + 'proceed button', + 'retry button', + 'step into button', + 'step over button', + 'step out button', + 'restart frame button' + ]) { + const button = this.ui[name]; + if (button && button.viewModel) button.viewModel.disable(); + } + } + + selectFrame (frame) { + this.selectedFrame = frame; + this.ui.sourcePane.textString = sourceSummary(frame); + const scopes = frame ? frame.scopes() : []; + this.ui.scopeList.items = scopes.map(scope => ({ + isListItem: true, + string: scopeLabel(scope), + value: scope + })); + this.ui.scopeList.selection = scopes[0] || null; + this.selectScope(scopes[0] || null); + } + + async selectScope (scope) { + this.selectedScope = scope; + const tree = this.ui.valueTree; + const treeData = InspectionTree.forObject(valueTreeObjectForScope(scope), this); + await treeData.collapse(treeData.root, false); + if (treeData.root.children && treeData.root.children[0]) { + await treeData.collapse(treeData.root.children[0], false); + } + tree.treeData = treeData; + if (tree.treeData.root.isCollapsed) { + await tree.onNodeCollapseChanged({ node: treeData.root, isCollapsed: false }); + tree.selectedIndex = 1; + } + } + + disabledAction () { + signal(this.view, 'debuggerActionUnavailable', this.selectedFrame); + } + + onWindowClose () { + if (this.continuation && this.continuation.release) this.continuation.release(); + this.continuation = null; + } + + closeDebugger () { + this.onWindowClose(); + const win = this.view.getWindow && this.view.getWindow(); + if (win) win.close(false); + else this.view.remove(); + } +} + +const ToolbarButton = component(DarkButton, { + extent: pt(30, 24), + padding: rect(4, 4, 0, 0), + submorphs: [{ + name: 'label', + fontSize: 13 + }] +}); + +export const LivelyDebugger = component({ + name: 'lively debugger', + defaultViewModel: LivelyDebuggerModel, + extent: pt(900, 560), + fill: Color.rgb(247, 248, 248), + borderRadius: 4, + layout: new GridLayout({ + autoAssign: false, + grid: [ + ['toolbar', 'toolbar'], + ['stack list', 'main pane'], + ['status', 'status'] + ], + groups: { + toolbar: { align: 'topLeft', resize: true }, + 'stack list': { align: 'topLeft', resize: true }, + 'main pane': { align: 'topLeft', resize: true }, + status: { align: 'topLeft', resize: true } + }, + columns: [ + 0, { fixed: 260, paddingRight: 6 }, + 1, { width: 1 } + ], + rows: [ + 0, { fixed: 36 }, + 1, { height: 1 }, + 2, { fixed: 26 } + ] + }), + submorphs: [{ + name: 'toolbar', + fill: Color.rgb(52, 73, 94), + layout: new TilingLayout({ + axisAlign: 'center', + orderByIndex: true, + padding: rect(6, 6, 6, 6), + spacing: 5, + resizePolicies: [['title', { width: 'fill', height: 'fixed' }]] + }), + submorphs: [ + part(ToolbarButton, { + name: 'close button', + tooltip: 'Close', + viewModel: { label: { value: Icon.textAttribute('times') } } + }), + part(ToolbarButton, { + name: 'proceed button', + tooltip: 'Proceed', + viewModel: { label: { value: Icon.textAttribute('play-circle') } } + }), + part(ToolbarButton, { + name: 'retry button', + tooltip: 'Retry', + viewModel: { label: { value: Icon.textAttribute('redo') } } + }), + part(ToolbarButton, { + name: 'step into button', + tooltip: 'Step Into', + viewModel: { label: { value: Icon.textAttribute('arrow-down') } } + }), + part(ToolbarButton, { + name: 'step over button', + tooltip: 'Step Over', + viewModel: { label: { value: Icon.textAttribute('arrow-right') } } + }), + part(ToolbarButton, { + name: 'step out button', + tooltip: 'Step Out', + viewModel: { label: { value: Icon.textAttribute('arrow-up') } } + }), + part(ToolbarButton, { + name: 'restart frame button', + tooltip: 'Restart Frame', + viewModel: { label: { value: Icon.textAttribute('rotate-left') } } + }), + { + type: Label, + name: 'title', + value: 'Lively Debugger', + fontColor: Color.white, + fontSize: 14, + fontWeight: 'bold', + reactsToPointer: false + } + ] + }, + part(DarkList, { + name: 'stack list', + fill: Color.rgb(43, 50, 55), + fontFamily: 'IBM Plex Mono', + fontSize: 12, + itemHeight: 24, + manualItemHeight: true, + padding: rect(4, 4, 4, 4) + }), + { + name: 'main pane', + fill: Color.transparent, + layout: new GridLayout({ + autoAssign: false, + grid: [ + ['source pane'], + ['scope/value pane'] + ], + groups: { + 'source pane': { align: 'topLeft', resize: true }, + 'scope/value pane': { align: 'topLeft', resize: true } + }, + rows: [ + 0, { fixed: 160, paddingBottom: 6 }, + 1, { height: 1 } + ] + }), + submorphs: [{ + type: Text, + name: 'source pane', + readOnly: true, + fixedWidth: true, + fixedHeight: true, + lineWrapping: 'by-words', + padding: rect(8, 8, 0, 0), + borderColor: Color.rgb(189, 195, 199), + borderWidth: 1, + fill: Color.rgb(253, 253, 253), + ...config.codeEditor.defaultStyle, + fontSize: 13, + textString: '' + }, { + name: 'scope/value pane', + fill: Color.transparent, + layout: new GridLayout({ + autoAssign: false, + grid: [['scope list', 'value tree']], + groups: { + 'scope list': { align: 'topLeft', resize: true }, + 'value tree': { align: 'topLeft', resize: true } + }, + columns: [ + 0, { fixed: 190, paddingRight: 6 }, + 1, { width: 1 } + ] + }), + submorphs: [ + part(DarkList, { + name: 'scope list', + fill: Color.rgb(56, 64, 71), + fontFamily: 'IBM Plex Mono', + fontSize: 12, + itemHeight: 22, + manualItemHeight: true, + padding: rect(4, 4, 4, 4) + }), + { + type: PropertyTree, + name: 'value tree', + fill: Color.white, + borderColor: Color.rgb(189, 195, 199), + borderWidth: 1, + clipMode: 'hidden', + fontFamily: 'IBM Plex Mono', + fontSize: 13, + treeData: {} + }] + }] + }, { + type: Label, + name: 'status', + value: '', + fill: Color.rgb(236, 240, 241), + fontColor: Color.rgb(44, 62, 80), + fontFamily: 'IBM Plex Sans', + fontSize: 12, + padding: rect(6, 4, 0, 0) + }] +}); + +export function openForContinuation (continuation, world = null) { + const debuggerMorph = part(LivelyDebugger, { viewModel: { continuation } }); + const targetWorld = world || (typeof $world !== 'undefined' && $world); + const win = debuggerMorph.openInWindow({ title: 'Lively Debugger', world: targetWorld }); + if (win && win.activate) win.activate(); + return debuggerMorph; +} From 888c1440d0dd5975ce72cff55d446d4121d0bea5 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 12:56:41 +0200 Subject: [PATCH 04/14] Fix Node 25 mocha-es6 bootstrap --- lively.context/lib/exception.js | 6 ++- lively.context/lib/stackReification.js | 2 +- mocha-es6/bin/mocha-es6.js | 28 +++++++++---- mocha-es6/mocha-es6.js | 56 ++++++-------------------- 4 files changed, 39 insertions(+), 53 deletions(-) diff --git a/lively.context/lib/exception.js b/lively.context/lib/exception.js index ebc53a66e4..bff43ada92 100644 --- a/lively.context/lib/exception.js +++ b/lively.context/lib/exception.js @@ -4,6 +4,8 @@ import { Scope, Frame, Function as AcornFunction } from "./interpreter.js"; import { getCurrentASTRegistry } from "lively.context"; import { acorn } from "lively.ast"; +let Global = typeof window !== "undefined" ? window : globalThis; + export function __createClosure(namespace, idx, parentFrameState, f) { // FIXME: Either save idx and use __getClosure later or attach the AST here and now (code dup.)? var registry = getCurrentASTRegistry(); @@ -14,7 +16,7 @@ export function __createClosure(namespace, idx, parentFrameState, f) { return f; } -window.__createClosure = __createClosure; +Global.__createClosure = __createClosure; // FIXME naming -- actually we return the ast node not a closure export function __getClosure(namespace, idx) { @@ -106,4 +108,4 @@ export class UnwindException { } // fixme: User proper reqriting that does not depend on global var -window.UnwindException = UnwindException; +Global.UnwindException = UnwindException; diff --git a/lively.context/lib/stackReification.js b/lively.context/lib/stackReification.js index 909b2f5d48..b5436cb2f2 100644 --- a/lively.context/lib/stackReification.js +++ b/lively.context/lib/stackReification.js @@ -4,7 +4,7 @@ import { escodegen, parseFunction } from "lively.ast"; import { Interpreter } from "./interpreter.js"; import { getCurrentASTRegistry, rewriteFunction } from "lively.context"; -let Global = window; +let Global = typeof window !== "undefined" ? window : globalThis; let NativeArrayFunctions = { diff --git a/mocha-es6/bin/mocha-es6.js b/mocha-es6/bin/mocha-es6.js index 1304adbdc5..16f6cd27ef 100755 --- a/mocha-es6/bin/mocha-es6.js +++ b/mocha-es6/bin/mocha-es6.js @@ -1,12 +1,21 @@ #! /usr/bin/env node /*global require, process, __dirname*/ -require("systemjs") +global.System = global.System || require("systemjs") var modules = require("lively.modules") -var resource = lively.resources.resource; +var lang = require("lively.lang") +var ast = require("lively.ast") +var resources = require("lively.resources") +var livelyGlobal = global.lively || (global.lively = {}); +livelyGlobal.modules = livelyGlobal.modules || modules; +livelyGlobal.lang = livelyGlobal.lang || lang; +livelyGlobal.ast = livelyGlobal.ast || ast; +livelyGlobal.resources = livelyGlobal.resources || resources; +var resource = resources.resource; var parseArgs = require('minimist'); var glob = require('glob'); +require("babel-regenerator-runtime/runtime.js"); var mochaEs6 = require("../mocha-es6.js") var path = require("path"); var fs = require("fs"); @@ -17,11 +26,12 @@ var depDir = path.join(dir, ".dependencies") var step = 1; var args; -lively.lang.promise.chain([ +lang.promise.chain([ () => { // prep modules.System.trace = true cacheMocha(modules.System, "file://" + mochaDir); - modules.unwrapModuleLoad(); + if (modules.unwrapModuleLoad) modules.unwrapModuleLoad(); + else modules.unwrapModuleResolution(); readProcessArgs(); }, () => setupFlatn(), @@ -37,7 +47,7 @@ lively.lang.promise.chain([ failureCount => !args.l2l && process.exit(failureCount) ]).catch(err => { console.error(err.stack || err); - if (!args.l2l) process.exit(1); + if (!args || !args.l2l) process.exit(1); }); function readProcessArgs() { @@ -96,9 +106,13 @@ function setupLivelyModulesTestSystem() { registry.devPackageDirs = env.FLATN_DEV_PACKAGE_DIRS.split(":").filter(Boolean).map(resourcify); lively.modules.changeSystem(System, true); cacheMocha(System, "file://" + mochaDir); - mochaEs6.installSystemInstantiateHook(); // System.debug = true; - return registry.update(); + return registry.update() + .then(() => import('lively.source-transform/babel/plugin.js')) + .then(({ setupBabelTranspiler }) => { + setupBabelTranspiler(System); + mochaEs6.installSystemInstantiateHook(); + }); function resourcify(path) { return resource("file://" + path).asDirectory(); } } diff --git a/mocha-es6/mocha-es6.js b/mocha-es6/mocha-es6.js index 7593fccaf7..796ee6ff01 100644 --- a/mocha-es6/mocha-es6.js +++ b/mocha-es6/mocha-es6.js @@ -19808,47 +19808,17 @@ function join(pathA, pathB) { function installSystemInstantiateHook() { var name = "mochaEs6TestInstantiater"; if (modules.isHookInstalled("instantiate", name)) return; - modules.installHook("instantiate", function () { - var _ref4 = asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee4(proceed, load) { - var executable, deps; - return regeneratorRuntime.wrap(function _callee4$(_context4) { - while (1) { - switch (_context4.prev = _context4.next) { - case 0: - _context4.next = 2; - return proceed(load); - - case 2: - executable = _context4.sent; - deps = executable.deps; - _context4.next = 6; - return isMochaTestLoad(load, executable); - - case 6: - if (!_context4.sent) { - _context4.next = 8; - break; - } - - installMochaEs6ModuleExecute(load, executable); - - case 8: - return _context4.abrupt("return", executable); - - case 9: - case "end": - return _context4.stop(); - } - } - }, _callee4, this); - })); - - function mochaEs6TestInstantiater(_x6, _x7) { - return _ref4.apply(this, arguments); - } - - return mochaEs6TestInstantiater; - }()); + modules.installHook("instantiate", async function mochaEs6TestInstantiater(proceed, load) { + await proceed(load); + var records = System.REGISTER_INTERNAL && System.REGISTER_INTERNAL.records; + var record = records && records[load.name]; + var instantiatePromise = record && record.linkRecord && record.linkRecord.instantiatePromise; + if (!instantiatePromise) return; + instantiatePromise.then(async function (link) { + var linkRecord = link && link.linkRecord; + if (await isMochaTestLoad(load, linkRecord)) installMochaEs6ModuleExecute(load, linkRecord); + }); + }); console.log("[mocha-es6] System.instantiate hook installed to allow loading mocha tests"); } @@ -19863,7 +19833,7 @@ var isMochaTestLoad = function () { while (1) { switch (_context5.prev = _context5.next) { case 0: - deps = executable.deps || []; + deps = executable && (executable.deps || executable.dependencies) || []; if (deps.some(function (ea) { return ea.endsWith("mocha-es6") || ea.endsWith("mocha-es6/index.js"); @@ -19976,4 +19946,4 @@ exports.isMochaTestLoad = isMochaTestLoad; if (typeof module !== "undefined" && module.exports) module.exports = GLOBAL.mochaEs6; -})(); \ No newline at end of file +})(); From 2a4371f623335708416df75870db0b0af9a26f1f Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 13:27:41 +0200 Subject: [PATCH 05/14] Fix Lively debugger capture compatibility Ensure the inspector runtime is installed before paused-frame capture, collect real frame arguments through CDP, and surface this/arguments/exception in LivelyDebugger. Restore legacy lively.ast and lively.lang helper surfaces used by the continuation backend on Node 25, and allow the class runtime to wrap user-defined new-only superclasses. --- lively.app/desktop/inspector-service.cjs | 65 +++++++++++++++++++++ lively.app/tests/inspector-service-test.cjs | 19 +++++- lively.ast/index.js | 2 +- lively.ast/lib/acorn-extension.js | 11 +++- lively.ast/lib/parser.js | 2 + lively.ast/tests/acorn-extension-test.js | 13 ++++- lively.classes/runtime.js | 3 +- lively.context/tests/rewriter-test.js | 2 + lively.ide/js/debugger/ui.cp.js | 26 ++++++++- lively.lang/array.js | 10 ++++ lively.lang/tests/array-test.js | 13 +++++ 11 files changed, 159 insertions(+), 7 deletions(-) diff --git a/lively.app/desktop/inspector-service.cjs b/lively.app/desktop/inspector-service.cjs index b77473ab98..21dcad0dbc 100644 --- a/lively.app/desktop/inspector-service.cjs +++ b/lively.app/desktop/inspector-service.cjs @@ -182,6 +182,44 @@ const IS_HALT_UNWIND_FUNCTION = `function livelyInspectorIsHaltUnwind() { return !!this && (this.isLivelyInspectorHaltUnwind || this.tag === '${HALT_UNWIND_TAG}'); }`; +const ENSURE_INSPECTOR_RUNTIME_EXPRESSION = `(() => { + const Global = typeof globalThis !== 'undefined' ? globalThis : window; + if (Global.__LIVELY_INSPECTOR_CAPTURE_RUNTIME__) return true; + + const install = mod => { + if (mod && typeof mod.installInspectorRuntime === 'function') { + mod.installInspectorRuntime(); + return true; + } + return false; + }; + + if (Global.System && typeof Global.System.import === 'function') { + return Global.System.import('lively.context').then(install); + } + + const modules = Global.lively && Global.lively.modules; + if (modules && typeof modules.importPackage === 'function') { + return modules.importPackage('lively.context').then(install); + } + + return false; +})()`; + +const STORE_ARGUMENTS_FUNCTION = `(payload => { + ${RENDERER_HELPERS} + const registry = livelyInspectorRegistryForCapture(payload); + let args = []; + try { + if (typeof arguments !== 'undefined') args = Array.prototype.slice.call(arguments); + } catch (err) {} + registry.storeFrame(payload.captureId, { + frameId: payload.frameId, + arguments: args + }); + return { contextId: payload.captureId, frameId: payload.frameId, arguments: true }; +})`; + class CDPClient { constructor (url, { WebSocketImpl = globalThis.WebSocket } = {}) { if (!WebSocketImpl) throw new Error('No WebSocket implementation available for CDP'); @@ -356,6 +394,7 @@ class InspectorService { }; const exceptionObjectId = params.reason === 'exception' && params.data && params.data.objectId; + await this.ensureInspectorRuntime(); for (let i = 0; i < callFrames.length; i++) { const frame = callFrames[i]; @@ -370,6 +409,7 @@ class InspectorService { location: locationForFrame(frame) }; await this.storeFrame(frame, framePayload); + await this.storeArguments(frame, framePayload); if (i === 0 && exceptionObjectId) { await this.storeException(captureId, exceptionObjectId, reason, descriptor.metadata); } @@ -451,6 +491,21 @@ class InspectorService { } } + async ensureInspectorRuntime () { + try { + const result = await this.client.send('Runtime.evaluate', { + expression: ENSURE_INSPECTOR_RUNTIME_EXPRESSION, + awaitPromise: true, + returnByValue: true, + silent: true + }); + return !!(result && result.result && result.result.value); + } catch (err) { + this.log('inspector runtime install failed: ' + (err.stack || err)); + return false; + } + } + async storeFrame (frame, payload) { const thisObject = frame && frame.this; if (thisObject && thisObject.objectId) { @@ -477,6 +532,16 @@ class InspectorService { }); } + async storeArguments (frame, payload) { + if (!frame || !frame.callFrameId) return; + await this.client.send('Debugger.evaluateOnCallFrame', { + callFrameId: frame.callFrameId, + expression: `(${STORE_ARGUMENTS_FUNCTION})(${jsonForExpression(payload)})`, + returnByValue: true, + silent: true + }); + } + async storeScope (objectId, payload) { await this.client.send('Runtime.callFunctionOn', { objectId, diff --git a/lively.app/tests/inspector-service-test.cjs b/lively.app/tests/inspector-service-test.cjs index 2e0944827d..2139948da3 100644 --- a/lively.app/tests/inspector-service-test.cjs +++ b/lively.app/tests/inspector-service-test.cjs @@ -98,6 +98,18 @@ async function testCaptureStoresValuesInRenderer () { const getPropertiesCalls = client.calls.filter(call => call.method === 'Runtime.getProperties'); assert.strictEqual(getPropertiesCalls.length, 2); + const ensureRuntime = client.calls.find(call => + call.method === 'Runtime.evaluate' && + call.params.expression.includes('installInspectorRuntime')); + assert(ensureRuntime); + assert.strictEqual(ensureRuntime.params.awaitPromise, true); + + const argumentStore = client.calls.find(call => + call.method === 'Debugger.evaluateOnCallFrame' && + call.params.expression.includes('Array.prototype.slice.call(arguments)')); + assert(argumentStore); + assert(!argumentStore.params.expression.includes('SHOULD_NOT_LEAVE_CDP')); + const storeCalls = client.calls.filter(call => call.method === 'Runtime.callFunctionOn'); assert(storeCalls.some(call => call.params.objectId === 'this-0')); assert(storeCalls.some(call => call.params.objectId === 'scope-local')); @@ -136,6 +148,9 @@ async function testExceptionCaptureStoresExceptionObject () { call.params.functionDeclaration.includes('storeException')); assert(frameStoreIndex > -1); assert(exceptionStoreIndex > frameStoreIndex); + assert(client.calls.some(call => + call.method === 'Runtime.evaluate' && + call.params.expression.includes('installInspectorRuntime'))); } async function testTaggedHaltUnwindIsIgnored () { @@ -164,7 +179,9 @@ async function testHandlePausedResumesAndDeliversDescriptor () { await service.handlePaused(pausedPayload()); assert(client.calls.some(call => call.method === 'Debugger.resume')); - const deliver = client.calls.find(call => call.method === 'Runtime.evaluate'); + const deliver = client.calls.find(call => + call.method === 'Runtime.evaluate' && + call.params.expression.includes('deliverCapture')); assert(deliver.params.expression.includes('capture-test')); assert(deliver.params.expression.includes('deliverCapture')); } diff --git a/lively.ast/index.js b/lively.ast/index.js index b5d7331919..7e40e2f615 100644 --- a/lively.ast/index.js +++ b/lively.ast/index.js @@ -8,7 +8,7 @@ export { } from './lib/mozilla-ast-visitor-interface.js'; export { ReplaceManyVisitor, ReplaceVisitor, AllNodesVisitor } from './lib/visitors.js'; -export { parse, parseFunction, fuzzyParse } from './lib/parser.js'; +export { parse, parseFunction, fuzzyParse, addSource } from './lib/parser.js'; import { acorn, walk, custom } from './lib/acorn-extension.js'; import stringify, { escodegen } from './lib/stringify.js'; diff --git a/lively.ast/lib/acorn-extension.js b/lively.ast/lib/acorn-extension.js index 687189b42a..7bd07a4282 100644 --- a/lively.ast/lib/acorn-extension.js +++ b/lively.ast/lib/acorn-extension.js @@ -46,6 +46,12 @@ function acornNamespace (imported, expectedProperty, requireName) { return namespace; } +function mutableNamespace (namespace) { + const copy = Object.create(Object.getPrototypeOf(namespace)); + Object.defineProperties(copy, Object.getOwnPropertyDescriptors(namespace)); + return copy; +} + if (isNode) { // we need to utilize the native require here to bypass the source transform of the class // we can not use the native import, since that is asynchronous. @@ -60,7 +66,7 @@ if (isNode) { } const acornDefault = acornNamespace(_acornDefault, 'Parser', 'acorn'); -const walk = acornNamespace(_walk, 'make', 'acorn-walk'); +const walk = mutableNamespace(acornNamespace(_walk, 'make', 'acorn-walk')); const loose = acornNamespace(_loose, 'parse', 'acorn-loose'); const custom = {}; @@ -679,6 +685,9 @@ custom.visitors = { }, walk.base) }; +Object.assign(walk, custom); +acorn.walk = walk; + // -=-=-=-=-=-=-=-=-=-=-=-=-=- // from lively.ast.AstHelper // -=-=-=-=-=-=-=-=-=-=-=-=-=- diff --git a/lively.ast/lib/parser.js b/lively.ast/lib/parser.js index 6cee8732e2..f415c463c5 100644 --- a/lively.ast/lib/parser.js +++ b/lively.ast/lib/parser.js @@ -18,6 +18,8 @@ export { }; custom.addSource = addSource; +walk.addSource = addSource; +if (acorn.walk) acorn.walk.addSource = addSource; function addSource (parsed, source) { if (typeof parsed === 'string') { diff --git a/lively.ast/tests/acorn-extension-test.js b/lively.ast/tests/acorn-extension-test.js index 8310552a2b..1f7968b237 100644 --- a/lively.ast/tests/acorn-extension-test.js +++ b/lively.ast/tests/acorn-extension-test.js @@ -4,11 +4,22 @@ import { expect } from "mocha-es6"; import { withMozillaAstDo, rematchAstWithSource } from "../lib/mozilla-ast-visitor-interface.js"; import { parse } from "../lib/parser.js"; import { arr } from "lively.lang"; -import { acorn, walk, findSiblings, findNodeByAstIndex, findStatementOfNode, copy } from "../lib/acorn-extension.js"; +import { acorn, walk, addAstIndex, findSiblings, findNodeByAstIndex, findStatementOfNode, copy } from "../lib/acorn-extension.js"; import stringify from "../lib/stringify.js"; describe('walk extension', function() { + it("exposes legacy helpers on acorn walk", function() { + expect(walk.addAstIndex).equals(addAstIndex); + expect(walk.findNodeByAstIndex).equals(findNodeByAstIndex); + expect(walk.findStatementOfNode).equals(findStatementOfNode); + expect(walk.copy).equals(copy); + expect(acorn.walk.addAstIndex).equals(addAstIndex); + expect(acorn.walk.findNodeByAstIndex).equals(findNodeByAstIndex); + expect(acorn.walk.findStatementOfNode).equals(findStatementOfNode); + expect(acorn.walk.copy).equals(copy); + }); + it("finds siblings", function() { var src = 'function foo() {\nvar a;\nvar b;\nvar c;\nvar d;\n}'; var parsed = parse(src); diff --git a/lively.classes/runtime.js b/lively.classes/runtime.js index cda84afe9b..7c319c913f 100644 --- a/lively.classes/runtime.js +++ b/lively.classes/runtime.js @@ -2,7 +2,6 @@ import { prepareClassForManagedPropertiesAfterCreation } from './properties.js'; import { superclassSymbol, moduleSubscribeToToplevelChangesSym, moduleMetaSymbol, objMetaSymbol, initializeSymbol } from './util.js'; import { setPrototypeOf } from 'lively.lang/object.js'; -import { isNativeFunction } from 'lively.lang/function.js'; const constructorArgMatcher = /\([^\\)]*\)/; const NEW_ONLY_CLASSES = [Proxy, Map, WeakMap, Set]; @@ -76,7 +75,7 @@ function wrapNativeClassAsSuper (Class) { function Wrapper () { return constructNewOnly(Class, arguments, Object.getPrototypeOf(this).constructor); } - if (Class === null || !isNativeFunction(Class)) return Class; + if (Class === null) return Class; if (typeof Class !== 'function') { throw new TypeError('Super expression must either be null or a function'); } diff --git a/lively.context/tests/rewriter-test.js b/lively.context/tests/rewriter-test.js index fb4a0a8d31..c8f5fde79c 100644 --- a/lively.context/tests/rewriter-test.js +++ b/lively.context/tests/rewriter-test.js @@ -7,6 +7,8 @@ import { escodegen, parse } from "lively.ast"; import { string, arr, obj } from "lively.lang"; import { getCurrentASTRegistry, RecordingRewriter, setCurrentASTRegistry } from "lively.context"; import { stackCaptureMode, asRewrittenClosure } from "../lib/stackReification.js"; +import shallow from 'chai-shallow-deep-equal'; +shallow(chai); chai.use(function(chai, utils) { chai.ast = chai.ast || {}; diff --git a/lively.ide/js/debugger/ui.cp.js b/lively.ide/js/debugger/ui.cp.js index 1bb42e6ee9..378b84fa6c 100644 --- a/lively.ide/js/debugger/ui.cp.js +++ b/lively.ide/js/debugger/ui.cp.js @@ -37,6 +37,30 @@ function valueTreeObjectForScope (scope) { return bindings || {}; } +function syntheticScope (name, value, type = name) { + return { + name, + type, + bindingNames () { return [name]; }, + hasBinding (candidate) { return candidate === name; }, + lookup (candidate) { return candidate === name ? value : undefined; }, + get bindings () { return { [name]: value }; } + }; +} + +function visibleScopesForFrame (frame) { + if (!frame) return []; + const scopes = []; + const thisValue = frame.getThis && frame.getThis(); + const args = frame.getArguments && frame.getArguments(); + const exception = frame.getException && frame.getException(); + + if (thisValue !== undefined) scopes.push(syntheticScope('this', thisValue, 'receiver')); + if (args !== undefined) scopes.push(syntheticScope('arguments', args, 'arguments')); + if (exception !== undefined) scopes.push(syntheticScope('exception', exception, 'exception')); + return scopes.concat(frame.scopes()); +} + export class LivelyDebuggerModel extends ViewModel { static get properties () { return { @@ -117,7 +141,7 @@ export class LivelyDebuggerModel extends ViewModel { selectFrame (frame) { this.selectedFrame = frame; this.ui.sourcePane.textString = sourceSummary(frame); - const scopes = frame ? frame.scopes() : []; + const scopes = visibleScopesForFrame(frame); this.ui.scopeList.items = scopes.map(scope => ({ isListItem: true, string: scopeLabel(scope), diff --git a/lively.lang/array.js b/lively.lang/array.js index 3f796ab324..8bba6e33ab 100644 --- a/lively.lang/array.js +++ b/lively.lang/array.js @@ -186,6 +186,14 @@ function without (array, elem) { return array.filter(val => val !== elem); } +function include (array, elem) { + return array.includes(elem); +} + +function from (arrayLike, mapFn, thisArg) { + return Array.from(arrayLike, mapFn, thisArg); +} + /** * Returns a copy of `array` without all elements in `otherArr`. * @param {any[]} array @@ -1067,6 +1075,8 @@ export { reject, rejectByKey, without, + include, + from, withoutAll, uniq, uniqBy, diff --git a/lively.lang/tests/array-test.js b/lively.lang/tests/array-test.js index d5d5cd469a..3a547d46eb 100644 --- a/lively.lang/tests/array-test.js +++ b/lively.lang/tests/array-test.js @@ -20,6 +20,8 @@ import { uniq, uniqBy, without, + include, + from, batchify, sum, min, @@ -52,6 +54,17 @@ describe('arr', function () { expect([]).to.eql(without(array, 'a')); }); + it('include', function () { + expect(include(['a', 'b'], 'a')).to.equal(true); + expect(include(['a', 'b'], 'c')).to.equal(false); + }); + + it('from', function () { + let args = (function () { return from(arguments); }('a', 'b')); + expect(args).to.eql(['a', 'b']); + expect(from([1, 2], n => n + 1)).to.eql([2, 3]); + }); + it('mutableCompact', function () { let a = ['a', 'b', 'c', undefined]; delete a[1]; From d8ca9c2c0c2b9a3900c467d15f399b1bc5494dec Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 13:38:32 +0200 Subject: [PATCH 06/14] Fix context rewriter compatibility tests Strip top-level recorder references before stack reification rewrites captured function source, preserve primitive receivers in interpreted function forwarding wrappers, and parse the redeclaration rewriter test as script source. --- lively.context/lib/interpreter.js | 7 ++++--- lively.context/lib/stackReification.js | 13 +++++++++++-- lively.context/tests/rewriter-test.js | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lively.context/lib/interpreter.js b/lively.context/lib/interpreter.js index 6649f04370..dd062ff48d 100644 --- a/lively.context/lib/interpreter.js +++ b/lively.context/lib/interpreter.js @@ -1017,6 +1017,7 @@ export class Function { var self = this, forwardFn = function FNAME(/*args*/) { + 'use strict'; return self.apply(this, arr.from(arguments)); }, forwardSrc = forwardFn.toStringRewritten ? forwardFn.toStringRewritten() : forwardFn.toString(); @@ -1026,13 +1027,13 @@ export class Function { eval('(' + forwardSrc.replace('FNAME', this.name() || '') + ')'), { isInterpretableFunction: true, forInterpretation: function(interpreter) { - return function(/*args*/) { return self.apply(this, arr.from(arguments), interpreter); } + return function(/*args*/) { 'use strict'; return self.apply(this, arr.from(arguments), interpreter); } }, ast: function() { return self.node; }, setParentFrame: function(frame) { self.parentFrame = frame; }, startHalted: function(interpreter) { interpreter.haltAtNextStatement(); - return function(/*args*/) { return self.apply(this, arr.from(arguments), interpreter); } + return function(/*args*/) { 'use strict'; return self.apply(this, arr.from(arguments), interpreter); } }, // TODO: reactivate when necessary // evaluatedSource: function() { return ...; } @@ -1256,7 +1257,7 @@ export class Frame { setThis(thisObj) { return this.thisObj = thisObj; } - getThis() { return this.thisObj ? this.thisObj : Global; } + getThis() { return this.thisObj !== undefined ? this.thisObj : Global; } // control flow diff --git a/lively.context/lib/stackReification.js b/lively.context/lib/stackReification.js index b5436cb2f2..7a278effdc 100644 --- a/lively.context/lib/stackReification.js +++ b/lively.context/lib/stackReification.js @@ -1,11 +1,20 @@ /*global global, module,Global*/ import { Path, arr, Closure } from "lively.lang"; -import { escodegen, parseFunction } from "lively.ast"; +import { ReplaceVisitor, escodegen, parseFunction } from "lively.ast"; import { Interpreter } from "./interpreter.js"; import { getCurrentASTRegistry, rewriteFunction } from "lively.context"; let Global = typeof window !== "undefined" ? window : globalThis; +function removeToplevelRecorderRefs(ast, recorderName = '__lvVarRecorder') { + return ReplaceVisitor.run(ast, node => { + if (!node) return node; + if (node.type !== 'MemberExpression' || node.computed || !node.object) return node; + if (node.object.type !== 'Identifier' || node.object.name !== recorderName) return node; + return node.property; + }); +} + let NativeArrayFunctions = { sort: function(sortFunc) { @@ -239,7 +248,7 @@ export class RewrittenClosure extends Closure { rewrite(astRegistry) { var src = this.getFuncSource(), - ast = parseFunction(src), + ast = removeToplevelRecorderRefs(parseFunction(src)), namespace = '[runtime]'; // FIXME: URL not available here // if (this.originalFunc && this.originalFunc.sourceModule) diff --git a/lively.context/tests/rewriter-test.js b/lively.context/tests/rewriter-test.js index c8f5fde79c..399b33f8f8 100644 --- a/lively.context/tests/rewriter-test.js +++ b/lively.context/tests/rewriter-test.js @@ -500,7 +500,7 @@ describe('rewriting', function() { it('rewrites function re-declarations', function() { var src = 'function foo() { 1; } foo(); function foo() { 2; }', - ast = parser.parse(src), + ast = parser.parse(src, { sourceType: 'script' }), astCopy = obj.deepCopy(ast), result = rewrite(ast), expected = tryCatch(0, { 'foo': closureWrapper(0, 'foo', [], {}, '2;\n') }, From 1509ba0ff75b35af8197871473b6afe8976e5a8b Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 16:23:10 +0200 Subject: [PATCH 07/14] Fix lively.ast CI assertions --- lively.ast/tests/es6-test.js | 3 +-- lively.ast/tests/parser-test.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lively.ast/tests/es6-test.js b/lively.ast/tests/es6-test.js index 7b48f2a7ff..f09a5386db 100644 --- a/lively.ast/tests/es6-test.js +++ b/lively.ast/tests/es6-test.js @@ -8,7 +8,6 @@ describe('es6', function () { it('arrow function', function () { let code = '() => 23;'; let parsed = parse(code); - expect(parsed).has.nested.property('body[0].expression.type') - .equals('ArrowFunctionExpression'); + expect(parsed.body[0].expression.type).equals('ArrowFunctionExpression'); }); }); diff --git a/lively.ast/tests/parser-test.js b/lively.ast/tests/parser-test.js index 23a0dae7d3..ce568c1a74 100644 --- a/lively.ast/tests/parser-test.js +++ b/lively.ast/tests/parser-test.js @@ -7,8 +7,7 @@ import { parse, parseFunction } from '../lib/parser.js'; describe('parse', function () { it('JavaScript code', () => - expect(parse('1 + 2')) - .nested.property('body[0].type') + expect(parse('1 + 2').body[0].type) .equals('ExpressionStatement')); describe('async / await', () => { From 90031f16ac83e7e6730d726f64385165d7a1c44a Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 16:48:32 +0200 Subject: [PATCH 08/14] Fix remaining CI test compatibility --- lively.classes/tests/properties-test.js | 6 +++--- mocha-es6/bin/mocha-es6.js | 11 +++++++++-- mocha-es6/index.js | 11 +++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lively.classes/tests/properties-test.js b/lively.classes/tests/properties-test.js index 208fd1cc97..1dadfdab8b 100644 --- a/lively.classes/tests/properties-test.js +++ b/lively.classes/tests/properties-test.js @@ -85,13 +85,13 @@ describe('properties', function () { let obj = new classA(); prepareInstanceForProperties(obj, { valueStoreProperty: '_store' }, { test: {} }); expect(obj).has.property('_store'); - expect(obj).has.nested.property('_store.test', undefined); + expect(obj._store.test).equals(undefined); }); it('sets default values', () => { let obj = new classA(); prepareInstanceForProperties(obj, { valueStoreProperty: '_store' }, { test: { defaultValue: 23 } }); - expect(obj).has.nested.property('_store.test', 23); + expect(obj._store.test).equals(23); }); it('sets default value with initializer for derived value', () => { @@ -140,7 +140,7 @@ describe('properties', function () { let x = 3; let obj = new classA(); prepareInstanceForProperties(obj, { valueStoreProperty: '_store' }, { test: { initialize: () => x += 2 } }); expect(x).equals(5); - expect(obj).has.nested.property('_store.test', undefined); + expect(obj._store.test).equals(undefined); }); it('initialize uses values from outside', () => { diff --git a/mocha-es6/bin/mocha-es6.js b/mocha-es6/bin/mocha-es6.js index 16f6cd27ef..46af30a5d8 100755 --- a/mocha-es6/bin/mocha-es6.js +++ b/mocha-es6/bin/mocha-es6.js @@ -98,9 +98,16 @@ function cacheMocha(System, mochaDirURL) { function setupLivelyModulesTestSystem() { var baseURL = "file://" + dir, - System = lively.modules.getSystem("system-for-test", {baseURL}), - registry = System["__lively.modules__packageRegistry"] = new modules.PackageRegistry(System), + systemConfig = {baseURL}, + nodeRequire = modules.System && modules.System._nodeRequire || + global.System && global.System._nodeRequire || + require, + System, + registry, env = process.env; + if (nodeRequire) systemConfig._nodeRequire = nodeRequire; + System = lively.modules.getSystem("system-for-test", systemConfig); + registry = System["__lively.modules__packageRegistry"] = new modules.PackageRegistry(System); registry.packageBaseDirs = env.FLATN_PACKAGE_COLLECTION_DIRS.split(":").filter(Boolean).map(resourcify); registry.individualPackageDirs = (env.FLATN_PACKAGE_DIRS || "").split(":").filter(Boolean).map(resourcify); registry.devPackageDirs = env.FLATN_DEV_PACKAGE_DIRS.split(":").filter(Boolean).map(resourcify); diff --git a/mocha-es6/index.js b/mocha-es6/index.js index 241e560c69..3e7f44f328 100644 --- a/mocha-es6/index.js +++ b/mocha-es6/index.js @@ -211,10 +211,13 @@ export function installSystemInstantiateHook () { if (modules.isHookInstalled('instantiate', name)) return; modules.installHook('instantiate', async function mochaEs6TestInstantiater (proceed, load) { await proceed(load); - System.REGISTER_INTERNAL.records[load.name].linkRecord.instantiatePromise.then(async (link) => { - if (await isMochaTestLoad(load, link.linkRecord)) { - installMochaEs6ModuleExecute(load, link.linkRecord); - } + let records = System.REGISTER_INTERNAL && System.REGISTER_INTERNAL.records; + let record = records && records[load.name]; + let instantiatePromise = record && record.linkRecord && record.linkRecord.instantiatePromise; + if (!instantiatePromise) return; + instantiatePromise.then(async link => { + let linkRecord = link && link.linkRecord; + if (await isMochaTestLoad(load, linkRecord)) installMochaEs6ModuleExecute(load, linkRecord); }); }); console.log('[mocha-es6] System.instantiate hook installed to allow loading mocha tests'); From b7b7759072e5504f7971bf40673763c154775c5e Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 17:04:54 +0200 Subject: [PATCH 09/14] Improve CI test error annotations --- scripts/test.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/test.js b/scripts/test.js index 9f972376d4..6a145cc66f 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -20,6 +20,13 @@ const targetPackage = process.argv[2]; let passed = 0; let failed = 0; let skipped = 0; let markdownListOfFailingTests = ''; +function githubCommandValue (value) { + return String(value) + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); +} + if (CI) { console.log(`Running Tests for ${targetPackage} šŸ“¦`); } else { @@ -57,7 +64,7 @@ const req = http.request(options, res => { errorOutput += `\nRecent browser console errors:\n${data.browserErrors}`; } if (CI) { - console.log(`::error:: Running the tests produced the following error:\n${errorOutput}`); + console.log(`::error title=${githubCommandValue(`Tests failed for ${targetPackage}`)}::${githubCommandValue(errorOutput)}`); fs.appendFileSync('summary.txt', `āŒ Running the tests produced the following error:\n${errorOutput}\n`); } else console.log(`āŒ Running the tests produced the following error:\n${errorOutput}`); @@ -132,7 +139,7 @@ const req = http.request(options, res => { } catch (err) { console.log('SUMMARY-INDICATE-FAILURE'); if (CI) { - console.log(`::error:: Running the tests produced the following error:\n"${err}"`); + console.log(`::error title=${githubCommandValue(`Could not parse test results for ${targetPackage}`)}::${githubCommandValue(err)}`); fs.appendFileSync('test_output.md', `\n---\nāŒ Running the tests for **${targetPackage}** produced the following error:\n"${err}"\n`); } else { console.log(`āŒ Running the tests produced the following error:\n"${err}"`); From c2db6d1fd353d122f6e035e1d34fab7824a6c6d7 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 17:19:07 +0200 Subject: [PATCH 10/14] Load repository packages in CI test runner --- lively.server/plugins/test-runner.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lively.server/plugins/test-runner.js b/lively.server/plugins/test-runner.js index 4abdbb4e64..e962f61633 100644 --- a/lively.server/plugins/test-runner.js +++ b/lively.server/plugins/test-runner.js @@ -33,9 +33,14 @@ export default class TestRunner { const { loadPackage } = await System.import("lively-system-interface/commands/packages.js"); const packageToTestLoaded = localInterface.coreInterface.getPackages().find(pkg => pkg.name === '${module_to_test}'); if (!packageToTestLoaded){ + const repositoryPackage = resource('http://localhost:9011/${module_to_test}/'); + const localProjectPackage = resource('http://localhost:9011/local_projects/${module_to_test}/'); + const packageBase = await repositoryPackage.join('package.json').exists() + ? repositoryPackage + : localProjectPackage; await loadPackage(localInterface.coreInterface, { name: '${module_to_test}', - address: 'http://localhost:9011/local_projects/${module_to_test}', + address: packageBase.asFile().url, type: 'package' }); } From dbf71101f8c53b12f23ff9a3d4ead6bac7b055b5 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 17:34:18 +0200 Subject: [PATCH 11/14] Remove context test shallow plugin dependency --- lively.context/tests/continuation-test.js | 4 +-- lively.context/tests/helpers.js | 31 +++++++++++++++++++++++ lively.context/tests/rewriter-test.js | 4 +-- 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 lively.context/tests/helpers.js diff --git a/lively.context/tests/continuation-test.js b/lively.context/tests/continuation-test.js index 28407ba53a..4975b69b14 100644 --- a/lively.context/tests/continuation-test.js +++ b/lively.context/tests/continuation-test.js @@ -7,8 +7,8 @@ import { parseFunction, stringify } from "lively.ast"; import { Continuation, stackCaptureMode } from "../lib/stackReification.js"; import * as StackReification from "../lib/stackReification.js"; import { Interpreter } from "../lib/interpreter.js"; -import shallow from 'chai-shallow-deep-equal'; -shallow(chai); +import { installShallowDeepEqual } from './helpers.js'; +installShallowDeepEqual(chai); describe('continuation', function() { var config, diff --git a/lively.context/tests/helpers.js b/lively.context/tests/helpers.js new file mode 100644 index 0000000000..58234110cb --- /dev/null +++ b/lively.context/tests/helpers.js @@ -0,0 +1,31 @@ +export function installShallowDeepEqual (chai) { + if (chai.__livelyContextShallowDeepEqual) return; + chai.__livelyContextShallowDeepEqual = true; + + const eql = chai.util.eql; + + function shallowDeepEqual (actual, expected) { + if (eql(actual, expected)) return true; + if (!expected || typeof expected !== 'object') return false; + if (!actual || typeof actual !== 'object') return false; + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((ea, i) => shallowDeepEqual(actual[i], ea)); + } + return Object.keys(expected) + .every(key => shallowDeepEqual(actual[key], expected[key])); + } + + function print (value) { + try { return JSON.stringify(value); } catch (_) { return String(value); } + } + + chai.Assertion.addMethod('shallowDeepEqual', function (expected) { + const actual = this._obj; + this.assert( + shallowDeepEqual(actual, expected), + `expected ${print(actual)} to shallow-deep equal ${print(expected)}`, + `expected ${print(actual)} not to shallow-deep equal ${print(expected)}` + ); + }); +} diff --git a/lively.context/tests/rewriter-test.js b/lively.context/tests/rewriter-test.js index 399b33f8f8..531451a1f6 100644 --- a/lively.context/tests/rewriter-test.js +++ b/lively.context/tests/rewriter-test.js @@ -7,8 +7,8 @@ import { escodegen, parse } from "lively.ast"; import { string, arr, obj } from "lively.lang"; import { getCurrentASTRegistry, RecordingRewriter, setCurrentASTRegistry } from "lively.context"; import { stackCaptureMode, asRewrittenClosure } from "../lib/stackReification.js"; -import shallow from 'chai-shallow-deep-equal'; -shallow(chai); +import { installShallowDeepEqual } from './helpers.js'; +installShallowDeepEqual(chai); chai.use(function(chai, utils) { chai.ast = chai.ast || {}; From ba0469dbbe52b275ef1650ae6e7de02ab4a37d57 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 17:51:38 +0200 Subject: [PATCH 12/14] Annotate CI test assertion failures --- scripts/test.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/test.js b/scripts/test.js index 6a145cc66f..07f1010a8a 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -27,6 +27,34 @@ function githubCommandValue (value) { .replace(/\n/g, '%0A'); } +function truncate (value, maxLength = 8000) { + const str = String(value); + return str.length > maxLength ? `${str.slice(0, maxLength)}...` : str; +} + +function shortJSON (value) { + try { + return JSON.stringify(value).slice(0, 1000); + } catch (_) { + return String(value).slice(0, 1000); + } +} + +function testFailureDetails (test) { + const details = [test.fullTitle]; + const err = test.error; + if (!err) return details.join('\n'); + + const message = err.message || err; + if (message) details.push(message); + if (err.expected !== undefined && err.actual !== undefined) { + details.push(`EXPECTED: ${shortJSON(err.expected)}`); + details.push(`ACTUAL: ${shortJSON(err.actual)}`); + } + if (err.stack && err.stack !== message) details.push(err.stack); + return details.join('\n'); +} + if (CI) { console.log(`Running Tests for ${targetPackage} šŸ“¦`); } else { @@ -112,6 +140,7 @@ const req = http.request(options, res => { failed += 1; if (CI) { console.log(`${test.fullTitle} failed āŒ`); + console.log(`::error title=${githubCommandValue(`Test failed in ${targetPackage}`)}::${githubCommandValue(truncate(testFailureDetails(test)))}`); markdownListOfFailingTests = markdownListOfFailingTests + `- ${test.fullTitle} failed āŒ\n`; } else { console.log(`${test.fullTitle} failed āŒ`); From e5c82398f8dd275fc626ef5f62f173a6924bcc59 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 18:08:41 +0200 Subject: [PATCH 13/14] Provide legacy Path global for context continuations --- lively.context/lib/stackReification.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lively.context/lib/stackReification.js b/lively.context/lib/stackReification.js index 7a278effdc..bf05861835 100644 --- a/lively.context/lib/stackReification.js +++ b/lively.context/lib/stackReification.js @@ -15,6 +15,12 @@ function removeToplevelRecorderRefs(ast, recorderName = '__lvVarRecorder') { }); } +function ensureLivelyLangPath() { + if (!Global.lively) Global.lively = {}; + if (!Global.lively.lang) Global.lively.lang = {}; + if (!Global.lively.lang.Path) Global.lively.lang.Path = Path; +} + let NativeArrayFunctions = { sort: function(sortFunc) { @@ -150,6 +156,7 @@ let debugOption = Path('lively.Config.enableDebuggerStatements'); export function enableDebugSupport(astRegistry) { // FIXME currently only takes care of Array try { + ensureLivelyLangPath(); if (!this.hasOwnProperty('configOption')) { this.configOption = this.debugOption.get(Global); this.debugOption.set(Global, true, true); From 44ad19e5dc4140871c4317f6ff1269d6e7852ed3 Mon Sep 17 00:00:00 2001 From: merryman Date: Thu, 4 Jun 2026 18:24:23 +0200 Subject: [PATCH 14/14] Stabilize context interpreter assertions --- lively.context/tests/interpreter-test.js | 41 +++++++++++++++++------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/lively.context/tests/interpreter-test.js b/lively.context/tests/interpreter-test.js index 8c883b4116..ff154be3d8 100644 --- a/lively.context/tests/interpreter-test.js +++ b/lively.context/tests/interpreter-test.js @@ -23,6 +23,19 @@ describe('interpretation', function() { return interpreter.runWithContext(node, ctx, optMapping); } + function expectThrown(fn) { + try { + fn(); + } catch (err) { + return err; + } + expect(false, 'expected function to throw').to.be.true; + } + + function valueOf(value) { + return value && typeof value.valueOf === 'function' ? value.valueOf() : value; + } + it('runs an empty program', function() { var node = parse(''); expect(interpret(node)).to.be.undefined; @@ -140,7 +153,7 @@ describe('interpretation', function() { it('handles this in function calls', function() { var node = parse('function foo() { return this; } foo.bind(4)();'); - expect(interpret(node)).to.equal(4); + expect(Number(interpret(node))).to.equal(4); }); it('handles this when bound using bind()', function() { @@ -191,12 +204,12 @@ describe('interpretation', function() { var node = parse('var obj = { i: 0 }; while (obj.i < 3) { ++obj.i; }'), mapping = { obj: { i: 0 } }; expect(interpret(node, mapping)).to.equal(3); - expect(mapping).to.have.deep.property('obj.i', 3); + expect(mapping.obj.i).to.equal(3); node = parse('var obj = { i: 0 }; while (obj.i < 3) { obj.i++; }'); mapping = { obj: { i: 0 } }; expect(interpret(node, mapping)).to.equal(2); - expect(mapping).to.have.deep.property('obj.i', 3); + expect(mapping.obj.i).to.equal(3); }); it('handles do-while-loop', function() { @@ -325,8 +338,9 @@ describe('interpretation', function() { }); it('handles simple try without catch but with finally (with error)', function() { - var node = parse('try { throw { a: 1 }; } finally { 2; }'); - expect(fun.curry(interpret, node)).to.throw({ a: 1 }); + var node = parse('try { throw { a: 1 }; } finally { 2; }'), + err = expectThrown(fun.curry(interpret, node)); + expect(err.a).to.equal(1); }); it('handles nested try and catch constructs', function() { @@ -359,9 +373,11 @@ describe('interpretation', function() { it('handles try and catch with variable change in finally', function() { var node = parse('var a = 1; try { throw 3; } catch (e) { throw 4; } finally { a = 2; }'), - mapping = {}; + mapping = {}, + err; - expect(fun.curry(interpret, node, mapping)).to.throw(4); + err = expectThrown(fun.curry(interpret, node, mapping)); + expect(valueOf(err)).to.equal(4); expect(mapping.a).to.equal(2); }); @@ -601,21 +617,21 @@ describe('interpretation', function() { var node = parse('delete x.a;'), mapping = { x: { a: 1 } }; expect(interpret(node, mapping)).to.equal(true); - expect(mapping).not.to.have.deep.property('x.a'); + expect(mapping.x.a).to.be.undefined; }); it('can delete a non-existing property from an object', function() { var node = parse('delete x.b;'), mapping = { x: { a: 1 } }; expect(interpret(node, mapping)).to.equal(true); - expect(mapping).not.to.have.deep.property('x.b'); + expect(mapping.x.b).to.be.undefined; }); it('can delete a deeply nested property from an object graph', function() { var node = parse('delete x.y.z;'), mapping = { x: { y: { z: 1 } } }; expect(interpret(node, mapping)).to.equal(true); - expect(mapping).not.to.have.deep.property('x.y.z'); + expect(mapping.x.y.z).to.be.undefined; }); it('cannot delete a property from a non-existing object', function() { @@ -703,8 +719,9 @@ describe('interpretation', function() { }); it('throws UnwindException for debugger statements', function() { - var node = parse('debugger; 123;'); - expect(fun.curry(interpret, node)).to.throw(/UNWIND.*Debugger/); + var node = parse('debugger; 123;'), + err = expectThrown(fun.curry(interpret, node)); + expect(String(err)).to.match(/UNWIND.*Debugger/); }); it('does not leak implementation for function names', function() {