From 77674c50ab9ab410b51c7b5688acb5beced9157e Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 15:07:21 +0300 Subject: [PATCH 1/3] [TS] Preserve protocol values across user code and validate number bits --- .../fast-check-adapter/src/execution-cli.ts | 3 +- .../fast-check-adapter/src/js-value.ts | 11 +++- .../fast-check-adapter/src/protocol-json.ts | 52 +++++++++++++++++++ .../test/execution-cli.test.ts | 21 ++++++++ .../fast-check-adapter/test/js-value.test.ts | 16 ++++++ .../usvm/ts/pbt/fastcheck/FastCheckBackend.kt | 10 +--- .../ts/pbt/fastcheck/FastCheckBackendTest.kt | 24 +++++---- .../org/usvm/ts/pbt/model/JsConcreteValue.kt | 14 ++++- .../ts/pbt/validation/PropertyValidation.kt | 12 +---- 9 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts diff --git a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts index 59caaf7117..e2fce65034 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/execution-cli.ts @@ -1,4 +1,5 @@ import { adapterDiagnostic } from './diagnostics.js'; +import { stringifyProtocolJson } from './protocol-json.js'; import { executeProperty, type FastCheckExecutionSuccess } from './execute-property.js'; import { ProtocolError, protocolError } from './js-value.js'; import type { ProtocolDiagnostic } from './js-value.js'; @@ -59,7 +60,7 @@ function protocolErrorResponse(error: ProtocolError): FastCheckExecutionFailure } async function writeResponse(response: FastCheckExecutionSuccess | FastCheckExecutionFailure): Promise { - const document = `${JSON.stringify(response)}\n`; + const document = `${stringifyProtocolJson(response)}\n`; await new Promise((resolve, reject) => { writeProtocolOutput(document, (error?: Error | null) => { diff --git a/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts b/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts index 39d3a400be..f7578c744e 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/js-value.ts @@ -134,7 +134,16 @@ export function decodeJsNumber(taggedNumber: unknown, path = 'number'): number { ); } - return bitsToDouble(taggedNumber.bits); + const finiteValue = bitsToDouble(taggedNumber.bits); + if (!Number.isFinite(finiteValue)) { + throw protocolError( + adapterDiagnostic.jsNumberEncodingInvalid, + 'Finite JavaScript numbers require finite IEEE-754 bits', + path, + ); + } + + return finiteValue; case 'nan': requireNoBits(taggedNumber, path); diff --git a/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts b/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts new file mode 100644 index 0000000000..d54bed0d30 --- /dev/null +++ b/usvm-ts-fast-check/fast-check-adapter/src/protocol-json.ts @@ -0,0 +1,52 @@ +// Protocol responses contain JSON data only. Walk them without consulting an +// object's toJSON method, including one inherited by an internal array. +const stringifyScalar = JSON.stringify.bind(JSON); +const ownKeys = Object.keys.bind(Object); +const isArray = Array.isArray.bind(Array); + +export function stringifyProtocolJson(value: unknown): string { + const ancestors = new Set(); + + function serialize(current: unknown, inArray: boolean): string | undefined { + if (current === null || typeof current === 'string' || typeof current === 'boolean' + || typeof current === 'number') { + return stringifyScalar(current); + } + if (current === undefined || typeof current === 'function' || typeof current === 'symbol') { + return inArray ? 'null' : undefined; + } + if (typeof current !== 'object') throw new TypeError('Unsupported protocol JSON value'); + if (ancestors.has(current)) throw new TypeError('Circular protocol JSON value'); + + ancestors.add(current); + try { + if (isArray(current)) { + let elements = '['; + for (let index = 0; index < current.length; index += 1) { + if (index > 0) elements += ','; + elements += serialize(current[index], true); + } + + return `${elements}]`; + } + + let fields = '{'; + for (const key of ownKeys(current)) { + const encoded = serialize((current as Record)[key], false); + if (encoded !== undefined) { + if (fields.length > 1) fields += ','; + fields += `${stringifyScalar(key)}:${encoded}`; + } + } + + return `${fields}}`; + } finally { + ancestors.delete(current); + } + } + + const encoded = serialize(value, false); + if (encoded === undefined) throw new TypeError('Protocol response has no JSON representation'); + + return encoded; +} diff --git a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts index 1128155aef..f8c5339820 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/execution-cli.test.ts @@ -40,6 +40,27 @@ test('execution CLI reports malformed JSON without crashing', async () => { assert.equal(response.diagnostics[0]?.code, 'protocol.json.invalid'); }); +test('execution CLI keeps one JSON response after a predicate adds inherited toJSON', async () => { + const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); + try { + await writeFile(sourceRoot + '/property.ts', ` +export function predicate(value: boolean) { + Object.prototype.toJSON = () => { throw new Error('sentinel toJSON'); }; + return value; +} +`); + + const invocation = await invokeCli(JSON.stringify(executionRequest(sourceRoot))); + const response = JSON.parse(invocation.stdout) as Record; + + assert.equal(invocation.exitCode, 0); + assert.equal(invocation.stdout.trim().split('\n').length, 1); + assert.equal(response.status, 'ok'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } +}); + test('execution CLI keeps user logging outside the protocol response', async () => { const sourceRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'usvm-execution-cli-'))); try { diff --git a/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts index 15154526fd..2cf1b03299 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/js-value.test.ts @@ -61,6 +61,22 @@ test('tagged finite numbers require exactly sixteen lowercase hexadecimal digits } }); +test('finite tags reject nonfinite bit patterns at the tagged input boundary', () => { + for (const bits of ['7ff0000000000000', 'fff0000000000000', '7ff8000000000000']) { + assert.throws( + () => decodeJsValue({ kind: 'number', value: 'finite', bits }), + /js-number\.encoding\.invalid/, + ); + assert.throws( + () => decodeJsValue({ + kind: 'array', + elements: [{ kind: 'array', elements: [{ kind: 'number', value: 'finite', bits }] }], + }), + /js-number\.encoding\.invalid/, + ); + } +}); + test('unknown tagged value kinds are rejected explicitly', () => { assert.throws(() => decodeJsValue({ kind: 'symbol' }), /js-value\.kind\.unknown/); }); diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt index 7b2d95510d..01f4233986 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackend.kt @@ -7,7 +7,6 @@ import org.usvm.ts.pbt.backend.PropertyRunConfiguration import org.usvm.ts.pbt.backend.PropertyRunResult import org.usvm.ts.pbt.manifest.toManifest import org.usvm.ts.pbt.model.JsConcreteValue -import org.usvm.ts.pbt.model.JsNumberKind import org.usvm.ts.pbt.model.PropertyDefinition import org.usvm.ts.pbt.model.contains import org.usvm.ts.pbt.validation.requireValid @@ -104,7 +103,7 @@ class FastCheckBackend( value: JsConcreteValue, path: String, ) { - if (value is JsConcreteValue.Number && !hasValidEncoding(value)) { + if (value is JsConcreteValue.Number && !value.number.hasValidEncoding()) { throw invalidRequest( code = FastCheckDiagnosticCode.BACKEND_EXAMPLES_VALUE_INVALID, message = "Explicit example contains an invalid tagged JavaScript number", @@ -124,11 +123,6 @@ class FastCheckBackend( } } - private fun hasValidEncoding(value: JsConcreteValue.Number): Boolean = when (value.number.value) { - JsNumberKind.FINITE -> value.number.bits?.matches(FINITE_NUMBER_BITS_REGEX) == true - else -> value.number.bits == null - } - private fun invalidRequest( code: String, message: String, @@ -145,8 +139,6 @@ class FastCheckBackend( companion object { const val FAST_CHECK_BACKEND_ID = "fast-check" - private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") - private fun canonicalizeSourceRoots(sourceRoots: List): List { if (sourceRoots.isEmpty()) { throw PbtBackendException( diff --git a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt index 764c6fefcb..22d06a9bd7 100644 --- a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckBackendTest.kt @@ -193,19 +193,21 @@ class FastCheckBackendTest { assertEquals(BackendErrorKind.INVALID_REQUEST, error.kind) assertEquals("backend.examples.arity", error.code) - val invalidNumber = JsConcreteValue.Number( - JsNumber(value = JsNumberKind.FINITE, bits = "invalid"), - ) - val encodingError = assertFailsWith { - missingNodeBackend.run( - property = property(predicate = "alwaysTrue"), - configuration = configuration.copy(examples = listOf(listOf(invalidNumber))), + for (bits in listOf("invalid", "7ff0000000000000", "fff0000000000000", "7ff8000000000000")) { + val invalidNumber = JsConcreteValue.Number( + JsNumber(value = JsNumberKind.FINITE, bits = bits), ) + val encodingError = assertFailsWith { + missingNodeBackend.run( + property = property(predicate = "alwaysTrue"), + configuration = configuration.copy(examples = listOf(listOf(invalidNumber))), + ) + } + + assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind) + assertEquals("backend.examples.value.invalid", encodingError.code) + assertEquals("examples[0][0]", encodingError.path) } - - assertEquals(BackendErrorKind.INVALID_REQUEST, encodingError.kind) - assertEquals("backend.examples.value.invalid", encodingError.code) - assertEquals("examples[0][0]", encodingError.path) } @Test diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt index 9dba12e1f3..d442482b83 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/model/JsConcreteValue.kt @@ -46,6 +46,17 @@ data class JsNumber( val value: JsNumberKind, val bits: String? = null, ) { + /** Whether this tag can represent exactly the encoded ECMAScript binary64 value. */ + fun hasValidEncoding(): Boolean = when (value) { + JsNumberKind.FINITE -> { + bits?.takeIf { it.matches(FINITE_NUMBER_BITS_REGEX) } + ?.let { Double.fromBits(it.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } + ?: false + } + + else -> bits == null + } + fun toDouble(): Double = when (value) { JsNumberKind.FINITE -> Double.fromBits( requireNotNull(bits) { "A finite JavaScript number requires IEEE-754 bits" } @@ -289,8 +300,7 @@ private fun JsonObject.requiredFiniteBits(): String { throw SerializationException("Finite JsConcreteValue requires sixteen lowercase hexadecimal bits") } - val number = Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()) - if (!number.isFinite()) { + if (!JsNumber(value = JsNumberKind.FINITE, bits = bits).hasValidEncoding()) { throw SerializationException("Finite JsConcreteValue requires finite IEEE-754 bits") } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt index 09079ca167..d78ad5613a 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/validation/PropertyValidation.kt @@ -261,10 +261,7 @@ private fun validateJsNumber( path: String, diagnostics: MutableList, ): Boolean { - val valid = when (number.value) { - JsNumberKind.FINITE -> number.bits.isFiniteNumberBits() - else -> number.bits == null - } + val valid = number.hasValidEncoding() if (!valid) { diagnostics += diagnostic( code = PbtDiagnosticCode.JS_NUMBER_ENCODING_INVALID, @@ -275,11 +272,6 @@ private fun validateJsNumber( return valid } -private fun String?.isFiniteNumberBits(): Boolean = this - ?.takeIf { bits -> bits.matches(FINITE_NUMBER_BITS_REGEX) } - ?.let { bits -> Double.fromBits(bits.toULong(JS_NUMBER_HEX_RADIX).toLong()).isFinite() } - ?: false - private fun validateLengths( minLength: Int, maxLength: Int, @@ -368,8 +360,6 @@ private fun diagnostic(code: String, message: String, path: String) = Validation path = path, ) -private val FINITE_NUMBER_BITS_REGEX = Regex("[0-9a-f]{16}") -private const val JS_NUMBER_HEX_RADIX = 16 // ECMAScript permits these otherwise invisible Unicode characters after the first identifier character. private const val ZERO_WIDTH_NON_JOINER_CODE_POINT = 0x200C From ab880e491973cbe05ff03615b3739010f1cfa6ac Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 15:08:21 +0300 Subject: [PATCH 2/3] [TS] Reuse bounded process and coverage fixes from PBT --- .../src/coverage-branches.ts | 215 ++++++++++++++++-- .../test/coverage-branches.test.ts | 118 +++++++++- .../pbt/fastcheck/FastCheckCoverageSession.kt | 74 +++--- .../fastcheck/FastCheckProcessTransport.kt | 31 ++- .../fastcheck/FastCheckProcessClientTest.kt | 46 ++++ .../pbt/coverage/IstanbulCoverageDecoder.kt | 3 + .../ts/pbt/coverage/IstanbulCoverageReport.kt | 11 +- .../coverage/IstanbulCoverageReportReader.kt | 19 +- .../pbt/coverage/IstanbulSourceFileDecoder.kt | 10 +- .../pbt/coverage/RawV8SourceMapInspector.kt | 29 ++- .../coverage/IstanbulCoverageReportTest.kt | 67 ++++++ 11 files changed, 539 insertions(+), 84 deletions(-) diff --git a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts index 7d78736563..4aa78be072 100644 --- a/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts +++ b/usvm-ts-fast-check/fast-check-adapter/src/coverage-branches.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile, readdir, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -72,20 +73,48 @@ interface ScriptCoverage { script: V8Script; cache: SourceMapCacheEntry; path: string; + sourceUrl: string; + scriptPath: string; +} + +export interface GeneratedModuleIdentity { + originalPath: string; + originalSha256: string; + generatedModule: string; + generatedSha256: string; + sourceMapSha256: string; } /** Reconstructs only source-mapped TypeScript if arms whose V8 counters agree. */ -export async function convertV8IfBranches(rawDirectory: string, sourceRoots: string[]): Promise { +export async function convertV8IfBranches( + rawDirectory: string, + sourceRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise { const roots = await Promise.all(sourceRoots.map(root => realpath(root))); + const lexicalRoots = [...sourceRoots.map(root => path.resolve(root)), ...roots.flatMap(root => + process.platform === 'darwin' && root.startsWith('/private/') ? [root.slice('/private'.length)] : [])]; + const identity = generatedIdentity === undefined ? undefined : { + ...generatedIdentity, + originalPath: await realpath(generatedIdentity.originalPath).catch(() => generatedIdentity.originalPath), + generatedModule: await realpath(generatedIdentity.generatedModule).catch(() => generatedIdentity.generatedModule), + }; const names = (await readdir(rawDirectory)).filter(name => name.endsWith('.json')).sort(); const reports = await Promise.all(names.map(async name => JSON.parse(await readFile(path.join(rawDirectory, name), 'utf8')) as V8Report, )); - const scripts = await collectScripts(reports, roots); + const collected = await collectScripts(reports, roots, lexicalRoots, identity); + const scripts = collected.scripts; const files: BranchReport['files'] = []; - const diagnostics: BranchDiagnostic[] = []; + const diagnostics: BranchDiagnostic[] = [...collected.diagnostics]; for (const [sourcePath, candidates] of scripts) { + if (identity !== undefined && sourcePath === identity.originalPath && + (collected.generatedScriptCount !== 1 || collected.diagnostics.some(entry => + entry.path === sourcePath && ['coverage.branch.ambiguous', 'coverage.branch.unknown'].includes(entry.code)))) { + continue; + } + if (candidates.length !== 1 || candidates[0] === undefined) { diagnostics.push(diagnostic( sourcePath, @@ -95,7 +124,16 @@ export async function convertV8IfBranches(rawDirectory: string, sourceRoots: str continue; } - const converted = await convertScript(candidates[0]); + const candidate = candidates[0]; + if (identity !== undefined && candidate.scriptPath === identity.generatedModule) { + const identityDiagnostic = await verifyGeneratedIdentity(candidate, identity); + if (identityDiagnostic !== undefined) { + diagnostics.push(identityDiagnostic); + continue; + } + } + + const converted = await convertScript(candidate); files.push({ path: sourcePath, branches: converted.branches.map((branch, branchId) => ({ ...branch, branchId, @@ -103,38 +141,169 @@ export async function convertV8IfBranches(rawDirectory: string, sourceRoots: str diagnostics.push(...converted.diagnostics); } + if (identity !== undefined && !scripts.has(identity.originalPath)) { + diagnostics.push(diagnostic(identity.originalPath, 'unknown', + 'Generated V8 script has no unambiguous source-map link to the requested TypeScript source')); + } + return { files, diagnostics }; } -async function collectScripts(reports: V8Report[], roots: string[]): Promise> { +async function collectScripts( + reports: V8Report[], + roots: string[], + lexicalRoots: string[], + generatedIdentity?: GeneratedModuleIdentity, +): Promise<{ + scripts: Map; + diagnostics: BranchDiagnostic[]; + generatedScriptCount: number; +}> { const scripts = new Map(); + const diagnostics: BranchDiagnostic[] = []; + let generatedScriptCount = 0; for (const report of reports) { for (const script of report.result ?? []) { if (!script.url.startsWith('file:')) continue; - const sourcePath = await realpath(fileURLToPath(script.url)).catch(() => undefined); - if (sourcePath === undefined || !sourcePath.endsWith('.ts')) continue; - if (!roots.some(root => sourcePath.startsWith(`${root}${path.sep}`))) continue; - + const scriptPath = await realpath(fileURLToPath(script.url)).catch(() => undefined); + if (scriptPath === undefined) continue; const cache = report['source-map-cache']?.[script.url]; - const candidates = scripts.get(sourcePath) ?? []; - candidates.push(cache === undefined ? undefined : { script, cache, path: sourcePath }); - scripts.set(sourcePath, candidates); + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule) { + generatedScriptCount += 1; + if (cache?.data === undefined) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated V8 script has no usable source-map cache entry')); + continue; + } + } + + const sources = cache?.data?.sources ?? []; + const mappedSources = await Promise.all(sources.map(async sourceUrl => { + if (!sourceUrl.startsWith('file:')) return { sourceUrl, sourcePath: undefined }; + + let lexicalPath: string; + try { + lexicalPath = path.resolve(fileURLToPath(sourceUrl)); + } catch { + return { sourceUrl, sourcePath: undefined }; + } + if (!lexicalPath.endsWith('.ts') || + (!isUnderRoot(lexicalPath, lexicalRoots) && !isUnderRoot(lexicalPath, roots))) { + return { sourceUrl, sourcePath: undefined }; + } + + const sourcePath = await realpath(lexicalPath).catch(() => undefined); + return { sourceUrl, sourcePath }; + })); + const selected = mappedSources.filter(({ sourcePath }) => sourcePath !== undefined && + sourcePath.endsWith('.ts') && isUnderRoot(sourcePath, roots)); + + if (selected.length === 0 && scriptPath.endsWith('.ts') && isUnderRoot(scriptPath, roots)) { + selected.push({ sourcePath: scriptPath, sourceUrl: pathToFileURL(scriptPath).href }); + } + + if (generatedIdentity !== undefined && scriptPath === generatedIdentity.generatedModule && + (selected.length !== 1 || selected[0]?.sourcePath !== generatedIdentity.originalPath)) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated V8 script does not map to exactly the requested TypeScript source')); + continue; + } + + for (const { sourcePath, sourceUrl } of selected) { + if (sourcePath === undefined) continue; + if (scriptPath !== sourcePath && scriptPath !== generatedIdentity?.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'unknown', + 'Generated V8 script requires a pinned module identity for exact branch conversion')); + continue; + } + if (generatedIdentity !== undefined && sourcePath === generatedIdentity.originalPath && + scriptPath !== generatedIdentity.generatedModule) { + diagnostics.push(diagnostic(sourcePath, 'ambiguous', + 'Requested TypeScript source also appears under another V8 script')); + continue; + } + + const candidates = scripts.get(sourcePath) ?? []; + candidates.push(cache?.data === undefined || cache.data === null + ? undefined + : { script, cache, path: sourcePath, sourceUrl, scriptPath }); + scripts.set(sourcePath, candidates); + } } } - return scripts; + if (generatedIdentity !== undefined && generatedScriptCount === 0) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'unknown', + 'Generated module is absent from raw V8 coverage')); + } + if (generatedIdentity !== undefined && generatedScriptCount > 1) { + diagnostics.push(diagnostic(generatedIdentity.originalPath, 'ambiguous', + 'Generated module appears in several raw V8 scripts')); + } + + return { scripts, diagnostics, generatedScriptCount }; } +function isUnderRoot(file: string, roots: string[]): boolean { + return roots.some(root => { + const relative = path.relative(root, file); + return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); + }); +} + +async function verifyGeneratedIdentity( + coverage: ScriptCoverage, + identity: GeneratedModuleIdentity, +): Promise { + const generated = await readFile(identity.generatedModule, 'utf8').catch(() => undefined); + const sourceMap = await readFile(`${identity.generatedModule}.map`, 'utf8').catch(() => undefined); + const source = await readFile(identity.originalPath, 'utf8').catch(() => undefined); + if (generated === undefined || sourceMap === undefined || source === undefined || + sha256(generated) !== identity.generatedSha256 || sha256(sourceMap) !== identity.sourceMapSha256 || + sha256(source) !== identity.originalSha256) { + return diagnostic(identity.originalPath, 'unsupported', 'Covered generated module or original source changed'); + } + + const lineLengths = generated.split('\n').map(line => line.endsWith('\r') ? line.length - 1 : line.length); + if (generated.endsWith('\n')) lineLengths.pop(); + if (JSON.stringify(lineLengths) !== JSON.stringify(coverage.cache.lineLengths)) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 generated script lengths differ from the pinned module'); + } + + let parsedMap: { mappings?: string }; + try { + parsedMap = JSON.parse(sourceMap) as { mappings?: string }; + } catch { + return diagnostic(identity.originalPath, 'unsupported', 'Pinned generated source map is invalid'); + } + if (parsedMap.mappings !== coverage.cache.data.mappings) { + return diagnostic(identity.originalPath, 'ambiguous', 'V8 source-map cache differs from the pinned map'); + } + + return undefined; +} + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + async function convertScript(coverage: ScriptCoverage): Promise<{ branches: Branch[]; diagnostics: BranchDiagnostic[]; }> { const source = await readFile(coverage.path, 'utf8'); - const sourceIndex = coverage.cache.data.sources.findIndex(candidate => - candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, + const sourceIndexes = coverage.cache.data.sources.flatMap((candidate, index) => + candidate === coverage.sourceUrl ? [index] : [], ); + if (sourceIndexes.length !== 1) { + return { + branches: [], + diagnostics: [diagnostic(coverage.path, 'ambiguous', 'V8 source map does not identify one TypeScript source')], + }; + } + + const sourceIndex = sourceIndexes[0] as number; const coveredSource = coverage.cache.data.sourcesContent?.[sourceIndex]; if (coveredSource !== source) { return { @@ -226,10 +395,7 @@ function countAt( map: TraceMap, ): number | undefined { const sourcePosition = ast.getLineAndCharacterOfPosition(sourceOffset); - const sourceUrl = coverage.cache.data.sources.find(candidate => - candidate.startsWith('file:') && fileURLToPath(candidate) === coverage.path, - ); - if (sourceUrl === undefined) return undefined; + const sourceUrl = coverage.sourceUrl; const generated = generatedPositionFor(map, { source: sourceUrl, @@ -329,8 +495,15 @@ function diagnostic(sourcePath: string, kind: string, message: string): BranchDi } if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [rawDirectory, ...sourceRoots] = process.argv.slice(2); + const [rawDirectory, ...argumentsAfterRaw] = process.argv.slice(2); if (rawDirectory === undefined) throw new Error('Raw V8 coverage directory is required'); + const identityArguments = argumentsAfterRaw.filter(argument => argument.startsWith('--generated-identity=')); + if (identityArguments.length > 1) throw new Error('Only one generated identity is supported'); + + const identityArgument = identityArguments[0]; + const generatedIdentity = identityArgument === undefined ? undefined + : JSON.parse(identityArgument.slice('--generated-identity='.length)) as GeneratedModuleIdentity; + const sourceRoots = argumentsAfterRaw.filter(argument => !argument.startsWith('--generated-identity=')); - process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots))}\n`); + process.stdout.write(`${JSON.stringify(await convertV8IfBranches(rawDirectory, sourceRoots, generatedIdentity))}\n`); } diff --git a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts index e0a3e06717..259b8fb25a 100644 --- a/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts +++ b/usvm-ts-fast-check/fast-check-adapter/test/coverage-branches.test.ts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import { convertV8IfBranches } from '../src/coverage-branches.js'; +import ts from 'typescript'; +import { convertV8IfBranches, type GeneratedModuleIdentity } from '../src/coverage-branches.js'; const adapterRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url)), '..'); const c8 = path.join(adapterRoot, 'node_modules/c8/bin/c8.js'); @@ -37,6 +39,118 @@ async function runCoverage(source: string): Promise<{ return { root, raw, sourcePath }; } +async function runGeneratedCoverage(source: string): Promise<{ + root: string; + raw: string; + identity: GeneratedModuleIdentity; +}> { + const root = await mkdtemp(path.join(os.tmpdir(), 'usvm-generated-branch-')); + const sourceRoot = path.join(root, 'original'); + const generatedRoot = path.join(root, 'generated'); + await Promise.all([mkdir(sourceRoot), mkdir(generatedRoot)]); + const originalPath = path.join(sourceRoot, 'subject.ts'); + const generatedModule = path.join(generatedRoot, 'subject.mjs'); + const raw = path.join(root, 'raw'); + await writeFile(originalPath, source); + + const output = ts.transpileModule(source, { + fileName: originalPath, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + sourceMap: true, inlineSources: true }, + }); + const map = JSON.parse(output.sourceMapText ?? '{}') as Record; + map.file = path.basename(generatedModule); + map.sourceRoot = ''; + map.sources = [originalPath]; + const sourceMap = JSON.stringify(map); + const generatedSource = output.outputText.replace(/\/\/# sourceMappingURL=.*$/m, + `//# sourceMappingURL=${path.basename(generatedModule)}.map`); + await writeFile(generatedModule, generatedSource); + await writeFile(`${generatedModule}.map`, sourceMap); + + const result = spawnSync(process.execPath, [ + c8, '--reporter=json', `--reports-dir=${path.join(root, 'report')}`, + `--temp-directory=${raw}`, '--exclude-after-remap', '--allowExternal', + '--exclude=__usvm_no_default_excludes__', process.execPath, generatedModule, + ], { cwd: adapterRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + + const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + return { root, raw, identity: { + originalPath: await realpath(originalPath), originalSha256: sha256(source), + generatedModule: await realpath(generatedModule), + generatedSha256: sha256(generatedSource), sourceMapSha256: sha256(sourceMap), + } }; +} + +test('generated JavaScript maps only original ordered if arms across true, false, and mixed runs', async () => { + for (const [calls, hits] of [ + ['subject(1);', [1, 0]], + ['subject(-1);', [0, 1]], + ['subject(1); subject(-1);', [1, 1]], + ] as const) { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) { return true; } + else { return false; } +} +${calls} +`); + + try { + const report = await convertV8IfBranches(fixture.raw, [path.dirname(fixture.identity.originalPath)], + fixture.identity); + + assert.deepEqual(report.files.map(file => file.path), [fixture.identity.originalPath]); + assert.deepEqual(report.files[0]?.branches.map(branch => branch.arms.map(arm => arm.hits)), [hits]); + assert.deepEqual(report.diagnostics, []); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('missing, duplicate, and changed generated maps cannot produce exact branch counts', async () => { + const fixture = await runGeneratedCoverage(` +export function subject(value: number): boolean { + if (value > 0) return true; + else return false; +} +subject(1); +`); + + try { + const sourceRoot = path.dirname(fixture.identity.originalPath); + const changed = await convertV8IfBranches(fixture.raw, [sourceRoot], { + ...fixture.identity, generatedSha256: '0'.repeat(64), + }); + assert.deepEqual(changed.files, []); + assert.equal(changed.diagnostics[0]?.code, 'coverage.branch.unsupported'); + + const reports = (await readdir(fixture.raw)).filter(name => name.endsWith('.json')); + const reportPath = path.join(fixture.raw, reports[0] as string); + const rawReport = JSON.parse(await readFile(reportPath, 'utf8')) as { + result?: { url: string }[]; + 'source-map-cache'?: Record; + }; + const generatedUrl = rawReport.result?.find(script => script.url.includes('/subject.mjs'))?.url; + assert.ok(generatedUrl); + + delete rawReport['source-map-cache']?.[generatedUrl]; + await writeFile(reportPath, JSON.stringify(rawReport)); + const missing = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(missing.files, []); + assert.equal(missing.diagnostics.some(entry => entry.code === 'coverage.branch.unknown'), true); + + await cp(reportPath, path.join(fixture.raw, 'duplicate.json')); + const duplicate = await convertV8IfBranches(fixture.raw, [sourceRoot], fixture.identity); + assert.deepEqual(duplicate.files, []); + assert.equal(duplicate.diagnostics.some(entry => entry.code === 'coverage.branch.ambiguous'), true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + test('real c8 and tsx preserve true, false, mixed, and nested TypeScript arms', async () => { for (const [calls, expected] of [ ['nested(20);', [[1, 0], [1, 0]]], diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt index 1c3a127915..acaf37c560 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckCoverageSession.kt @@ -17,7 +17,6 @@ import org.usvm.ts.pbt.manifest.PropertyManifestJson import java.io.IOException import java.nio.file.Files import java.nio.file.Path -import java.util.concurrent.TimeUnit /** Prepared c8 invocation and the temporary artifacts produced by one property run. */ internal class FastCheckCoverageSession private constructor( @@ -251,59 +250,46 @@ internal class FastCheckCoverageSession private constructor( nodeExecutable: String, request: FastCheckExecutionRequest, ): String { - val process = try { - ProcessBuilder(nodeExecutable, "--version").start() - } catch (error: IOException) { - failPreparation( - request = request, - code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version: ${error.message}", - cause = error, + val transport = FastCheckProcessTransport( + nodeExecutable = nodeExecutable, + maxRequestBytes = 1, + maxStdoutBytes = MAX_VERSION_OUTPUT_BYTES, + maxStderrBytes = MAX_VERSION_OUTPUT_BYTES, + shutdownGraceMillis = VERSION_SHUTDOWN_GRACE_MILLIS, + useProcessSupervisor = false, + ) + val output = try { + transport.invoke( + command = listOf(nodeExecutable, "--version"), + request = "", + timeoutMillis = NODE_VERSION_TIMEOUT_MILLIS, + reportedTimeoutMillis = NODE_VERSION_TIMEOUT_MILLIS, + description = "Node.js version probe", ) - } - - try { - awaitNodeVersion(process, request) - val version = process.inputStream.bufferedReader(Charsets.UTF_8).use { input -> - input.readLine().orEmpty().trim() - } - if (process.exitValue() != 0 || version.isBlank()) { - failPreparation( - request = request, - code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Cannot query the Node.js runtime version", - ) - } - - return requireSupportedNodeVersion(version, request) - } finally { - runCatching { process.outputStream.close() } - runCatching { process.inputStream.close() } - runCatching { process.errorStream.close() } - } - } - - private fun awaitNodeVersion(process: Process, request: FastCheckExecutionRequest) { - val completed = try { - process.waitFor(NODE_VERSION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() + } catch (error: FastCheckTransportException) { + val interrupted = error.code == FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED failPreparation( request = request, - code = FastCheckDiagnosticCode.BACKEND_PROCESS_INTERRUPTED, - message = "Interrupted while querying the Node.js runtime version", - kind = BackendErrorKind.PROCESS_FAILURE, + code = if (interrupted) { + error.code + } else { + FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE + }, + message = "Cannot query the Node.js runtime version: ${error.message}", + kind = if (interrupted) BackendErrorKind.PROCESS_FAILURE else BackendErrorKind.COVERAGE, cause = error, ) } - if (!completed) { - process.destroyForcibly() + val version = output.stdout.trim() + if (output.exitCode != 0 || version.isBlank()) { failPreparation( request = request, code = FastCheckDiagnosticCode.COVERAGE_RUNTIME_VERSION_UNAVAILABLE, - message = "Timed out while querying the Node.js runtime version", + message = "Cannot query the Node.js runtime version", ) } + + return requireSupportedNodeVersion(version, request) } private fun requireSupportedNodeVersion(version: String, request: FastCheckExecutionRequest): String { @@ -349,6 +335,8 @@ internal class FastCheckCoverageSession private constructor( ) private const val NODE_VERSION_TIMEOUT_MILLIS = 5_000L + private const val VERSION_SHUTDOWN_GRACE_MILLIS = 100L + private const val MAX_VERSION_OUTPUT_BYTES = 1_000 private const val BRANCH_CONVERSION_TIMEOUT_MILLIS = 10_000L private const val BRANCH_SHUTDOWN_GRACE_MILLIS = 500L private const val MAX_BRANCH_REPORT_BYTES = 10_000_000 diff --git a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt index fa2cb7205f..72448b653f 100644 --- a/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt +++ b/usvm-ts-fast-check/src/main/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessTransport.kt @@ -39,6 +39,7 @@ internal class FastCheckProcessTransport( private val maxStdoutBytes: Int, private val maxStderrBytes: Int, private val shutdownGraceMillis: Long, + private val useProcessSupervisor: Boolean = true, ) { init { require(maxRequestBytes > 0) { "Maximum request size must be positive" } @@ -91,7 +92,7 @@ internal class FastCheckProcessTransport( tasks.all.forEach { task -> task.cancel() } terminate(managedProcess, deadlineNanos) closeStreams(managedProcess.process) - runCatching { Files.deleteIfExists(managedProcess.processGroupFile) } + managedProcess.processGroupFile?.let { file -> runCatching { Files.deleteIfExists(file) } } executor.shutdownNow() } } @@ -193,6 +194,16 @@ internal class FastCheckProcessTransport( supervisedCommand: List, description: String, ): SupervisedProcessHandle { + if (!useProcessSupervisor) { + val process = try { + ProcessBuilder(supervisedCommand).start() + } catch (error: IOException) { + processStartFailure(description, error) + } + + return SupervisedProcessHandle(process = process, processGroupFile = null) + } + val processGroupFile = try { Files.createTempFile(PROCESS_GROUP_FILE_PREFIX, ".pid") } catch (error: IOException) { @@ -228,6 +239,13 @@ internal class FastCheckProcessTransport( private fun terminate(managedProcess: SupervisedProcessHandle, deadlineNanos: Long) { val process = managedProcess.process + if (managedProcess.processGroupFile == null) { + process.toHandle().descendants().forEach { child -> child.destroyForcibly() } + if (process.isAlive) process.destroyForcibly() + awaitProcessExit(process, deadlineNanos) + return + } + if (!process.isAlive) { forceTerminateOwnedProcessGroup(managedProcess.processGroupFile, deadlineNanos) return @@ -274,6 +292,15 @@ internal class FastCheckProcessTransport( } private fun awaitProcessExit(process: Process, deadlineNanos: Long): Boolean { + val wasInterrupted = Thread.interrupted() + try { + return pollProcessExit(process, deadlineNanos) + } finally { + if (wasInterrupted) Thread.currentThread().interrupt() + } + } + + private fun pollProcessExit(process: Process, deadlineNanos: Long): Boolean { while (process.isAlive) { val waitMillis = minOf(remainingMillis(deadlineNanos), PROCESS_POLL_MILLIS) if (waitMillis == 0L) return false @@ -318,7 +345,7 @@ internal class FastCheckProcessTransport( private data class SupervisedProcessHandle( val process: Process, - val processGroupFile: Path, + val processGroupFile: Path?, ) private data class ProcessIoTasks( diff --git a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt index 08220b6ff1..5f6b47dce2 100644 --- a/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt +++ b/usvm-ts-fast-check/src/test/kotlin/org/usvm/ts/pbt/fastcheck/FastCheckProcessClientTest.kt @@ -13,6 +13,9 @@ import org.usvm.ts.pbt.testResourcesRoot import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory @@ -26,6 +29,49 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class FastCheckProcessClientTest { + @Test + fun `interrupted coverage version probe terminates its process and preserves interruption`() { + val runtime = createTempDirectory(prefix = "interrupted-version-probe-") + val node = runtime.resolve("fake-node") + val pidFile = runtime.resolve("pid") + val failure = AtomicReference() + val interrupted = AtomicBoolean() + node.writeText("#!/bin/sh\necho ${'$'}${'$'} > '$pidFile'\nexec sleep 30\n") + check(node.toFile().setExecutable(true)) + val worker = thread(name = "interrupted-version-probe") { + try { + FastCheckProcessClient( + nodeExecutable = node.toString(), + adapterEntryPoint = runtime.resolve("unused.js"), + ).check(validRequest.copy(coverageRequest = PropertyCoverageRequest())) + } catch (error: Exception) { + failure.set(error) + interrupted.set(Thread.currentThread().isInterrupted) + } + } + + try { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!processIsAlive(pidFile) && worker.isAlive && System.nanoTime() < deadline) { + Thread.sleep(10) + } + assertTrue(processIsAlive(pidFile), "Version probe did not start") + + worker.interrupt() + worker.join(2_000) + + assertFalse(worker.isAlive, "Interrupted version probe did not finish") + assertEquals("backend.process.interrupted", (failure.get() as PbtBackendException).code) + assertTrue(interrupted.get()) + assertFalse(processIsAlive(pidFile), "Version probe process survived interruption") + } finally { + worker.interrupt() + terminateProcess(pidFile) + worker.join(2_000) + runtime.toFile().deleteRecursively() + } + } + @Test fun `process client rejects a shutdown grace period outside the Node timer range`() { assertFailsWith { diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt index 1722c94681..8f1a2430a1 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageDecoder.kt @@ -14,6 +14,7 @@ import java.nio.file.Path /** Selects relevant report entries and assembles the coverage artifact for one property run. */ internal class IstanbulCoverageDecoder( private val context: IstanbulCoverageContext, + private val checkBudget: () -> Unit = {}, ) { private val sourceRoots = context.sourceRoots.map(::normalizeCoveragePath) private val propertyEntryPoints = context.propertyEntryPointPaths @@ -36,6 +37,7 @@ internal class IstanbulCoverageDecoder( ): List { val decodedFiles = buildList { for ((reportKey, reportEntry) in report) { + checkBudget() val decodedFile = decodeFile(reportKey, reportEntry, diagnostics) if (decodedFile != null) { add(decodedFile) @@ -71,6 +73,7 @@ internal class IstanbulCoverageDecoder( file = fileObject, path = path, reportKey = reportKey, + checkBudget = checkBudget, ) return decoder.decode() diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt index 700602872e..5721d09fcd 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReport.kt @@ -42,13 +42,18 @@ class CoverageArtifactException( } } -/** Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run. */ +/** + * Decodes and filters one source-mapped Istanbul JSON report produced by an isolated c8 run. + * [checkBudget] can cancel between I/O, parsing and entry decoding; the bounded JSON parse itself is not preemptible. + */ fun decodeIstanbulCoverageReport( reportPath: Path, context: IstanbulCoverageContext, + checkBudget: () -> Unit = {}, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, ): PropertyCoverageArtifact { - val report = IstanbulCoverageReportReader.read(reportPath) - val decoder = IstanbulCoverageDecoder(context) + val report = IstanbulCoverageReportReader.read(reportPath, checkBudget, maxReportBytes) + val decoder = IstanbulCoverageDecoder(context, checkBudget) return decoder.decode(report) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt index a43b97bb9b..2de6f40ece 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportReader.kt @@ -10,13 +10,20 @@ import java.nio.file.Path /** Reads one bounded Istanbul report and converts I/O and JSON failures into coverage diagnostics. */ internal object IstanbulCoverageReportReader { - fun read(reportPath: Path): JsonObject { + fun read( + reportPath: Path, + checkBudget: () -> Unit = {}, + maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, + ): JsonObject { + require(maxReportBytes > 0) + checkBudget() requireRegularFile(reportPath) - requireAllowedSize(reportPath) + requireAllowedSize(reportPath, maxReportBytes) val reportText = readText(reportPath) + checkBudget() - return parse(reportText, reportPath) + return parse(reportText, reportPath).also { checkBudget() } } private fun requireRegularFile(reportPath: Path) { @@ -29,17 +36,17 @@ internal object IstanbulCoverageReportReader { } } - private fun requireAllowedSize(reportPath: Path) { + private fun requireAllowedSize(reportPath: Path, maxReportBytes: Long) { val reportSize = try { Files.size(reportPath) } catch (error: IOException) { throw unreadableReport(reportPath, error) } - if (reportSize > MAX_COVERAGE_REPORT_BYTES) { + if (reportSize > maxReportBytes) { throw CoverageArtifactException.create( code = PbtDiagnosticCode.COVERAGE_REPORT_INVALID, - message = "Istanbul coverage report exceeds $MAX_COVERAGE_REPORT_BYTES bytes", + message = "Istanbul coverage report exceeds $maxReportBytes bytes", path = reportPath.toString(), ) } diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt index ec03fc3176..4dab7dd44e 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/IstanbulSourceFileDecoder.kt @@ -14,6 +14,7 @@ internal class IstanbulSourceFileDecoder( file: JsonObject, private val path: String, reportKey: String, + private val checkBudget: () -> Unit = {}, ) { private val coveragePath = "coverage[$reportKey]" private val fileJson = IstanbulJsonValue(file, coveragePath) @@ -47,6 +48,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { statementId -> + checkBudget() val location = coverageMap.location(statementId).asRange() val hitCount = coverageMap.hitCount(statementId).asHitCount() @@ -65,6 +67,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { functionId -> + checkBudget() decodeFunction( functionId = functionId, coverageMap = coverageMap, @@ -100,6 +103,7 @@ internal class IstanbulSourceFileDecoder( ) return coverageMap.ids.map { branchId -> + checkBudget() decodeBranch( branchId = branchId, coverageMap = coverageMap, @@ -118,6 +122,7 @@ internal class IstanbulSourceFileDecoder( val type = branchJson.requiredString(name = "type") val location = branchJson.required(name = "loc").asRange() val arms = branchArms.locations.indices.map { armIndex -> + checkBudget() BranchArmCoverage( location = branchArms.location(armIndex).asRange(), hits = branchArms.hitCount(armIndex).asHitCount(), @@ -171,7 +176,10 @@ internal class IstanbulSourceFileDecoder( ) } - val ids = locations.keys.map(::parseCoverageId).sorted() + val ids = locations.keys.map { id -> + checkBudget() + parseCoverageId(id) + }.sorted() return CoverageMap( ids = ids, diff --git a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt index 7ceef0c0a0..2d2c3e6371 100644 --- a/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt +++ b/usvm-ts-pbt/src/main/kotlin/org/usvm/ts/pbt/coverage/RawV8SourceMapInspector.kt @@ -23,6 +23,7 @@ fun inspectRawV8SourceMapDiagnostics( sourceRoots: List, maxReportFiles: Int = MAX_RAW_V8_REPORT_FILES, maxReportBytes: Long = MAX_COVERAGE_REPORT_BYTES, + checkBudget: () -> Unit = {}, ): List { require(maxReportFiles > 0) { "Raw V8 report file limit must be positive" } require(maxReportBytes in 1 until Int.MAX_VALUE.toLong()) { @@ -31,14 +32,20 @@ fun inspectRawV8SourceMapDiagnostics( val reports = listRawReports(rawDirectory, maxReportFiles) val roots = sourceRoots.map { sourceRoot -> Path.of(sourceRoot).toAbsolutePath().normalize() } - val reader = RawV8ReportReader(maxReportBytes = maxReportBytes) - val diagnostics = reports.flatMap { report -> inspectRawReport(report, roots, reader) } + val reader = RawV8ReportReader(maxReportBytes = maxReportBytes, checkBudget = checkBudget) + val diagnostics = reports.flatMap { report -> + checkBudget() + inspectRawReport(report, roots, reader, checkBudget).also { checkBudget() } + } return coalesceSourceMapDiagnostics(diagnostics) } /** Reads several reports under one aggregate byte budget. */ -internal class RawV8ReportReader(maxReportBytes: Long) { +internal class RawV8ReportReader( + maxReportBytes: Long, + private val checkBudget: () -> Unit = {}, +) { private var remainingBytes = maxReportBytes init { @@ -48,6 +55,7 @@ internal class RawV8ReportReader(maxReportBytes: Long) { } fun readText(reportPath: Path): String { + checkBudget() // One byte past the remaining budget detects growth without a separate size preflight or manual read loop. val readLimit = remainingBytes.toInt() + LIMIT_OVERFLOW_SENTINEL_BYTES val bytes = try { @@ -68,8 +76,9 @@ internal class RawV8ReportReader(maxReportBytes: Long) { ) } remainingBytes -= bytes.size + checkBudget() - return decodeUtf8(bytes, reportPath) + return decodeUtf8(bytes, reportPath).also { checkBudget() } } } @@ -132,7 +141,9 @@ private fun inspectRawReport( reportPath: Path, sourceRoots: List, reader: RawV8ReportReader, -): List = readUnappliedSourceMaps(reportPath, reader).mapNotNull { sourceMap -> + checkBudget: () -> Unit, +): List = readUnappliedSourceMaps(reportPath, reader, checkBudget).mapNotNull { sourceMap -> + checkBudget() sourceMap.inspect(sourceRoots) } @@ -140,8 +151,13 @@ private fun inspectRawReport( * Extracts only cache entries for which c8 saw a source-map URL but could not load its data. * Entries with object-valued `data` are already usable by c8 and need no diagnostic. */ -private fun readUnappliedSourceMaps(reportPath: Path, reader: RawV8ReportReader): List { +private fun readUnappliedSourceMaps( + reportPath: Path, + reader: RawV8ReportReader, + checkBudget: () -> Unit, +): List { val report = parseRawReport(reader.readText(reportPath), reportPath) + checkBudget() val sourceMapCache = report["source-map-cache"] ?: return emptyList() if (sourceMapCache !is JsonObject) { failInvalidRawReport( @@ -151,6 +167,7 @@ private fun readUnappliedSourceMaps(reportPath: Path, reader: RawV8ReportReader) } return sourceMapCache.mapNotNull { (scriptUrl, entry) -> + checkBudget() parseUnappliedSourceMap(reportPath, scriptUrl, entry) } } diff --git a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt index 81e2ade2be..0ccfe14bdc 100644 --- a/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt +++ b/usvm-ts-pbt/src/test/kotlin/org/usvm/ts/pbt/coverage/IstanbulCoverageReportTest.kt @@ -1,5 +1,6 @@ package org.usvm.ts.pbt.coverage +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Test import org.usvm.ts.pbt.backend.CoverageCollectorIdentity import org.usvm.ts.pbt.backend.CoverageScope @@ -9,6 +10,7 @@ import java.nio.ByteBuffer import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardOpenOption +import java.util.concurrent.TimeUnit import kotlin.io.path.absolute import kotlin.io.path.createTempDirectory import kotlin.io.path.createTempFile @@ -16,6 +18,7 @@ import kotlin.io.path.deleteIfExists import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class IstanbulCoverageReportTest { @Test @@ -138,6 +141,67 @@ class IstanbulCoverageReportTest { } } + @Test + fun `dense legal report near the strict limit observes an expired deadline`() { + val report = createTempFile(suffix = ".json") + val statements = 30_000 + + try { + val statementMap = (0 until statements).joinToString(separator = ",") { id -> + "\"$id\":{\"start\":{\"line\":1,\"column\":0},\"end\":{\"line\":1,\"column\":1}}" + } + val hits = (0 until statements).joinToString(separator = ",") { id -> "\"$id\":1" } + val prefix = "{\"/workspace/src/math.ts\":{\"path\":\"/workspace/src/math.ts\"," + + "\"statementMap\":{$statementMap},\"fnMap\":{},\"branchMap\":{}," + + "\"s\":{$hits},\"f\":{},\"b\":{}}}" + val padding = (STRICT_REPORT_LIMIT_BYTES - prefix.toByteArray().size - 1).toInt() + assertTrue(padding > 0) + report.writeText(prefix + " ".repeat(padding)) + assertEquals(STRICT_REPORT_LIMIT_BYTES - 1, Files.size(report)) + + val startedAt = System.nanoTime() + var deadline = 0L + var checkpoints = 0 + assertFailsWith { + decodeIstanbulCoverageReport( + reportPath = report, + context = context(), + maxReportBytes = STRICT_REPORT_LIMIT_BYTES, + checkBudget = { + if (deadline == 0L) deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(5) + checkpoints++ + if (System.nanoTime() >= deadline) throw TestDeadlineExpired() + }, + ) + } + val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + assertTrue(checkpoints >= 2) + assertTrue(elapsedMillis >= 5) + } finally { + report.deleteIfExists() + } + } + + @Test + fun `Istanbul entry iteration checks the deadline within one retained file`() { + val report = IstanbulCoverageReportReader.read(goldenReport()) + val file = report.getValue("/workspace/src/math.ts").jsonObject + var checkpoints = 0 + val decoder = IstanbulSourceFileDecoder( + file = file, + path = "/workspace/src/math.ts", + reportKey = "/workspace/src/math.ts", + checkBudget = { + checkpoints++ + if (checkpoints == 2) throw TestDeadlineExpired() + }, + ) + + assertFailsWith { decoder.decode() } + assertEquals(2, checkpoints) + } + @Test fun `generated JavaScript with an unusable map reports an invalid source map`() { val directory = createTempDirectory("invalid-source-map-") @@ -202,5 +266,8 @@ class IstanbulCoverageReportTest { private companion object { const val TEST_COVERAGE_REPORT_LIMIT_BYTES = 64L * 1024 * 1024 + const val STRICT_REPORT_LIMIT_BYTES = 4L * 1024 * 1024 } + + private class TestDeadlineExpired : RuntimeException() } From b1c0c7dffe8c515b550b48c9c8e44500cc0ec88b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 1 Oct 2026 15:08:48 +0300 Subject: [PATCH 3/3] [TS] Apply frontend cleanup and retain modeled thrown Error types --- .../org/usvm/ts/calls/CallsSourceProject.kt | 22 +++++- .../usvm/ts/calls/CallsSourceProjectTest.kt | 67 +++++++++++++++++++ .../usvm/census/UnknownCallCensusRunner.kt | 1 + .../machine/call/TsEtsIrUnknownCallModel.kt | 1 + .../usvm/machine/interpreter/TsInterpreter.kt | 11 +-- .../machine/call/TsErrorEtsIrModelTest.kt | 14 +++- .../src/test/resources/models/ErrorEtsIr.ts | 4 ++ 7 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt diff --git a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt index 9f110f8c81..c5f6bce4b2 100644 --- a/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt +++ b/usvm-ts-calls/src/main/kotlin/org/usvm/ts/calls/CallsSourceProject.kt @@ -1,10 +1,12 @@ package org.usvm.ts.calls +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsFile import org.jacodb.ets.model.EtsFileSignature import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.generateEtsIR -import org.jacodb.ets.utils.loadEtsFileAutoConvert import org.jacodb.ets.utils.loadEtsProjectFromIR import org.usvm.ts.pbt.fastcheck.TypeScriptSourceInspector import java.nio.file.Files @@ -35,7 +37,7 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource val entryModule = sourceRoot.relativize(source).toString() require(entryModule in closure.files) { "Dependency closure does not contain its entry source" } if (closure.files.size == 1) { - val entry = loadEtsFileAutoConvert(source, provider = EtsIrProvider.TS_FRONTEND) + val entry = loadSingleCallsSource(source) return CallsSourceProject.Loaded( scene = EtsScene(projectFiles = listOf(entry)), entryModule = source.fileName.toString(), @@ -58,6 +60,7 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource Files.copy(sourceRoot.resolve(relative), copy) } val generatedIr = generateEtsIR( + keepPartialOutputOnFailure = false, projectPath = temporaryProject, isProject = true, loadEntrypoints = false, @@ -87,3 +90,18 @@ internal fun loadCallsSourceProject(sourceRoot: Path, source: Path): CallsSource temporaryProject.toFile().deleteRecursively() } } + +private fun loadSingleCallsSource(source: Path): EtsFile { + val generatedIr = generateEtsIR( + keepPartialOutputOnFailure = false, + projectPath = source, + useArkAnalyzerTypeInference = 1, + provider = EtsIrProvider.TS_FRONTEND, + ) + + return try { + Files.newInputStream(generatedIr).use { stream -> EtsFileDto.loadFromJson(stream).toEtsFile() } + } finally { + Files.deleteIfExists(generatedIr) + } +} diff --git a/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt new file mode 100644 index 0000000000..4aa94ff589 --- /dev/null +++ b/usvm-ts-calls/src/test/kotlin/org/usvm/ts/calls/CallsSourceProjectTest.kt @@ -0,0 +1,67 @@ +package org.usvm.ts.calls + +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertFails +import kotlin.test.assertFalse + +class CallsSourceProjectTest { + @Test + fun `single and multi file conversion failures remove partial IR`() { + checkCleanup(frontendExitCode = 1) + } + + @Test + fun `single and multi file IR decoding failures remove generated output`() { + checkCleanup(frontendExitCode = 0) + } + + private fun checkCleanup(frontendExitCode: Int) { + val root = createTempDirectory(prefix = "calls-frontend-cleanup-") + val frontend = root.resolve("frontend") + val script = frontend.resolve("dist/index.js") + val recordedPath = script.resolveSibling("generated-path") + val sourceRoot = root.resolve("sources").createDirectories() + val source = sourceRoot.resolve("entry.ts") + val previousFrontend = System.getProperty("ets.frontend.dir") + script.parent.createDirectories() + script.writeText( + """ + const fs = require('node:fs'); + const path = require('node:path'); + const output = process.argv.filter(arg => arg !== '-v').at(-1); + fs.writeFileSync(path.join(__dirname, 'generated-path'), output); + const file = fs.statSync(output).isDirectory() ? path.join(output, 'partial.json') : output; + fs.writeFileSync(file, '{'); + process.exit($frontendExitCode); + """.trimIndent(), + ) + System.setProperty("ets.frontend.dir", frontend.toString()) + + try { + source.writeText("export function entry(value: number): number { return value; }") + assertFails { loadCallsSourceProject(sourceRoot = sourceRoot, source = source) } + assertFalse(Files.exists(Path.of(recordedPath.readText()))) + + sourceRoot.resolve("helper.ts").writeText("export function helper(value: number): number { return value; }") + source.writeText( + "import { helper } from './helper'; " + + "export function entry(value: number): number { return helper(value); }", + ) + assertFails { loadCallsSourceProject(sourceRoot = sourceRoot, source = source) } + assertFalse(Files.exists(Path.of(recordedPath.readText()))) + } finally { + if (previousFrontend == null) { + System.clearProperty("ets.frontend.dir") + } else { + System.setProperty("ets.frontend.dir", previousFrontend) + } + root.toFile().deleteRecursively() + } + } +} diff --git a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt index 4666162d04..970a67e3aa 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/census/UnknownCallCensusRunner.kt @@ -325,6 +325,7 @@ internal class UnknownCallCensusRunner( ) val generatedIr = try { generateEtsIR( + keepPartialOutputOnFailure = false, projectPath = sourceRoot, isProject = true, loadEntrypoints = false, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt index a55c40d2e2..c8214ec174 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/call/TsEtsIrUnknownCallModel.kt @@ -53,6 +53,7 @@ fun loadEtsIrUnknownCallModelArtifact( entryPointMethodName: String, ): TsEtsIrUnknownCallModelArtifact { val irPath = generateEtsIR( + keepPartialOutputOnFailure = false, projectPath = sourcePath, isProject = false, loadEntrypoints = true, diff --git a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt index bb62cd3840..323723628f 100644 --- a/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt +++ b/usvm-ts/src/main/kotlin/org/usvm/machine/interpreter/TsInterpreter.kt @@ -33,6 +33,7 @@ import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME import org.jacodb.ets.utils.callExpr import org.usvm.StepResult import org.usvm.StepScope +import org.usvm.UConcreteHeapRef import org.usvm.UExpr import org.usvm.UInterpreter import org.usvm.USort @@ -60,6 +61,7 @@ import org.usvm.machine.expr.handleAssignToArrayIndex import org.usvm.machine.expr.handleAssignToInstanceField import org.usvm.machine.expr.handleAssignToLocal import org.usvm.machine.expr.handleAssignToStaticField +import org.usvm.machine.expr.isModeledErrorType import org.usvm.machine.expr.mkTruthyExpr import org.usvm.machine.expr.readGlobal import org.usvm.machine.expr.tryApproximateInstanceCall @@ -79,6 +81,7 @@ import org.usvm.targets.UTargetsSet import org.usvm.types.TypesResult import org.usvm.types.first import org.usvm.types.single +import org.usvm.types.singleOrNull import org.usvm.util.executableOverloadImplementation import org.usvm.util.mkArrayLengthLValue import org.usvm.util.mkFieldLValue @@ -663,10 +666,10 @@ class TsInterpreter( if (exception != null) { val exceptionType: EtsType = when (exception.sort) { ctx.addressSort -> { - // If it's an object reference, try to determine its type - val ref = exception.asExpr(ctx.addressSort) - // For now, assume it's a generic error type - EtsStringType // TODO: improve type detection + val ref = exception as? UConcreteHeapRef + val runtimeType = ref?.let { scope.calcOnState { memory.typeStreamOf(it).singleOrNull() } } + runtimeType?.takeIf { it.isModeledErrorType() } + ?: EtsStringType // TODO: improve type detection for other object exceptions } ctx.fp64Sort -> EtsNumberType diff --git a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt index 70a33fa077..e06c301de4 100644 --- a/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt +++ b/usvm-ts/src/test/kotlin/org/usvm/machine/call/TsErrorEtsIrModelTest.kt @@ -1,5 +1,7 @@ package org.usvm.machine.call +import org.jacodb.ets.model.EtsClassSignature +import org.jacodb.ets.model.EtsClassType import org.jacodb.ets.model.EtsMethod import org.jacodb.ets.model.EtsScene import org.jacodb.ets.utils.EtsIrProvider @@ -58,11 +60,21 @@ class TsErrorEtsIrModelTest { val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "throwError") assertEquals(1, result.states.size) - assertIs(result.states.single().methodResult) + val exception = assertIs(result.states.single().methodResult) + assertEquals(EtsClassType(EtsClassSignature.UNKNOWN.copy(name = "Error")), exception.type) assertEquals(listOf(TsErrorEtsIrModelFamily.CONSTRUCTOR_ID), result.modelIds) assertTrue(result.values.single() is TsTestValue.TsException) } + @Test + fun `modeled TypeError retains its type when thrown`() { + val result = analyze(scene = builtInScene, className = "ErrorEtsIr", methodName = "throwTypeError") + + val exception = assertIs(result.states.single().methodResult) + assertEquals(EtsClassType(EtsClassSignature.UNKNOWN.copy(name = "TypeError")), exception.type) + assertEquals(listOf("ts.typeError.constructor"), result.modelIds) + } + @Test fun `Error model storage does not affect ordinary any field resolution`() { listOf("anyForeignNameComparison", "anyForeignMessageComparison").forEach { methodName -> diff --git a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts index 74c2ee6d86..343cb319d1 100644 --- a/usvm-ts/src/test/resources/models/ErrorEtsIr.ts +++ b/usvm-ts/src/test/resources/models/ErrorEtsIr.ts @@ -16,6 +16,10 @@ export class ErrorEtsIr { throw new Error("expected message"); } + static throwTypeError(): number { + throw new TypeError("expected type error"); + } + static overwrittenName(): string { const error = new Error("expected message"); error.name = "CustomError";