diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs index b3e9e6250..05b5c57b8 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs @@ -1,12 +1,12 @@ +#[cfg(feature = "typescript-compiler-profiling")] +use crate::internal::runtime_services::{ExecutionProfile, ExecutionProfileSnapshot}; use crate::internal::runtime_services::{ OwnedJsRuntime, RuntimeOutputSink, RuntimeServices, normalize_absolute_path, }; -#[cfg(feature = "typescript-compiler-profiling")] -use crate::internal::runtime_services::{ExecutionProfile, ExecutionProfileSnapshot}; use futures::future::{Either, pending, poll_fn, select}; use futures::task::AtomicWaker; -use rquickjs::{CatchResultExt, Ctx, Module, Promise, async_with}; +use rquickjs::{CatchResultExt, Ctx, Function, Module, Promise, Value, async_with}; use serde::Deserialize; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; @@ -162,23 +162,41 @@ fn execution_control_error(job: &ExecutionJob, deadline: Option) -> Opt } #[cfg(feature = "typescript-runtime")] -fn transform_typescript_execution_source(source: String, name: &str) -> Result { +fn transform_typescript_execution_source( + source: String, + name: &str, + source_map: bool, +) -> Result { crate::internal::typescript::transform( source, name, crate::internal::typescript::runtime_mode(), - false, + source_map, Some(true), ) - .map(|output| output.code) + .map(|output| output.into_code_with_inline_source_map()) .map_err(|error| error.message) } #[cfg(not(feature = "typescript-runtime"))] -fn transform_typescript_execution_source(_source: String, _name: &str) -> Result { +fn transform_typescript_execution_source( + _source: String, + _name: &str, + _source_map: bool, +) -> Result { Err("TypeScript runtime support is not enabled".to_string()) } +#[cfg(feature = "typescript-runtime")] +fn execution_source_maps_enabled(ctx: &Ctx<'_>) -> bool { + crate::internal::typescript::source_maps_enabled(ctx) +} + +#[cfg(not(feature = "typescript-runtime"))] +fn execution_source_maps_enabled(_ctx: &Ctx<'_>) -> bool { + false +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct PollResult { @@ -355,7 +373,10 @@ async fn run_job(options: ExecutionOptions, job: Rc) { let profile = { let started = Instant::now(); let profile = Rc::new(ExecutionProfile::new(started)); - profile.set_duration("queueDelay", started.saturating_duration_since(job.created_at)); + profile.set_duration( + "queueDelay", + started.saturating_duration_since(job.created_at), + ); profile }; #[cfg(feature = "typescript-compiler-profiling")] @@ -481,10 +502,11 @@ async fn run_job(options: ExecutionOptions, job: Rc) { }))) .await; - let wrapper_name = cwd.join(if entry.is_some() { - "__wasm_rquickjs_execution_entry.mjs" - } else { + let is_inline = entry.is_none(); + let wrapper_name = cwd.join(if is_inline { "__wasm_rquickjs_execution_inline.mjs" + } else { + "__wasm_rquickjs_execution_entry.mjs" }); let name = wrapper_name.to_string_lossy().into_owned(); let mut source = if let Some(entry) = entry { @@ -500,16 +522,21 @@ async fn run_job(options: ExecutionOptions, job: Rc) { ) } else { format!( - "globalThis.__wasmRquickjsExecutionResult = (async () => __wasmRquickjsSerializeExecutionResult(await (async () => {{ {}\n}})()))();", + "globalThis.__wasmRquickjsExecutionResult = (async () => __wasmRquickjsSerializeExecutionResult(await (async () => {{\n{}\n}})()))();", options.source.unwrap_or_default() ) }; + let source_maps_enabled = if options.language == ExecutionLanguage::Typescript { + async_with!(runtime.ctx => |ctx| { execution_source_maps_enabled(&ctx) }).await + } else { + false + }; mark_profile!("wrapperPreparation"); if options.language == ExecutionLanguage::Typescript { if let Some(error) = execution_control_error(&job, deadline) { complete_job!(Err(error.to_string())); } - source = match transform_typescript_execution_source(source, &name) { + source = match transform_typescript_execution_source(source, &name, source_maps_enabled) { Ok(source) => source, Err(error) => { complete_job!(Err(error)); @@ -525,6 +552,32 @@ async fn run_job(options: ExecutionOptions, job: Rc) { let result = { let execution = async { async_with!(runtime.ctx => |ctx| { + if (options.language == ExecutionLanguage::Typescript || is_inline) + && let Ok(register_source_map) = ctx.globals().get::<_, Function>( + "__wasm_rquickjs_register_transformed_source_map", + ) + { + let (line_offset, original_line_offset, force_line_offset) = + if options.language == ExecutionLanguage::Typescript && source_maps_enabled { + (0, usize::from(is_inline), false) + } else if is_inline { + (1, 0, true) + } else { + (0, 0, false) + }; + register_source_map + .call::<_, ()>(( + name.as_str(), + source.as_str(), + Value::new_null(ctx.clone()), + line_offset, + original_line_offset, + force_line_offset, + )) + .map_err(|error| { + format!("failed to register execution source map: {error:?}") + })?; + } Module::evaluate(ctx.clone(), name, source).catch(&ctx) .map_err(|e| crate::internal::format_caught_error(e))?.finish::<()>().catch(&ctx) .map_err(|e| crate::internal::format_caught_error(e))?; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js index 3aa0b28ce..aa74c4bf7 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js @@ -318,61 +318,6 @@ function findGlobalConstructorNameByPrototype(value) { return ""; } -const inspectNewCallPattern = /\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)?inspect\s*\(\s*new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/; -const stackLocationPattern = /\(?(.+):(\d+):(\d+)\)?\s*$/; - -function inferConstructorNameFromCallsite() { - const currentModule = globalThis.__wasm_rquickjs_current_module; - if ( - currentModule === undefined || - currentModule === null || - typeof currentModule.source !== "string" - ) { - return ""; - } - - let stack; - try { - stack = String(new Error().stack || ""); - } catch { - return ""; - } - - const sourceLines = currentModule.source.split("\n"); - const stackLines = stack.split("\n"); - for (let i = stackLines.length - 1; i >= 1; i--) { - if (!stackLines[i].includes("anonymous (") && !stackLines[i].includes(" (")) { - continue; - } - - const locationMatch = stackLocationPattern.exec(stackLines[i]); - if (locationMatch === null) { - continue; - } - - const lineNumber = Number(locationMatch[2]); - if (!Number.isInteger(lineNumber) || lineNumber < 1 || lineNumber > sourceLines.length) { - continue; - } - - const snippet = [ - sourceLines[lineNumber - 4], - sourceLines[lineNumber - 3], - sourceLines[lineNumber - 2], - sourceLines[lineNumber - 1], - ] - .filter((line) => typeof line === "string") - .join(" "); - - const constructorMatch = inspectNewCallPattern.exec(snippet); - if (constructorMatch !== null) { - return constructorMatch[1]; - } - } - - return ""; -} - function trackNullPrototypeConstructor(target, proto) { if (!isObjectLike(target)) { return; @@ -420,7 +365,7 @@ Reflect.setPrototypeOf = function setPrototypeOf(target, proto) { } }; -export function getConstructorName(value, allowCallsiteFallback = false) { +export function getConstructorName(value) { if (!isObjectLike(value)) { return "Object"; } @@ -445,16 +390,6 @@ export function getConstructorName(value, allowCallsiteFallback = false) { return globalConstructorName; } - // QuickJS does not expose V8's hidden-class constructor-name recovery API. - // When inspecting `new Foo()` objects whose prototype was replaced with a - // null-prototype object, infer `Foo` from the user callsite as a fallback. - if (allowCallsiteFallback) { - const callsiteConstructorName = inferConstructorNameFromCallsite(); - if (callsiteConstructorName !== "") { - return callsiteConstructorName; - } - } - return "Object"; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 1097af4f9..6238145d0 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -14,52 +14,203 @@ import { inspect, format } from "__wasm_rquickjs_builtin/internal/util/inspect"; const _callSiteWithFnPattern = /^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/; const _callSiteNoFnPattern = /^\s*at\s+(.+):(\d+):(\d+)\s*$/; -function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { +function _isInternalErrorFrame(fileName) { + return fileName === '__wasm_rquickjs_builtin/internal/errors'; +} + +function _isInternalErrorStackLine(line) { + return /^\s*at construct \(native\)\s*$/.test(line); +} + +function _remapSourceMappedLocation(fileName, lineNumber, columnNumber) { + const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; + if (typeof mapper !== 'function') return undefined; + try { + return mapper(fileName, lineNumber, columnNumber); + } catch { + return undefined; + } +} + +function _remapSourceMappedStack(stackString) { + if (typeof stackString !== 'string') return stackString; + if (globalThis.__wasm_rquickjs_suppress_source_map_stack === true) return stackString; + const lines = stackString.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (_isInternalErrorStackLine(line)) { + lines.splice(i, 1); + i -= 1; + continue; + } + let match = line.match(_callSiteWithFnPattern); + let fileName; + let lineNumber; + let columnNumber; + if (match) { + fileName = match[2]; + lineNumber = parseInt(match[3], 10); + columnNumber = parseInt(match[4], 10); + } else { + match = line.match(_callSiteNoFnPattern); + if (!match) continue; + fileName = match[1]; + lineNumber = parseInt(match[2], 10); + columnNumber = parseInt(match[3], 10); + } + if (_isInternalErrorFrame(fileName)) { + lines.splice(i, 1); + i -= 1; + continue; + } + const origin = _remapSourceMappedLocation(fileName, lineNumber, columnNumber); + if (!origin || typeof origin.fileName !== 'string') continue; + const location = `${fileName}:${lineNumber}:${columnNumber}`; + const offset = line.lastIndexOf(location); + if (offset === -1) continue; + const mapped = `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`; + lines[i] = line.slice(0, offset) + mapped + line.slice(offset + location.length); + } + return lines.join('\n'); +} + +function _formatNativeCallSites(error, callSites) { + if (!Array.isArray(callSites)) return ''; + let header; + try { + header = nativeErrorToString.call(error); + } catch { + header = 'Error'; + } + const lines = [header]; + for (const callSite of callSites) { + let functionName; + let fileName; + let lineNumber; + let columnNumber; + try { + functionName = callSite.getFunctionName(); + if (!functionName && typeof callSite.getMethodName === 'function') { + functionName = callSite.getMethodName(); + } + fileName = callSite.getFileName(); + lineNumber = callSite.getLineNumber(); + columnNumber = callSite.getColumnNumber(); + } catch { + continue; + } + let text; + if (fileName) { + const location = `${fileName}:${lineNumber}:${columnNumber}`; + text = functionName ? `${functionName} (${location})` : location; + } else { + let isNative = false; + try { + isNative = callSite.isNative(); + } catch { + // Use the generic fallback below. + } + text = functionName || ''; + if (isNative) text += ' (native)'; + } + lines.push(` at ${text}`); + } + return lines.join('\n'); +} + +function _prepareSourceMappedStack(error, callSites) { + return _remapSourceMappedStack(_formatNativeCallSites(error, callSites)); +} + +const nativeCallSiteCaptures = new WeakMap(); + +function _captureNativeCallSites(error, callSites) { + if (error && (typeof error === 'object' || typeof error === 'function')) { + nativeCallSiteCaptures.set(error, callSites); + } + return ''; +} + +function _takeNativeCallSites(error) { + const callSites = nativeCallSiteCaptures.get(error); + if (!callSites) return undefined; + nativeCallSiteCaptures.delete(error); + return callSites; +} + +Object.defineProperty(globalThis, '__wasm_rquickjs_remap_source_mapped_stack', { + value: _remapSourceMappedStack, + writable: false, + configurable: false, +}); + +function _callSiteMethod(callSite, name, fallback) { + if (callSite && typeof callSite[name] === 'function') { + try { + return callSite[name](); + } catch { + // Use the compatibility fallback below. + } + } + return fallback; +} + +function _makeCallSite(functionName, fileName, lineNumber, columnNumber, callSite) { return { - getThis() { return undefined; }, - getTypeName() { return null; }, - getFunction() { return undefined; }, + getThis() { return _callSiteMethod(callSite, 'getThis', undefined); }, + getTypeName() { return _callSiteMethod(callSite, 'getTypeName', null); }, + getFunction() { return _callSiteMethod(callSite, 'getFunction', undefined); }, getFunctionName() { return functionName || null; }, - getMethodName() { return null; }, + getMethodName() { return _callSiteMethod(callSite, 'getMethodName', null); }, getFileName() { return fileName || null; }, getLineNumber() { return lineNumber | 0; }, getColumnNumber() { return columnNumber | 0; }, - getEvalOrigin() { return undefined; }, - isToplevel() { return true; }, - isEval() { return false; }, - isNative() { return false; }, - isConstructor() { return false; }, - isAsync() { return false; }, - isPromiseAll() { return false; }, - getPromiseIndex() { return null; }, + getEvalOrigin() { return _callSiteMethod(callSite, 'getEvalOrigin', undefined); }, + isToplevel() { return _callSiteMethod(callSite, 'isToplevel', true); }, + isEval() { return _callSiteMethod(callSite, 'isEval', false); }, + isNative() { return _callSiteMethod(callSite, 'isNative', false); }, + isConstructor() { return _callSiteMethod(callSite, 'isConstructor', false); }, + isAsync() { return _callSiteMethod(callSite, 'isAsync', false); }, + isPromiseAll() { return _callSiteMethod(callSite, 'isPromiseAll', false); }, + getPromiseIndex() { return _callSiteMethod(callSite, 'getPromiseIndex', null); }, getScriptNameOrSourceURL() { return fileName || null; }, toString() { - const name = functionName || ''; if (fileName) { - return `${name} (${fileName}:${lineNumber}:${columnNumber})`; + const location = `${fileName}:${lineNumber}:${columnNumber}`; + return functionName ? `${functionName} (${location})` : location; + } + const name = functionName || ''; + if (_callSiteMethod(callSite, 'isNative', false)) { + return `${name} (native)`; } return name; }, }; } -function _parseStackStringToCallSites(stackString) { - if (typeof stackString !== 'string') return []; - const lines = stackString.split('\n'); - const sites = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - let m = line.match(_callSiteWithFnPattern); - if (m) { - sites.push(_makeCallSite(m[1], m[2], parseInt(m[3], 10), parseInt(m[4], 10))); - continue; - } - m = line.match(_callSiteNoFnPattern); - if (m) { - sites.push(_makeCallSite(null, m[1], parseInt(m[2], 10), parseInt(m[3], 10))); +function _toCompatibleCallSite(callSite) { + let functionName; + let fileName; + let lineNumber; + let columnNumber; + try { + functionName = callSite.getFunctionName(); + if (!functionName && typeof callSite.getMethodName === 'function') { + functionName = callSite.getMethodName(); } + fileName = callSite.getFileName(); + lineNumber = callSite.getLineNumber(); + columnNumber = callSite.getColumnNumber(); + } catch { + // Return a complete neutral CallSite when the native object is partial. } - return sites; + return _makeCallSite( + functionName, + fileName, + lineNumber, + columnNumber, + callSite, + ); } // Helper to create property descriptors immune to Object.prototype pollution. @@ -75,224 +226,111 @@ function _dataDesc(value) { return d; } -// Normalize `Error.prototype.stack` so deleting an instance stack works like -// Node (i.e. `delete err.stack` makes subsequent `err.stack` reads undefined). -const nativeErrorStackDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, "stack"); const NativeError = Error; -const nativeErrorToString = Error.prototype.toString; -const materializedErrorStacks = new WeakSet(); - -function materializeOwnStack(errorInstance) { - if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { - return; - } - - const own = Object.getOwnPropertyDescriptor(errorInstance, "stack"); - if (own && Object.prototype.hasOwnProperty.call(own, "value") && own.configurable === true) { - materializedErrorStacks.add(errorInstance); - return; - } - - let stackValue; - try { - if (own && typeof own.get === "function") { - stackValue = own.get.call(errorInstance); - } else if (nativeErrorStackDescriptor && typeof nativeErrorStackDescriptor.get === "function") { - stackValue = nativeErrorStackDescriptor.get.call(errorInstance); - } else if (nativeErrorStackDescriptor && Object.prototype.hasOwnProperty.call(nativeErrorStackDescriptor, "value")) { - stackValue = nativeErrorStackDescriptor.value; - } - } catch { - stackValue = undefined; - } - - if (stackValue === undefined || stackValue === "") { - try { - stackValue = nativeErrorToString.call(errorInstance); - } catch { - stackValue = undefined; - } - } - - try { - Object.defineProperty(errorInstance, "stack", _dataDesc(stackValue)); - materializedErrorStacks.add(errorInstance); - } catch { - // Best effort only. - } -} - -function installErrorStackShimForNonConfigurablePrototype() { - const ErrorShimPrototype = Object.create(NativeError.prototype); - - Object.defineProperty(ErrorShimPrototype, "stack", { - configurable: true, - enumerable: false, - get() { - const own = Object.getOwnPropertyDescriptor(this, "stack"); - if (!own) { - return undefined; - } - if (Object.prototype.hasOwnProperty.call(own, "value")) { - return own.value; - } - if (typeof own.get === "function") { - return own.get.call(this); - } - return undefined; - }, - set(value) { - Object.defineProperty(this, "stack", _dataDesc(value)); - }, - }); - - const ErrorShim = function Error() { - const ctorTarget = new.target || ErrorShim; - const errorInstance = Reflect.construct(NativeError, arguments, NativeError); - materializeOwnStack(errorInstance); - - // Error.prepareStackTrace support (V8 compat). - // Replace the materialized .stack data property with a lazy getter that - // checks Error.prepareStackTrace on first access, then materializes the - // result as a plain data property so subsequent reads have no overhead. - const rawStack = errorInstance.stack; - Object.defineProperty(errorInstance, "stack", { - get() { - const prepareStackTrace = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepareStackTrace === "function" - ? prepareStackTrace(errorInstance, _parseStackStringToCallSites(rawStack)) - : rawStack; - Object.defineProperty(errorInstance, "stack", _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(errorInstance, "stack", _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - - const targetPrototype = (ctorTarget && ctorTarget.prototype) || ErrorShimPrototype; - if (Object.getPrototypeOf(errorInstance) !== targetPrototype) { - Object.setPrototypeOf(errorInstance, targetPrototype); - } - - return errorInstance; - }; - - Object.setPrototypeOf(ErrorShim, NativeError); - ErrorShim.prototype = ErrorShimPrototype; - Object.defineProperty(ErrorShimPrototype, "constructor", { - value: NativeError, +const nativeErrorToString = NativeError.prototype.toString; +const nativeErrorPrepareStackTraceDescriptor = Object.getOwnPropertyDescriptor( + NativeError, + "prepareStackTrace", +); +const nativeErrorCaptureStackTrace = NativeError.captureStackTrace; +const initialPublicPrepareStackTrace = NativeError.prepareStackTrace; +const preparedNativeStacks = new WeakMap(); + +export function isPreparedNativeStack(error, stack) { + return error !== null && + (typeof error === 'object' || typeof error === 'function') && + preparedNativeStacks.get(error) === stack; +} + +function _dispatchPrepareStackTrace(error, callSites) { + if (error && (typeof error === 'object' || typeof error === 'function') && + preparedNativeStacks.has(error)) { + return preparedNativeStacks.get(error); + } + const prepare = NativeError.prepareStackTrace; + const result = typeof prepare === 'function' + ? prepare(error, callSites.map(_toCompatibleCallSite)) + : _prepareSourceMappedStack(error, callSites); + if (error && (typeof error === 'object' || typeof error === 'function')) { + preparedNativeStacks.set(error, result); + } + return result; +} + +if (nativeErrorPrepareStackTraceDescriptor && + typeof nativeErrorPrepareStackTraceDescriptor.set === 'function') { + // Keep QuickJS's eager native backtrace construction behind a stable hidden + // dispatcher. The public property remains an ordinary Node-shaped data + // property, so defineProperty(), delete, seal, and freeze need no proxy + // synchronization. Explicit captureStackTrace below supplies Node's lazy + // hook selection; ordinary Error construction retains QuickJS's eager + // prepare timing. + Object.defineProperty(NativeError, 'prepareStackTrace', { + value: initialPublicPrepareStackTrace, writable: true, configurable: true, enumerable: false, }); - - Object.defineProperty(ErrorShim, Symbol.hasInstance, { - value(value) { - return value instanceof NativeError; - }, - configurable: true, - }); - - globalThis.Error = ErrorShim; + nativeErrorPrepareStackTraceDescriptor.set.call( + NativeError, + _dispatchPrepareStackTrace, + ); } -if (nativeErrorStackDescriptor && nativeErrorStackDescriptor.configurable === false) { - try { - installErrorStackShimForNonConfigurablePrototype(); - } catch { - // Keep the runtime default behavior if shimming fails. - } -} else { - try { - Object.defineProperty(Error.prototype, "stack", { - configurable: true, - enumerable: false, - get: function getErrorStack() { - if (this === Error.prototype) { - return undefined; - } - - const own = Object.getOwnPropertyDescriptor(this, "stack"); - if (own) { - if (Object.prototype.hasOwnProperty.call(own, "value")) { - return own.value; - } - if (typeof own.get === "function" && own.get !== getErrorStack) { - return own.get.call(this); - } - return undefined; - } - return undefined; - }, - set(value) { - Object.defineProperty(this, "stack", _dataDesc(value)); - materializedErrorStacks.add(this); - }, - }); - } catch { - // Keep best-effort compatibility if the runtime forbids reconfiguration. - } -} +Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { + value() { + // The dispatcher reads the source-map registry dynamically, so enabling + // source maps later does not replace a public constructor or property. + }, + writable: false, + configurable: false, +}); // --------------------------------------------------------------------------- // Global Error.captureStackTrace & Error.stackTraceLimit (V8 compat) // --------------------------------------------------------------------------- -// QuickJS natively provides Error.captureStackTrace, Error.prepareStackTrace, -// Error.stackTraceLimit, and native CallSite objects. However, the ErrorShim -// above replaces globalThis.Error, which can interfere with the native -// prepareStackTrace getter/setter chain when Error.captureStackTrace is called. -// -// We wrap the native captureStackTrace so that it checks the JS-level -// Error.prepareStackTrace (which may be set on the ErrorShim) and, if set, -// parses the raw stack string into our JS CallSite objects. This ensures -// libraries like depd that set Error.prepareStackTrace and then call -// Error.captureStackTrace get proper CallSite objects. +// QuickJS invokes prepareStackTrace while captureStackTrace runs. Node defers +// it until the first `.stack` read. Capture the native CallSites with an +// internal hook, then select the public hook lazily and normalize incomplete +// QuickJS CallSites to the V8-compatible shape expected by user code. { - const nativeCaptureStackTrace = globalThis.Error.captureStackTrace; - if (typeof nativeCaptureStackTrace === 'function') { - globalThis.Error.captureStackTrace = function captureStackTrace(targetObject, constructorOpt) { - // If prepareStackTrace is set at the JS level (e.g., on the ErrorShim), - // we need to handle it ourselves since the native captureStackTrace - // may not see it through the ErrorShim prototype chain. - const currentPrepare = globalThis.Error && globalThis.Error.prepareStackTrace; - if (typeof currentPrepare === 'function') { - // Temporarily clear prepareStackTrace so the native implementation - // produces a raw string stack (not CallSite objects). - globalThis.Error.prepareStackTrace = undefined; - try { - nativeCaptureStackTrace(targetObject, constructorOpt); - } finally { - globalThis.Error.prepareStackTrace = currentPrepare; - } - - // Read the raw stack string, parse into CallSites, and install - // a lazy getter that calls prepareStackTrace on first access. - const rawStack = targetObject.stack; - if (typeof rawStack === 'string') { - const callSites = _parseStackStringToCallSites(rawStack); - Object.defineProperty(targetObject, "stack", { - get() { - const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepare === "function" - ? prepare(targetObject, callSites) - : rawStack; - Object.defineProperty(targetObject, "stack", _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(targetObject, "stack", _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - } - } else { - // No prepareStackTrace set — just use the native implementation as-is. - nativeCaptureStackTrace(targetObject, constructorOpt); + if (typeof nativeErrorCaptureStackTrace === 'function' && + nativeErrorPrepareStackTraceDescriptor && + typeof nativeErrorPrepareStackTraceDescriptor.set === 'function') { + function captureCallSites(targetObject, constructorOpt) { + nativeErrorPrepareStackTraceDescriptor.set.call( + NativeError, + _captureNativeCallSites, + ); + try { + nativeErrorCaptureStackTrace(targetObject, constructorOpt); + return _takeNativeCallSites(targetObject) ?? []; + } finally { + nativeErrorPrepareStackTraceDescriptor.set.call( + NativeError, + _dispatchPrepareStackTrace, + ); } + } + + globalThis.Error.captureStackTrace = function captureStackTrace(targetObject, constructorOpt) { + const callSites = captureCallSites(targetObject, constructorOpt) + .map(_toCompatibleCallSite); + Object.defineProperty(targetObject, "stack", { + get() { + const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; + const result = typeof prepare === "function" + ? prepare(targetObject, callSites) + : _prepareSourceMappedStack(targetObject, callSites); + Object.defineProperty(targetObject, "stack", _dataDesc(result)); + return result; + }, + set(value) { + Object.defineProperty(targetObject, "stack", _dataDesc(value)); + }, + configurable: true, + enumerable: false, + }); }; } } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs index 398dd2c8c..d8eac8e31 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs @@ -5,6 +5,7 @@ pub fn add_to_resolver(resolver: BuiltinResolver) -> BuiltinResolver { .with_module("__wasm_rquickjs_builtin/internal/http") .with_module("internal/http") .with_module("__wasm_rquickjs_builtin/internal/errors") + .with_module("__wasm_rquickjs_builtin/internal/source_map_url") .with_module("__wasm_rquickjs_builtin/internal/fs/utils") .with_module("__wasm_rquickjs_builtin/internal/fs/shared") .with_module("__wasm_rquickjs_builtin/internal/normalize_encoding") @@ -47,6 +48,10 @@ pub fn module_loader() -> BuiltinLoader { "__wasm_rquickjs_builtin/internal/errors", include_str!("errors.js"), ) + .with_module( + "__wasm_rquickjs_builtin/internal/source_map_url", + include_str!("source_map_url.js"), + ) .with_module( "__wasm_rquickjs_builtin/internal/fs/utils", include_str!("fs/utils.js"), diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js new file mode 100644 index 000000000..eace7c103 --- /dev/null +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js @@ -0,0 +1,389 @@ +function isIdentifierChar(ch) { + return ( + ch === 0x5f || + ch === 0x24 || + (ch >= 0x30 && ch <= 0x39) || + (ch >= 0x41 && ch <= 0x5a) || + (ch >= 0x61 && ch <= 0x7a) || + ch >= 0x80 + ); +} + +function skipStringLiteral(source, index, quote) { + index++; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + index++; + if (ch === quote) break; + } + return index; +} + +function skipTemplateLiteral(source, index) { + index++; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + index++; + if (ch === 0x60) break; + } + return index; +} + +function skipWhitespaceAndComments(source, index) { + while (index < source.length) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2f) { + index += 2; + while ( + index < source.length && + source.charCodeAt(index) !== 0x0a && + source.charCodeAt(index) !== 0x0d + ) + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2a) { + index += 2; + while ( + index + 1 < source.length && + !( + source.charCodeAt(index) === 0x2a && + source.charCodeAt(index + 1) === 0x2f + ) + ) + index++; + index = Math.min(index + 2, source.length); + continue; + } + break; + } + return index; +} + +function previousSignificantChar(source, index) { + index--; + while (index >= 0) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index--; + continue; + } + if (ch === 0x2f && source.charCodeAt(index - 1) === 0x2a) { + const start = source.lastIndexOf("/*", index - 2); + if (start >= 0) { + index = start - 1; + continue; + } + } + return ch; + } + return 0; +} + +function previousSignificantWord(source, index) { + index--; + while (index >= 0) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index--; + continue; + } + if (ch === 0x2f && source.charCodeAt(index - 1) === 0x2a) { + const start = source.lastIndexOf("/*", index - 2); + if (start >= 0) { + index = start - 1; + continue; + } + } + break; + } + const end = index + 1; + while (index >= 0 && isIdentifierChar(source.charCodeAt(index))) index--; + return end === index + 1 ? "" : source.slice(index + 1, end); +} + +function skipRegexLiteral(source, index) { + index++; + let inClass = false; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + if (ch === 0x5b) inClass = true; + else if (ch === 0x5d) inClass = false; + else if (ch === 0x2f && !inClass) { + index++; + while ( + index < source.length && + isIdentifierChar(source.charCodeAt(index)) + ) + index++; + break; + } + index++; + } + return index; +} + +function isLikelyRegexLiteral(source, index) { + const end = skipRegexLiteral(source, index); + if (end >= source.length) return true; + if (end === index + 1) return false; + const next = source.charCodeAt(end); + return ( + next === 0x20 || + next === 0x09 || + next === 0x0a || + next === 0x0d || + next === 0x2e || + next === 0x3b || + next === 0x2c || + next === 0x29 || + next === 0x5d || + next === 0x7d + ); +} + +function previousWordBeforeMatchingParen(source, closeIndex) { + let depth = 1; + let index = closeIndex - 1; + while (index >= 0) { + const ch = source.charCodeAt(index); + if (ch === 0x29) depth++; + else if (ch === 0x28) { + depth--; + if (depth === 0) return previousSignificantWord(source, index); + } + index--; + } + return ""; +} + +function regexCanFollowParen(source, index) { + if (previousSignificantChar(source, index) !== 0x29) return false; + const word = previousWordBeforeMatchingParen(source, index - 1); + return word === "if" || word === "while" || word === "for" || word === "with"; +} + +function regexCanFollow(source, index) { + const previous = previousSignificantChar(source, index); + if ( + previous === 0 || + previous === 0x28 || + previous === 0x5b || + previous === 0x7b || + previous === 0x2c || + previous === 0x3b || + previous === 0x3a || + previous === 0x3d || + previous === 0x21 || + previous === 0x3f || + previous === 0x26 || + previous === 0x7c || + previous === 0x2b || + previous === 0x2d || + previous === 0x2a || + previous === 0x2f || + previous === 0x25 || + previous === 0x7e || + previous === 0x5e || + previous === 0x3c || + previous === 0x3e + ) + return true; + const word = previousSignificantWord(source, index); + return ( + word === "return" || + word === "throw" || + word === "case" || + word === "delete" || + word === "void" || + word === "typeof" || + word === "yield" || + word === "await" || + word === "else" || + word === "do" || + word === "in" || + word === "instanceof" || + word === "of" + ); +} + +function findTemplateExpressionEnd(source, start) { + let index = start; + let depth = 0; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x27 || ch === 0x22) { + index = skipStringLiteral(source, index, ch); + continue; + } + if (ch === 0x60) { + index = skipTemplateLiteral(source, index); + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2f) { + index += 2; + while ( + index < source.length && + source.charCodeAt(index) !== 0x0a && + source.charCodeAt(index) !== 0x0d + ) + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2a) { + index = skipWhitespaceAndComments(source, index); + continue; + } + if (ch === 0x2f && regexCanFollow(source, index)) { + index = skipRegexLiteral(source, index); + continue; + } + if (ch === 0x7b) depth++; + else if (ch === 0x7d) { + if (depth === 0) return index; + depth--; + } + index++; + } + return -1; +} + +function sourceMapURLFromComment(comment) { + let index = 3; + const separator = comment.charCodeAt(index); + if ( + separator !== 0x09 && + separator !== 0x0b && + separator !== 0x0c && + separator !== 0x20 && + separator !== 0xa0 + ) + return undefined; + index++; + if (!comment.startsWith("sourceMappingURL=", index)) return undefined; + const valueStart = index + 17; + let valueEnd = valueStart; + while (valueEnd < comment.length && !/\s/.test(comment[valueEnd])) valueEnd++; + if (valueEnd === valueStart || comment.slice(valueEnd).trim() !== "") return undefined; + return comment.slice(valueStart, valueEnd); +} + +export function extractSourceMapURL(code) { + const source = String(code); + if (source.indexOf("sourceMappingURL=") === -1) return undefined; + let result; + + function scan(start, end) { + let index = start; + while (index < end) { + const ch = source.charCodeAt(index); + if (ch === 0x27 || ch === 0x22) { + index = skipStringLiteral(source, index, ch); + continue; + } + if (ch === 0x60) { + index++; + while (index < end) { + const templateCh = source.charCodeAt(index); + if (templateCh === 0x5c) { + index += 2; + continue; + } + if (templateCh === 0x60) { + index++; + break; + } + if (templateCh === 0x24 && source.charCodeAt(index + 1) === 0x7b) { + const expressionStart = index + 2; + const expressionEnd = findTemplateExpressionEnd( + source, + expressionStart, + ); + if (expressionEnd === -1) return; + scan(expressionStart, expressionEnd); + index = expressionEnd + 1; + continue; + } + index++; + } + continue; + } + if (ch !== 0x2f) { + index++; + continue; + } + const next = source.charCodeAt(index + 1); + if (next === 0x2f) { + let lineEnd = index + 2; + while ( + lineEnd < end && + source.charCodeAt(lineEnd) !== 0x0a && + source.charCodeAt(lineEnd) !== 0x0d + ) + lineEnd++; + const marker = source.charCodeAt(index + 2); + if (marker === 0x23 || marker === 0x40) { + const value = sourceMapURLFromComment(source.slice(index, lineEnd)); + if (value !== undefined) result = value; + } + index = lineEnd; + continue; + } + if (next === 0x2a) { + const close = source.indexOf("*/", index + 2); + const blockEnd = close === -1 ? end : Math.min(close + 2, end); + index = blockEnd; + continue; + } + if ( + regexCanFollow(source, index) || + (regexCanFollowParen(source, index) && + isLikelyRegexLiteral(source, index)) + ) { + index = skipRegexLiteral(source, index); + continue; + } + index++; + } + } + + scan(0, source.length); + return result; +} diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js index 33972a9a5..3046bf181 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js @@ -1207,7 +1207,7 @@ function getConstructorName( return null; } - const res = internalGetConstructorName(tmp, recurseTimes === 0); + const res = internalGetConstructorName(tmp); if (recurseTimes > ctx.depth && ctx.depth !== null) { return `${res} `; @@ -1652,6 +1652,28 @@ function formatError( } } + // QuickJS prepares native Error stacks during construction, before a guest + // can replace `name`. Refresh only the recognizable native summary line so + // inspect() retains Node's current-name formatting without rewriting a + // manually assigned stack. + if ( + typeof stack === "string" && + nativeErrorConstructorNames.has(constructor) && + codes.isPreparedNativeStack(err, stack) + ) { + const stackStart = stack.indexOf("\n at"); + if (stackStart !== -1) { + const initialHeader = stack.slice(0, stackStart); + const nativeHeader = err.message ? `${constructor}: ${err.message}` : constructor; + if (initialHeader === nativeHeader) { + const currentHeader = Error.prototype.toString.call(err); + if (typeof currentHeader === "string" && currentHeader !== initialHeader) { + stack = currentHeader + stack.slice(stackStart); + } + } + } + } + // QuickJS may drop the summary line and return only stack frames for some // tampered error-name cases. Reconstruct the header from toString() so // improveStack() can normalize the first line like Node does. diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index e1afb53a0..d4a64f89f 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -61,6 +61,7 @@ import * as internalWebstreamsUtil from '__wasm_rquickjs_builtin/internal/webstr import * as internalStreamsAddAbortSignal from '__wasm_rquickjs_builtin/internal/streams/add-abort-signal'; import * as internalStreamsState from '__wasm_rquickjs_builtin/internal/streams/state'; import * as internalTestBinding from '__wasm_rquickjs_builtin/internal/test/binding'; +import { extractSourceMapURL } from '__wasm_rquickjs_builtin/internal/source_map_url'; import { eval_with_filename as _evalWithFilename, require_esm as _requireEsm } from '__wasm_rquickjs_builtin/vm_native'; import { transform_typescript as transformTypeScriptNative, @@ -1207,16 +1208,41 @@ function rustHasExecArgvFlag(flag) { return wasmRquickjsModuleGlobalThis.__wasm_rquickjs_module_has_exec_argv_flag(flag); } -function isExperimentalTransformTypesEnabled() { - return rustHasExecArgvFlag('--experimental-transform-types'); +let sourceMapsSupportEnabled = Boolean( + (processModule.features && processModule.features.typescript === 'transform') || + (wasmRquickjsModuleGlobalThis.process && + wasmRquickjsModuleGlobalThis.process.features && + wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform') +); +const sourceMapsSupportDefault = sourceMapsSupportEnabled; + +function configureSourceMapsFromStartupArgs(args) { + let enabled = sourceMapsSupportDefault; + if (Array.isArray(args)) { + for (const value of args) { + const arg = String(value); + if (arg === '--no-enable-source-maps') enabled = false; + else if (arg === '--enable-source-maps' || + arg === '--experimental-transform-types') enabled = true; + } + } + sourceMapsSupportEnabled = enabled; } function isSourceMapsEnabled() { - if (rustHasExecArgvFlag('--no-enable-source-maps')) { - return false; - } + return sourceMapsSupportEnabled; +} - return rustHasExecArgvFlag('--enable-source-maps') || isExperimentalTransformTypesEnabled(); +// Transform-mode source maps are active before user modules execute. Install +// their Error hooks here, after node:process has initialized, so constructors +// do not change identity on the first transformed module load. Strip-only and +// source-map-disabled runtimes keep their normal Error subclasses. +if (isSourceMapsEnabled() && + processModule.features && + processModule.features.typescript === 'transform') { + const installErrorStackShim = + wasmRquickjsModuleGlobalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); } function getSimpleSourceMapRegistry() { @@ -1228,24 +1254,27 @@ function getSimpleSourceMapRegistry() { return registry; } -function getCjsSourceMapOwnerRegistry() { - let registry = globalThis.__wasm_rquickjs_cjs_source_map_owners; +function getCjsLineOffsetRegistry() { + let registry = globalThis.__wasm_rquickjs_cjs_line_offsets; if (!registry || typeof registry !== 'object') { registry = Object.create(null); - globalThis.__wasm_rquickjs_cjs_source_map_owners = registry; + globalThis.__wasm_rquickjs_cjs_line_offsets = registry; } return registry; } -function getCjsLineOffsetRegistry() { - let registry = globalThis.__wasm_rquickjs_cjs_line_offsets; +function getForcedSourceMapLineOffsetRegistry() { + let registry = globalThis.__wasm_rquickjs_forced_source_map_line_offsets; if (!registry || typeof registry !== 'object') { registry = Object.create(null); - globalThis.__wasm_rquickjs_cjs_line_offsets = registry; + globalThis.__wasm_rquickjs_forced_source_map_line_offsets = registry; } return registry; } +// `_evalWithFilename` compiles the CommonJS function wrapper with six lines +// ahead of the user source. QuickJS reports those wrapper-relative positions +// to util.getCallSites(), so remove them before querying the source map. const cjsLineOffset = 6; function derefWeakRef(ref) { @@ -1274,6 +1303,39 @@ function makeWeakRef(value) { } } +const cjsSourceMapSymbol = Symbol('wasm-rquickjs.cjsSourceMap'); +const thrownErrorSourceMapSymbol = Symbol('wasm-rquickjs.thrownErrorSourceMap'); +const weakSourceMapEntrySymbol = Symbol('wasm-rquickjs.weakSourceMapEntry'); +let cjsSourceMapStoresUntilSweep = 32; + +function sourceMapFromRegistry(path) { + const registry = getSimpleSourceMapRegistry(); + const entry = registry[path]; + if (!entry || entry[weakSourceMapEntrySymbol] !== true) return entry; + const sourceMap = derefWeakRef(entry.ref); + if (sourceMap !== undefined) return sourceMap; + delete registry[path]; + delete getCjsLineOffsetRegistry()[path]; + delete getForcedSourceMapLineOffsetRegistry()[path]; + return undefined; +} + +function sweepReleasedSourceMaps() { + const registry = getSimpleSourceMapRegistry(); + for (const path of Object.keys(registry)) sourceMapFromRegistry(path); +} + +function retainSourceMapForThrownError(error, filename) { + if (!error || (typeof error !== 'object' && typeof error !== 'function')) return; + const sourceMap = sourceMapFromRegistry(filename); + if (sourceMap === undefined) return; + try { + Object.defineProperty(error, thrownErrorSourceMapSymbol, { value: sourceMap }); + } catch (_) { + // Stack remapping remains best-effort for non-extensible thrown values. + } +} + const sourceMapVlqChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const sourceMapVlqMap = Object.create(null); for (let i = 0; i < sourceMapVlqChars.length; i++) { @@ -1486,6 +1548,8 @@ class SourceMap { if (options.lineLengths !== undefined) { this.lineLengths = Array.prototype.slice.call(options.lineLengths); } + this._originalLineOffset = Number(options.originalLineOffset) || 0; + this._generatedLineOffset = Number(options.generatedLineOffset) || 0; this._decodedMappings = decodeSourceMapPayload(this.payload, options.sourceBasePath); } @@ -1497,7 +1561,7 @@ class SourceMap { } findOrigin(lineNumber, columnNumber) { - const generatedLine = Number(lineNumber) - 1; + const generatedLine = Number(lineNumber) - 1 - this._generatedLineOffset; const generatedColumn = Number(columnNumber) - 1; if (!Number.isFinite(generatedLine) || !Number.isFinite(generatedColumn)) return {}; const match = findSourceMapMapping(this._decodedMappings, generatedLine, generatedColumn); @@ -1505,7 +1569,7 @@ class SourceMap { return { name: match.name, fileName: match.originalSource, - lineNumber: match.originalLine + 1, + lineNumber: match.originalLine + 1 - this._originalLineOffset, columnNumber: match.originalColumn + (generatedColumn - match.generatedColumn) + 1, }; } @@ -1513,19 +1577,13 @@ class SourceMap { function findSourceMap(path) { path = String(path); - const owners = getCjsSourceMapOwnerRegistry(); - const ownerRef = owners[path]; - if (ownerRef !== undefined && derefWeakRef(ownerRef) === undefined) { - delete owners[path]; - delete getSimpleSourceMapRegistry()[path]; - return undefined; - } - const registry = getSimpleSourceMapRegistry(); - return registry[path]; + return sourceMapFromRegistry(path); } function sourceMapLineLengths(source) { - return String(source).split(/\r\n|[\n\r\u2028\u2029]/).map(line => line.length); + return String(source) + .split(/\r\n|[\n\r\u2028\u2029]/) + .map(line => line.length); } function decodeInlineSourceMap(url) { @@ -1541,25 +1599,68 @@ function decodeInlineSourceMap(url) { } } -function registerSourceMapForCjs(filename, source, moduleObject) { +function storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options) { + const registry = getSimpleSourceMapRegistry(); + if (!isSourceMapsEnabled() || payload === null || typeof payload !== 'object') { + delete registry[filename]; + return; + } + const sourceMap = new SourceMap(payload, { + lineLengths: sourceMapLineLengths(source), + sourceBasePath, + originalLineOffset: options && options.originalLineOffset, + }); + const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); + if (moduleObject) { + const sourceMapRef = makeWeakRef(sourceMap); + if (sourceMapRef !== undefined) { + try { + Object.defineProperty(moduleObject, cjsSourceMapSymbol, { + value: sourceMap, + configurable: true, + }); + registry[filename] = { + [weakSourceMapEntrySymbol]: true, + ref: sourceMapRef, + }; + } catch (_) { + registry[filename] = sourceMap; + } + } else { + registry[filename] = sourceMap; + } + cjsSourceMapStoresUntilSweep--; + if (cjsSourceMapStoresUntilSweep === 0) { + sweepReleasedSourceMaps(); + cjsSourceMapStoresUntilSweep = 32; + } + } else { + registry[filename] = sourceMap; + } +} + +function registerSourceMapPayload(filename, source, sourceMap, moduleObject) { + let payload = null; + try { + payload = typeof sourceMap === 'string' ? JSON.parse(sourceMap) : sourceMap; + } catch (_) { + payload = null; + } + storeSourceMap(filename, source, payload, pathModule.dirname(filename), moduleObject); +} + +function registerSourceMapForCjs(filename, source, moduleObject, options = undefined) { const registry = getSimpleSourceMapRegistry(); - const owners = getCjsSourceMapOwnerRegistry(); if (!isSourceMapsEnabled()) { delete registry[filename]; - delete owners[filename]; return; } const sourceText = String(source); - const directiveRe = /\/\/[#@]\s*sourceMappingURL=([^\r\n]+)|\/\*[#@]\s*sourceMappingURL=([\s\S]*?)\*\//g; - let match; - let url = null; - while ((match = directiveRe.exec(sourceText)) !== null) { - url = (match[1] !== undefined ? match[1] : match[2]).trim(); - } - if (url === null) { + const url = extractSourceMapURL(sourceText); + if (url === undefined) { delete registry[filename]; - delete owners[filename]; return; } @@ -1581,25 +1682,109 @@ function registerSourceMapForCjs(filename, source, moduleObject) { } if (payload === null) { delete registry[filename]; - delete owners[filename]; return; } - registry[filename] = new SourceMap(payload, { - lineLengths: sourceMapLineLengths(source), - sourceBasePath, - }); - if (moduleObject) { - const ownerRef = makeWeakRef(moduleObject); - if (ownerRef !== undefined) { - owners[filename] = ownerRef; - } else { - delete owners[filename]; - } - } else { - delete owners[filename]; + storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options); +} + +function registerSourceMapForTransformedSource( + filename, + source, + alias, + lineOffset = 0, + originalLineOffset = 0, + forceLineOffset = false, +) { + filename = String(filename); + registerSourceMapForCjs(filename, String(source), undefined, { originalLineOffset }); + if (forceLineOffset) { + const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); + } + const registry = getSimpleSourceMapRegistry(); + const offsets = getCjsLineOffsetRegistry(); + const forcedOffsets = getForcedSourceMapLineOffsetRegistry(); + lineOffset = Number(lineOffset); + if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[filename] = lineOffset; + else delete offsets[filename]; + const sourceMap = sourceMapFromRegistry(filename); + if (sourceMap !== undefined) { + sourceMap._generatedLineOffset = Number.isFinite(lineOffset) ? lineOffset : 0; + } + if (forceLineOffset) forcedOffsets[filename] = true; + else delete forcedOffsets[filename]; + if (alias !== undefined && alias !== null) { + alias = String(alias); + if (registry[filename] !== undefined) registry[alias] = registry[filename]; + else delete registry[alias]; + if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[alias] = lineOffset; + else delete offsets[alias]; + if (forceLineOffset) forcedOffsets[alias] = true; + else delete forcedOffsets[alias]; + } +} + +function remapSourceMappedPosition( + scriptName, + lineNumber, + columnNumber, + hasCjsWrapperOffset = false, +) { + scriptName = String(scriptName); + lineNumber = Number(lineNumber); + columnNumber = Number(columnNumber); + if (!Number.isFinite(lineNumber) || !Number.isFinite(columnNumber)) return undefined; + + const sourceMap = sourceMapFromRegistry(scriptName); + const lineOffsets = getCjsLineOffsetRegistry(); + const forcedOffsets = getForcedSourceMapLineOffsetRegistry(); + const lineOffset = lineOffsets[scriptName]; + const forcedLineOffset = forcedOffsets[scriptName] === true; + if (typeof lineOffset === 'number' && Number.isFinite(lineOffset) && lineOffset > 0 && + (forcedLineOffset || hasCjsWrapperOffset === true)) { + if (!sourceMap || !forcedLineOffset) lineNumber -= lineOffset; + } + if (lineNumber <= 0) return undefined; + if (!isSourceMapsEnabled() || !sourceMap || typeof sourceMap.findOrigin !== 'function') { + return forcedLineOffset + ? { fileName: scriptName, lineNumber, columnNumber } + : undefined; + } + + const origin = sourceMap.findOrigin(lineNumber, columnNumber); + if (!origin || typeof origin.fileName !== 'string' || + !Number.isFinite(origin.lineNumber) || !Number.isFinite(origin.columnNumber)) { + return undefined; } + return origin; } +Object.defineProperties(globalThis, { + __wasm_rquickjs_source_maps_enabled: { + value: isSourceMapsEnabled, + writable: false, + configurable: false, + }, + __wasm_rquickjs_register_transformed_source_map: { + value: registerSourceMapForTransformedSource, + writable: false, + configurable: false, + }, + __wasm_rquickjs_remap_source_mapped_position: { + value: remapSourceMappedPosition, + writable: false, + configurable: false, + }, + // The compatibility runner calls this before loading a test file to model + // immutable Node startup flags. Product code uses module.setSourceMapsSupport + // and cannot toggle behavior by mutating the informational execArgv array. + __wasm_rquickjs_configure_source_maps_from_startup_args: { + value: configureSourceMapsFromStartupArgs, + writable: false, + configurable: false, + }, +}); + function isTypeScriptFilename(filename) { return filename.endsWith('.ts') || filename.endsWith('.cts') || filename.endsWith('.mts'); } @@ -1645,23 +1830,35 @@ if (testObservabilityEnabledNative()) { }); } -function transpileTypeScriptModule(filename, source, module = undefined) { +function transformTypeScriptModuleOutput(filename, source, module = undefined) { if (!isTypeScriptFilename(filename)) { - return source; + return { code: source, sourceMap: null }; } // Rust owns the transform semantics. This adapter only applies CommonJS // loader policy; the Rust filesystem loader applies the same service for ESM. const output = JSON.parse(transformTypeScriptModuleNative( - String(source), filename, module + String(source), filename, isSourceMapsEnabled(), module )); recordTypeScriptModuleTransform(); - return output.code; + return output; +} + +function appendInlineSourceMap(code, sourceMap) { + if (!sourceMap) return code; + // Keep this wire format in sync with + // TypeScriptOutput::into_code_with_inline_source_map. Runtime coverage + // decodes maps emitted through both the Rust ESM and JavaScript CJS/public + // transformation paths and asserts their original coordinates. + const encoded = buffer.Buffer.from(sourceMap, 'utf8').toString('base64'); + return code + `\n\n//# sourceMappingURL=data:application/json;base64,${encoded}`; +} + +function codeWithInlineSourceMap(output) { + return appendInlineSourceMap(output.code, output.sourceMap); } -function prepareCommonJsTypeScript(filename, source) { - return isTypeScriptFilename(filename) - ? transpileTypeScriptModule(filename, source, false) - : source; +function transpileTypeScriptModule(filename, source, module = undefined) { + return codeWithInlineSourceMap(transformTypeScriptModuleOutput(filename, source, module)); } function clearPreparedTypeScriptGraph(graph) { @@ -1702,11 +1899,10 @@ export function stripTypeScriptTypes(code, options = undefined) { const transformed = JSON.parse(transformTypeScriptNative( code, sourceUrl === undefined ? '' : sourceUrl, mode, sourceMap, undefined )); - let result = transformed.code; if (sourceMap) { - const encoded = buffer.Buffer.from(transformed.sourceMap, 'utf8').toString('base64'); - result += `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; + return appendInlineSourceMap(transformed.code, transformed.sourceMap); } + let result = transformed.code; if (sourceUrl !== undefined) { result += `\n\n//# sourceURL=${sourceUrl}`; } @@ -2348,7 +2544,13 @@ function wrapForCompile(script, dynamicImportBindings) { return activeWrapper[0] + script + activeWrapper[1]; } -function compileCjs(filename, source, isPreparedTypeScript = false) { +function compileCjs( + filename, + source, + isPreparedTypeScript = false, + moduleObject = undefined, + sourceMap = undefined, +) { if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { source = source.slice(1); } @@ -2358,8 +2560,12 @@ function compileCjs(filename, source, isPreparedTypeScript = false) { } if (!isPreparedTypeScript) { - source = transpileTypeScriptModule(filename, source, false); + const output = transformTypeScriptModuleOutput(filename, source, false); + source = output.code; + sourceMap = output.sourceMap; } + if (sourceMap) registerSourceMapPayload(filename, source, sourceMap, moduleObject); + else registerSourceMapForCjs(filename, source, moduleObject); source = stripV8OptimizationIntrinsics(source); const strippedImportAttributes = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_prepare_cjs_source( source, @@ -2426,14 +2632,13 @@ function callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, chi function compileModuleInto(mod, source, filename, requireOverride) { filename = filename === undefined || filename === null ? mod.filename : filename; source = String(source); - registerSourceMapForCjs(filename, source, mod); const requireParentFilename = filename === '' && mod && typeof mod.filename === 'string' ? mod.filename : filename; const dirname = pathModule.dirname(filename); const requireDirname = pathModule.dirname(requireParentFilename); const childRequire = requireOverride || makeRequire(requireDirname, mod, requireParentFilename); - const compiledFn = compileCjs(filename, source); + const compiledFn = compileCjs(filename, source, false, mod); return callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, childRequire); } @@ -3055,18 +3260,25 @@ function loadCommonJsTransaction(descriptor) { source = preparedTypeScript ? preparedTypeScript.originalSource : fsModule.readFileSync(filename, 'utf8'); - registerSourceMapForCjs(filename, source, mod); } catch (err) { discardCjsModuleLoad(cacheKey, parentModule, mod); throw err; } const dirname = pathModule.dirname(filename); let compiledSource; + let preparedSourceForCache; + let typeScriptSourceMap; let typeScriptExportNames; try { - compiledSource = preparedTypeScript - ? preparedTypeScript.preparedSource - : prepareCommonJsTypeScript(filename, source); + if (preparedTypeScript) { + compiledSource = preparedTypeScript.preparedSource; + preparedSourceForCache = compiledSource; + } else { + const output = transformTypeScriptModuleOutput(filename, source, false); + compiledSource = output.code; + typeScriptSourceMap = output.sourceMap; + preparedSourceForCache = codeWithInlineSourceMap(output); + } typeScriptExportNames = preparedTypeScript && preparedTypeScript.exportNames; } catch (err) { discardCjsModuleLoad(cacheKey, parentModule, mod); @@ -3090,6 +3302,8 @@ function loadCommonJsTransaction(descriptor) { filename, compiledSource, true, + mod, + typeScriptSourceMap, ); } catch (err) { // Normalize QuickJS SyntaxError messages for ESM keywords in CJS context @@ -3135,6 +3349,7 @@ function loadCommonJsTransaction(descriptor) { try { callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, childRequire); } catch (err) { + retainSourceMapForThrownError(err, filename); discardCjsModuleLoad(cacheKey, parentModule, mod); maybeSetArrowMessageOnSyntaxError(err, filename, source); throw err; @@ -3150,7 +3365,7 @@ function loadCommonJsTransaction(descriptor) { captureCjsTypeScriptPreparedSource( mod, source, - compiledSource, + preparedSourceForCache, ); } cjsEsmDefaultSnapshotEligible = true; @@ -4591,6 +4806,7 @@ function setSourceMapsSupport(enabled, options) { if (generatedCode !== undefined && typeof generatedCode !== 'boolean') { throw new ERR_INVALID_ARG_TYPE('options.generatedCode', 'boolean', generatedCode); } + sourceMapsSupportEnabled = enabled; } const globalPaths = []; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs index 26db648a3..be517d98c 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs @@ -73,18 +73,21 @@ pub mod native_module { ctx: Ctx<'_>, source: String, filename: String, + source_map: bool, module: Option, ) -> rquickjs::Result { #[cfg(feature = "typescript-runtime")] { serialize_transform_result( &ctx, - crate::internal::typescript::transform_module(source, &filename, false, module), + crate::internal::typescript::transform_module( + source, &filename, source_map, module, + ), ) } #[cfg(not(feature = "typescript-runtime"))] { - let _ = (source, filename, module); + let _ = (source, filename, source_map, module); Err(rquickjs::Exception::throw_message( &ctx, "TypeScript runtime support is not enabled", diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/util.js index 76e0a67b5..43bf9867d 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/util.js @@ -1245,38 +1245,9 @@ function _isInternalUtilCallSite(scriptName) { scriptName.indexOf('/builtin/util.js') !== -1; } -function _hasExecArgvFlag(flag) { - if (typeof process === 'undefined' || !Array.isArray(process.execArgv)) { - return false; - } - - const prefixed = flag + '='; - for (let i = 0; i < process.execArgv.length; i++) { - const arg = String(process.execArgv[i]); - if (arg === flag || arg.indexOf(prefixed) === 0) { - return true; - } - } - - return false; -} - function _isSourceMapsEnabledFromExecArgv() { - if (_hasExecArgvFlag('--no-enable-source-maps')) { - return false; - } - - return _hasExecArgvFlag('--enable-source-maps') || - _hasExecArgvFlag('--experimental-transform-types'); -} - -function _getSimpleSourceMapRegistry() { - const registry = globalThis.__wasm_rquickjs_simple_source_maps; - if (!registry || typeof registry !== 'object') { - return null; - } - - return registry; + const isEnabled = globalThis.__wasm_rquickjs_source_maps_enabled; + return typeof isEnabled === 'function' && isEnabled(); } function _getCjsLineOffsetRegistry() { @@ -1314,28 +1285,25 @@ function _normalizeCallSiteLineNumber(callSite) { } function _mapCallSiteWithSimpleSourceMap(callSite) { - const registry = _getSimpleSourceMapRegistry(); - if (!registry) { - return callSite; - } - - const sourceMap = registry[callSite.scriptName]; - if (!sourceMap || !sourceMap.generatedLineToOriginalLine) { - return callSite; + const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; + if (typeof mapper !== 'function') return _normalizeCallSiteLineNumber(callSite); + let origin; + try { + origin = mapper(callSite.scriptName, callSite.lineNumber, callSite.columnNumber, true); + } catch (_) { + return _normalizeCallSiteLineNumber(callSite); } - - const mappedLine = sourceMap.generatedLineToOriginalLine[callSite.lineNumber]; - if (typeof mappedLine !== 'number' || !Number.isFinite(mappedLine)) { - return callSite; + if (!origin || typeof origin.fileName !== 'string') { + return _normalizeCallSiteLineNumber(callSite); } const mappedCallSite = Object.create(null); mappedCallSite.functionName = callSite.functionName; mappedCallSite.scriptId = callSite.scriptId; - mappedCallSite.scriptName = callSite.scriptName; - mappedCallSite.lineNumber = mappedLine; - mappedCallSite.columnNumber = callSite.columnNumber; - mappedCallSite.column = callSite.column; + mappedCallSite.scriptName = origin.fileName; + mappedCallSite.lineNumber = origin.lineNumber; + mappedCallSite.columnNumber = origin.columnNumber; + mappedCallSite.column = origin.columnNumber; return mappedCallSite; } @@ -1392,7 +1360,22 @@ export function getCallSites(frameCount = 10, options) { const shouldMapSourceLocations = normalizedOptions.sourceMap === true || (_isSourceMapsEnabledFromExecArgv() && normalizedOptions.sourceMap !== false); - const stack = _captureGetCallSitesStack(getCallSites, frameCount); + const hadSuppression = Object.prototype.hasOwnProperty.call( + globalThis, + '__wasm_rquickjs_suppress_source_map_stack', + ); + const previousSuppression = globalThis.__wasm_rquickjs_suppress_source_map_stack; + globalThis.__wasm_rquickjs_suppress_source_map_stack = true; + let stack; + try { + stack = _captureGetCallSitesStack(getCallSites, frameCount); + } finally { + if (hadSuppression) { + globalThis.__wasm_rquickjs_suppress_source_map_stack = previousSuppression; + } else { + delete globalThis.__wasm_rquickjs_suppress_source_map_stack; + } + } const lines = stack.split('\n'); const callSites = []; @@ -1413,10 +1396,10 @@ export function getCallSites(frameCount = 10, options) { continue; } - parsedCallSite = _normalizeCallSiteLineNumber(parsedCallSite); - if (shouldMapSourceLocations) { parsedCallSite = _mapCallSiteWithSimpleSourceMap(parsedCallSite); + } else { + parsedCallSite = _normalizeCallSiteLineNumber(parsedCallSite); } callSites.push(parsedCallSite); diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js index 1ea7b74b5..e462c42c1 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js @@ -3,6 +3,7 @@ import { eval_with_filename as evalWithFilename, } from '__wasm_rquickjs_builtin/vm_native'; import * as pathModule from 'node:path'; +import { extractSourceMapURL } from '__wasm_rquickjs_builtin/internal/source_map_url'; let contextIdCounter = 1; const contextIds = new WeakMap(); @@ -2657,88 +2658,6 @@ function rewriteDynamicImports(code, replacementOpenSource) { return { code: out + code.slice(last), changed }; } -function extractSourceMapURL(code) { - const text = String(code); - let url; - - if (text.indexOf('sourceMappingURL=') === -1) { - return undefined; - } - - function isSourceMapURLSeparator(ch) { - return ch === 0x09 || - ch === 0x0b || - ch === 0x0c || - ch === 0x20 || - ch === 0xa0; - } - - function scan(start, end) { - for (let i = start; i < end; i++) { - const ch = text.charCodeAt(i); - if (ch === 0x27 || ch === 0x22) { - i = skipStringLiteral(text, i, ch) - 1; - continue; - } - if (ch === 0x60) { - i++; - while (i < end) { - const templateCh = text.charCodeAt(i); - if (templateCh === 0x5c) { - i += 2; - continue; - } - if (templateCh === 0x60) break; - if (templateCh === 0x24 && text.charCodeAt(i + 1) === 0x7b) { - const expressionStart = i + 2; - const expressionEnd = findTemplateExpressionEnd(text, expressionStart); - if (expressionEnd === -1) return; - scan(expressionStart, expressionEnd); - i = expressionEnd + 1; - continue; - } - i++; - } - continue; - } - if (ch === 0x2f && text.charCodeAt(i + 1) === 0x2a) { - i = skipWhitespaceAndComments(text, i) - 1; - continue; - } - if (ch !== 0x2f || text.charCodeAt(i + 1) !== 0x2f) { - if (ch === 0x2f && (regexCanFollow(text, i) || (regexCanFollowParen(text, i) && isLikelyRegexLiteral(text, i)))) { - i = skipRegexLiteral(text, i) - 1; - } - continue; - } - - const marker = text.charCodeAt(i + 2); - if (marker === 0x23 || marker === 0x40) { - const separator = text.charCodeAt(i + 3); - if (isSourceMapURLSeparator(separator) && - text.startsWith('sourceMappingURL=', i + 4)) { - let lineEnd = i + 21; - while (lineEnd < end) { - const endChar = text.charCodeAt(lineEnd); - if (endChar === 0x0a || endChar === 0x0d) break; - lineEnd++; - } - const value = text.slice(i + 21, lineEnd).trim(); - if (value.length > 0) { - url = value; - } - } - } - - i += 2; - while (i < end && text.charCodeAt(i) !== 0x0a && text.charCodeAt(i) !== 0x0d) i++; - } - } - - scan(0, text.length); - return url; -} - export class Script { constructor(code, options) { scriptBrandSet.add(this); diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index 68d5bb769..8bc1f59ef 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -4324,7 +4324,10 @@ fn cjs_module_path_stat(ctx: Ctx<'_>, filename: String) -> i32 { let services = ctx .userdata::() .expect("runtime services not initialized"); - let Ok(resolved) = services.process.resolve_path(std::path::Path::new(&filename)) else { + let Ok(resolved) = services + .process + .resolve_path(std::path::Path::new(&filename)) + else { return -2; }; let normalized = resolved.to_string_lossy(); @@ -6987,10 +6990,11 @@ fn transform_typescript_module_source<'js>( .execution_profile(), std::time::Instant::now(), ); + let source_map = crate::internal::typescript::source_maps_enabled(ctx); match crate::internal::typescript::transform_module( source, fs_path, - false, + source_map, match std::path::Path::new(fs_path) .extension() .and_then(|extension| extension.to_str()) @@ -7010,7 +7014,7 @@ fn transform_typescript_module_source<'js>( started.elapsed().as_micros().min(u64::MAX as u128) as u64, ); } - Ok(output.code) + Ok(output.into_code_with_inline_source_map()) } Err(error) => { #[cfg(feature = "typescript-compiler-profiling")] @@ -9176,7 +9180,7 @@ fn analyze_cjs_reexport_specifier_names( let Ok(output) = crate::internal::typescript::transform_module( original_source.clone(), &child_filename, - false, + crate::internal::typescript::source_maps_enabled(ctx), match std::path::Path::new(&child_filename) .extension() .and_then(|extension| extension.to_str()) @@ -9188,18 +9192,19 @@ fn analyze_cjs_reexport_specifier_names( ) else { continue; }; + let prepared_source = output.into_code_with_inline_source_map(); if let Some(graph) = prepared_typescript.as_deref_mut() { let _ = record_typescript_module_transform(ctx); graph.insert( child_filename.clone(), PreparedCjsTypeScript { original_source, - prepared_source: output.code.clone(), + prepared_source: prepared_source.clone(), export_names: Vec::new(), }, ); } - output.code + prepared_source } } else { source @@ -11800,6 +11805,18 @@ fn declare_esm_file_module_from_source<'js>( source, processed.dynamic_import_binding_names.as_ref(), ); + if let Ok(register_source_map) = + globals.get::<_, Function>("__wasm_rquickjs_register_transformed_source_map") + { + register_source_map.call::<_, ()>(( + fs_abs_path.as_str(), + injected.as_str(), + module_id, + 1, + 0, + true, + ))?; + } match declare_module_with_import_meta(ctx, module_id, &injected, &init) { Ok(module) => { if has_top_level_await { diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 6a831315e..af2759933 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -1,6 +1,8 @@ use std::io::Write; use std::sync::{Arc, Mutex}; +use base64ct::Encoding; +use rquickjs::{Ctx, Function as JsFunction}; use swc_common::{ FileName, GLOBALS, Globals, SourceMap, errors::{HANDLER, Handler}, @@ -28,6 +30,17 @@ pub(crate) fn runtime_mode() -> TypeScriptMode { } } +pub(crate) fn source_maps_enabled(ctx: &Ctx<'_>) -> bool { + if !cfg!(feature = "typescript-transform-runtime") { + return false; + } + ctx.globals() + .get::<_, JsFunction>("__wasm_rquickjs_source_maps_enabled") + .ok() + .and_then(|is_enabled| is_enabled.call::<_, bool>(()).ok()) + .unwrap_or(false) +} + pub(crate) fn source_uses_esm_format(source: &str, filename: &str) -> Result { let source_map: Lrc = Default::default(); let source_file = source_map.new_source_file( @@ -233,6 +246,22 @@ pub(crate) struct TypeScriptOutput { pub(crate) source_map: Option, } +impl TypeScriptOutput { + pub(crate) fn into_code_with_inline_source_map(self) -> String { + let Some(source_map) = self.source_map else { + return self.code; + }; + // This directive is the cross-language transform contract. Keep it in + // sync with module.js::appendInlineSourceMap; runtime tests decode and + // verify both the Rust ESM and JavaScript CJS/public paths. + let encoded = base64ct::Base64::encode_string(source_map.as_bytes()); + format!( + "{}\n\n//# sourceMappingURL=data:application/json;base64,{encoded}", + self.code + ) + } +} + #[derive(Debug)] pub(crate) struct TypeScriptError { pub(crate) code: &'static str, diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 4bbbd8914..5e3412886 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -7375,6 +7375,8 @@ export const testVmMainContextDefaultLoader = async () => { assert.strictEqual(new vm.Script('/[//# sourceMappingURL=regex.map]/;').sourceMapURL, undefined); assert.strictEqual(new vm.Script('/*\n//# sourceMappingURL=inside-block.map\n*/').sourceMapURL, undefined); assert.strictEqual(new vm.Script('1 + 1\n/*# sourceMappingURL=block.map */').sourceMapURL, undefined); + assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=bad.map trailing').sourceMapURL, undefined); + assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=good.map \t').sourceMapURL, 'good.map'); assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=script.map').sourceMapURL, 'script.map'); assert.strictEqual(new vm.Script('1;\n//# sourceMappingURL=semi.map').sourceMapURL, 'semi.map'); assert.strictEqual(new vm.Script('1 + 1\n//#\tsourceMappingURL=tab.map').sourceMapURL, 'tab.map'); diff --git a/examples/runtime/node-compat-runner/src/node-compat-runner.js b/examples/runtime/node-compat-runner/src/node-compat-runner.js index a22ecdcc9..c86627e3a 100644 --- a/examples/runtime/node-compat-runner/src/node-compat-runner.js +++ b/examples/runtime/node-compat-runner/src/node-compat-runner.js @@ -162,6 +162,9 @@ function applyTestFlagsToProcess(testPath) { for (var i = 0; i < flags.length; i++) { globalThis.process.execArgv.push(flags[i]); } + if (typeof globalThis.__wasm_rquickjs_configure_source_maps_from_startup_args === 'function') { + globalThis.__wasm_rquickjs_configure_source_maps_from_startup_args(flags); + } globalThis.__wasm_rquickjs_package_conditions = packageConditionsFromFlags(flags); return flags; } diff --git a/examples/runtime/source-map/src/source-map.js b/examples/runtime/source-map/src/source-map.js index 818fdb81d..60c0999bb 100644 --- a/examples/runtime/source-map/src/source-map.js +++ b/examples/runtime/source-map/src/source-map.js @@ -14,9 +14,28 @@ function writeJson(path, value) { } export function testSourceMapApi() { - const originalExecArgv = process.execArgv.slice(); try { - process.execArgv = originalExecArgv.concat('--enable-source-maps'); + const errorConstructorNames = [ + 'Error', + 'TypeError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'EvalError', + 'URIError', + 'AggregateError', + ]; + const errorConstructorsBefore = errorConstructorNames.map(name => { + const Constructor = globalThis[name]; + return { + Constructor, + prototype: Constructor.prototype, + prototypeConstructor: Constructor.prototype.constructor, + name: Constructor.name, + length: Constructor.length, + }; + }); + module.setSourceMapsSupport(true); const previousLine = new module.SourceMap({ sources: ['previous.js'], @@ -38,6 +57,31 @@ export function testSourceMapApi() { const expectedLineLengths = lineLengthSource.split('\n').map(line => line.length).join(','); assert(module.findSourceMap(withTrailingNewline).lineLengths.join(',') === expectedLineLengths, 'line lengths'); + const withoutTrailingNewline = '/source-map-line-lengths-no-trailing-newline.cjs'; + const noTrailingNewlineSource = 'module.exports = 1;\n//# sourceMappingURL=line-lengths.map'; + fs.writeFileSync(withoutTrailingNewline, noTrailingNewlineSource); + require(withoutTrailingNewline); + const expectedNoTrailingNewlineLengths = noTrailingNewlineSource + .split('\n') + .map(line => line.length) + .join(','); + assert( + module.findSourceMap(withoutTrailingNewline).lineLengths.join(',') === + expectedNoTrailingNewlineLengths, + 'line lengths retain a final directive without a trailing newline', + ); + + const errorConstructorsStable = errorConstructorNames.every((name, index) => { + const before = errorConstructorsBefore[index]; + const Constructor = globalThis[name]; + return Constructor === before.Constructor && + Constructor.prototype === before.prototype && + Constructor.prototype.constructor === before.prototypeConstructor && + Constructor.name === before.name && + Constructor.length === before.length; + }); + assert(errorConstructorsStable, 'enabling source maps preserves Error constructor identity and metadata'); + const rawOffsets = '/source-map-raw-offsets.cjs'; fs.writeFileSync(rawOffsets, '\n\n\n\n\n\n\nmodule.exports = 1;\n//# sourceMappingURL=raw-offsets.map\n'); writeJson('/raw-offsets.map', { @@ -76,7 +120,35 @@ export function testSourceMapApi() { mappings: 'AAAA', }); require(blockDirective); - assert(module.findSourceMap(blockDirective).findEntry(0, 0).originalSource.endsWith('/right.js'), 'last block directive wins'); + assert(module.findSourceMap(blockDirective).findEntry(0, 0).originalSource.endsWith('/wrong.js'), 'block directives are ignored'); + + const lexicalDirective = '/source-map-lexical-directive.cjs'; + fs.writeFileSync(lexicalDirective, [ + 'module.exports = 1;', + '//@ sourceMappingURL=right.map \t', + 'const stringDecoy = "//# sourceMappingURL=wrong-map.json";', + 'const templateDecoy = `//# sourceMappingURL=wrong-map.json`;', + 'const regexDecoy = /[//# sourceMappingURL=wrong-map.json]/;', + '//# sourceMappingURL=wrong-map.json trailing-garbage', + ].join('\n')); + writeJson('/right.map', { + version: 3, + sources: ['lexically-selected.js'], + names: [], + mappings: 'AAAA', + }); + writeJson('/wrong-map.json', { + version: 3, + sources: ['textually-selected.js'], + names: [], + mappings: 'AAAA', + }); + require(lexicalDirective); + assert( + module.findSourceMap(lexicalDirective).findEntry(0, 0).originalSource + .endsWith('/lexically-selected.js'), + 'only the last valid lexical line directive wins', + ); const customExtension = '/source-map-custom-extension.probe'; const customMap = '/custom-extension.map'; @@ -108,6 +180,6 @@ export function testSourceMapApi() { console.log(e && e.stack ? e.stack : String(e)); return false; } finally { - process.execArgv = originalExecArgv; + module.setSourceMapsSupport(false); } } diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 213706ae5..8555c98ba 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -625,6 +625,52 @@ export async function run() { language: 'typescript', source: largeSource, }); + fs.writeFileSync( + '/typescript-runtime/strip-stack.mts', + `export function failStrip(): never { + const value: number = 42; + throw new Error('strip-typescript-stack-' + value); + }`, + ); + let stripRuntimeStack; + try { + (await import('/typescript-runtime/strip-stack.mts')).failStrip(); + } catch (error) { + stripRuntimeStack = error.stack; + } + let stripInlineExecutionStack; + try { + await runJavaScript({ + language: 'typescript', + source: `const value: number = 42; + throw new Error('strip-inline-stack-' + value);`, + }); + } catch (error) { + stripInlineExecutionStack = error.message; + } + let plainInlineExecutionStack; + try { + await runJavaScript({ + source: `const value = 42; + throw new Error('plain-inline-stack-' + value);`, + }); + } catch (error) { + plainInlineExecutionStack = error.message; + } + const errorConstructorMetadata = [ + Error, + TypeError, + RangeError, + ReferenceError, + SyntaxError, + EvalError, + URIError, + AggregateError, + ].map((Constructor) => ({ name: Constructor.name, length: Constructor.length })); + class NarrowTypeError extends TypeError {} + const errorConstructorRelationships = Error.isPrototypeOf(TypeError) && + !(new TypeError('base') instanceof NarrowTypeError) && + new NarrowTypeError('narrow') instanceof NarrowTypeError; return JSON.stringify({ stripped, @@ -758,5 +804,10 @@ export async function run() { entryRunner: entryRunner.value, commonJsEntryRunner: commonJsEntryRunner.value, largeInlineRunner: largeInlineRunner.value, + stripRuntimeStack, + stripInlineExecutionStack, + plainInlineExecutionStack, + errorConstructorMetadata, + errorConstructorRelationships, }); } diff --git a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js index c83b780d6..2da4d2673 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -1,8 +1,10 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; +import module, { createRequire } from 'node:module'; import { runJavaScript } from 'wasm-rquickjs:execution'; export async function run() { + const errorConstructorBefore = Error; + const typeErrorConstructorBefore = TypeError; fs.mkdirSync('/typescript-transform-runtime', { recursive: true }); fs.writeFileSync( '/typescript-transform-runtime/transformed.mts', @@ -96,6 +98,280 @@ export async function run() { language: 'typescript', source: largeSource, }); + fs.writeFileSync( + '/typescript-transform-runtime/stack-esm.mts', + `enum StackShift { Value } + export function failEsm(): never { + throw new Error('esm-typescript-stack'); + }`, + ); + let esmRuntimeStack; + try { + (await import('/typescript-transform-runtime/stack-esm.mts')).failEsm(); + } catch (error) { + esmRuntimeStack = error.stack; + } + fs.writeFileSync( + '/typescript-transform-runtime/stack-errors.mts', + `enum StackShift { Value } + export class CustomStackError extends Error {} + export function failTypeError(): never { + throw new TypeError('type-error-typescript-stack'); + } + export function failCustomError(): never { + throw new CustomStackError('custom-error-typescript-stack'); + } + export function captureGeneratedSite() { + const previous = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_error, sites) => sites[0]; + return new Error('prepared-typescript-stack').stack; + } finally { + Error.prepareStackTrace = previous; + } + } + export function failSyntaxError(): never { + throw new SyntaxError('syntax-error-typescript-stack'); + }`, + ); + const stackErrorsModule = await import('/typescript-transform-runtime/stack-errors.mts'); + let typeErrorRuntimeStack; + try { + stackErrorsModule.failTypeError(); + } catch (error) { + typeErrorRuntimeStack = error.stack; + } + let customErrorRuntimeStack; + try { + stackErrorsModule.failCustomError(); + } catch (error) { + customErrorRuntimeStack = error.stack; + } + let syntaxErrorRuntimeStack; + try { + stackErrorsModule.failSyntaxError(); + } catch (error) { + syntaxErrorRuntimeStack = error.stack; + } + const generatedSite = stackErrorsModule.captureGeneratedSite(); + const generatedSiteFile = generatedSite.getFileName(); + const generatedSiteLine = generatedSite.getLineNumber(); + const generatedSiteColumn = generatedSite.getColumnNumber(); + const preparedSourceMap = module.findSourceMap(generatedSiteFile); + const preparedOrigin = preparedSourceMap && + preparedSourceMap.findOrigin(generatedSiteLine, generatedSiteColumn); + const errorConstructorMetadata = [ + Error, + TypeError, + RangeError, + ReferenceError, + SyntaxError, + EvalError, + URIError, + AggregateError, + ].map((Constructor) => ({ name: Constructor.name, length: Constructor.length })); + class NarrowTypeError extends TypeError {} + const errorConstructorRelationships = Error.isPrototypeOf(TypeError) && + !(new TypeError('base') instanceof NarrowTypeError) && + new NarrowTypeError('narrow') instanceof NarrowTypeError; + + const originalPrepareDescriptor = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + const originalPrepareValue = Error.prepareStackTrace; + Object.defineProperty(Error, 'prepareStackTrace', { + value: () => 'non-writable-prepare', + writable: false, + configurable: true, + }); + const nonWritablePrepareStack = new TypeError('non-writable').stack; + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; + + let prepareSetterCalls = 0; + Object.defineProperty(Error, 'prepareStackTrace', { + get() { return undefined; }, + set() { prepareSetterCalls++; }, + configurable: true, + }); + new TypeError('accessor-backed'); + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; + + let nestedPrepareCalls = 0; + Error.prepareStackTrace = () => { + nestedPrepareCalls++; + new TypeError('nested'); + return 'nested-prepare'; + }; + const nestedPrepareStack = new Error('outer').stack; + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; + const errorConstructorsStable = Error === errorConstructorBefore && + TypeError === typeErrorConstructorBefore && + new Error().constructor === Error && + new TypeError().constructor === TypeError; + fs.writeFileSync( + '/typescript-transform-runtime/stack-sites.mts', + `import { getCallSites } from 'node:util'; + enum StackShift { Value } + export function captureSites() { + return { + mapped: getCallSites(1)[0], + generated: getCallSites(1, { sourceMap: false })[0], + }; + }`, + ); + const callSites = (await import('/typescript-transform-runtime/stack-sites.mts')).captureSites(); + fs.writeFileSync( + '/typescript-transform-runtime/stack-cjs.cts', + `enum StackShift { Value } + exports.failCjs = function failCjs(): never { + throw new Error('cjs-typescript-stack'); + };`, + ); + let cjsRuntimeStack; + const cjsStackModule = require('/typescript-transform-runtime/stack-cjs.cts'); + try { + cjsStackModule.failCjs(); + } catch (error) { + cjsRuntimeStack = error.stack; + } + let importedCjsRuntimeStack; + try { + (await import('/typescript-transform-runtime/stack-cjs.cts')).default.failCjs(); + } catch (error) { + importedCjsRuntimeStack = error.stack; + } + fs.writeFileSync( + '/typescript-transform-runtime/stack-reexport-child.cts', + `enum StackShift { Value } + exports.failPrepared = function failPrepared(): never { + throw new Error('prepared-reexport-typescript-stack'); + };`, + ); + fs.writeFileSync( + '/typescript-transform-runtime/stack-reexport-parent.cts', + `const child = require('./stack-reexport-child.cts'); + Object.keys(child).forEach(function (key) { + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + const reexportStackModule = await import('/typescript-transform-runtime/stack-reexport-parent.cts'); + let reexportPreparedRuntimeStack; + try { + reexportStackModule.default.failPrepared(); + } catch (error) { + reexportPreparedRuntimeStack = error.stack; + } + delete require.cache[require.resolve('/typescript-transform-runtime/stack-cjs.cts')]; + fs.writeFileSync( + '/typescript-transform-runtime/stack-cjs.cts', + `enum StackShift { Value } + const marker: number = StackShift.Value; + exports.failCjs = function failCjs(): never { + throw new Error('rewritten-cjs-typescript-stack-' + marker); + };`, + ); + fs.writeFileSync( + '/typescript-transform-runtime/stack-caller.cjs', + `const target = require('./stack-cjs.cts'); + module.exports = function callTypeScript() { target.failCjs(); };`, + ); + let rewrittenCjsRuntimeStack; + try { + require('/typescript-transform-runtime/stack-caller.cjs')(); + } catch (error) { + rewrittenCjsRuntimeStack = error.stack; + } + module.setSourceMapsSupport(false); + fs.writeFileSync( + '/typescript-transform-runtime/stack-disabled-sites.mts', + `import { getCallSites } from 'node:util'; + enum StackShift { Value } + export function captureDisabledSite() { + return getCallSites(1)[0]; + }`, + ); + fs.writeFileSync( + '/typescript-transform-runtime/stack-disabled.mts', + `enum StackShift { Value } + export function failDisabled(): never { + throw new Error('disabled-typescript-stack'); + }`, + ); + let disabledRuntimeStack; + let disabledCallSite; + try { + (await import('/typescript-transform-runtime/stack-disabled.mts')).failDisabled(); + } catch (error) { + disabledRuntimeStack = error.stack; + } + try { + disabledCallSite = (await import( + '/typescript-transform-runtime/stack-disabled-sites.mts' + )).captureDisabledSite(); + } finally { + module.setSourceMapsSupport(true); + } + const reclaimableSourceMapPaths = []; + for (let i = 0; i < 96; i++) { + const filename = `/typescript-transform-runtime/gc-map-${i}.cts`; + reclaimableSourceMapPaths.push(filename); + fs.writeFileSync( + filename, + `enum Marker { Value } exports.value = Marker.Value;`, + ); + require(filename); + const resolved = require.resolve(filename); + const loadedModule = require.cache[resolved]; + delete require.cache[resolved]; + if (loadedModule && loadedModule.parent && Array.isArray(loadedModule.parent.children)) { + const index = loadedModule.parent.children.indexOf(loadedModule); + if (index !== -1) loadedModule.parent.children.splice(index, 1); + } + } + if (typeof gc !== 'function') throw new Error('gc test hook is unavailable'); + gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const retainedCjsSourceMaps = reclaimableSourceMapPaths.filter( + (filename) => module.findSourceMap(filename) !== undefined, + ).length; + // WeakRef targets observed by the periodic sweep remain alive through the + // current QuickJS job. The sweep batch therefore defines the strict upper + // bound until the next exported call/job boundary. + const cjsSourceMapsReclaimed = retainedCjsSourceMaps <= 32; + fs.writeFileSync( + '/typescript-transform-runtime/stack-entry.mts', + `enum StackShift { Value } + export async function run(): Promise { + await Promise.resolve(); + throw new Error('entry-typescript-stack'); + }`, + ); + let executionEntryStack; + try { + await runJavaScript({ + cwd: '/typescript-transform-runtime', + entry: './stack-entry.mts', + }); + } catch (error) { + executionEntryStack = error.message; + } + let executionInlineStack; + try { + await runJavaScript({ + language: 'typescript', + source: `enum StackShift { Value } + await Promise.resolve(); + throw new Error('inline-typescript-stack');`, + }); + } catch (error) { + executionInlineStack = error.message; + } return JSON.stringify({ processFeature: process.features.typescript, transformObservability: typeof globalThis.__wasm_rquickjs_get_typescript_module_transform_count, @@ -109,5 +385,33 @@ export async function run() { commonJsNodeModulesTypeScriptErrorName, executionInline: executionInline.value, largeInlineExecution: largeInlineExecution.value, + esmRuntimeStack, + cjsRuntimeStack, + importedCjsRuntimeStack, + rewrittenCjsRuntimeStack, + disabledRuntimeStack, + executionEntryStack, + executionInlineStack, + typeErrorRuntimeStack, + customErrorRuntimeStack, + syntaxErrorRuntimeStack, + errorConstructorsStable, + errorConstructorMetadata, + errorConstructorRelationships, + nonWritablePrepareStack, + prepareSetterCalls, + nestedPrepareCalls, + nestedPrepareStack, + generatedSite: { + fileName: generatedSiteFile, + lineNumber: generatedSiteLine, + columnNumber: generatedSiteColumn, + }, + preparedOrigin, + callSites, + disabledCallSite, + reexportPreparedRuntimeStack, + cjsSourceMapsReclaimed, + retainedCjsSourceMaps, }); } diff --git a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js index e9c48cb3b..3e9d8b7f1 100644 --- a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js +++ b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js @@ -1,4 +1,10 @@ import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import module, { createRequire } from 'node:module'; +import { inspect } from 'node:util'; + +const require = createRequire(import.meta.url); // Test 1: Error.captureStackTrace exists as a function export const testCaptureStackTraceExists = () => { @@ -117,6 +123,173 @@ export const testCallSiteMethods = () => { } }; +export const testLatePrepareStackTrace = () => { + const originalPrepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = undefined; + const lateTarget = {}; + Error.captureStackTrace(lateTarget); + Error.prepareStackTrace = () => 'late-prepare'; + assert.strictEqual(lateTarget.stack, 'late-prepare'); + + Error.prepareStackTrace = () => 'capture-time-prepare'; + const removedTarget = {}; + Error.captureStackTrace(removedTarget); + Error.prepareStackTrace = undefined; + assert.strictEqual(typeof removedTarget.stack, 'string'); + assert.notStrictEqual(removedTarget.stack, 'capture-time-prepare'); + return true; + } catch (e) { + console.error('testLatePrepareStackTrace FAIL:', e.message); + return false; + } finally { + Error.prepareStackTrace = originalPrepare; + } +}; + +export const testDefaultErrorStackHeaders = () => { + const originalPrepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = undefined; + assert.match(new Error('base').stack, /^Error: base(?:\n|$)/); + assert.match(new TypeError('native').stack, /^TypeError: native(?:\n|$)/); + assert.match(new Error().stack, /^Error(?:\n|$)/); + class CustomError extends Error {} + CustomError.prototype.name = 'CustomError'; + assert.match(new CustomError('custom').stack, /^CustomError: custom(?:\n|$)/); + class EmptyNameError extends Error {} + EmptyNameError.prototype.name = ''; + assert.match(new EmptyNameError('message-only').stack, /^message-only(?:\n|$)/); + const originalToString = Error.prototype.toString; + Error.prototype.toString = () => 'POISON'; + const protectedHeader = new Error('protected').stack; + Error.prototype.toString = originalToString; + assert.match(protectedHeader, /^Error: protected(?:\n|$)/); + function captureNamedFrame() { + return new Error('named-frame').stack; + } + assert.match(captureNamedFrame().split('\n')[1], /^\s*at captureNamedFrame \(.+:\d+:\d+\)$/); + const originalObjectToString = Object.prototype.toString; + let protectedCallSiteStack; + try { + Object.prototype.toString = () => 'FAKE'; + protectedCallSiteStack = new Error('protected-call-site').stack; + } finally { + Object.prototype.toString = originalObjectToString; + } + assert.doesNotMatch(protectedCallSiteStack, /FAKE/); + assert.match(protectedCallSiteStack.split('\n')[1], /^\s*at .+:\d+:\d+/); + return true; + } catch (e) { + console.error('testDefaultErrorStackHeaders FAIL:', e.message); + return false; + } finally { + Error.prepareStackTrace = originalPrepare; + } +}; + +export const testErrorInspectUsesCurrentName = () => { + try { + const error = new RangeError('foo'); + error.name = 404; + assert.match(inspect(error), /^404 \[RangeError\]: foo(?:\n|$)/); + + const manualStackError = new RangeError('foo'); + manualStackError.stack = 'RangeError: foo\n at manual (x.js:1:2)'; + manualStackError.name = 404; + assert.strictEqual( + inspect(manualStackError), + 'RangeError: foo\n at manual (x.js:1:2)', + ); + return true; + } catch (e) { + console.error('testErrorInspectUsesCurrentName FAIL:', e.message); + return false; + } +}; + +export const testAssertionListenerStackFrame = () => { + const emitter = new EventEmitter(); + emitter.on('failure', assert); + try { + emitter.emit('failure', false); + } catch (error) { + return String(error.stack).split('\n')[1] || ''; + } + return ''; +}; + +export const testCjsCallSiteLineOffset = () => { + const originalPrepare = Error.prepareStackTrace; + const filename = '/v8-call-site-line-offset.cjs'; + const mapFilename = '/v8-call-site-line-offset.cjs.map'; + try { + module.setSourceMapsSupport(true); + Error.prepareStackTrace = (_error, sites) => sites[0]; + fs.writeFileSync(filename, [ + 'function branch() {', + " throw Error('site');", + '}', + 'branch();', + '//# sourceMappingURL=v8-call-site-line-offset.cjs.map', + ].join('\n')); + fs.writeFileSync(mapFilename, JSON.stringify({ + version: 3, + sources: ['v8-call-site-line-offset.ts'], + names: [], + mappings: 'AAAA;AACA;AACA;AACA', + })); + try { + require(filename); + } catch (error) { + return String(error.stack.getLineNumber()); + } + return ''; + } finally { + delete require.cache[filename]; + Error.prepareStackTrace = originalPrepare; + } +}; + +export const testPrepareStackTraceDescriptors = () => { + try { + const customPrepare = () => 'custom-prepare'; + Error.prepareStackTrace = customPrepare; + Object.defineProperty(Error, 'prepareStackTrace', { enumerable: false }); + assert.strictEqual(Error.prepareStackTrace, customPrepare); + assert.strictEqual(new Error('partial-descriptor').stack, 'custom-prepare'); + + let getterCalls = 0; + Object.defineProperty(Error, 'prepareStackTrace', { + get() { + getterCalls++; + return customPrepare; + }, + configurable: true, + }); + assert.strictEqual(getterCalls, 0); + assert.strictEqual(new TypeError('accessor').stack, 'custom-prepare'); + + delete Error.prepareStackTrace; + assert.match(new Error('deleted').stack, /^Error: deleted(?:\n|$)/); + + Object.defineProperty(Error, 'prepareStackTrace', { + value: customPrepare, + writable: true, + configurable: true, + }); + Object.freeze(Error); + const descriptor = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + assert.strictEqual(descriptor.writable, false); + assert.strictEqual(descriptor.configurable, false); + assert.strictEqual(new Error('frozen').stack, 'custom-prepare'); + return true; + } catch (e) { + console.error('testPrepareStackTraceDescriptors FAIL:', e.message); + return false; + } +}; + // Test 5: constructorOpt parameter strips frames export const testConstructorOpt = () => { try { diff --git a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit index 49f9fcbed..e217d6934 100644 --- a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit +++ b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit @@ -5,6 +5,12 @@ world v8-stack-trace { export test-capture-stack-trace-basic: func() -> bool; export test-prepare-stack-trace: func() -> bool; export test-call-site-methods: func() -> bool; + export test-late-prepare-stack-trace: func() -> bool; + export test-default-error-stack-headers: func() -> bool; + export test-error-inspect-uses-current-name: func() -> bool; + export test-assertion-listener-stack-frame: func() -> string; + export test-cjs-call-site-line-offset: func() -> string; + export test-prepare-stack-trace-descriptors: func() -> bool; export test-constructor-opt: func() -> bool; export test-stack-trace-limit: func() -> bool; export test-depd-pattern: func() -> bool; diff --git a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts index fd0f6df05..d484b719f 100644 --- a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts +++ b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts @@ -3,6 +3,12 @@ declare module 'v8-stack-trace' { export function testCaptureStackTraceBasic(): Promise; export function testPrepareStackTrace(): Promise; export function testCallSiteMethods(): Promise; + export function testLatePrepareStackTrace(): Promise; + export function testDefaultErrorStackHeaders(): Promise; + export function testErrorInspectUsesCurrentName(): Promise; + export function testAssertionListenerStackFrame(): Promise; + export function testCjsCallSiteLineOffset(): Promise; + export function testPrepareStackTraceDescriptors(): Promise; export function testConstructorOpt(): Promise; export function testStackTraceLimit(): Promise; export function testDepdPattern(): Promise; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 649eb0085..5c1f2a11f 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -343,7 +343,7 @@ "block_09_error_stacktracelimit_should_not_influence_callsite_size": {}, "block_10_block_10": { "category": "known-gap", - "reason": "TypeScript transform source maps are not applied to QuickJS stack call-site locations" + "reason": "simulated child_process TypeScript CLI execution does not publish its transform map to util.getCallSites" }, "block_11_block_11": {}, "block_12_block_12": {} @@ -438,7 +438,10 @@ "block_70_verify_non_enumerable_keys_get_escaped": { "category": "runnable" }, "block_71_check_for_special_colors": { "category": "runnable" }, "block_72_non_indices_array_properties_are_sorted_as_well": { "category": "runnable" }, - "block_73_manipulate_the_prototype_in_weird_ways": { "category": "runnable" }, + "block_73_manipulate_the_prototype_in_weird_ways": { + "category": "known-gap", + "reason": "requires V8 hidden-class constructor-name recovery after the prototype constructor is detached" + }, "block_74_check_that_the_fallback_always_works": { "category": "runnable" }, "block_75_check_the_getter_option": { "category": "runnable" }, "block_76_check_compact_number_mode": { "category": "runnable" }, @@ -8276,7 +8279,7 @@ "block_00_it_should_throw_with_invalid_args": { "category": "runnable" }, "block_01_findsourcemap_should_return_undefined_when_no_source_map_is_": { "category": "runnable" }, "block_02_non_exceptional_case": { "category": "runnable" }, - "block_03_source_map_attached_to_error": { "category": "engine-difference", "reason": "native QuickJS Error.prepareStackTrace CallSite positions include the CJS wrapper offset" }, + "block_03_source_map_attached_to_error": { "category": "runnable" }, "block_04_sourcemap_can_be_instantiated_with_source_map_v3_object_as_p": { "category": "runnable" }, "block_05_error_when_receiving_a_malformed_mappings": { "category": "runnable" }, "block_06_sourcemap_can_be_instantiated_with_index_source_map_v3_objec": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index a536d1d3d..901e5300e 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,16 +8,16 @@ This report is generated from `config.jsonc` and the pinned vendored Node.js sou Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3180/4387 (72.5%) +**Primary compatibility (CI-enforced):** 3180/4388 (72.5%) When comparing revisions, read the runnable count and secondary full-public percentage alongside the primary percentage. Reclassifying a test into an excluded category can increase the primary percentage without increasing runnable coverage. | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| | ✅ passing (runnable) | 3180 | 72.5% | 55.3% | 46.3% | -| 🧩 known gap | 1207 | 27.5% | 21.0% | 17.6% | +| 🧩 known gap | 1208 | 27.5% | 21.0% | 17.6% | | 🚫 WASI-impossible (excluded) | 1195 | — | 20.8% | 17.4% | -| ⚙️ engine difference (excluded) | 168 | — | 2.9% | 2.4% | +| ⚙️ engine difference (excluded) | 167 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1123 | — | — | 16.3% | | **Total** | **6873** | | | **100.0%** | @@ -59,7 +59,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 148 | 38 | 19 | 1 | 0 | 17 | 79.6% | 71.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 614 | 187 | 145 | 86 | 13 | 0 | 183 | 56.3% | 43.4% | +| other | 614 | 188 | 145 | 86 | 12 | 0 | 183 | 56.5% | 43.6% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -81,7 +81,7 @@ Secondary full-public compatibility, including public tests that are currently e | trace_events | 35 | 15 | 10 | 6 | 0 | 0 | 4 | 60.0% | 48.4% | | tty | 5 | 0 | 3 | 0 | 0 | 0 | 2 | 0.0% | 0.0% | | url | 29 | 28 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| util | 174 | 88 | 9 | 0 | 0 | 0 | 77 | 90.7% | 90.7% | +| util | 174 | 87 | 10 | 0 | 0 | 0 | 77 | 89.7% | 89.7% | | v8 | 45 | 14 | 1 | 0 | 30 | 0 | 0 | 93.3% | 31.1% | | vm | 128 | 73 | 39 | 3 | 13 | 0 | 0 | 65.2% | 57.0% | | webcrypto | 107 | 43 | 21 | 1 | 0 | 0 | 42 | 67.2% | 66.2% | @@ -474,7 +474,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-snapshot-typescript.js` | 2 | 0 | 0 | 0 | 2 | 0 | 0 | | `test-snapshot-umd.js` | 2 | 0 | 0 | 0 | 2 | 0 | 0 | | `test-snapshot-warning.js` | 3 | 0 | 0 | 0 | 3 | 0 | 0 | -| `test-source-map-api.js` | 9 | 8 | 0 | 0 | 1 | 0 | 0 | +| `test-source-map-api.js` | 9 | 9 | 0 | 0 | 0 | 0 | 0 | | `test-source-map-enable.js` | 23 | 23 | 0 | 0 | 0 | 0 | 0 | | `test-sqlite-database-sync.js` | 5 | 5 | 0 | 0 | 0 | 0 | 0 | | `test-sqlite-session.js` | 14 | 13 | 1 | 0 | 0 | 0 | 0 | @@ -584,7 +584,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-util-format.js` | 5 | 0 | 5 | 0 | 0 | 0 | 0 | | `test-util-getcallsites.js` | 13 | 12 | 1 | 0 | 0 | 0 | 0 | | `test-util-inspect-getters-accessing-this.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | -| `test-util-inspect.js` | 99 | 48 | 2 | 0 | 0 | 0 | 49 | +| `test-util-inspect.js` | 99 | 47 | 3 | 0 | 0 | 0 | 49 | | `test-util-isDeepStrictEqual.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-util-promisify.js` | 19 | 0 | 0 | 0 | 0 | 0 | 19 | | `test-util-types.js` | 3 | 0 | 0 | 0 | 0 | 0 | 3 | @@ -686,7 +686,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1207) +### known gap (1208) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -954,7 +954,6 @@ Secondary full-public compatibility, including public tests that are currently e | SourceTextModule evaluation timeout does not interrupt an infinite loop | 1 | `parallel/test-vm-module-basic.js#block_02_statement_02` | | SourceTextModule identifiers are not incremented per VM context like Node | 1 | `parallel/test-vm-module-basic.js#block_03_check_the_generated_identifier_for_each_module` | | Timeout listener bookkeeping on keep-alive sockets is not Node-compatible | 1 | `parallel/test-http-client-timeout-option-listeners.js` | -| TypeScript transform source maps are not applied to QuickJS stack call-site locations | 1 | `parallel/test-util-getcallsites.js#block_10_block_10` | | URL inspect output uses the URL string instead of Node's structured URL representation | 1 | `parallel/test-whatwg-url-custom-inspect.js` | | WASI UDP ping-pong over loopback does not reliably deliver datagrams in the local runtime despite Node-compatible hostname resolution | 1 | `sequential/test-dgram-pingpong.js` | | WASM child emulation does not support --experimental-test-module-mocks CLI flag | 1 | `parallel/test-runner-module-mocking.js#test_11_node_modules_can_be_used_by_both_module_systems` | @@ -1207,6 +1206,7 @@ Secondary full-public compatibility, including public tests that are currently e | request/response pause-resume flow control does not complete with Node-compatible behavior | 1 | `parallel/test-http-pause.js` | | requires ERR_INVALID_ARG_TYPE validation on resolve methods (not yet implemented) | 1 | `parallel/test-dns-resolvens-typeerror.js` | | requires HTTP server functionality, we only support clients | 1 | `parallel/test-diagnostic-channel-http-response-created.js` | +| requires V8 hidden-class constructor-name recovery after the prototype constructor is detached | 1 | `parallel/test-util-inspect.js#block_73_manipulate_the_prototype_in_weird_ways` | | requires V8-style GC/finalization behavior for rapidly churned HTTP client requests; current QuickJS/WASM runtime does not collect all watched request objects reliably | 1 | `parallel/test-gc-http-client-connaborted.js` | | requires V8-style GC/finalization behavior for rapidly churned net sockets with timeouts; current QuickJS/WASM runtime does not collect all watched socket objects reliably | 1 | `parallel/test-gc-net-timeout.js` | | requires actual TCP socket reuse with remotePort identity tracking via server; wasi:http creates new connections per request | 1 | `parallel/test-http-agent-scheduling.js` | @@ -1265,6 +1265,7 @@ Secondary full-public compatibility, including public tests that are currently e | setDefaultHeaders:false still injects/default-normalizes headers (Host/Content-Length/casing/duplicates) | 1 | `parallel/test-http-dont-set-default-headers-with-set-header.js` | | setImmediate queue turn semantics are unstable and can trap in the timeout scheduler | 1 | `parallel/test-timers-immediate-queue.js` | | setInterval scheduling incorrectly includes callback execution time | 1 | `sequential/test-timers-set-interval-excludes-callback-duration.js` | +| simulated child_process TypeScript CLI execution does not publish its transform map to util.getCallSites | 1 | `parallel/test-util-getcallsites.js#block_10_block_10` | | snapshot update/read flow via node:test is incomplete in WASM child emulation | 1 | `parallel/test-runner-snapshot-file-tests.js#test_01_t_assert_filesnapshot_update_read_flow` | | spawn() stdio handling is incomplete: non-requested stderr stream is still created | 1 | `sequential/test-child-process-exit.js` | | spawn() timeout validation path hangs in WASM child emulation | 1 | `parallel/test-child-process-spawn-timeout-kill-signal.js#block_02_block_02` | @@ -1540,7 +1541,7 @@ Secondary full-public compatibility, including public tests that are currently e | wasi:http does not expose custom HTTP reason phrases (status messages) | 1 | `parallel/test-http-response-status-message.js` | | wasi:http normalizes response header names, so raw header case preservation assertions cannot be satisfied | 1 | `parallel/test-http-write-head.js` | -### engine difference (168) +### engine difference (167) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1574,7 +1575,6 @@ Secondary full-public compatibility, including public tests that are currently e | depends on V8 native syntax and runtime flags not available in QuickJS | 1 | `parallel/test-v8-flags.js` | | depends on engine-specific ArrayBuffer OOM RangeError message text in skip path | 1 | `sequential/test-buffer-creation-regression.js` | | expects V8 heap space statistics that QuickJS does not expose | 1 | `parallel/test-v8-stats.js` | -| native QuickJS Error.prepareStackTrace CallSite positions include the CJS wrapper offset | 1 | `parallel/test-source-map-api.js#block_03_source_map_attached_to_error` | | uses V8 natives syntax intrinsics (`%DebugPrint`, `%HaveSameMap`, `%CollectGarbage`) unavailable in QuickJS | 1 | `parallel/test-http-same-map.js` | | uses v8.getHeapSnapshot, which is V8-specific and unavailable in QuickJS | 1 | `parallel/test-http2-ping-settings-heapdump.js` | | v8.cachedDataVersionTag depends on V8 internals unavailable in QuickJS | 1 | `parallel/test-v8-version-tag.js` | diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index d5be02b8d..a684478eb 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -36,12 +36,9 @@ async fn strip_typescript_types_matches_node_contract( }; let report: serde_json::Value = serde_json::from_str(&json)?; assert_eq!(report["stripped"], "const value = 1;"); - assert!( - report["transformed"] - .as_str() - .is_some_and(|output| output.contains("MathUtil") - && output.contains("sourceMappingURL=data:application/json;base64,") - && output.ends_with("//# sourceURL=input.ts")) + assert_eq!( + report["transformed"], + "(function(MathUtil) {\n MathUtil.add = (a, b)=>a + b;\n})(MathUtil || (MathUtil = {}));\nvar MathUtil;\n\n\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImlucHV0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJVQUNZO2FBQ0ssTUFBTSxDQUFDLEdBQVcsSUFBYyxJQUFJO0FBQ25ELEdBRlUsYUFBQSJ9" ); assert_eq!( report["sourceMap"], @@ -270,6 +267,41 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["entryRunner"], 42); assert_eq!(report["commonJsEntryRunner"], 42); assert_eq!(report["largeInlineRunner"], 42); + assert!( + report["stripRuntimeStack"] + .as_str() + .is_some_and(|stack| stack.contains("strip-stack.mts:3:")), + "strip mode did not preserve original coordinates: {}", + report["stripRuntimeStack"] + ); + assert!( + report["stripInlineExecutionStack"] + .as_str() + .is_some_and(|stack| stack.contains("__wasm_rquickjs_execution_inline.mjs:2:")), + "strip inline execution did not remove its wrapper offset: {}", + report["stripInlineExecutionStack"] + ); + assert!( + report["plainInlineExecutionStack"] + .as_str() + .is_some_and(|stack| stack.contains("__wasm_rquickjs_execution_inline.mjs:2:")), + "plain JavaScript inline execution retained its wrapper offset: {}", + report["plainInlineExecutionStack"] + ); + assert_eq!( + report["errorConstructorMetadata"], + serde_json::json!([ + { "name": "Error", "length": 1 }, + { "name": "TypeError", "length": 1 }, + { "name": "RangeError", "length": 1 }, + { "name": "ReferenceError", "length": 1 }, + { "name": "SyntaxError", "length": 1 }, + { "name": "EvalError", "length": 1 }, + { "name": "URIError", "length": 1 }, + { "name": "AggregateError", "length": 2 }, + ]) + ); + assert_eq!(report["errorConstructorRelationships"], true); assert!( report["unsupported"] .as_str() @@ -311,5 +343,112 @@ async fn typescript_transform_runtime_is_immutable( assert_eq!(report["commonJsNodeModulesTypeScriptErrorName"], "Error"); assert_eq!(report["executionInline"], 1); assert_eq!(report["largeInlineExecution"], 1); + for (field, file, line) in [ + ("esmRuntimeStack", "stack-esm.mts", 3), + ("cjsRuntimeStack", "stack-cjs.cts", 3), + ("importedCjsRuntimeStack", "stack-cjs.cts", 3), + ("executionEntryStack", "stack-entry.mts", 4), + ("typeErrorRuntimeStack", "stack-errors.mts", 4), + ("customErrorRuntimeStack", "stack-errors.mts", 7), + ("syntaxErrorRuntimeStack", "stack-errors.mts", 19), + ( + "reexportPreparedRuntimeStack", + "stack-reexport-child.cts", + 3, + ), + ( + "executionInlineStack", + "__wasm_rquickjs_execution_inline.mjs", + 3, + ), + ] { + let stack = report[field] + .as_str() + .unwrap_or_else(|| panic!("missing {field}")); + assert!( + stack.contains(&format!("{file}:{line}:")), + "{field} did not map to the original TypeScript location: {stack}" + ); + assert!( + !stack.contains("__wasm_rquickjs_builtin/internal/errors"), + "{field} leaked Error shim frames: {stack}" + ); + } + let rewritten_cjs_stack = report["rewrittenCjsRuntimeStack"] + .as_str() + .expect("missing rewrittenCjsRuntimeStack"); + assert!( + rewritten_cjs_stack.contains("stack-cjs.cts:4:"), + "rewritten CJS map was stale: {rewritten_cjs_stack}" + ); + assert!( + rewritten_cjs_stack.contains("stack-caller.cjs:2:"), + "mixed JavaScript frame was not preserved: {rewritten_cjs_stack}" + ); + let disabled_stack = report["disabledRuntimeStack"] + .as_str() + .expect("missing disabledRuntimeStack"); + assert!(disabled_stack.contains("stack-disabled.mts:")); + assert!( + !disabled_stack.contains("stack-disabled.mts:3:"), + "disabled source-map support unexpectedly remapped the stack: {disabled_stack}" + ); + assert_eq!(report["errorConstructorsStable"], true); + assert_eq!( + report["cjsSourceMapsReclaimed"], true, + "CJS source maps were retained after their modules were reclaimed: retained={}", + report["retainedCjsSourceMaps"] + ); + assert_eq!( + report["errorConstructorMetadata"], + serde_json::json!([ + { "name": "Error", "length": 1 }, + { "name": "TypeError", "length": 1 }, + { "name": "RangeError", "length": 1 }, + { "name": "ReferenceError", "length": 1 }, + { "name": "SyntaxError", "length": 1 }, + { "name": "EvalError", "length": 1 }, + { "name": "URIError", "length": 1 }, + { "name": "AggregateError", "length": 2 }, + ]) + ); + assert_eq!(report["errorConstructorRelationships"], true); + assert_eq!(report["nonWritablePrepareStack"], "non-writable-prepare"); + assert_eq!(report["prepareSetterCalls"], 0); + assert_eq!(report["nestedPrepareCalls"], 1); + assert_eq!(report["nestedPrepareStack"], "nested-prepare"); + assert!( + report["generatedSite"]["fileName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-errors.mts")), + "unexpected generated custom prepare call site: {}", + report["generatedSite"] + ); + assert_ne!(report["generatedSite"]["lineNumber"], 13); + assert!( + report["preparedOrigin"]["fileName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-errors.mts")) + ); + assert_eq!(report["preparedOrigin"]["lineNumber"], 13); + assert!( + report["callSites"]["mapped"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-sites.mts")) + ); + assert_eq!(report["callSites"]["mapped"]["lineNumber"], 5); + assert_eq!(report["callSites"]["mapped"]["columnNumber"], 26); + assert!( + report["callSites"]["generated"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-sites.mts")) + ); + assert_ne!(report["callSites"]["generated"]["lineNumber"], 6); + assert!( + report["disabledCallSite"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-disabled-sites.mts")) + ); + assert_ne!(report["disabledCallSite"]["lineNumber"], 4); Ok(()) } diff --git a/tests/runtime/v8_stack_trace.rs b/tests/runtime/v8_stack_trace.rs index a1567fe3f..cab916c33 100644 --- a/tests/runtime/v8_stack_trace.rs +++ b/tests/runtime/v8_stack_trace.rs @@ -79,6 +79,114 @@ async fn v8_stack_trace_call_site_methods( Ok(()) } +#[test] +async fn v8_stack_trace_late_prepare( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-late-prepare-stack-trace", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_default_error_stack_headers( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-default-error-stack-headers", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_error_inspect_uses_current_name( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-error-inspect-uses-current-name", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_assertion_listener_stack_frame( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-assertion-listener-stack-frame", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + let Some(Val::String(frame)) = r else { + anyhow::bail!("expected a stack-frame string, got {r:?}"); + }; + assert!( + frame.contains(" (") && frame.ends_with(')'), + "expected a parenthesized stack frame, got {frame:?}" + ); + Ok(()) +} + +#[test] +async fn v8_stack_trace_cjs_call_site_line_offset( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-call-site-line-offset", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::String("2".into()))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_prepare_stack_trace_descriptors( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-prepare-stack-trace-descriptors", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn v8_stack_trace_constructor_opt( #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest,