diff --git a/install.sh b/install.sh index 237180b687..7292a2180f 100755 --- a/install.sh +++ b/install.sh @@ -117,6 +117,19 @@ then exit fi +section "Installing Puppeteer browser" +step "Preparing Chrome for headless tests..." +node "$(node -p "require.resolve('puppeteer/install.mjs')")" || exit 1 +node <<'NODE' || exit 1 +const fs = require('fs'); +const puppeteer = require('puppeteer'); +const executable = puppeteer.executablePath(); +if (!fs.existsSync(executable)) { + throw new Error(`Puppeteer browser executable does not exist: ${executable}`); +} +console.log(` Puppeteer Chrome ready at ${executable}`); +NODE + section "Building SWC plugin" if ! rustup target list --installed | grep -q "^wasm32-wasip1$"; then step "Adding Rust target wasm32-wasip1..." diff --git a/lively.app/desktop/inject.js b/lively.app/desktop/inject.js index 31b561bbdc..5a4b97b108 100644 --- a/lively.app/desktop/inject.js +++ b/lively.app/desktop/inject.js @@ -47,11 +47,63 @@ return window.confirm([title, message].filter(Boolean).join('\n\n')); } + const desktop = window.livelyDesktop || {}; + const desktopDebugger = desktop.debugger || {}; + const debuggerState = window.__LIVELY_DESKTOP_DEBUGGER__ || (window.__LIVELY_DESKTOP_DEBUGGER__ = { + armedHalt: null, + captures: [], + serviceAttached: !!desktopDebugger.inspectorServiceAttached + }); + if (desktopDebugger.inspectorServiceAttached) debuggerState.serviceAttached = true; + + function armHalt (capture) { + if (!debuggerState.serviceAttached) return false; + 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 isAvailable () { + return !!debuggerState.serviceAttached; + } + + function setServiceAttached (attached) { + debuggerState.serviceAttached = !!attached; + return debuggerState.serviceAttached; + } + + function deliverCapture (descriptor) { + debuggerState.captures.push(descriptor); + debuggerState.lastCapture = descriptor; + try { + window.dispatchEvent(new CustomEvent('lively-desktop-debugger-capture', { detail: descriptor })); + } catch (_) {} + return true; + } + 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 || isAvailable, + setServiceAttached: desktopDebugger.setServiceAttached || setServiceAttached, + deliverCapture: desktopDebugger.deliverCapture || deliverCapture + } }; // Keyboard shortcut: Cmd/Ctrl + Shift + D → Dashboard. diff --git a/lively.app/desktop/inspector-service-runner.cjs b/lively.app/desktop/inspector-service-runner.cjs new file mode 100644 index 0000000000..8b49b5afc8 --- /dev/null +++ b/lively.app/desktop/inspector-service-runner.cjs @@ -0,0 +1,40 @@ +// Runs the CDP-backed inspector service outside NW.js's renderer isolate. +// +// The renderer pauses briefly when lively.context throws its halt unwind +// exception. This plain Node process handles the CDP event, captures the +// paused stack, then resumes the renderer so normal exception unwinding can +// return control to the Lively UI. + +const { createInspectorService } = require('./inspector-service.cjs'); + +function parseArgs () { + const args = {}; + for (const arg of process.argv.slice(2)) { + const match = arg.match(/^--([^=]+)=(.*)$/); + if (match) args[match[1]] = match[2]; + } + return args; +} + +const args = parseArgs(); +const cdpPort = Number(args.cdpPort || process.env.LIVELY_APP_CDP_PORT || 9222); +const service = createInspectorService({ + cdpPort: Number.isFinite(cdpPort) && cdpPort > 0 ? cdpPort : 9222, + log: msg => console.log(msg) +}); + +function stop () { + try { service.stop(); } catch (_) {} + process.exit(0); +} + +process.on('SIGTERM', stop); +process.on('SIGINT', stop); + +service.start().then(() => { + // Keep the helper alive; the parent-death watchdog is preloaded by node-main. + setInterval(() => {}, 1 << 30); +}).catch(err => { + console.error(err && (err.stack || err.message) || String(err)); + process.exit(1); +}); diff --git a/lively.app/desktop/inspector-service.cjs b/lively.app/desktop/inspector-service.cjs new file mode 100644 index 0000000000..205052d492 --- /dev/null +++ b/lively.app/desktop/inspector-service.cjs @@ -0,0 +1,776 @@ +// 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; +const HALT_UNWIND_TAG = 'lively.context.inspector.halt'; +const BASE64_VLQ_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const BASE64_VLQ_VALUES = new Map([...BASE64_VLQ_CHARS].map((char, index) => [char, index])); + +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 generatedLocationForFrame (frame) { + const location = frame.location || {}; + return { + scriptId: location.scriptId || '', + lineNumber: location.lineNumber, + columnNumber: location.columnNumber + }; +} + +function sourceForFrame (frame, sourceInfo = {}) { + const generatedLocation = generatedLocationForFrame(frame); + return { + url: sourceInfo.url || frame.url || '', + scriptId: generatedLocation.scriptId || '', + sourceText: sourceInfo.sourceText || '', + generatedUrl: frame.url || '', + generatedLocation, + sourceMap: sourceInfo.sourceMap || null + }; +} + +function locationForFrame (frame, sourceInfo = {}) { + return sourceInfo.location || generatedLocationForFrame(frame); +} + +function decodeInlineSourceMap (sourceText = '') { + const matches = [...String(sourceText).matchAll(/(?:^|\n)\s*\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[^;,]+)?(;base64)?,([^\s]+)/g)]; + const match = matches[matches.length - 1]; + if (!match) return null; + try { + const encoded = match[2]; + const json = match[1] + ? Buffer.from(encoded, 'base64').toString('utf8') + : decodeURIComponent(encoded); + return JSON.parse(json); + } catch (err) { + return null; + } +} + +function resolveSourceUrl (source, generatedUrl = '') { + if (!source) return ''; + try { + return generatedUrl ? new URL(source, generatedUrl).href : source; + } catch (err) { + return source; + } +} + +function decodeVlqSegment (segment) { + const values = []; + let value = 0; + let shift = 0; + for (const char of segment) { + const digit = BASE64_VLQ_VALUES.get(char); + if (digit === undefined) return []; + const continuation = digit & 32; + value += (digit & 31) << shift; + if (continuation) { + shift += 5; + continue; + } + const negative = value & 1; + values.push((value >> 1) * (negative ? -1 : 1)); + value = 0; + shift = 0; + } + return shift === 0 ? values : []; +} + +function originalPositionFromSourceMap (sourceMap, line, column) { + if (!sourceMap || typeof sourceMap.mappings !== 'string') return null; + if (!Number.isFinite(line)) return null; + + const targetLine = Math.max(1, line); + const targetColumn = Number.isFinite(column) ? Math.max(0, column) : 0; + const lines = sourceMap.mappings.split(';'); + let sourceIndex = 0; + let originalLine = 0; + let originalColumn = 0; + let nameIndex = 0; + + for (let generatedLine = 1; generatedLine <= lines.length; generatedLine++) { + let generatedColumn = 0; + let closest = null; + let first = null; + const segments = lines[generatedLine - 1].split(',').filter(Boolean); + + for (const segment of segments) { + const values = decodeVlqSegment(segment); + if (!values.length) continue; + generatedColumn += values[0]; + if (values.length < 4) continue; + + sourceIndex += values[1]; + originalLine += values[2]; + originalColumn += values[3]; + if (values.length >= 5) nameIndex += values[4]; + + const mapping = { + source: sourceMap.sources && sourceMap.sources[sourceIndex], + line: originalLine + 1, + column: originalColumn, + name: sourceMap.names && sourceMap.names[nameIndex], + generatedColumn + }; + if (generatedLine === targetLine) { + if (!first) first = mapping; + if (generatedColumn <= targetColumn) closest = mapping; + } + } + + if (generatedLine === targetLine) return closest || first; + } + return null; +} + +function originalSourceForGenerated (generatedSourceText, location = {}, generatedUrl = '') { + const sourceMap = decodeInlineSourceMap(generatedSourceText); + if (!sourceMap || !Number.isFinite(location.lineNumber)) return null; + + const original = originalPositionFromSourceMap( + sourceMap, + location.lineNumber + 1, + Number.isFinite(location.columnNumber) ? location.columnNumber : 0 + ); + if (!original || !original.source || !Number.isFinite(original.line)) return null; + + const sourceIndex = sourceMap.sources ? sourceMap.sources.indexOf(original.source) : -1; + const sourceText = sourceIndex >= 0 && sourceMap.sourcesContent + ? sourceMap.sourcesContent[sourceIndex] || '' + : ''; + return { + url: resolveSourceUrl(original.source, generatedUrl), + sourceText, + location: { + scriptId: location.scriptId || '', + lineNumber: original.line - 1, + columnNumber: Number.isFinite(original.column) ? original.column : 0 + }, + sourceMap: { + source: original.source, + generatedUrl + } + }; +} + +function remoteObjectLabel (remoteObject) { + if (!remoteObject) return ''; + return remoteObject.description || remoteObject.value || remoteObject.type || ''; +} + +function exceptionIsUncaught (data) { + return !!(data && data.uncaught === true); +} + +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(); +})()`; + +function serviceAttachedExpression (attached) { + return `(() => { + const desktop = globalThis.livelyDesktop || (globalThis.livelyDesktop = {}); + const debuggerBridge = desktop.debugger || (desktop.debugger = {}); + debuggerBridge.inspectorServiceAttached = ${attached ? 'true' : 'false'}; + if (typeof debuggerBridge.setServiceAttached === 'function') { + return debuggerBridge.setServiceAttached(${attached ? 'true' : 'false'}); + } + debuggerBridge.isAvailable = function () { + return !!debuggerBridge.inspectorServiceAttached; + }; + return debuggerBridge.inspectorServiceAttached; + })()`; +} + +const HALT_UNWIND_DESCRIPTOR_FUNCTION = `function livelyInspectorHaltUnwindDescriptor() { + const isHaltUnwind = !!this && (this.isLivelyInspectorHaltUnwind || this.tag === '${HALT_UNWIND_TAG}'); + if (!isHaltUnwind) return null; + return { + isHaltUnwind: true, + captureId: this.captureId || null, + reason: this.reason || 'halt' + }; +}`; + +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'); + 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); + if (method === 'Runtime.executionContextCreated' || method === 'Page.loadEventFired') { + this.markRendererServiceAttached(true).catch(err => { + this.log('inspector service status refresh failed: ' + (err.stack || err)); + }); + } + }); + } + await this.client.send('Runtime.enable'); + await this.client.send('Page.enable').catch(() => {}); + await this.client.send('Debugger.enable'); + await this.client.send('Debugger.setPauseOnExceptions', { state: 'all' }); + this.started = true; + await this.markRendererServiceAttached(true); + this.log('inspector service attached to renderer target'); + return this; + } + + stop () { + this.started = false; + this.markRendererServiceAttached(false).catch(() => {}); + if (this.client && typeof this.client.close === 'function') this.client.close(); + } + + async markRendererServiceAttached (attached) { + if (!this.client) return false; + try { + const result = await this.client.send('Runtime.evaluate', { + expression: serviceAttachedExpression(attached), + returnByValue: true, + silent: true + }); + return !!(result && result.result && result.result.value); + } catch (err) { + this.log('inspector service status update failed: ' + (err.stack || err)); + return false; + } + } + + 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 exceptionObjectId = params.reason === 'exception' && params.data && params.data.objectId; + const haltUnwind = exceptionObjectId + ? await this.haltUnwindDescriptor(exceptionObjectId) + : null; + let armed = await this.consumeArmedHalt(callFrames[0]); + if (!armed && haltUnwind) { + armed = { + captureId: haltUnwind.captureId, + reason: haltUnwind.reason || 'halt' + }; + } + if (!armed && params.reason === 'exception' && !exceptionIsUncaught(params.data)) return null; + 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: [] + }; + + for (let i = 0; i < callFrames.length; i++) { + const frame = callFrames[i]; + const frameId = 'frame-' + i; + const sourceInfo = await this.sourceInfoForFrame(frame); + const framePayload = { + captureId, + reason, + metadata: descriptor.metadata, + frameId, + functionName: frame.functionName || '', + source: sourceForFrame(frame, sourceInfo), + location: locationForFrame(frame, sourceInfo) + }; + await this.storeFrame(frame, framePayload); + await this.storeArguments(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 scriptSourceForFrame (frame) { + const scriptId = frame && frame.location && frame.location.scriptId; + if (!scriptId) return ''; + try { + const result = await this.client.send('Debugger.getScriptSource', { scriptId }); + return result && result.scriptSource || ''; + } catch (err) { + this.log('inspector source lookup failed: ' + (err.stack || err)); + return ''; + } + } + + async sourceInfoForFrame (frame) { + const generatedSourceText = await this.scriptSourceForFrame(frame); + const original = originalSourceForGenerated( + generatedSourceText, + frame && frame.location || {}, + frame && frame.url || '' + ); + return original || { sourceText: generatedSourceText }; + } + + 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 isHaltUnwindException (objectId) { + const descriptor = await this.haltUnwindDescriptor(objectId); + return !!(descriptor && descriptor.isHaltUnwind); + } + + async haltUnwindDescriptor (objectId) { + try { + const result = await this.client.send('Runtime.callFunctionOn', { + objectId, + functionDeclaration: HALT_UNWIND_DESCRIPTOR_FUNCTION, + returnByValue: true, + silent: true + }); + return result && result.result && result.result.value || null; + } catch (err) { + this.log('inspector halt unwind check 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 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, + 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') { + debuggerBridge.deliverCapture(descriptor); + return true; + } + 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, + decodeInlineSourceMap, + originalSourceForGenerated, + pageTarget +}; diff --git a/lively.app/desktop/start-server.cjs b/lively.app/desktop/start-server.cjs index 2fdf436fd9..03123f4312 100644 --- a/lively.app/desktop/start-server.cjs +++ b/lively.app/desktop/start-server.cjs @@ -488,6 +488,13 @@ function emitError (msg) { if (b && b.error) b.error(msg); } +function bootUrlForPort (port) { + const bootUrl = process.env.LIVELY_APP_BOOT_URL || '/dashboard/'; + if (/^https?:\/\//.test(bootUrl)) return bootUrl; + const path = String(bootUrl || '/dashboard/'); + return 'http://127.0.0.1:' + port + (path.startsWith('/') ? path : '/' + path); +} + // --------------------------------------------------------------------------- // 5. Flatn env setup // --------------------------------------------------------------------------- @@ -693,16 +700,83 @@ function setupFlatnEnv () { emitStatus('Server ready, loading lively...'); - const dashboardUrl = 'http://127.0.0.1:' + port + '/dashboard/'; + const dashboardUrl = bootUrlForPort(port); if (typeof nw === 'undefined') { log('NW.js global not available; server is ready for direct smoke mode.'); return; } const win = nw.Window.get(); + let inspectorChild = null; + let inspectorStartScheduled = false; + let inspectorStarted = false; + + function startInspectorService (trigger) { + if (closing || inspectorStarted || process.env.LIVELY_APP_INSPECTOR_SERVICE === '0') return; + inspectorStarted = true; + const cdpPort = Number(process.env.LIVELY_APP_CDP_PORT || 9222); + const inspectorPort = Number.isFinite(cdpPort) && cdpPort > 0 ? cdpPort : 9222; + log('starting inspector service after ' + trigger + ' on CDP port ' + inspectorPort); + inspectorChild = spawn(nodeBin, [ + '--no-warnings', + '-r', path.join(desktopDir, 'watchdog.cjs'), + path.join(desktopDir, 'inspector-service-runner.cjs'), + '--cdpPort=' + inspectorPort + ], { + cwd: rootDir, + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + inspectorChild.stdout.on('data', d => log('inspector: ' + d.toString().trimEnd())); + inspectorChild.stderr.on('data', d => log('inspector err: ' + d.toString().trimEnd())); + inspectorChild.on('error', err => { + log('inspector: service failed to start: ' + (err.stack || err)); + }); + inspectorChild.on('exit', (code, signal) => { + log('inspector: service exited (code=' + code + ', signal=' + signal + ')'); + try { + const debuggerBridge = win.window.livelyDesktop && win.window.livelyDesktop.debugger; + if (debuggerBridge && typeof debuggerBridge.setServiceAttached === 'function') { + debuggerBridge.setServiceAttached(false); + } else if (debuggerBridge) { + debuggerBridge.inspectorServiceAttached = false; + } + } catch (_) {} + }); + } + + function scheduleInspectorStart (trigger) { + if (inspectorStartScheduled || inspectorStarted || process.env.LIVELY_APP_INSPECTOR_SERVICE === '0') return; + inspectorStartScheduled = true; + const timer = setTimeout(() => { + inspectorStartScheduled = false; + startInspectorService(trigger); + }, Number(process.env.LIVELY_APP_INSPECTOR_START_DELAY || 500)); + if (timer && typeof timer.unref === 'function') timer.unref(); + } const b = livelyBoot(); if (b && b.setDashboardUrl) b.setDashboardUrl(dashboardUrl); + + if (process.env.LIVELY_APP_INSPECTOR_SERVICE !== '0') { + win.once('loaded', function () { + let href = ''; + try { href = String(win.window.location && win.window.location.href || ''); } catch (_) {} + log('window loaded after server boot: ' + (href || '(unknown location)')); + if (/^https?:\/\//.test(href)) scheduleInspectorStart('initial page load'); + else log('inspector service waiting for HTTP page before attaching'); + }); + const fallbackTimer = setTimeout(() => { + let href = ''; + try { href = String(win.window.location && win.window.location.href || ''); } catch (_) {} + if (/^https?:\/\//.test(href)) { + log('inspector service load-event fallback at ' + href); + scheduleInspectorStart('load-event fallback'); + } + }, Number(process.env.LIVELY_APP_INSPECTOR_FALLBACK_DELAY || 15000)); + if (fallbackTimer && typeof fallbackTimer.unref === 'function') fallbackTimer.unref(); + } + if (b && b.navigate) b.navigate(dashboardUrl); else { // boot.html's script hasn't run yet — fall back and hope the direct @@ -717,6 +791,7 @@ function setupFlatnEnv () { log('Window closing, killing server...'); closing = true; clearTimeout(restartTimer); + if (inspectorChild) inspectorChild.kill('SIGTERM'); if (currentChild) currentChild.kill('SIGTERM'); setTimeout(() => this.close(true), 2000); }); diff --git a/lively.app/package.json b/lively.app/package.json index fdac4a45a4..6bdb2b863c 100644 --- a/lively.app/package.json +++ b/lively.app/package.json @@ -34,6 +34,7 @@ }, "scripts": { "start": "bash start.sh", - "setup": "bash setup.sh" + "setup": "bash setup.sh", + "smoke:debugger": "node scripts/smoke-desktop-bundle.mjs --devRoot=.. --debuggerSmoke=1 --headless=1" } } diff --git a/lively.app/scripts/build.mjs b/lively.app/scripts/build.mjs index 115fc6bc55..15dd03c56c 100644 --- a/lively.app/scripts/build.mjs +++ b/lively.app/scripts/build.mjs @@ -587,7 +587,7 @@ async function main () { fs.copyFileSync(path.join(APP_DIR, 'desktop', 'boot.html'), path.join(BUNDLE, 'boot.html')); fs.mkdirSync(path.join(BUNDLE, 'desktop'), { recursive: true }); - for (const f of ['background-menu.js', 'start-server.cjs', 'watchdog.cjs', 'server-config.js', 'inject.js', 'updates.cjs', 'velopack-helper.cjs']) { + for (const f of ['background-menu.js', 'start-server.cjs', 'watchdog.cjs', 'server-config.js', 'inject.js', 'inspector-service.cjs', 'inspector-service-runner.cjs', 'updates.cjs', 'velopack-helper.cjs']) { fs.copyFileSync(path.join(APP_DIR, 'desktop', f), path.join(BUNDLE, 'desktop', f)); } // Stamp the build SHA so boot.log identifies the exact commit, no more diff --git a/lively.app/scripts/smoke-desktop-bundle.mjs b/lively.app/scripts/smoke-desktop-bundle.mjs index 576c5c4aee..121179c927 100644 --- a/lively.app/scripts/smoke-desktop-bundle.mjs +++ b/lively.app/scripts/smoke-desktop-bundle.mjs @@ -7,6 +7,9 @@ import { spawn, spawnSync } from 'node:child_process'; const CDP_PORT = Number(process.env.LIVELY_APP_SMOKE_CDP_PORT || 9222); const DEFAULT_TIMEOUT = 300000; +const DEBUGGER_SMOKE_REASON = 'desktop debugger smoke'; +const DEBUGGER_SOURCE_MAP_SMOKE_REASON = 'desktop debugger source map smoke'; +const DEBUGGER_CURRENT_LINE_MARKER_ID = 'lively-debugger-current-line'; const WORLD_PATH = '/worlds/load?name=__newWorld__&askForWorldName=false&fastLoad=true'; const PROJECT_PATH = '/projects/load?name=__newProject__&askForWorldName=false&fastLoad=true'; const CORE_PACKAGES = [ @@ -72,15 +75,20 @@ function hostPlatform () { return process.platform; } -function appCommand (bundleDir, platform) { +function headlessArgs (headless) { + return headless ? ['--headless=new', '--disable-gpu'] : []; +} + +function appCommand (bundleDir, platform, headless = false) { + const chromiumArgs = headlessArgs(headless); if (platform === 'linux') { - return { command: path.join(bundleDir, 'nw'), args: [bundleDir] }; + return { command: path.join(bundleDir, 'nw'), args: chromiumArgs.concat(bundleDir) }; } if (platform === 'osx') { - return { command: path.join(bundleDir, 'lively.next.app', 'Contents', 'MacOS', 'nwjs'), args: [] }; + return { command: path.join(bundleDir, 'lively.next.app', 'Contents', 'MacOS', 'nwjs'), args: chromiumArgs }; } if (platform === 'win') { - return { command: path.join(bundleDir, 'lively.next.exe'), args: [bundleDir] }; + return { command: path.join(bundleDir, 'lively.next.exe'), args: chromiumArgs.concat(bundleDir) }; } throw new Error(`Unsupported smoke platform: ${platform}`); } @@ -180,12 +188,33 @@ class CDPClient { } } - send (method, params = {}) { + send (method, params = {}, options = {}) { const id = this.nextId++; const payload = JSON.stringify({ id, method, params }); + const timeoutMs = Number(options.timeoutMs || options.timeout || 0); return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.ws.send(payload); + let timer = null; + const finish = fn => value => { + if (timer) clearTimeout(timer); + fn(value); + }; + if (timeoutMs > 0) { + timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + } + this.pending.set(id, { + resolve: finish(resolve), + reject: finish(reject) + }); + try { + this.ws.send(payload); + } catch (err) { + if (timer) clearTimeout(timer); + this.pending.delete(id); + reject(err); + } }); } @@ -417,18 +446,687 @@ async function assertRendererUsesHttpSystemURLs (client, port, timeoutMs, option } } +async function waitForDesktopDebuggerBridge (client, timeoutMs) { + await waitFor('desktop debugger bridge attachment', async () => { + const result = await client.send('Runtime.evaluate', { + expression: `(() => { + const bridge = globalThis.livelyDesktop && globalThis.livelyDesktop.debugger; + return Boolean(bridge && typeof bridge.isAvailable === 'function' && bridge.isAvailable()); + })()`, + returnByValue: true + }, { timeoutMs: 10000 }); + return result.result && result.result.value === true; + }, timeoutMs); +} + +function debuggerSmokeExpression () { + return `(() => Promise.resolve().then(async () => { + async function importLivelyContext() { + return Function('url', 'return import(url)')( + new URL('/lively.context/lib/inspector-runtime.js', location.origin).href); + } + function describeError(err) { + if (!err) return null; + return { + name: err.name || '', + message: err.message || String(err), + stack: err.stack || '', + originalErr: err.originalErr ? describeError(err.originalErr) : null + }; + } + try { + const mod = await importLivelyContext(); + const { halt, isInspectorHaltUnwind, installInspectorRuntime } = mod; + installInspectorRuntime(); + + const marker = { + label: 'desktop-debugger-smoke-marker', + value: 23, + nested: { identity: 'actual-object' } + }; + + globalThis.__LIVELY_DEBUGGER_SMOKE_MARKER__ = marker; + globalThis.__LIVELY_DEBUGGER_SMOKE_AFTER_HALT__ = false; + + try { + function smokeOuter() { + const closedOver = { marker, closed: true }; + function smokeInner(arg) { + const localObject = { marker, arg, closedOver }; + halt(${JSON.stringify(DEBUGGER_SMOKE_REASON)}); + globalThis.__LIVELY_DEBUGGER_SMOKE_AFTER_HALT__ = true; + return localObject; + } + return smokeInner(marker); + } + smokeOuter(); + } catch (err) { + if (!isInspectorHaltUnwind(err)) { + return { + unwound: false, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SMOKE_AFTER_HALT__, + error: describeError(err) + }; + } + return { + unwound: true, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SMOKE_AFTER_HALT__ + }; + } + + return { + unwound: false, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SMOKE_AFTER_HALT__ + }; + } catch (err) { + return { + unwound: false, + setupError: describeError(err) + }; + } + }))()`; +} + +function debuggerSourceMapSmokeExpression () { + return `(() => Promise.resolve().then(async () => { + async function importLivelyContext() { + return Function('url', 'return import(url)')( + new URL('/lively.context/lib/inspector-runtime.js', location.origin).href); + } + function describeError(err) { + if (!err) return null; + return { + name: err.name || '', + message: err.message || String(err), + stack: err.stack || '', + originalErr: err.originalErr ? describeError(err.originalErr) : null + }; + } + try { + const mod = await importLivelyContext(); + const { halt, isInspectorHaltUnwind, installInspectorRuntime } = mod; + installInspectorRuntime(); + + const marker = { + label: 'desktop-debugger-source-map-marker', + value: 29, + nested: { identity: 'actual-source-map-object' } + }; + + globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_MARKER__ = marker; + globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__ = false; + globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_HALT__ = halt; + globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_REASON__ = ${JSON.stringify(DEBUGGER_SOURCE_MAP_SMOKE_REASON)}; + + try { + const originalSource = [ + 'const marker = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_MARKER__;', + 'const halt = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_HALT__;', + 'const reason = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_REASON__;', + 'const closedOver = { marker, closed: true, originalOnly: "source-map-original-token" };', + 'const localObject = { marker, arg: marker, closedOver };', + 'halt(reason); // source-map-original-halt-line', + 'globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__ = true;', + 'localObject;' + ].join('\\n'); + const generatedSource = [ + 'const marker = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_MARKER__;', + 'const halt = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_HALT__;', + 'const reason = globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_REASON__;', + 'const closedOver = { marker, closed: true, generatedOnly: "source-map-generated-token" };', + 'const localObject = { marker, arg: marker, closedOver };', + 'halt(reason);', + 'globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__ = true;', + 'localObject;' + ].join('\\n'); + const sourceMap = { + version: 3, + file: '/debugger-source-map-generated.js', + sources: ['/debugger-source-map-original.js'], + sourcesContent: [originalSource], + names: [], + mappings: ';;;;;AAKA' + }; + function base64Unicode(text) { + return btoa(unescape(encodeURIComponent(text))); + } + eval([ + generatedSource, + '//# sourceURL=' + new URL('/debugger-source-map-generated.js', location.origin).href, + '//# sourceMappingURL=data:application/json;base64,' + base64Unicode(JSON.stringify(sourceMap)) + ].join('\\n')); + } catch (err) { + if (!isInspectorHaltUnwind(err)) { + return { + unwound: false, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__, + error: describeError(err) + }; + } + return { + unwound: true, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__ + }; + } + + return { + unwound: false, + markerValue: marker.value, + afterHaltRan: globalThis.__LIVELY_DEBUGGER_SOURCE_MAP_AFTER_HALT__ + }; + } catch (err) { + return { + unwound: false, + setupError: describeError(err) + }; + } + }))()`; +} + +function debuggerSourceMapSmokeStateExpression () { + return `(() => { + const currentLineMarkerId = ${JSON.stringify(DEBUGGER_CURRENT_LINE_MARKER_ID)}; + const system = globalThis.System; + const env = system && system.get && system.get('@lively-env'); + const registry = env && env.debuggerContexts; + const contexts = registry && registry.contexts || {}; + const context = Object.values(contexts).find(ctx => ctx && ctx.reason === ${JSON.stringify(DEBUGGER_SOURCE_MAP_SMOKE_REASON)}) || null; + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + const debuggerMorph = windowTarget(debuggerWindow); + const sourcePane = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('source pane'); + const locationLabel = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('location label'); + const sourceText = sourcePane && sourcePane.textString || ''; + const markers = sourcePane && sourcePane.markers || []; + const selection = sourcePane && sourcePane.selection; + const start = selection && (selection.start || selection.range && selection.range.start); + return { + hasRegistry: Boolean(registry), + hasContext: Boolean(context), + contextId: context && context.id, + reason: context && context.reason, + frameCount: context ? Object.keys(context.frames || {}).length : 0, + scopeCount: context ? Object.keys(context.scopes || {}).length : 0, + hasDebuggerWindow: Boolean(debuggerWindow), + hasSourcePane: Boolean(sourcePane), + sourceTextLength: sourceText.length, + sourceHasLocalObject: sourceText.includes('localObject'), + sourceHasHaltCall: sourceText.includes('halt('), + sourceHasOriginalOnlyToken: sourceText.includes('source-map-original-token'), + sourceHasGeneratedOnlyToken: sourceText.includes('source-map-generated-token'), + sourceSelectedRow: start && Number.isFinite(start.row) ? start.row : null, + hasCurrentLineMarker: markers.some(marker => marker && marker.id === currentLineMarkerId), + locationLabelText: locationLabel && locationLabel.textString || '', + debuggerWindowTitle: debuggerWindow && (debuggerWindow.title || debuggerWindow.name), + windowTitles: windows.map(win => win && (win.title || win.name || '')).filter(Boolean) + }; + })()`; +} + +function debuggerSmokeStateExpression () { + return `(() => Promise.resolve().then(async () => { + const currentLineMarkerId = ${JSON.stringify(DEBUGGER_CURRENT_LINE_MARKER_ID)}; + const system = globalThis.System; + const env = system && system.get && system.get('@lively-env'); + const registry = env && env.debuggerContexts; + const contexts = registry && registry.contexts || {}; + const context = Object.values(contexts).find(ctx => ctx && ctx.reason === ${JSON.stringify(DEBUGGER_SMOKE_REASON)}) || null; + const marker = globalThis.__LIVELY_DEBUGGER_SMOKE_MARKER__; + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + const debuggerMorph = windowTarget(debuggerWindow); + const sourcePane = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('source pane'); + const locationLabel = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('location label'); + const statusMorph = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('status'); + const stepIntoButton = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('step into button'); + const workspaceInput = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('workspace input'); + const workspaceResult = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('workspace result'); + + function summarizeBinding (value) { + if (value === marker) return { actualMarker: true, value: value.value, label: value.label }; + if (value && typeof value === 'object') { + if (value.marker === marker) return { containsActualMarker: true, keys: Object.keys(value) }; + return { + type: Object.prototype.toString.call(value), + keys: Object.keys(value).slice(0, 10), + value: value.value, + label: value.label + }; + } + return { primitive: value }; + } + + function selectionRowOf (textMorph) { + const selection = textMorph && textMorph.selection; + if (!selection) return null; + const start = selection.start || selection.range && selection.range.start; + return start && Number.isFinite(start.row) ? start.row : null; + } + + const inspectedBindings = []; + let hasActualMarker = false; + let hasActualMarkerCarrier = false; + + if (context) { + for (const scope of Object.values(context.scopes || {})) { + for (const [name, value] of Object.entries(scope.bindings || {})) { + if (['marker', 'arg', 'localObject', 'closedOver'].includes(name)) { + const summary = summarizeBinding(value); + inspectedBindings.push({ + frameId: scope.frameId, + scopeId: scope.scopeId, + scopeType: scope.type, + name, + summary + }); + if (value === marker) hasActualMarker = true; + if (value && typeof value === 'object' && value.marker === marker) hasActualMarkerCarrier = true; + } + } + } + } + + let workspaceActionResult = null; + let workspaceActionText = null; + let workspaceActionError = null; + if (debuggerMorph && debuggerMorph.viewModel && typeof debuggerMorph.viewModel.evaluateWorkspace === 'function' && workspaceInput) { + try { + workspaceInput.textString = 'localObject.marker === marker && localObject.closedOver === closedOver'; + workspaceActionResult = await debuggerMorph.viewModel.evaluateWorkspace(); + workspaceActionText = workspaceResult && workspaceResult.textString || ''; + } catch (err) { + workspaceActionError = err && (err.stack || err.message) || String(err); + } + } + + let stepActionStatus = null; + let stepActionError = null; + if (stepIntoButton && stepIntoButton.viewModel && typeof stepIntoButton.viewModel.trigger === 'function') { + try { + stepIntoButton.viewModel.trigger(); + stepActionStatus = statusMorph && statusMorph.textString || ''; + } catch (err) { + stepActionError = err && (err.stack || err.message) || String(err); + } + } + + const sourceText = sourcePane && sourcePane.textString || ''; + const markers = sourcePane && sourcePane.markers || []; + + return { + hasRegistry: Boolean(registry), + hasContext: Boolean(context), + contextId: context && context.id, + reason: context && context.reason, + frameCount: context ? Object.keys(context.frames || {}).length : 0, + scopeCount: context ? Object.keys(context.scopes || {}).length : 0, + hasActualMarker, + hasActualMarkerCarrier, + inspectedBindings, + hasDebuggerWindow: Boolean(debuggerWindow), + hasSourcePane: Boolean(sourcePane), + sourceTextLength: sourceText.length, + sourceHasSmokeInner: sourceText.includes('smokeInner'), + sourceHasHaltCall: sourceText.includes('halt('), + sourceSelectedRow: selectionRowOf(sourcePane), + hasCurrentLineMarker: markers.some(marker => marker && marker.id === currentLineMarkerId), + locationLabelText: locationLabel && locationLabel.textString || '', + hasWorkspaceInput: Boolean(workspaceInput), + workspaceActionResult, + workspaceActionText, + workspaceActionError, + stepActionStatus, + stepActionError, + debuggerWindowTitle: debuggerWindow && (debuggerWindow.title || debuggerWindow.name), + windowTitles: windows.map(win => win && (win.title || win.name || '')).filter(Boolean) + }; + }))()`; +} + +function debuggerProceedTriggerExpression () { + return `(() => { + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + const debuggerMorph = windowTarget(debuggerWindow); + const proceedButton = debuggerMorph && debuggerMorph.getSubmorphNamed && debuggerMorph.getSubmorphNamed('proceed button'); + if (!proceedButton || !proceedButton.viewModel || typeof proceedButton.viewModel.trigger !== 'function') { + return { triggered: false }; + } + proceedButton.viewModel.trigger(); + return { triggered: true }; + })()`; +} + +function debuggerCloseTriggerExpression () { + return `(() => { + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + const debuggerMorph = windowTarget(debuggerWindow); + if (debuggerMorph && debuggerMorph.viewModel && typeof debuggerMorph.viewModel.closeDebugger === 'function') { + debuggerMorph.viewModel.closeDebugger(); + return { triggered: true }; + } + return { triggered: false }; + })()`; +} + +function debuggerProceedStateExpression () { + return `(() => { + const system = globalThis.System; + const env = system && system.get && system.get('@lively-env'); + const registry = env && env.debuggerContexts; + const contexts = registry && registry.contexts || {}; + const context = Object.values(contexts).find(ctx => ctx && ctx.reason === ${JSON.stringify(DEBUGGER_SMOKE_REASON)}) || null; + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + return { + hasContext: Boolean(context), + hasDebuggerWindow: Boolean(debuggerWindow), + windowTitles: windows.map(win => win && (win.title || win.name || '')).filter(Boolean) + }; + })()`; +} + +function debuggerClosedStateExpression (reason) { + return `(() => { + const system = globalThis.System; + const env = system && system.get && system.get('@lively-env'); + const registry = env && env.debuggerContexts; + const contexts = registry && registry.contexts || {}; + const context = Object.values(contexts).find(ctx => ctx && ctx.reason === ${JSON.stringify(reason)}) || null; + const windows = globalThis.$world && typeof $world.getWindows === 'function' ? $world.getWindows() : []; + function windowTarget(win) { + return win && (win.targetMorph || win.owner || win.contentMorph) || null; + } + const debuggerWindow = windows.find(win => { + const target = windowTarget(win); + return win && ( + win.title === 'Lively Debugger' || + win.name === 'Lively Debugger' || + target && target.name === 'lively debugger' + ); + }); + return { + hasContext: Boolean(context), + hasDebuggerWindow: Boolean(debuggerWindow), + windowTitles: windows.map(win => win && (win.title || win.name || '')).filter(Boolean) + }; + })()`; +} + +async function assertDesktopDebuggerSourceMapSmoke (client, timeoutMs) { + const trigger = await client.send('Runtime.evaluate', { + expression: debuggerSourceMapSmokeExpression(), + awaitPromise: true, + returnByValue: true + }, { timeoutMs: Math.min(timeoutMs, 30000) }); + + const triggerValue = trigger.result && trigger.result.value; + if (!triggerValue || triggerValue.unwound !== true || triggerValue.afterHaltRan) { + throw new Error([ + 'Desktop debugger source-map smoke did not unwind at halt().', + `Observed trigger result: ${JSON.stringify(triggerValue, null, 2)}`, + `Raw CDP trigger result: ${JSON.stringify(trigger, null, 2)}` + ].join('\n')); + } + + let lastState = null; + const state = await waitFor('desktop debugger source-map capture and UI', async () => { + const result = await client.send('Runtime.evaluate', { + expression: debuggerSourceMapSmokeStateExpression(), + returnByValue: true + }, { timeoutMs: 10000 }); + const value = result.result && result.result.value; + lastState = value || null; + if (!value || !value.hasContext || !value.hasDebuggerWindow) return null; + if (!value.hasSourcePane || !value.sourceTextLength || value.sourceSelectedRow === null || !value.hasCurrentLineMarker) return null; + return value; + }, timeoutMs).catch(err => { + throw new Error([ + err.message || String(err), + `Last observed source-map debugger smoke state: ${JSON.stringify(lastState, null, 2)}` + ].join('\n')); + }); + + const errors = []; + if (!state.frameCount) errors.push('source-map capture did not record any stack frames'); + if (!state.scopeCount) errors.push('source-map capture did not record any scopes'); + if (!state.sourceHasLocalObject || !state.sourceHasHaltCall) { + errors.push('source-map debugger source pane did not show the paused source code'); + } + if (!state.sourceHasOriginalOnlyToken || state.sourceHasGeneratedOnlyToken) { + errors.push('source-map debugger source pane did not apply the captured source map to show original source'); + } + if (!state.locationLabelText || !state.locationLabelText.includes('/debugger-source-map-original.js:6:')) { + errors.push('source-map debugger did not show the mapped original source location'); + } + if (errors.length) { + throw new Error([ + 'Desktop debugger source-map smoke failed.', + ...errors, + `Observed state: ${JSON.stringify(state, null, 2)}` + ].join('\n')); + } + + const closeTrigger = await client.send('Runtime.evaluate', { + expression: debuggerCloseTriggerExpression(), + returnByValue: true + }, { timeoutMs: 10000 }); + const closeValue = closeTrigger.result && closeTrigger.result.value; + if (!closeValue || !closeValue.triggered) { + throw new Error([ + 'Desktop debugger source-map smoke failed.', + 'debugger close could not be triggered', + `Observed close trigger: ${JSON.stringify(closeValue, null, 2)}` + ].join('\n')); + } + + let lastClosedState = null; + await waitFor('desktop debugger source-map close release', async () => { + const result = await client.send('Runtime.evaluate', { + expression: debuggerClosedStateExpression(DEBUGGER_SOURCE_MAP_SMOKE_REASON), + returnByValue: true + }, { timeoutMs: 10000 }); + const value = result.result && result.result.value; + lastClosedState = value || null; + if (!value || value.hasContext || value.hasDebuggerWindow) return null; + return value; + }, timeoutMs).catch(err => { + throw new Error([ + err.message || String(err), + `Last observed source-map debugger close state: ${JSON.stringify(lastClosedState, null, 2)}` + ].join('\n')); + }); + + console.log('Desktop app smoke passed: debugger applies inline source maps to captured source'); +} + +async function assertDesktopDebuggerSmoke (client, timeoutMs) { + await waitForDesktopDebuggerBridge(client, timeoutMs); + await waitFor('final lively world load before debugger smoke', async () => { + const result = await client.send('Runtime.evaluate', { + expression: `Boolean(globalThis.$world && + $world.name && + $world.name !== 'lively.next' && + typeof $world.getWindows === 'function')`, + returnByValue: true + }, { timeoutMs: 10000 }); + return result.result && result.result.value === true; + }, timeoutMs); + + await assertDesktopDebuggerSourceMapSmoke(client, timeoutMs); + + const trigger = await client.send('Runtime.evaluate', { + expression: debuggerSmokeExpression(), + awaitPromise: true, + returnByValue: true + }, { timeoutMs: Math.min(timeoutMs, 30000) }); + + const triggerValue = trigger.result && trigger.result.value; + if (!triggerValue || triggerValue.unwound !== true || triggerValue.afterHaltRan) { + throw new Error([ + 'Desktop debugger smoke did not unwind at halt().', + `Observed trigger result: ${JSON.stringify(triggerValue, null, 2)}`, + `Raw CDP trigger result: ${JSON.stringify(trigger, null, 2)}` + ].join('\n')); + } + + let lastDebuggerSmokeState = null; + const state = await waitFor('desktop debugger capture and UI', async () => { + const result = await client.send('Runtime.evaluate', { + expression: debuggerSmokeStateExpression(), + awaitPromise: true, + returnByValue: true + }, { timeoutMs: 10000 }); + const value = result.result && result.result.value; + lastDebuggerSmokeState = value || null; + if (!value || !value.hasContext || !value.hasDebuggerWindow) return null; + if (!value.hasSourcePane || !value.sourceTextLength || value.sourceSelectedRow === null || !value.hasCurrentLineMarker) return null; + return value; + }, timeoutMs).catch(err => { + throw new Error([ + err.message || String(err), + `Last observed debugger smoke state: ${JSON.stringify(lastDebuggerSmokeState, null, 2)}` + ].join('\n')); + }); + + const errors = []; + if (!state.frameCount) errors.push('capture did not record any stack frames'); + if (!state.scopeCount) errors.push('capture did not record any scopes'); + if (!state.hasActualMarker && !state.hasActualMarkerCarrier) { + errors.push('capture did not expose the in-process marker object through a scope binding'); + } + if (!state.sourceHasSmokeInner || !state.sourceHasHaltCall) { + errors.push('debugger source pane did not show the paused source code'); + } + if (!state.locationLabelText || !state.locationLabelText.includes(':')) { + errors.push('debugger did not show the paused source location'); + } + if (!state.hasWorkspaceInput) { + errors.push('debugger did not show a workspace input'); + } + if (state.workspaceActionError) { + errors.push('workspace evaluation threw while reading selected-scope values'); + } + if (state.workspaceActionResult !== true || !String(state.workspaceActionText).includes('true')) { + errors.push('workspace evaluation did not run in the selected frame scope'); + } + if (state.stepActionError) { + errors.push('step into button threw while stepping through the interpreter'); + } + if (!state.stepActionStatus || !state.stepActionStatus.includes('stopped')) { + errors.push('step into button did not produce a stopped interpreter continuation'); + } + if (errors.length) { + throw new Error([ + 'Desktop debugger smoke failed.', + ...errors, + `Observed state: ${JSON.stringify(state, null, 2)}` + ].join('\n')); + } + + const proceedTrigger = await client.send('Runtime.evaluate', { + expression: debuggerProceedTriggerExpression(), + returnByValue: true + }, { timeoutMs: 10000 }); + const proceedTriggerValue = proceedTrigger.result && proceedTrigger.result.value; + if (!proceedTriggerValue || !proceedTriggerValue.triggered) { + throw new Error([ + 'Desktop debugger smoke failed.', + 'proceed button could not be triggered', + `Observed proceed trigger: ${JSON.stringify(proceedTriggerValue, null, 2)}` + ].join('\n')); + } + + let lastProceedState = null; + await waitFor('desktop debugger proceed release', async () => { + const result = await client.send('Runtime.evaluate', { + expression: debuggerProceedStateExpression(), + returnByValue: true + }, { timeoutMs: 10000 }); + const value = result.result && result.result.value; + lastProceedState = value || null; + if (!value || value.hasContext || value.hasDebuggerWindow) return null; + return value; + }, timeoutMs).catch(err => { + throw new Error([ + err.message || String(err), + `Last observed debugger proceed state: ${JSON.stringify(lastProceedState, null, 2)}` + ].join('\n')); + }); + + console.log('Desktop app smoke passed: lively.context debugger captures stack values and opens UI'); +} + async function main () { const args = parseArgs(); const devRoot = args.devRoot ? path.resolve(args.devRoot) : null; const bundleDir = devRoot ? null : path.resolve(args.bundleDir || ''); const platform = args.platform || hostPlatform(); const timeoutMs = Number(args.timeout || process.env.LIVELY_APP_SMOKE_TIMEOUT || DEFAULT_TIMEOUT); + const debuggerSmoke = args.debuggerSmoke === '1' || args.debuggerSmoke === 'true'; + const headless = args.headless === '1' || args.headless === 'true'; if (!devRoot && (!bundleDir || bundleDir === process.cwd())) throw new Error('Pass --bundleDir= or --devRoot='); if (devRoot && !fs.existsSync(path.join(devRoot, 'lively.app', 'start.sh'))) { throw new Error(`Dev root does not look like lively.next: ${devRoot}`); } - const { command, args: commandArgs } = devRoot ? devAppCommand(devRoot) : appCommand(bundleDir, platform); + const { command, args: commandArgs } = devRoot ? devAppCommand(devRoot) : appCommand(bundleDir, platform, headless); assertExecutableExists(command); const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'lively-app-smoke-')); @@ -448,7 +1146,9 @@ async function main () { ...process.env, LIVELY_APP_DATA_DIR: dataDir, LIVELY_APP_CACHE_DIR: cacheDir, - LIVELY_APP_SMOKE: '1' + LIVELY_APP_SMOKE: '1', + LIVELY_APP_HEADLESS: headless ? '1' : '', + LIVELY_APP_BOOT_URL: WORLD_PATH }, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -467,8 +1167,8 @@ async function main () { const port = await waitForBootLogReady(logFile, timeoutMs); console.log(`Desktop server reported ready on port ${port}`); - await waitForHttpOk(`http://127.0.0.1:${port}/dashboard/`, 60000); - console.log('Desktop server dashboard responded'); + await waitForHttpOk(`http://127.0.0.1:${port}${WORLD_PATH}`, 60000); + console.log('Desktop server world route responded'); const target = await waitForPageTarget(60000); const client = new CDPClient(target.webSocketDebuggerUrl); @@ -490,6 +1190,7 @@ async function main () { }, timeoutMs); await assertRendererUsesHttpSystemURLs(client, port, timeoutMs); console.log('Desktop app smoke passed: renderer System uses HTTP module URLs'); + if (debuggerSmoke) await assertDesktopDebuggerSmoke(client, timeoutMs); const projectUrl = `http://127.0.0.1:${port}${PROJECT_PATH}`; console.log(`Navigating app window to ${projectUrl}`); diff --git a/lively.app/start.sh b/lively.app/start.sh index 4125d413fd..7666035160 100755 --- a/lively.app/start.sh +++ b/lively.app/start.sh @@ -39,4 +39,9 @@ if [ "$ENV_STATUS" -ne 0 ]; then fi unset NODE_OPTIONS -exec "$NW_BIN" "$SCRIPT_DIR" "$@" +NW_ARGS=() +if [ "${LIVELY_APP_HEADLESS:-}" = "1" ]; then + NW_ARGS+=(--headless=new --disable-gpu) +fi + +exec "$NW_BIN" "${NW_ARGS[@]}" "$SCRIPT_DIR" "$@" diff --git a/lively.app/tests/inspector-service-test.cjs b/lively.app/tests/inspector-service-test.cjs new file mode 100644 index 0000000000..274d0336f8 --- /dev/null +++ b/lively.app/tests/inspector-service-test.cjs @@ -0,0 +1,316 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { + InspectorService, + bindingNamesFromProperties, + originalSourceForGenerated, + pageTarget +} = require('../desktop/inspector-service.cjs'); + +class FakeCDPClient { + constructor ({ armed = { captureId: 'capture-test', reason: 'halt' }, properties = {}, scriptSources = {} } = {}) { + this.armed = armed; + this.properties = properties; + this.scriptSources = scriptSources; + this.calls = []; + this.eventHandler = null; + } + + onEvent (handler) { + this.eventHandler = handler; + } + + 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') { + if (params.functionDeclaration.includes('HaltUnwindDescriptor')) { + return { + result: { + value: params.objectId === 'halt-unwind' + ? { isHaltUnwind: true, captureId: 'capture-test', reason: 'halt' } + : null + } + }; + } + return { result: { value: true } }; + } + if (method === 'Runtime.evaluate') { + return { result: { value: true } }; + } + if (method === 'Debugger.getScriptSource') { + return { scriptSource: this.scriptSources[params.scriptId] || '' }; + } + 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 testStartMarksRendererServiceAttached () { + const client = new FakeCDPClient(); + const service = new InspectorService({ client }); + await service.start(); + + assert(client.calls.some(call => call.method === 'Runtime.enable')); + assert(client.calls.some(call => call.method === 'Debugger.enable')); + assert(client.calls.some(call => + call.method === 'Debugger.setPauseOnExceptions' && + call.params.state === 'all')); + assert(!client.calls.some(call => + call.method === 'Debugger.setBreakpointOnFunctionCall')); + assert(client.calls.some(call => + call.method === 'Runtime.evaluate' && + call.params.expression.includes('inspectorServiceAttached = true'))); +} + +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 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')); + 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')); +} + +function inlineSourceMapFor ({ generatedLine, generatedColumn, originalLine, originalColumn, source, sourceText }) { + assert.strictEqual(generatedLine, 2); + assert.strictEqual(generatedColumn, 0); + assert.strictEqual(originalLine, 3); + assert.strictEqual(originalColumn, 2); + const sourceMap = { + version: 3, + file: 'generated.js', + sources: [source], + sourcesContent: [sourceText], + names: [], + mappings: ';AAEE' + }; + return '//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(JSON.stringify(sourceMap)).toString('base64'); +} + +async function testInlineSourceMapResolvesOriginalSource () { + const originalText = [ + 'function original() {', + ' const value = 23;', + ' halt("mapped");', + '}' + ].join('\n'); + const generatedText = [ + 'function original(){', + 'halt("mapped");', + '}', + inlineSourceMapFor({ + generatedLine: 2, + generatedColumn: 0, + originalLine: 3, + originalColumn: 2, + source: 'original.js', + sourceText: originalText + }) + ].join('\n'); + + const source = originalSourceForGenerated( + generatedText, + { scriptId: 'script-1', lineNumber: 1, columnNumber: 0 }, + 'http://127.0.0.1:9011/generated.js' + ); + + assert.strictEqual(source.url, 'http://127.0.0.1:9011/original.js'); + assert.strictEqual(source.sourceText, originalText); + assert.deepStrictEqual(source.location, { + scriptId: 'script-1', + lineNumber: 2, + columnNumber: 2 + }); +} + +async function testInlineSourceMapIgnoresStringLiterals () { + const source = [ + 'const text = "sourceMappingURL=data:application/json;base64,not-json";', + 'const comment = "//# sourceMappingURL=data:application/json;base64,not-json";' + ].join('\n'); + + assert.strictEqual( + originalSourceForGenerated(source, { scriptId: 'script-1', lineNumber: 0, columnNumber: 0 }, ''), + null); +} + +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', uncaught: true } + })); + + 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 testTaggedHaltUnwindIsCaptured () { + 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.captureId, 'capture-test'); + assert.strictEqual(descriptor.reason, 'halt'); + const unwindCheck = client.calls.find(call => + call.method === 'Runtime.callFunctionOn' && + call.params.objectId === 'halt-unwind'); + assert.doesNotThrow(() => new Function('return (' + unwindCheck.params.functionDeclaration + ')')()); +} + +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' && + call.params.expression.includes('deliverCapture')); + assert(deliver.params.expression.includes('capture-test')); + assert(deliver.params.expression.includes('deliverCapture')); +} + +async function testDesktopBuildCopiesNodeMainSiblingRequires () { + const startServerSource = fs.readFileSync(path.join(__dirname, '..', 'desktop', 'start-server.cjs'), 'utf8'); + const buildSource = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build.mjs'), 'utf8'); + + const copiedFilesMatch = buildSource.match(/for \(const f of \[([^\]]+)\]\) \{\s*fs\.copyFileSync\(path\.join\(APP_DIR, 'desktop', f\)/s); + assert(copiedFilesMatch, 'could not find desktop script copy list in build.mjs'); + + const copiedFiles = new Set([...copiedFilesMatch[1].matchAll(/'([^']+)'/g)].map(match => match[1])); + const siblingRequires = [...startServerSource.matchAll(/require\('\.\/([^']+)'\)/g)].map(match => match[1]); + + for (const file of siblingRequires) { + assert(copiedFiles.has(file), `build.mjs must copy desktop/${file} because start-server.cjs requires it`); + } + for (const file of ['inspector-service.cjs', 'inspector-service-runner.cjs']) { + assert(copiedFiles.has(file), `build.mjs must copy desktop/${file} for the debugger service`); + } +} + +async function run () { + await testBindingNameFiltering(); + await testTargetSelection(); + await testStartMarksRendererServiceAttached(); + await testCaptureStoresValuesInRenderer(); + await testInlineSourceMapResolvesOriginalSource(); + await testInlineSourceMapIgnoresStringLiterals(); + await testExceptionCaptureStoresExceptionObject(); + await testTaggedHaltUnwindIsCaptured(); + await testHandlePausedResumesAndDeliversDescriptor(); + await testDesktopBuildCopiesNodeMainSiblingRequires(); + 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.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.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', () => { 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.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/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/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/inspector-interpreter.js b/lively.context/lib/inspector-interpreter.js new file mode 100644 index 0000000000..05b051d8f9 --- /dev/null +++ b/lively.context/lib/inspector-interpreter.js @@ -0,0 +1,369 @@ +import { parse } from 'lively.ast'; +import { Continuation } from './stackReification.js'; +import { Frame, Function as AcornFunction, Interpreter, Scope } from './interpreter.js'; + +const STATEMENT_TYPES = new Set([ + 'EmptyStatement', + 'ExpressionStatement', + 'IfStatement', + 'LabeledStatement', + 'BreakStatement', + 'ContinueStatement', + 'WithStatement', + 'SwitchStatement', + 'ReturnStatement', + 'ThrowStatement', + 'WhileStatement', + 'DoWhileStatement', + 'ForStatement', + 'ForInStatement', + 'DebuggerStatement', + 'VariableDeclaration', + 'FunctionDeclaration', + 'SwitchCase' +]); + +const FUNCTION_TYPES = new Set([ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression' +]); + +const PENDING_RESULT_EXPRESSION_TYPES = new Set([ + 'CallExpression', + 'NewExpression' +]); + +const Global = typeof globalThis !== 'undefined' ? globalThis : window; + +export class InspectorInterpreterError extends Error { + constructor (message, { frame } = {}) { + super(message); + this.name = 'InspectorInterpreterError'; + this.frame = frame; + } +} + +function sourceTextForFrame (frame) { + return frame && frame.source && frame.source.sourceText || ''; +} + +function positionForFrame (frame) { + const location = frame && frame.location || {}; + if (!Number.isFinite(location.lineNumber)) return null; + return { + line: location.lineNumber + 1, + column: Number.isFinite(location.columnNumber) ? location.columnNumber : 0 + }; +} + +function comparePosition (a, b) { + if (a.line !== b.line) return a.line - b.line; + return a.column - b.column; +} + +function containsPosition (node, position) { + if (!node || !node.loc || !position) return false; + return comparePosition(node.loc.start, position) <= 0 && + comparePosition(position, node.loc.end) <= 0; +} + +function nodeSize (node) { + return (node.end || 0) - (node.start || 0); +} + +function functionNameOf (node, parent) { + if (!node) return ''; + if (node.id && node.id.name) return node.id.name; + if (parent && parent.type === 'VariableDeclarator' && parent.id && parent.id.name) return parent.id.name; + if (parent && parent.type === 'Property') { + if (parent.key && parent.key.name) return parent.key.name; + if (parent.key && parent.key.value) return String(parent.key.value); + } + if (parent && parent.type === 'MethodDefinition') { + if (parent.key && parent.key.name) return parent.key.name; + if (parent.key && parent.key.value) return String(parent.key.value); + } + return ''; +} + +function visitAst (node, visitor, parent = null) { + if (!node || typeof node !== 'object' || typeof node.type !== 'string') return; + visitor(node, parent); + for (const key of Object.keys(node)) { + if (key === 'loc' || key === 'range' || key === 'source') continue; + const value = node[key]; + if (Array.isArray(value)) { + value.forEach(ea => visitAst(ea, visitor, node)); + } else if (value && typeof value === 'object' && typeof value.type === 'string') { + visitAst(value, visitor, node); + } + } +} + +function parseFrameSource (frame) { + const sourceText = sourceTextForFrame(frame); + if (!sourceText) { + throw new InspectorInterpreterError('Cannot interpret inspector frame without captured source text.', { frame }); + } + try { + const ast = parse(sourceText, { + locations: true, + addSource: true, + addAstIndex: true, + allowReturnOutsideFunction: true + }); + normalizeForInterpreter(ast); + return ast; + } catch (err) { + throw new InspectorInterpreterError('Cannot parse captured source for interpreter stepping: ' + (err.message || err), { frame }); + } +} + +function normalizeForInterpreter (ast) { + visitAst(ast, node => { + if (node.type === 'VariableDeclaration' && node.kind !== 'var') node.kind = 'var'; + if (node.type === 'ArrowFunctionExpression') { + node.type = 'FunctionExpression'; + node.id = null; + node.expression = false; + if (node.body && node.body.type !== 'BlockStatement') { + node.body = { + type: 'BlockStatement', + body: [{ + type: 'ReturnStatement', + argument: node.body, + start: node.body.start, + end: node.body.end, + loc: node.body.loc, + source: node.body.source, + astIndex: node.body.astIndex + }], + start: node.body.start, + end: node.body.end, + loc: node.body.loc, + source: node.body.source, + astIndex: node.body.astIndex + }; + } + } + }); + return ast; +} + +function findEnclosingFunction (ast, frame) { + const position = positionForFrame(frame); + const functionName = frame && frame.functionName; + const candidates = []; + visitAst(ast, (node, parent) => { + if (!FUNCTION_TYPES.has(node.type)) return; + if (!containsPosition(node, position)) return; + candidates.push({ node, parent, name: functionNameOf(node, parent) }); + }); + const named = functionName + ? candidates.filter(candidate => candidate.name === functionName) + : []; + const choices = named.length ? named : candidates; + choices.sort((a, b) => nodeSize(a.node) - nodeSize(b.node)); + return choices[0] && choices[0].node; +} + +function findSmallestNodeAt (root, position, predicate = () => true) { + const found = []; + visitAst(root, node => { + if (!predicate(node)) return; + if (containsPosition(node, position)) found.push(node); + }); + found.sort((a, b) => nodeSize(a) - nodeSize(b)); + return found[0] || null; +} + +function findStoppedStatement (functionNode, position) { + return findSmallestNodeAt(functionNode.body || functionNode, position, node => + STATEMENT_TYPES.has(node.type)); +} + +function findPendingResultExpression (functionNode, position) { + return findSmallestNodeAt(functionNode.body || functionNode, position, node => + PENDING_RESULT_EXPRESSION_TYPES.has(node.type)); +} + +function scopeForInspectorFrame (frame) { + if (frame && frame.getScope && !frame.scopes) { + const scope = frame.getScope(); + return scope && scope.copy ? scope.copy() : scope; + } + const inspectorScopes = frame && frame.scopes ? frame.scopes() : []; + let scope = new Scope(Global); + for (let i = inspectorScopes.length - 1; i >= 0; i--) { + const inspectorScope = inspectorScopes[i]; + scope = new Scope({ ...(inspectorScope.bindings || {}) }, scope); + } + return scope; +} + +function locationFromNode (node, fallback = null) { + if (!node || !node.loc) return fallback; + return { + scriptId: fallback && fallback.scriptId || '', + lineNumber: node.loc.start.line - 1, + columnNumber: node.loc.start.column + }; +} + +function decorateInterpreterFrame (interpreterFrame, inspectorFrame) { + const source = inspectorFrame && inspectorFrame.source || null; + const fallbackLocation = inspectorFrame && inspectorFrame.location || null; + Object.defineProperty(interpreterFrame, 'id', { + configurable: true, + get () { return inspectorFrame && inspectorFrame.id; } + }); + Object.defineProperty(interpreterFrame, 'functionName', { + configurable: true, + get () { return this.func && this.func.name() || inspectorFrame && inspectorFrame.functionName || ''; } + }); + Object.defineProperty(interpreterFrame, 'source', { + configurable: true, + get () { return source; } + }); + Object.defineProperty(interpreterFrame, 'location', { + configurable: true, + get () { return locationFromNode(this.getPC && this.getPC(), fallbackLocation); } + }); + interpreterFrame.inspectorFrame = inspectorFrame; + return interpreterFrame; +} + +export function isInspectorRuntimeFrame (frame) { + const source = frame && frame.source || {}; + const url = source.url || ''; + const sourceText = source.sourceText || ''; + return url.includes('/lively.context/lib/inspector-runtime.js') || + url.endsWith('/lively.context/lib/inspector-runtime.js') || + (frame && frame.functionName === 'halt' && + sourceText.includes('HALT_UNWIND_TAG') && + sourceText.includes('InspectorHaltUnwind')); +} + +export function interpreterFramesForInspectorContinuation (continuation, { startFrame = null } = {}) { + const frames = continuation && continuation.frames ? continuation.frames() : []; + const firstRelevantIndex = frames.findIndex(frame => !isInspectorRuntimeFrame(frame)); + const firstIndex = firstRelevantIndex >= 0 ? firstRelevantIndex : 0; + const startIndex = startFrame + ? frames.findIndex(frame => frame.id === startFrame.id) + : firstIndex; + return frames.slice(startIndex >= 0 ? startIndex : firstIndex) + .filter(frame => !isInspectorRuntimeFrame(frame)); +} + +export function materializeInspectorFrame (frame, { + skipStoppedStatement = false, + restart = false +} = {}) { + const ast = parseFrameSource(frame); + const position = positionForFrame(frame); + const functionNode = findEnclosingFunction(ast, frame) || ast; + const stoppedStatement = findStoppedStatement(functionNode, position); + const pcNode = restart + ? null + : (skipStoppedStatement && stoppedStatement + ? stoppedStatement + : findPendingResultExpression(functionNode, position) || + findSmallestNodeAt(functionNode, position) || + stoppedStatement || + functionNode); + + const scope = scopeForInspectorFrame(frame); + const func = new AcornFunction(functionNode, scope); + const interpreterFrame = Frame.create(func); + interpreterFrame.setScope(scope); + decorateInterpreterFrame(interpreterFrame, frame); + interpreterFrame.setThis(frame.getThis ? frame.getThis() : undefined); + if (functionNode.type !== 'Program' && frame.getArguments) { + let args; + try { args = frame.getArguments(); } catch (err) {} + if (args !== undefined) interpreterFrame.setArguments(args); + } + if (pcNode) interpreterFrame.setPC(pcNode); + if (skipStoppedStatement && stoppedStatement && stoppedStatement.astIndex !== undefined) { + interpreterFrame.alreadyComputed[stoppedStatement.astIndex] = undefined; + } + return interpreterFrame; +} + +export function materializeInspectorContinuation (continuation, { + startFrame = null, + restart = false +} = {}) { + const frames = interpreterFramesForInspectorContinuation(continuation, { startFrame }); + if (!frames.length) { + throw new InspectorInterpreterError('Cannot interpret inspector continuation without user frames.'); + } + + let parentFrame = null; + for (let i = frames.length - 1; i >= 0; i--) { + const frame = materializeInspectorFrame(frames[i], { + skipStoppedStatement: i === 0 && !restart, + restart: i === 0 && restart + }); + frame.setParentFrame(parentFrame); + parentFrame = frame; + } + return new Continuation(parentFrame); +} + +export function asInterpreterContinuation (continuation, options = {}) { + const currentFrame = continuation && continuation.currentFrame; + if (currentFrame && currentFrame.getOriginalAst && currentFrame.getOriginalAst()) return continuation; + return materializeInspectorContinuation(continuation, options); +} + +function continuationFromStepResult (result) { + const unwind = result && result.isUnwindException + ? result + : result && result.unwindException; + if (unwind) return Continuation.fromUnwindException(unwind); + return result; +} + +export function stepInspectorContinuation (continuation, { + action = 'stepOver', + startFrame = null +} = {}) { + const interpreterContinuation = asInterpreterContinuation(continuation, { startFrame }); + const interpreter = new Interpreter(); + const frame = interpreterContinuation.currentFrame; + const result = action === 'stepInto' + ? interpreter.stepToNextCallOrStatement(frame) + : interpreter.stepToNextStatement(frame); + return continuationFromStepResult(result); +} + +export function stepOutInspectorContinuation (continuation, { + startFrame = null +} = {}) { + const interpreterContinuation = asInterpreterContinuation(continuation, { startFrame }); + const frame = interpreterContinuation.currentFrame; + const parentFrame = frame && frame.getParentFrame && frame.getParentFrame(); + const result = continuationFromStepResult(new Interpreter().runFromPC(frame)); + if (result && result.isContinuation) return result; + if (!parentFrame) return result; + const parentPC = parentFrame.getPC && parentFrame.getPC(); + if (parentPC && parentPC.astIndex !== undefined) { + parentFrame.alreadyComputed[parentPC.astIndex] = result; + } + return new Continuation(parentFrame); +} + +export function restartInspectorFrame (continuation, { startFrame = null } = {}) { + const interpreterContinuation = materializeInspectorContinuation(continuation, { + startFrame, + restart: true + }); + const result = new Interpreter().stepToNextStatement(interpreterContinuation.currentFrame); + return continuationFromStepResult(result); +} + +export function resumeInspectorContinuation (continuation, options = {}) { + return asInterpreterContinuation(continuation, options).resume(); +} diff --git a/lively.context/lib/inspector-runtime.js b/lively.context/lib/inspector-runtime.js new file mode 100644 index 0000000000..f62629be33 --- /dev/null +++ b/lively.context/lib/inspector-runtime.js @@ -0,0 +1,564 @@ +/*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 false; }, + deliverCapture: function (descriptor) { + return runtime.deliverCapture(descriptor); + } + }; + + return desktop.debugger; +} + +function frameIdsFromContext (context) { + return context.frameOrder.slice(); +} + +function importDebuggerUI () { + const system = systemObject(); + const Global = globalObject(); + const uiUrl = Global.location + ? new URL('/lively.ide/js/debugger/ui.cp.js', Global.location.origin).href + : 'lively.ide/js/debugger/ui.cp.js'; + if (system && typeof system.import === 'function') { + return system.import('lively.ide/js/debugger/ui.cp.js') + .catch(() => system.import(uiUrl)); + } + return Function('moduleId', 'return import(moduleId)')(uiUrl); +} + +function debuggerWorld () { + const Global = globalObject(); + const world = Global.$world; + return world && + world.name && + world.name !== 'lively.next' && + typeof world.getWindows === 'function' + ? world + : null; +} + +function waitForDebuggerWorld ({ timeout = 10000, interval = 50 } = {}) { + const world = debuggerWorld(); + if (world) return Promise.resolve(world); + + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const world = debuggerWorld(); + if (world) return resolve(world); + if (Date.now() - started >= timeout) { + return reject(new Error('Cannot open lively.context debugger before the Lively world is ready.')); + } + setTimeout(tick, interval); + }; + tick(); + }); +} + +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 world = await waitForDebuggerWorld(); + const mod = await importDebuggerUI(); + return mod.openForContinuation(continuation, world); + }); + + const openPromise = Promise.resolve().then(() => open(continuation)).catch(err => { + runtime.openedCaptureIds.delete(continuation.id); + runtime.lastOpenError = err; + if (typeof console !== 'undefined' && console.warn) { + console.warn('Could not open lively.context debugger', err); + } + return null; + }); + runtime.lastOpenPromise = openPromise; + return openPromise; +} + +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; + 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 = context.frames[frameId] || { + frameId, + contextId, + index: context.frameOrder.length, + 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'); + 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; + } + + 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); + 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, autoOpen = true, openForContinuation = null } = {}) { + 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, + autoOpen, + openForContinuation, + openedCaptureIds: new Set(), + lastOpenPromise: null, + lastOpenError: null, + captureListenerInstalled: false, + deliverCapture (descriptor) { + const continuation = registry.deliverCapture(descriptor); + openContinuationForRuntime(this, continuation); + return continuation; + } + }; + + const desktopBridge = installDesktopDebuggerBridge(runtime); + if (!runtime.bridge && desktopBridge && typeof desktopBridge.armHalt === 'function') { + runtime.bridge = desktopBridge; + } + installCaptureListener(runtime); + installHaltUnwindSuppression(); + + 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') { + throw new Error('lively.context inspector service is not available; halt would freeze this runtime'); + } + if (typeof bridge.isAvailable === 'function' && !bridge.isAvailable()) { + throw new Error('lively.context inspector service is not attached yet; try again after the desktop app finishes booting'); + } + if (bridge.armHalt({ reason, captureId }) === false) { + throw new Error('lively.context inspector service rejected the halt request'); + } + + throw new InspectorHaltUnwind(reason, captureId); +} + +export { HALT_UNWIND_TAG }; 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 909b2f5d48..bf05861835 100644 --- a/lively.context/lib/stackReification.js +++ b/lively.context/lib/stackReification.js @@ -1,10 +1,25 @@ /*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 = window; +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; + }); +} + +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 = { @@ -141,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); @@ -239,7 +255,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/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/inspector-interpreter-test.js b/lively.context/tests/inspector-interpreter-test.js new file mode 100644 index 0000000000..f720400376 --- /dev/null +++ b/lively.context/tests/inspector-interpreter-test.js @@ -0,0 +1,219 @@ +"format esm"; +/*global describe, it, beforeEach*/ +import { expect } from 'mocha-es6'; +import { InspectorRegistry } from '../lib/inspector-runtime.js'; +import { + materializeInspectorContinuation, + restartInspectorFrame, + resumeInspectorContinuation, + stepOutInspectorContinuation, + stepInspectorContinuation +} from '../lib/inspector-interpreter.js'; + +const SOURCE = [ + 'function smokeInner(arg) {', + ' const local = 2;', + ' halt("x");', + ' const after = local + arg;', + ' return after;', + '}' +].join('\n'); + +const NESTED_SOURCE = [ + 'function smokeOuter(marker) {', + ' function smokeInner(arg) {', + ' const localObject = { marker: arg };', + ' halt("x");', + ' return localObject;', + ' }', + ' return smokeInner(marker);', + '}', + 'smokeOuter(marker);' +].join('\n'); + +const ASYNC_WRAPPER_SOURCE = [ + 'Promise.resolve().then(async () => {', + ' try {', + ' smokeOuter(marker);', + ' } catch (err) {', + ' return err;', + ' }', + ' return marker;', + '});' +].join('\n'); + +describe('inspector interpreter continuation', function () { + let registry; + + beforeEach(function () { + registry = new InspectorRegistry(); + }); + + function createContinuation () { + const context = registry.createContext({ + id: 'capture-1', + reason: 'halt', + frames: [{ + frameId: 'frame-0', + functionName: 'halt', + source: { + scriptId: 'runtime', + sourceText: 'const HALT_UNWIND_TAG = "x"; class InspectorHaltUnwind {} function halt() {}' + }, + location: { scriptId: 'runtime', lineNumber: 0, columnNumber: 68 }, + scopes: [{ scopeId: 'runtime-local', type: 'local', bindings: {} }] + }, { + frameId: 'frame-1', + functionName: 'smokeInner', + source: { scriptId: 'script-1', sourceText: SOURCE }, + location: { scriptId: 'script-1', lineNumber: 2, columnNumber: 2 }, + arguments: [5], + thisValue: { receiver: true }, + scopes: [{ + scopeId: 'local', + type: 'local', + bindings: { + arg: 5, + local: 2, + halt () { throw new Error('halt should have been skipped'); } + } + }] + }] + }); + return registry.continuationFor(context.id); + } + + function createNestedContinuation ({ includeAsyncWrapper = false } = {}) { + const marker = { label: 'actual-marker' }; + const localObject = { marker }; + const frames = [{ + frameId: 'frame-0', + functionName: 'halt', + source: { + scriptId: 'runtime', + sourceText: 'const HALT_UNWIND_TAG = "x"; class InspectorHaltUnwind {} function halt() {}' + }, + location: { scriptId: 'runtime', lineNumber: 0, columnNumber: 68 }, + scopes: [{ scopeId: 'runtime-local', type: 'local', bindings: {} }] + }, { + frameId: 'frame-1', + functionName: 'smokeInner', + source: { scriptId: 'script-nested', sourceText: NESTED_SOURCE }, + location: { scriptId: 'script-nested', lineNumber: 3, columnNumber: 4 }, + arguments: [marker], + scopes: [{ + scopeId: 'inner-local', + type: 'local', + bindings: { + arg: marker, + localObject, + halt () { throw new Error('halt should have been skipped'); } + } + }] + }, { + frameId: 'frame-2', + functionName: 'smokeOuter', + source: { scriptId: 'script-nested', sourceText: NESTED_SOURCE }, + location: { scriptId: 'script-nested', lineNumber: 6, columnNumber: 9 }, + arguments: [marker], + scopes: [{ + scopeId: 'outer-local', + type: 'local', + bindings: { + marker, + smokeInner () { throw new Error('smokeInner should not be called again'); } + } + }] + }]; + if (includeAsyncWrapper) { + frames.push({ + frameId: 'frame-3', + functionName: '', + source: { scriptId: 'script-wrapper', sourceText: ASYNC_WRAPPER_SOURCE }, + location: { scriptId: 'script-wrapper', lineNumber: 2, columnNumber: 4 }, + scopes: [{ + scopeId: 'wrapper-local', + type: 'local', + bindings: { + marker, + smokeOuter () { throw new Error('smokeOuter should not be called again'); } + } + }] + }); + } + const context = registry.createContext({ + id: 'capture-nested', + reason: 'halt', + frames + }); + return { continuation: registry.continuationFor(context.id), localObject, marker }; + } + + it('materializes inspector frames as executable interpreter frames', function () { + const continuation = materializeInspectorContinuation(createContinuation()); + const frame = continuation.currentFrame; + + expect(frame.lookup('local')).equals(2); + expect(frame.getThis()).deep.equals({ receiver: true }); + expect(frame.getArguments()).deep.equals([5]); + expect(frame.getPC().type).equals('ExpressionStatement'); + expect(frame.getPC().source).equals('halt("x");'); + expect(frame.isAlreadyComputed(frame.getPC())).equals(true); + }); + + it('steps over the halted statement to the next source line', function () { + const next = stepInspectorContinuation(createContinuation(), { action: 'stepOver' }); + const frame = next.currentFrame; + + expect(next.isContinuation).equals(true); + expect(frame.getPC().source).equals('const after = local + arg;'); + expect(frame.lookup('local')).equals(2); + }); + + it('resumes from the halted statement through the interpreter', function () { + const result = resumeInspectorContinuation(createContinuation()); + + expect(result).equals(7); + }); + + it('resumes caller frames by plugging the callee result into the pending call', function () { + const { continuation, localObject } = createNestedContinuation(); + const result = resumeInspectorContinuation(continuation); + + expect(result).equals(localObject); + }); + + it('normalizes async arrow wrapper frames for interpreter resume', function () { + const { continuation, marker } = createNestedContinuation({ includeAsyncWrapper: true }); + const result = resumeInspectorContinuation(continuation); + + expect(result).equals(marker); + }); + + it('steps out to the caller frame with the callee result recorded', function () { + const { continuation, localObject } = createNestedContinuation(); + const steppedOut = stepOutInspectorContinuation(continuation); + const frame = steppedOut.currentFrame; + + expect(steppedOut.isContinuation).equals(true); + expect(frame.functionName).equals('smokeOuter'); + expect(frame.getPC().source).equals('smokeInner(marker)'); + expect(resumeInspectorContinuation(steppedOut)).equals(localObject); + }); + + it('restarts a captured frame at the first statement', function () { + const next = restartInspectorFrame(createContinuation()); + const frame = next.currentFrame; + + expect(frame.getPC().source).equals('const local = 2;'); + }); + + it('restarts an already materialized interpreter frame', function () { + const stepped = stepInspectorContinuation(createContinuation(), { action: 'stepOver' }); + const restarted = restartInspectorFrame(stepped); + const frame = restarted.currentFrame; + + expect(frame.getPC().source).equals('const local = 2;'); + expect(frame.lookup('arg')).equals(5); + }); +}); diff --git a/lively.context/tests/inspector-runtime-test.js b/lively.context/tests/inspector-runtime-test.js new file mode 100644 index 0000000000..ac5729cd85 --- /dev/null +++ b/lively.context/tests/inspector-runtime-test.js @@ -0,0 +1,295 @@ +"format esm"; +/*global describe, it, beforeEach, afterEach, globalThis*/ +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(); + }); + + afterEach(function () { + delete globalThis.__LIVELY_PENDING_DEBUGGER_CAPTURES__; + }); + + 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('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 } }] + }); + + 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('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('records auto-open failures for diagnostics', async function () { + const failure = new Error('open failed'); + const oldWarn = console.warn; + console.warn = () => {}; + const runtime = installInspectorRuntime({ + env: {}, + openForContinuation: () => { throw failure; } + }); + + try { + runtime.deliverCapture({ captureId: 'capture-open-failure', frames: [] }); + const result = await runtime.lastOpenPromise; + + expect(result).equals(null); + expect(runtime.lastOpenError).equals(failure); + expect(runtime.openedCaptureIds.has('capture-open-failure')).equals(false); + } finally { + console.warn = oldWarn; + } + }); + + 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({ + env: {}, + bridge: { + isAvailable: () => true, + 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'); + }); + + it('refuses to halt when the inspector service is not attached', function () { + const calls = []; + installInspectorRuntime({ + env: {}, + bridge: { + isAvailable: () => false, + armHalt: capture => calls.push(capture), + breakpointTrap () {} + } + }); + + expect(() => halt('not ready')).to.throw(/not attached/); + expect(calls).to.have.length(0); + }); + + it('refuses to halt when the inspector service rejects the halt request', function () { + const calls = []; + installInspectorRuntime({ + env: {}, + bridge: { + isAvailable: () => true, + armHalt: capture => { + calls.push(capture); + return false; + } + } + }); + + expect(() => halt('rejected')).to.throw(/rejected/); + expect(calls).to.have.length(1); + }); +}); 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() { diff --git a/lively.context/tests/rewriter-test.js b/lively.context/tests/rewriter-test.js index fb4a0a8d31..531451a1f6 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 { installShallowDeepEqual } from './helpers.js'; +installShallowDeepEqual(chai); chai.use(function(chai, utils) { chai.ast = chai.ast || {}; @@ -498,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') }, diff --git a/lively.freezer/tools/assert-no-node-builtins.mjs b/lively.freezer/tools/assert-no-node-builtins.mjs new file mode 100644 index 0000000000..4a50d285e7 --- /dev/null +++ b/lively.freezer/tools/assert-no-node-builtins.mjs @@ -0,0 +1,78 @@ +/* global process */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { builtinModules } from 'node:module'; + +// Node built-ins, both bare ('fs') and prefixed ('node:fs') forms. +const NODE_BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map(m => 'node:' + m) +]); + +/** + * Collect Node built-in specifiers that appear as *static* dependencies of a + * `System.register([...deps...], ...)` call in a frozen (System-format) chunk. + * + * Only bare/`node:` specifiers inside a register dependency array count: those + * are the ones SystemJS will try to fetch under the page URL at runtime (e.g. + * `GET /dashboard/path` → 404). Runtime `require('child_process')` strings in + * guarded Node-only branches are *not* register deps and are correctly ignored. + * + * @param { string } code - The chunk source. + * @returns { string[] } - Sorted, de-duplicated offending specifiers. + */ +function nodeBuiltinRegisterDeps (code) { + const found = new Set(); + const re = /register\(\s*\[/g; + let m; + while ((m = re.exec(code))) { + // Walk from the opening `[` to its matching `]` to isolate the dep array. + let depth = 0; + const start = m.index + m[0].length - 1; + let i = start; + for (; i < code.length; i++) { + const c = code[i]; + if (c === '[') depth++; + else if (c === ']') { depth--; if (depth === 0) break; } + } + const arr = code.slice(start, i + 1); + const specRe = /'([^']*)'|"([^"]*)"/g; + let s; + while ((s = specRe.exec(arr))) { + const spec = s[1] ?? s[2]; + if (NODE_BUILTINS.has(spec)) found.add(spec); + } + re.lastIndex = i + 1; + } + return [...found].sort(); +} + +/** + * Scan every top-level `.js` chunk in a freezer output directory and throw if + * any references a Node built-in as a static module dependency. This turns a + * silent runtime 404 (server-only code reachable from the browser entry graph) + * into a loud, immediate build failure. + * + * @param { string } dir - The output directory (e.g. 'landing-page'). + */ +export async function assertNoNodeBuiltins (dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const offenders = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + const code = await fs.readFile(path.join(dir, entry.name), 'utf8'); + const bad = nodeBuiltinRegisterDeps(code); + if (bad.length) offenders.push({ file: entry.name, builtins: bad }); + } + if (offenders.length) { + const detail = offenders.map(o => ` - ${o.file}: ${o.builtins.join(', ')}`).join('\n'); + throw new Error( + `Node built-in modules leaked into the browser bundle "${dir}":\n${detail}\n` + + ` These resolve to bare specifiers that SystemJS fetches under the page URL ` + + `(e.g. GET /dashboard/path) and 404 at runtime, aborting boot.\n` + + ` Cause: a server-only module (e.g. lively.shell/server-command.js) is statically ` + + `reachable from the entry graph. Make the offending import dynamic/lazy, or split the ` + + `Node-only code out of the browser-reachable path.` + ); + } +} diff --git a/lively.freezer/tools/build.landing-page.mjs b/lively.freezer/tools/build.landing-page.mjs index 37a19ed2bb..0a8f527c12 100644 --- a/lively.freezer/tools/build.landing-page.mjs +++ b/lively.freezer/tools/build.landing-page.mjs @@ -5,6 +5,7 @@ import { babel } from '@rollup/plugin-babel'; import { lively } from 'lively.freezer/src/plugins/rollup'; import resolver from 'lively.freezer/src/resolvers/node.cjs'; import PresetEnv from '@babel/preset-env'; +import { assertNoNodeBuiltins } from './assert-no-node-builtins.mjs'; const verbose = process.argv[2] === '--verbose'; const minify = !process.env.CI; @@ -66,6 +67,9 @@ try { }, }); + await assertNoNodeBuiltins('landing-page'); + console.log(' Verified: no Node built-ins reachable in browser bundle'); + console.log(' Landing page build complete'); } catch (err) { diff --git a/lively.freezer/tools/build.loading-screen.mjs b/lively.freezer/tools/build.loading-screen.mjs index 59f723ecf2..5f413815b4 100644 --- a/lively.freezer/tools/build.loading-screen.mjs +++ b/lively.freezer/tools/build.loading-screen.mjs @@ -4,6 +4,7 @@ import jsonPlugin from '@rollup/plugin-json'; import { rm, writeFile } from 'node:fs/promises'; import { lively } from 'lively.freezer/src/plugins/rollup'; import resolver from 'lively.freezer/src/resolvers/node.cjs'; +import { assertNoNodeBuiltins } from './assert-no-node-builtins.mjs'; const verbose = true; // process.argv[2] === '--verbose'; const minify = !process.env.CI; @@ -61,6 +62,9 @@ try { await writeLoadingScreenCompatibilityEntry(output); + await assertNoNodeBuiltins('loading-screen'); + console.log(' Verified: no Node built-ins reachable in browser bundle'); + console.log(' Loading screen build complete'); } catch (err) { diff --git a/lively.freezer/tools/build.unified.mjs b/lively.freezer/tools/build.unified.mjs index a3a793772b..a830b3ac04 100644 --- a/lively.freezer/tools/build.unified.mjs +++ b/lively.freezer/tools/build.unified.mjs @@ -5,6 +5,7 @@ import util from 'node:util'; import fs from 'node:fs/promises'; import { lively } from 'lively.freezer/src/plugins/rollup'; import resolver from 'lively.freezer/src/resolvers/node.cjs'; +import { assertNoNodeBuiltins } from './assert-no-node-builtins.mjs'; const verbose = process.argv[2] === '--verbose'; const minify = !process.env.CI; @@ -112,6 +113,13 @@ try { console.warn('\x1b[33m [!] Could not copy loading-screen index.html: ' + err.message + '\x1b[0m'); } + // Guard: a server-only module reachable from the entry graph would leak Node + // built-ins into these browser bundles and 404 at runtime (e.g. /dashboard/path). + // Fail the build loudly here instead of shipping a bundle that breaks on boot. + await assertNoNodeBuiltins('landing-page'); + await assertNoNodeBuiltins('loading-screen'); + console.log(' Verified: no Node built-ins reachable in browser bundles'); + console.log(' Unified build complete'); } catch (err) { diff --git a/lively.ide/js/debugger/evaluation.js b/lively.ide/js/debugger/evaluation.js new file mode 100644 index 0000000000..097e390b9e --- /dev/null +++ b/lively.ide/js/debugger/evaluation.js @@ -0,0 +1,45 @@ +function own (obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +export function bindingsForScope (scope) { + if (!scope) return {}; + const bindings = scope.bindings; + return bindings || {}; +} + +export function scopeLookupProxy (scopes = [], fallback = globalThis) { + const mappings = scopes.map(bindingsForScope).filter(Boolean); + return new Proxy(Object.create(null), { + has (target, key) { + if (key === Symbol.unscopables) return false; + return mappings.some(mapping => own(mapping, key)) || key in fallback; + }, + + get (target, key) { + if (key === Symbol.unscopables) return undefined; + const mapping = mappings.find(mapping => own(mapping, key)); + return mapping ? mapping[key] : fallback[key]; + }, + + set (target, key, value) { + const mapping = mappings.find(mapping => own(mapping, key)); + if (mapping) mapping[key] = value; + else fallback[key] = value; + return true; + } + }); +} + +function evaluatorForSource (source) { + try { + return Function('__scope__', `with (__scope__) { return (${source}); }`); + } catch (err) { + return Function('__scope__', `with (__scope__) { ${source} }`); + } +} + +export function evaluateInDebuggerScopes (source, scopes = [], fallback = globalThis) { + const proxy = scopeLookupProxy(scopes, fallback); + return evaluatorForSource(String(source || ''))(proxy); +} diff --git a/lively.ide/js/debugger/source.js b/lively.ide/js/debugger/source.js new file mode 100644 index 0000000000..13c3c46eb0 --- /dev/null +++ b/lively.ide/js/debugger/source.js @@ -0,0 +1,77 @@ +import { resource } from 'lively.resources'; + +export const CURRENT_LINE_MARKER_ID = 'lively-debugger-current-line'; + +export 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'); +} + +export function sourceUrlForFrame (frame) { + const source = frame && frame.source || {}; + return source.url || ''; +} + +export function isInspectorRuntimeFrame (frame) { + const url = sourceUrlForFrame(frame); + const sourceText = frame && frame.source && frame.source.sourceText || ''; + return url.includes('/lively.context/lib/inspector-runtime.js') || + url.endsWith('/lively.context/lib/inspector-runtime.js') || + (frame && frame.functionName === 'halt' && + sourceText.includes('HALT_UNWIND_TAG') && + sourceText.includes('InspectorHaltUnwind')); +} + +export function initialFrameForContinuation (continuation, frames = continuation ? continuation.frames() : []) { + if (!frames.length) return null; + if (isInspectorRuntimeFrame(frames[0])) { + return frames.find(frame => !isInspectorRuntimeFrame(frame)) || frames[0]; + } + if ((continuation && continuation.reason) !== 'halt') return frames[0]; + return frames.find(frame => !isInspectorRuntimeFrame(frame)) || frames[0]; +} + +export function locationStringForFrame (frame) { + if (!frame) return ''; + const source = frame.source || {}; + const location = frame.location || {}; + const url = source.url || source.scriptId || '(no source url)'; + if (!Number.isFinite(location.lineNumber)) return url; + return url + ':' + (location.lineNumber + 1) + ':' + ((location.columnNumber || 0) + 1); +} + +export function lineRangeForFrame (frame, sourceText = '') { + const location = frame && frame.location || {}; + if (!Number.isFinite(location.lineNumber)) return null; + const lines = String(sourceText || '').split('\n'); + if (!lines.length) return null; + const row = Math.max(0, Math.min(location.lineNumber, lines.length - 1)); + return { + start: { row, column: 0 }, + end: { row, column: lines[row].length } + }; +} + +export async function readFrameSource (frame, read = url => resource(url).read()) { + if (!frame) return ''; + const capturedSource = frame.source && frame.source.sourceText; + if (capturedSource) return String(capturedSource); + const url = sourceUrlForFrame(frame); + if (!url) return sourceSummary(frame); + try { + const source = await read(url); + return source == null ? sourceSummary(frame) : String(source); + } catch (err) { + const message = err && err.message || String(err); + return sourceSummary(frame) + '\n\nUnable to load source: ' + message; + } +} diff --git a/lively.ide/js/debugger/ui.cp.js b/lively.ide/js/debugger/ui.cp.js new file mode 100644 index 0000000000..2289537611 --- /dev/null +++ b/lively.ide/js/debugger/ui.cp.js @@ -0,0 +1,641 @@ +import { GridLayout, TilingLayout, ViewModel, component, part, Label, Text, Icon, config } from 'lively.morphic'; +import { Color, pt, rect } from 'lively.graphics'; +import { SystemButton } from 'lively.components/buttons.cp.js'; +import { SystemList } from '../../styling/shared.cp.js'; +import { signal } from 'lively.bindings'; +import { InspectionTree, PropertyTree, printValue } from '../inspector/context.js'; +import { + restartInspectorFrame, + resumeInspectorContinuation, + stepOutInspectorContinuation, + stepInspectorContinuation +} from 'lively.context/lib/inspector-interpreter.js'; +import { + CURRENT_LINE_MARKER_ID, + initialFrameForContinuation, + lineRangeForFrame, + locationStringForFrame, + readFrameSource +} from './source.js'; +import { evaluateInDebuggerScopes } from './evaluation.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 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 || {}; +} + +function interpreterScopesForFrame (frame) { + const scopes = []; + let scope = frame && frame.getScope && frame.getScope(); + while (scope) { + const mapping = scope.getMapping ? scope.getMapping() : {}; + scopes.push({ + name: mapping === globalThis ? 'global' : 'scope', + type: mapping === globalThis ? 'global' : 'local', + bindingNames () { return Object.keys(mapping); }, + hasBinding (candidate) { return Object.prototype.hasOwnProperty.call(mapping, candidate); }, + lookup (candidate) { return mapping[candidate]; }, + get bindings () { return mapping; } + }); + scope = scope.getParentScope && scope.getParentScope(); + } + return scopes; +} + +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 frameValue (frame, getterName) { + if (!frame || typeof frame[getterName] !== 'function') return undefined; + try { + return frame[getterName](); + } catch (err) { + return undefined; + } +} + +function visibleScopesForFrame (frame) { + if (!frame) return []; + const scopes = []; + const thisValue = frameValue(frame, 'getThis'); + const args = frameValue(frame, 'getArguments'); + const exception = frameValue(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')); + const frameScopes = frame.scopes + ? frame.scopes() + : interpreterScopesForFrame(frame); + return scopes.concat(frameScopes); +} + +function workspaceScope (bindings) { + return { + name: 'workspace', + type: 'workspace', + bindingNames () { return Object.keys(bindings); }, + hasBinding (candidate) { return Object.prototype.hasOwnProperty.call(bindings, candidate); }, + lookup (candidate) { return bindings[candidate]; }, + get bindings () { return bindings; } + }; +} + +export class LivelyDebuggerModel extends ViewModel { + static get properties () { + return { + continuation: {}, + inspectorContinuation: {}, + selectedFrame: {}, + selectedScope: {}, + workspaceBindings: { + initialize () { this.workspaceBindings = {}; } + }, + + expose: { + get () { return ['continuation', 'onWindowClose', 'closeDebugger', 'proceed']; } + }, + + 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: 'proceed' }, + { target: 'retry button', signal: 'fire', handler: 'retry' }, + { target: 'step into button', signal: 'fire', handler: 'stepInto' }, + { target: 'step over button', signal: 'fire', handler: 'stepOver' }, + { target: 'step out button', signal: 'fire', handler: 'stepOut' }, + { target: 'restart frame button', signal: 'fire', handler: 'restartFrame' }, + { target: 'workspace do button', signal: 'fire', handler: 'evaluateWorkspace' } + ]; + } + } + }; + } + + viewDidLoad () { + this.rememberReleasableContinuation(this.continuation); + this.refreshFromContinuation(); + } + + renderDraggableTreeLabel (args) { + return args.value; + } + + renderPropertyControl ({ keyString, valueString }) { + return keyString + ': ' + valueString; + } + + refreshSelectedLine (sourceText = this.currentSourceText || '') { + const sourcePane = this.ui.sourcePane; + if (!sourcePane) return null; + if (!sourcePane.document && sourcePane.backWithDocument) sourcePane.backWithDocument(); + if (sourcePane.removeMarker) sourcePane.removeMarker(CURRENT_LINE_MARKER_ID); + + const range = lineRangeForFrame(this.selectedFrame, sourceText); + this.ui.locationLabel.textString = locationStringForFrame(this.selectedFrame); + if (!range) return null; + + const row = range.start.row; + if (sourcePane.selectLine) sourcePane.selectLine(row, false); + else sourcePane.selection = range; + + if (sourcePane.addMarker) { + sourcePane.addMarker({ + id: CURRENT_LINE_MARKER_ID, + range, + style: { + 'background-color': 'rgba(66, 165, 245, 0.18)', + 'box-shadow': 'inset 3px 0 0 rgba(41, 121, 255, 0.85)', + 'pointer-events': 'none' + } + }); + } + + try { + if (sourcePane.centerRow) sourcePane.centerRow(row); + else if (sourcePane.centerRange) sourcePane.centerRange(range); + else if (sourcePane.scrollCursorIntoView) sourcePane.scrollCursorIntoView(); + } catch (err) { + if (sourcePane.scrollCursorIntoView) sourcePane.scrollCursorIntoView(); + } + return range; + } + + refreshFromContinuation () { + const frames = this.continuation ? this.continuation.frames() : []; + this.ui.stackList.items = frames.map((frame, index) => ({ + isListItem: true, + string: frameLabel(frame, index), + value: frame + })); + const initialFrame = initialFrameForContinuation(this.continuation, frames); + this.ui.stackList.selection = initialFrame; + this.selectFrame(initialFrame); + this.updateStatus(); + } + + 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; + } + + async selectFrame (frame) { + this.selectedFrame = frame; + const source = await readFrameSource(frame); + if (this.selectedFrame !== frame) return; + this.currentSourceText = source; + this.ui.sourcePane.textString = source; + this.refreshSelectedLine(source); + const scopes = visibleScopesForFrame(frame); + 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; + } + } + + evaluationScopes () { + const frameScopes = visibleScopesForFrame(this.selectedFrame); + const selectedScope = this.selectedScope; + const orderedScopes = selectedScope + ? [selectedScope].concat(frameScopes.filter(scope => scope !== selectedScope)) + : frameScopes; + return [workspaceScope(this.workspaceBindings || {})].concat(orderedScopes); + } + + async evaluateWorkspace () { + const editor = this.ui.workspaceInput; + const source = editor && editor.textString || ''; + this.workspaceBindings = this.workspaceBindings || {}; + try { + const result = await Promise.resolve(evaluateInDebuggerScopes(source, this.evaluationScopes())); + this.workspaceBindings.it = result; + this.ui.workspaceResult.textString = printValue(result); + this.ui.status.textString = 'workspace: ' + printValue(result); + return result; + } catch (err) { + const message = err && (err.stack || err.message) || String(err); + this.ui.workspaceResult.textString = message; + this.ui.status.textString = 'workspace failed: ' + (err && err.message || err); + signal(this.view, 'debuggerActionFailed', { actionName: 'Workspace', frame: this.selectedFrame, error: err }); + return false; + } + } + + async proceed () { + try { + this.rememberReleasableContinuation(this.continuation); + const result = resumeInspectorContinuation(this.continuation, { startFrame: this.selectedFrame }); + signal(this.view, 'debuggerProceed', this.continuation); + if (result && result.isContinuation) { + this.continuation = result; + this.refreshFromContinuation(); + this.ui.status.textString = 'proceed stopped'; + } else { + this.closeDebugger(); + } + return result; + } catch (err) { + return this.interpreterActionFailed('Proceed', err); + } + } + + retry () { + return this.restartFrame(); + } + + stepInto () { + return this.stepWithInterpreter('Step Into', 'stepInto'); + } + + stepOver () { + return this.stepWithInterpreter('Step Over', 'stepOver'); + } + + stepOut () { + try { + const result = stepOutInspectorContinuation(this.continuation, { + startFrame: this.selectedFrame + }); + return this.updateAfterInterpreterResult('Step Out', result); + } catch (err) { + return this.interpreterActionFailed('Step Out', err); + } + } + + restartFrame () { + try { + const result = restartInspectorFrame(this.continuation, { startFrame: this.selectedFrame }); + return this.updateAfterInterpreterResult('Restart Frame', result); + } catch (err) { + return this.interpreterActionFailed('Restart Frame', err); + } + } + + stepWithInterpreter (label, action) { + try { + const result = stepInspectorContinuation(this.continuation, { + action, + startFrame: this.selectedFrame + }); + return this.updateAfterInterpreterResult(label, result); + } catch (err) { + return this.interpreterActionFailed(label, err); + } + } + + updateAfterInterpreterResult (label, result) { + if (result && result.isContinuation) { + this.rememberReleasableContinuation(this.continuation); + this.continuation = result; + this.refreshFromContinuation(); + this.ui.status.textString = label + ' stopped'; + return result; + } + this.ui.status.textString = label + ' completed: ' + printValue(result); + return result; + } + + interpreterActionFailed (actionName, err) { + const message = actionName + ' failed: ' + (err && err.message || err); + this.ui.status.textString = message; + signal(this.view, 'debuggerActionFailed', { actionName, frame: this.selectedFrame, error: err }); + return false; + } + + rememberReleasableContinuation (continuation) { + if (continuation && typeof continuation.release === 'function') { + this.inspectorContinuation = continuation; + } + } + + onWindowClose () { + const continuation = this.inspectorContinuation || this.continuation; + if (continuation && continuation.release) continuation.release(); + this.inspectorContinuation = null; + 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(SystemButton, { + extent: pt(35, 26), + borderRadius: 5, + padding: rect(0, 0, 0, 0), + submorphs: [{ + name: 'label', + fontColor: Color.rgb(52, 73, 94), + fontSize: 15 + }] +}); + +export const LivelyDebugger = component({ + name: 'lively debugger', + defaultViewModel: LivelyDebuggerModel, + extent: pt(900, 560), + fill: Color.rgb(245, 247, 248), + borderColor: Color.rgb(149, 165, 166), + borderRadius: 3, + borderWidth: 1, + 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(236, 240, 241), + borderColor: Color.rgb(215, 219, 221), + borderWidth: { bottom: 1 }, + 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.rgb(52, 73, 94), + fontFamily: 'IBM Plex Sans', + fontSize: 14, + fontWeight: 'bold', + reactsToPointer: false + } + ] + }, + part(SystemList, { + name: 'stack list', + fontFamily: 'IBM Plex Mono', + fontSize: 12, + itemHeight: 24, + manualItemHeight: true, + padding: rect(4, 4, 4, 4), + borderRadius: 0 + }), + { + name: 'main pane', + fill: Color.transparent, + layout: new GridLayout({ + autoAssign: false, + grid: [ + ['source header'], + ['source pane'], + ['scope/value pane'], + ['workspace header'], + ['workspace pane'] + ], + groups: { + 'source header': { align: 'topLeft', resize: true }, + 'source pane': { align: 'topLeft', resize: true }, + 'scope/value pane': { align: 'topLeft', resize: true }, + 'workspace header': { align: 'topLeft', resize: true }, + 'workspace pane': { align: 'topLeft', resize: true } + }, + rows: [ + 0, { fixed: 26 }, + 1, { fixed: 210, paddingBottom: 6 }, + 2, { height: 1, paddingBottom: 6 }, + 3, { fixed: 26 }, + 4, { fixed: 110 } + ] + }), + submorphs: [{ + name: 'source header', + fill: Color.rgb(245, 247, 248), + borderColor: Color.rgb(215, 219, 221), + borderWidth: { bottom: 1 }, + layout: new TilingLayout({ + axisAlign: 'center', + orderByIndex: true, + padding: rect(8, 0, 8, 0) + }), + submorphs: [{ + type: Label, + name: 'location label', + value: '', + fontColor: Color.rgb(52, 73, 94), + fontFamily: 'IBM Plex Sans', + fontSize: 12, + padding: rect(0, 3, 0, 0), + reactsToPointer: false + }] + }, { + type: Text, + name: 'source pane', + readOnly: true, + fixedWidth: true, + fixedHeight: true, + lineWrapping: 'by-chars', + 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(SystemList, { + name: 'scope list', + fontFamily: 'IBM Plex Mono', + fontSize: 12, + itemHeight: 22, + manualItemHeight: true, + padding: rect(4, 4, 4, 4), + borderRadius: 0 + }), + { + 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: {} + }] + }, { + name: 'workspace header', + fill: Color.rgb(245, 247, 248), + borderColor: Color.rgb(215, 219, 221), + borderWidth: { bottom: 1 }, + layout: new TilingLayout({ + axisAlign: 'center', + orderByIndex: true, + padding: rect(8, 2, 8, 2), + spacing: 6, + resizePolicies: [['workspace result', { width: 'fill', height: 'fixed' }]] + }), + submorphs: [ + part(ToolbarButton, { + name: 'workspace do button', + tooltip: 'Do It', + viewModel: { label: { value: Icon.textAttribute('play') } } + }), + { + type: Label, + name: 'workspace result', + value: '', + fontColor: Color.rgb(52, 73, 94), + fontFamily: 'IBM Plex Mono', + fontSize: 12, + clipMode: 'hidden', + reactsToPointer: false + } + ] + }, { + type: Text, + name: 'workspace input', + readOnly: false, + fixedWidth: true, + fixedHeight: true, + lineWrapping: 'by-chars', + 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: '' + }] + }, { + 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; +} diff --git a/lively.ide/tests/js/debugger-ui-test.js b/lively.ide/tests/js/debugger-ui-test.js new file mode 100644 index 0000000000..86ac3c070f --- /dev/null +++ b/lively.ide/tests/js/debugger-ui-test.js @@ -0,0 +1,121 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + initialFrameForContinuation, + lineRangeForFrame, + locationStringForFrame, + readFrameSource, + sourceSummary, + sourceUrlForFrame +} from '../../js/debugger/source.js'; +import { evaluateInDebuggerScopes } from '../../js/debugger/evaluation.js'; + +function frame (spec = {}) { + return { + functionName: spec.functionName || 'smokeInner', + source: { + url: spec.url === undefined ? 'file:///tmp/debugger-smoke.js' : spec.url, + scriptId: spec.scriptId || 'script-1' + }, + location: { + scriptId: spec.scriptId || 'script-1', + lineNumber: spec.lineNumber === undefined ? 1 : spec.lineNumber, + columnNumber: spec.columnNumber === undefined ? 3 : spec.columnNumber + }, + scopes () { return []; } + }; +} + +describe('lively debugger ui', function () { + it('loads source text through the captured frame URL', async function () { + const capturedFrame = frame(); + let requestedUrl; + const source = await readFrameSource(capturedFrame, async url => { + requestedUrl = url; + return 'function smokeInner() {\n halt();\n}\n'; + }); + + expect(sourceUrlForFrame(capturedFrame)).equals('file:///tmp/debugger-smoke.js'); + expect(requestedUrl).equals('file:///tmp/debugger-smoke.js'); + expect(source).contains('halt();'); + }); + + it('uses source text captured by the inspector before fetching URLs', async function () { + const capturedFrame = frame(); + capturedFrame.source.sourceText = 'function captured() {\n halt();\n}\n'; + const source = await readFrameSource(capturedFrame, async () => { + throw new Error('should not fetch'); + }); + + expect(source).contains('function captured'); + expect(source).contains('halt();'); + }); + + it('falls back to a source summary when source cannot be loaded', async function () { + const capturedFrame = frame({ url: '' }); + const summary = await readFrameSource(capturedFrame); + + expect(summary).equals(sourceSummary(capturedFrame)); + expect(summary).contains('function smokeInner'); + expect(summary).contains('line 2, column 4'); + }); + + it('maps captured V8 locations to a source line range', function () { + const source = 'const first = 1;\nconst stopped = 2;\nconst third = 3;'; + const capturedFrame = frame({ lineNumber: 1, columnNumber: 14 }); + + expect(locationStringForFrame(capturedFrame)).equals('file:///tmp/debugger-smoke.js:2:15'); + expect(lineRangeForFrame(capturedFrame, source)).deep.equals({ + start: { row: 1, column: 0 }, + end: { row: 1, column: 'const stopped = 2;'.length } + }); + }); + + it('opens halt captures on the caller frame instead of the inspector runtime', function () { + const runtimeFrame = frame({ + functionName: 'halt', + url: 'http://127.0.0.1:9012/lively.context/lib/inspector-runtime.js', + lineNumber: 525 + }); + const runtimeFrameWithoutUrl = frame({ + functionName: 'halt', + url: '', + lineNumber: 525 + }); + runtimeFrameWithoutUrl.source.sourceText = [ + 'const HALT_UNWIND_TAG = "lively.context.inspector.halt";', + 'class InspectorHaltUnwind {}', + 'export function halt() {}' + ].join('\n'); + const callerFrame = frame({ + functionName: 'smokeInner', + url: 'http://127.0.0.1:9012/smoke-debugger.js', + lineNumber: 12 + }); + + expect(initialFrameForContinuation({ reason: 'halt' }, [runtimeFrame, callerFrame])).equals(callerFrame); + expect(initialFrameForContinuation({ reason: 'desktop debugger smoke' }, [runtimeFrameWithoutUrl, callerFrame])).equals(callerFrame); + expect(initialFrameForContinuation({ reason: 'desktop debugger smoke' }, [runtimeFrame, callerFrame])).equals(callerFrame); + expect(initialFrameForContinuation({ reason: 'exception' }, [callerFrame, runtimeFrame])).equals(callerFrame); + }); + + it('evaluates workspace code against actual selected scope bindings', function () { + const marker = { label: 'actual object' }; + const selectedScope = { bindings: { marker, count: 2 } }; + const outerScope = { bindings: { count: 99, outer: 4 } }; + + const result = evaluateInDebuggerScopes('marker.count = count + outer, marker', [selectedScope, outerScope]); + + expect(result).equals(marker); + expect(marker.count).equals(6); + }); + + it('writes workspace assignments back into the selected scope binding', function () { + const selectedScope = { bindings: { count: 2 } }; + + const result = evaluateInDebuggerScopes('count = count + 5', [selectedScope]); + + expect(result).equals(7); + expect(selectedScope.bindings.count).equals(7); + }); +}); 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]; diff --git a/lively.server/plugins/dav.js b/lively.server/plugins/dav.js index 4921a776e8..a4f9bc0ebe 100644 --- a/lively.server/plugins/dav.js +++ b/lively.server/plugins/dav.js @@ -298,7 +298,8 @@ export default class LivelyDAVPlugin { return; } - if (req.url == '/livelyClassesRuntime.js') { + const requestPath = String(req.url).split('?')[0]; + if (requestPath == '/livelyClassesRuntime.js' || requestPath.endsWith('/livelyClassesRuntime.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); res.end(await resource(System.baseURL).join('lively.classes/build/runtime.js').read()); return; diff --git a/lively.server/plugins/test-runner.js b/lively.server/plugins/test-runner.js index 4abdbb4e64..989802738d 100644 --- a/lively.server/plugins/test-runner.js +++ b/lively.server/plugins/test-runner.js @@ -1,6 +1,9 @@ import { HeadlessSession } from 'lively.headless'; import { promise } from 'lively.lang'; +const TEST_RUNNER_EVAL_TIMEOUT = Number(process.env.LIVELY_TEST_RUNNER_EVAL_TIMEOUT) || 5 * 60 * 1000; +const TEST_RUNNER_OPEN_TIMEOUT = Number(process.env.LIVELY_TEST_RUNNER_OPEN_TIMEOUT) || 5 * 60 * 1000; + export default class TestRunner { async setup (livelyServer) { console.log('[Test Runner] Started'); @@ -12,7 +15,7 @@ export default class TestRunner { let attempts = 1; while (true) { try { - this.headlessSession = new HeadlessSession(); + this.headlessSession = new HeadlessSession({ aliveTimeout: TEST_RUNNER_OPEN_TIMEOUT }); await this.headlessSession.open('http://localhost:9011/worlds/load?name=__newWorld__&askForWorldName=false&fastLoad=false', (sess) => sess.runEval(`typeof $world !== 'undefined' && $world.isWorld && $world._uiInitialized`, { timeout: 5000 }).catch(() => false)); } catch (err) { if (attempts < 3) { @@ -33,9 +36,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' }); } @@ -62,7 +70,7 @@ export default class TestRunner { }) }); JSON.stringify(results); - `) + `, { timeout: TEST_RUNNER_EVAL_TIMEOUT }) } catch (err) { const browserErrors = this.headlessSession?.summarizeRecentConsoleErrors(); results = JSON.stringify({ diff --git a/mocha-es6/bin/mocha-es6.js b/mocha-es6/bin/mocha-es6.js index 1304adbdc5..46af30a5d8 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() { @@ -88,17 +98,28 @@ 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); 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/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'); 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 +})(); diff --git a/scripts/lively-next-env.sh b/scripts/lively-next-env.sh index e11d7c7898..288ce2f78a 100644 --- a/scripts/lively-next-env.sh +++ b/scripts/lively-next-env.sh @@ -1,23 +1,27 @@ #!/bin/bash function lively_next_env { - export NODE_OPTIONS=--max_old_space_size=8192 lv_next_dir=$1 + export NODE_OPTIONS="--max_old_space_size=8192 --loader $lv_next_dir/flatn/resolver.mjs" export PUPPETEER_CACHE_DIR=$lv_next_dir/.puppeteer-browser-cache export PATH=$lv_next_dir/flatn/bin:$PATH export FLATN_PACKAGE_DIRS= export FLATN_PACKAGE_COLLECTION_DIRS=$lv_next_dir/lively.next-node_modules:$lv_next_dir/custom-npm-modules + mkdir -p $lv_next_dir/lively.next-node_modules + mkdir -p $lv_next_dir/custom-npm-modules + mkdir -p $lv_next_dir/local_projects + mkdir -p $lv_next_dir/esm_cache read -r -d '' SETUP_FLATN_DEV_PACKAGE_DIRS <<- EOM - const packageConfig = require("fs").readFileSync("$lv_next_dir/lively.installer/packages-config.json"); + const fs = require("fs"); + const packageConfig = fs.readFileSync("$lv_next_dir/lively.installer/packages-config.json"); const packageDirs = JSON.parse(packageConfig).map(ea => "$lv_next_dir/" + ea.name); - const localProjects = require("fs").readdirSync("$lv_next_dir/local_projects", { withFileTypes: true }) + const localProjects = fs.existsSync("$lv_next_dir/local_projects") + ? fs.readdirSync("$lv_next_dir/local_projects", { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) - .map(dirent => "$lv_next_dir/local_projects/" + dirent.name); + .map(dirent => "$lv_next_dir/local_projects/" + dirent.name) + : []; packageDirs.concat(localProjects).join(":"); EOM export FLATN_DEV_PACKAGE_DIRS=$(node -p "${SETUP_FLATN_DEV_PACKAGE_DIRS}") export lv_next_dir=$lv_next_dir - - mkdir -p $lv_next_dir/lively.next-node_modules - mkdir -p $lv_next_dir/custom-npm-modules } diff --git a/scripts/test.js b/scripts/test.js index 9f972376d4..07f1010a8a 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -20,6 +20,41 @@ 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'); +} + +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 { @@ -57,7 +92,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}`); @@ -105,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 āŒ`); @@ -132,7 +168,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}"`);