@platformatic/wattpm-pprof-capture 3.27.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -229,7 +229,7 @@ function startIfOverThreshold (type, state, wasRunning = state.profilerStarted)
229
229
  }
230
230
  }
231
231
 
232
- async function initializeSourceMapper () {
232
+ async function initializeSourceMapper (options = {}) {
233
233
  if (sourceMapperInitialized) {
234
234
  return
235
235
  }
@@ -251,6 +251,19 @@ async function initializeSourceMapper () {
251
251
  const debug = process.env.PLT_PPROF_SOURCEMAP_DEBUG === 'true'
252
252
  const innerMapper = await SourceMapper.create([appPath], debug)
253
253
 
254
+ // Load additional node_modules sourcemaps if specified
255
+ if (options.nodeModulesSourceMaps?.length > 0) {
256
+ const { loadNodeModulesSourceMaps } = await import('./lib/node-modules-sourcemaps.js')
257
+ const moduleEntries = await loadNodeModulesSourceMaps(
258
+ appPath,
259
+ options.nodeModulesSourceMaps,
260
+ debug
261
+ )
262
+ for (const [generatedPath, info] of moduleEntries) {
263
+ innerMapper.infoMap.set(generatedPath, info)
264
+ }
265
+ }
266
+
254
267
  // Wrap the SourceMapper to fix Windows path normalization
255
268
  sourceMapper = new SourceMapperWrapper(innerMapper)
256
269
 
@@ -280,9 +293,16 @@ export async function startProfiling (options = {}) {
280
293
  }
281
294
 
282
295
  // Initialize source mapper if source maps are requested
283
- state.sourceMapsEnabled = options.sourceMaps === true
296
+ if (options.sourceMaps === undefined) {
297
+ state.sourceMapsEnabled = process.sourceMapsEnabled
298
+ } else if (options.sourceMaps === true) {
299
+ state.sourceMapsEnabled = true
300
+ }
301
+
284
302
  if (state.sourceMapsEnabled) {
285
- await initializeSourceMapper()
303
+ await initializeSourceMapper({
304
+ nodeModulesSourceMaps: options.nodeModulesSourceMaps
305
+ })
286
306
  }
287
307
 
288
308
  state.isCapturing = true
@@ -0,0 +1,231 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import * as sourceMap from 'source-map'
5
+
6
+ const MAP_EXT = '.map'
7
+ const MAP_FILE_PATTERN = /\.[cm]?js\.map$/
8
+
9
+ /**
10
+ * Check if an error is a non-fatal filesystem error that should be silently ignored.
11
+ */
12
+ function isNonFatalError (error) {
13
+ const nonFatalErrors = ['ENOENT', 'EPERM', 'EACCES', 'ELOOP']
14
+ return error instanceof Error && error.code && nonFatalErrors.includes(error.code)
15
+ }
16
+
17
+ /**
18
+ * Async generator that recursively walks a directory looking for .map files.
19
+ * Similar to @datadog/pprof's walk function but without the node_modules exclusion.
20
+ */
21
+ async function * walkForMapFiles (dir) {
22
+ async function * walkRecursive (currentDir) {
23
+ try {
24
+ const dirHandle = await fs.opendir(currentDir)
25
+ for await (const entry of dirHandle) {
26
+ const entryPath = path.join(currentDir, entry.name)
27
+ if (entry.isDirectory()) {
28
+ // Skip .git directories but NOT node_modules (unlike @datadog/pprof)
29
+ if (entry.name !== '.git') {
30
+ yield * walkRecursive(entryPath)
31
+ }
32
+ } else if (entry.isFile() && MAP_FILE_PATTERN.test(entry.name)) {
33
+ // Verify the file is readable
34
+ try {
35
+ await fs.access(entryPath, fs.constants.R_OK)
36
+ yield entryPath
37
+ } catch {
38
+ // Skip unreadable files
39
+ }
40
+ }
41
+ }
42
+ } catch (error) {
43
+ if (!isNonFatalError(error)) {
44
+ throw error
45
+ }
46
+ // Silently ignore non-fatal errors (ENOENT, EPERM, etc.)
47
+ }
48
+ }
49
+ yield * walkRecursive(dir)
50
+ }
51
+
52
+ /**
53
+ * Process a single .map file and return the entry for the infoMap.
54
+ * Returns { generatedPath, info } or null if processing fails.
55
+ */
56
+ async function processSourceMapFile (mapPath, debug, logger) {
57
+ if (!mapPath || !mapPath.endsWith(MAP_EXT)) {
58
+ return null
59
+ }
60
+
61
+ mapPath = path.normalize(mapPath)
62
+
63
+ let contents
64
+ try {
65
+ contents = await fs.readFile(mapPath, 'utf8')
66
+ } catch (error) {
67
+ if (debug && logger) {
68
+ logger.debug({ mapPath, error: error.message }, 'Could not read source map file')
69
+ }
70
+ return null
71
+ }
72
+
73
+ let consumer
74
+ try {
75
+ consumer = await new sourceMap.SourceMapConsumer(contents)
76
+ } catch (error) {
77
+ if (debug && logger) {
78
+ logger.debug({ mapPath, error: error.message }, 'Could not parse source map file')
79
+ }
80
+ return null
81
+ }
82
+
83
+ // Determine the generated file path
84
+ // Same logic as @datadog/pprof: try consumer.file first, then basename without .map
85
+ const dir = path.dirname(mapPath)
86
+ const generatedPathCandidates = []
87
+
88
+ if (consumer.file) {
89
+ generatedPathCandidates.push(path.resolve(dir, consumer.file))
90
+ }
91
+
92
+ const samePath = path.resolve(dir, path.basename(mapPath, MAP_EXT))
93
+ if (generatedPathCandidates.length === 0 || generatedPathCandidates[0] !== samePath) {
94
+ generatedPathCandidates.push(samePath)
95
+ }
96
+
97
+ // Find the first candidate that exists
98
+ for (const generatedPath of generatedPathCandidates) {
99
+ try {
100
+ await fs.access(generatedPath, fs.constants.F_OK)
101
+ if (debug && logger) {
102
+ logger.debug({ generatedPath, mapPath }, 'Loaded source map for node_modules file')
103
+ }
104
+ return {
105
+ generatedPath,
106
+ info: {
107
+ mapFileDir: dir,
108
+ mapConsumer: consumer
109
+ }
110
+ }
111
+ } catch {
112
+ if (debug && logger) {
113
+ logger.debug({ generatedPath }, 'Generated path does not exist')
114
+ }
115
+ }
116
+ }
117
+
118
+ if (debug && logger) {
119
+ logger.debug({ mapPath }, 'Unable to find generated file for source map')
120
+ }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * Resolve a module path from the application directory.
126
+ * Handles both regular and scoped packages (e.g., 'next' and '@next/next-server').
127
+ */
128
+ function resolveModulePath (appPath, moduleName) {
129
+ // Try using require.resolve from the app's context
130
+ try {
131
+ const require = createRequire(path.join(appPath, 'package.json'))
132
+ const modulePath = require.resolve(moduleName)
133
+ // Get the module's root directory
134
+ // For regular packages: node_modules/next/dist/file.js -> node_modules/next
135
+ // For scoped packages: node_modules/@next/next-server/dist/file.js -> node_modules/@next/next-server
136
+ const nodeModulesIndex = modulePath.lastIndexOf('node_modules')
137
+ if (nodeModulesIndex === -1) {
138
+ return null
139
+ }
140
+
141
+ const afterNodeModules = modulePath.substring(nodeModulesIndex + 'node_modules'.length + 1)
142
+ let moduleRoot
143
+ if (moduleName.startsWith('@')) {
144
+ // Scoped package: @scope/package
145
+ const parts = afterNodeModules.split(path.sep)
146
+ moduleRoot = path.join(modulePath.substring(0, nodeModulesIndex), 'node_modules', parts[0], parts[1])
147
+ } else {
148
+ // Regular package
149
+ const parts = afterNodeModules.split(path.sep)
150
+ moduleRoot = path.join(modulePath.substring(0, nodeModulesIndex), 'node_modules', parts[0])
151
+ }
152
+
153
+ return moduleRoot
154
+ } catch {
155
+ // Module not found, try walking up from appPath
156
+ }
157
+
158
+ // Fallback: walk up directory tree looking for node_modules
159
+ let currentDir = appPath
160
+ while (currentDir !== path.dirname(currentDir)) {
161
+ const modulePath = path.join(currentDir, 'node_modules', moduleName)
162
+ try {
163
+ // Check if the module directory exists synchronously (we're in a sync context here)
164
+ const stat = require('node:fs').statSync(modulePath)
165
+ if (stat.isDirectory()) {
166
+ return modulePath
167
+ }
168
+ } catch {
169
+ // Not found, continue up
170
+ }
171
+ currentDir = path.dirname(currentDir)
172
+ }
173
+
174
+ return null
175
+ }
176
+
177
+ /**
178
+ * Load source maps from specified node_modules packages.
179
+ *
180
+ * @param {string} appPath - The application root directory
181
+ * @param {string[]} moduleNames - Array of module names to load sourcemaps from (e.g., ['next', '@next/next-server'])
182
+ * @param {boolean} debug - Whether to enable debug logging
183
+ * @returns {Promise<Map<string, {mapFileDir: string, mapConsumer: SourceMapConsumer}>>}
184
+ */
185
+ export async function loadNodeModulesSourceMaps (appPath, moduleNames, debug = false) {
186
+ const entries = new Map()
187
+ const logger = globalThis.platformatic?.logger
188
+
189
+ if (debug && logger) {
190
+ logger.debug({ appPath, moduleNames }, 'Loading source maps from node_modules')
191
+ }
192
+
193
+ for (const moduleName of moduleNames) {
194
+ const modulePath = resolveModulePath(appPath, moduleName)
195
+ if (!modulePath) {
196
+ if (logger) {
197
+ logger.warn({ moduleName }, 'Could not resolve module path for sourcemap loading')
198
+ }
199
+ continue
200
+ }
201
+
202
+ if (debug && logger) {
203
+ logger.debug({ moduleName, modulePath }, 'Scanning module for source maps')
204
+ }
205
+
206
+ let mapCount = 0
207
+ try {
208
+ for await (const mapFile of walkForMapFiles(modulePath)) {
209
+ const entry = await processSourceMapFile(mapFile, debug, logger)
210
+ if (entry) {
211
+ entries.set(entry.generatedPath, entry.info)
212
+ mapCount++
213
+ }
214
+ }
215
+ } catch (error) {
216
+ if (logger) {
217
+ logger.warn({ moduleName, error: error.message }, 'Error scanning module for source maps')
218
+ }
219
+ }
220
+
221
+ if (debug && logger) {
222
+ logger.debug({ moduleName, mapCount }, 'Finished scanning module for source maps')
223
+ }
224
+ }
225
+
226
+ if (debug && logger) {
227
+ logger.debug({ totalMaps: entries.size }, 'Finished loading node_modules source maps')
228
+ }
229
+
230
+ return entries
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/wattpm-pprof-capture",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
4
4
  "description": "pprof profiling capture for wattpm",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "neostandard": "^0.12.0",
27
27
  "pprof-format": "^2.1.0",
28
28
  "typescript": "^5.0.0",
29
- "@platformatic/foundation": "3.27.0",
30
- "@platformatic/service": "3.27.0"
29
+ "@platformatic/foundation": "3.28.0",
30
+ "@platformatic/service": "3.28.0"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=22.19.0"
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://schemas.platformatic.dev/@platformatic/runtime/2.0.0.json",
3
+ "entrypoint": "service",
4
+ "preload": "../../../index.js",
5
+ "autoload": {
6
+ "path": "./"
7
+ },
8
+ "server": {
9
+ "hostname": "127.0.0.1",
10
+ "port": 0
11
+ },
12
+ "logger": {
13
+ "level": "error"
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://schemas.platformatic.dev/@platformatic/service/2.0.0.json",
3
+ "server": {
4
+ "logger": {
5
+ "level": "warn"
6
+ }
7
+ },
8
+ "plugins": {
9
+ "paths": [{
10
+ "path": "plugin.js",
11
+ "encapsulate": false
12
+ }]
13
+ }
14
+ }
@@ -0,0 +1,17 @@
1
+ // Simple plugin that can trigger CPU-intensive work
2
+ // The test uses this to generate profiles while testing nodeModulesSourceMaps option
3
+
4
+ export default async function (app) {
5
+ app.get('/', async () => {
6
+ return { message: 'Hello from Node Modules Sourcemap Test' }
7
+ })
8
+
9
+ app.get('/compute', async () => {
10
+ // Do some CPU-intensive work to generate profiling samples
11
+ let result = 0
12
+ for (let i = 0; i < 1000000; i++) {
13
+ result += Math.sqrt(i)
14
+ }
15
+ return { result }
16
+ })
17
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://schemas.platformatic.dev/@platformatic/runtime/3.20.0.json",
3
+ "entrypoint": "service",
4
+ "preload": "../../../index.js",
5
+ "autoload": {
6
+ "path": "./"
7
+ },
8
+ "server": {
9
+ "hostname": "127.0.0.1",
10
+ "port": 0
11
+ },
12
+ "logger": {
13
+ "level": "error"
14
+ },
15
+ "sourceMaps": true
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://schemas.platformatic.dev/@platformatic/service/2.0.0.json",
3
+ "server": {
4
+ "logger": {
5
+ "level": "warn"
6
+ }
7
+ },
8
+ "plugins": {
9
+ "paths": [{
10
+ "path": "dist/plugin.js",
11
+ "encapsulate": false
12
+ }]
13
+ }
14
+ }
@@ -0,0 +1,71 @@
1
+ import { type FastifyInstance } from 'fastify'
2
+
3
+ // A function with a distinctive name that we can find in the profile
4
+ // This function allocates significant memory to ensure heap profiler captures samples
5
+ function myTypeScriptFunction(value: number): number {
6
+ let result = 0
7
+ const allocations: Array<{ data: number[], metadata: object }> = []
8
+
9
+ // Allocate memory in chunks larger than heap profiler sampling interval (512KB)
10
+ // Each iteration allocates ~1MB to ensure we trigger heap profiler sampling
11
+ // Reduced to 3 iterations to make test faster while still generating enough samples
12
+ for (let i = 0; i < 3; i++) {
13
+ // Allocate a large array (~800KB of numbers)
14
+ const largeArray = new Array(100000)
15
+ for (let j = 0; j < largeArray.length; j++) {
16
+ largeArray[j] = Math.sqrt(j * value)
17
+ result += largeArray[j]
18
+ }
19
+
20
+ // Allocate objects with metadata
21
+ const metadata = {
22
+ index: i,
23
+ timestamp: Date.now(),
24
+ value,
25
+ description: 'Memory allocation for heap profiling test'.repeat(10)
26
+ }
27
+
28
+ allocations.push({ data: largeArray, metadata })
29
+ }
30
+
31
+ // Keep allocations alive until we're done computing
32
+ return result + allocations.length
33
+ }
34
+
35
+ export default async function (app: FastifyInstance) {
36
+ app.get('/', async () => {
37
+ return { message: 'Hello from TypeScript' }
38
+ })
39
+
40
+ app.get('/compute', async () => {
41
+ const result = myTypeScriptFunction(42)
42
+ return { result }
43
+ })
44
+
45
+ app.get('/diagnostic', async () => {
46
+ const fs = await import('node:fs/promises')
47
+ const path = await import('node:path')
48
+ const url = await import('node:url')
49
+ const serviceDir = path.dirname(url.fileURLToPath(import.meta.url))
50
+ const pluginJsPath = path.join(serviceDir, 'plugin.js')
51
+ const pluginMapPath = path.join(serviceDir, 'plugin.js.map')
52
+
53
+ const diagnostics = {
54
+ serviceDir,
55
+ pluginJsExists: false,
56
+ pluginMapExists: false
57
+ }
58
+
59
+ try {
60
+ await fs.access(pluginJsPath)
61
+ diagnostics.pluginJsExists = true
62
+ } catch {}
63
+
64
+ try {
65
+ await fs.access(pluginMapPath)
66
+ diagnostics.pluginMapExists = true
67
+ } catch {}
68
+
69
+ return diagnostics
70
+ })
71
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./",
9
+ "sourceMap": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "strict": false
14
+ },
15
+ "include": ["*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
@@ -3,8 +3,10 @@ import { Worker } from 'node:worker_threads'
3
3
  import { resolve } from 'node:path'
4
4
  import { exec } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
+ import { platform } from 'node:os'
6
7
 
7
8
  const execAsync = promisify(exec)
9
+ const isWindows = platform() === 'win32'
8
10
 
9
11
  test.before(async () => {
10
12
  const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
@@ -16,7 +18,7 @@ test.before(async () => {
16
18
  })
17
19
 
18
20
  // Test if SourceMapper works in a plain worker thread on Windows
19
- test('minimal SourceMapper test in worker thread', async (t) => {
21
+ test('minimal SourceMapper test in worker thread', { skip: isWindows }, async (t) => {
20
22
  await new Promise((resolve, reject) => {
21
23
  const worker = new Worker(new URL('./fixtures/minimal-sourcemap-worker.js', import.meta.url))
22
24
 
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert'
2
+ import { resolve } from 'node:path'
3
+ import test from 'node:test'
4
+ import { loadNodeModulesSourceMaps } from '../lib/node-modules-sourcemaps.js'
5
+
6
+ // Unit test for loadNodeModulesSourceMaps
7
+ test('loadNodeModulesSourceMaps should load source maps from next package', async (t) => {
8
+ // Get the path to the platformatic monorepo root (where next is installed)
9
+ // From packages/wattpm-pprof-capture/test -> go up 3 levels to reach monorepo root
10
+ const monorepoRoot = resolve(import.meta.dirname, '../../..')
11
+
12
+ // Load source maps from the next package
13
+ const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next'], false)
14
+
15
+ // Verify that we found source maps
16
+ assert.ok(entries.size > 0, `Should have found source maps in next package, found ${entries.size}`)
17
+
18
+ // Verify the structure of entries
19
+ for (const [generatedPath, info] of entries) {
20
+ assert.ok(typeof generatedPath === 'string', 'Generated path should be a string')
21
+ assert.ok(generatedPath.endsWith('.js') || generatedPath.endsWith('.cjs') || generatedPath.endsWith('.mjs'),
22
+ `Generated path should end with .js, .cjs, or .mjs: ${generatedPath}`)
23
+ assert.ok(info.mapFileDir, 'Info should have mapFileDir')
24
+ assert.ok(info.mapConsumer, 'Info should have mapConsumer')
25
+ assert.ok(typeof info.mapConsumer.originalPositionFor === 'function',
26
+ 'mapConsumer should have originalPositionFor method')
27
+ }
28
+
29
+ // Log some stats
30
+ console.log(`Loaded ${entries.size} source maps from next package`)
31
+ })
32
+
33
+ // Unit test for loadNodeModulesSourceMaps with scoped package
34
+ test('loadNodeModulesSourceMaps should handle non-existent packages gracefully', async (t) => {
35
+ const monorepoRoot = resolve(import.meta.dirname, '../../..')
36
+
37
+ // Try to load from a non-existent package
38
+ const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['non-existent-package-12345'], false)
39
+
40
+ // Should return empty map without throwing
41
+ assert.strictEqual(entries.size, 0, 'Should return empty map for non-existent package')
42
+ })
43
+
44
+ // Unit test for loadNodeModulesSourceMaps with multiple packages
45
+ test('loadNodeModulesSourceMaps should load source maps from multiple packages', async (t) => {
46
+ const monorepoRoot = resolve(import.meta.dirname, '../../..')
47
+
48
+ // Load from multiple packages (next has source maps, fastify typically doesn't)
49
+ const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next', 'fastify'], false)
50
+
51
+ // Should have at least the next package's source maps
52
+ assert.ok(entries.size > 0, 'Should have found source maps')
53
+ })
54
+
55
+ // Test that source map data can be used for lookups
56
+ test('loaded source maps should have valid mapping data', async (t) => {
57
+ const monorepoRoot = resolve(import.meta.dirname, '../../..')
58
+
59
+ const entries = await loadNodeModulesSourceMaps(monorepoRoot, ['next'], false)
60
+ assert.ok(entries.size > 0, 'Should have source maps to test')
61
+
62
+ // Pick a random entry and verify we can do a lookup
63
+ const [generatedPath, info] = entries.entries().next().value
64
+
65
+ // Try to get original position for line 1, column 0
66
+ const pos = info.mapConsumer.originalPositionFor({ line: 1, column: 0 })
67
+
68
+ // The position might be null if line 1 col 0 has no mapping,
69
+ // but the function should not throw
70
+ assert.ok(pos !== undefined, 'originalPositionFor should return a result')
71
+
72
+ // Verify the sources array exists
73
+ assert.ok(info.mapConsumer.sources, 'mapConsumer should have sources')
74
+ assert.ok(Array.isArray(info.mapConsumer.sources), 'sources should be an array')
75
+
76
+ console.log(`Tested lookup on ${generatedPath}`)
77
+ console.log(` Sources: ${info.mapConsumer.sources.length}`)
78
+ })
@@ -61,10 +61,7 @@ function decodeAndValidateProfile (encodedProfile, checkLocations) {
61
61
  return profile
62
62
  }
63
63
 
64
- // Build TypeScript service once before all tests
65
- test.before(async () => {
66
- const serviceDir = resolve(import.meta.dirname, 'fixtures/sourcemap-test/service')
67
-
64
+ async function compile (serviceDir) {
68
65
  // Build the TypeScript (dependencies are in parent package devDependencies)
69
66
  try {
70
67
  await execAsync('npx tsc', { cwd: serviceDir, timeout: 30000 })
@@ -82,10 +79,22 @@ test.before(async () => {
82
79
  } catch (err) {
83
80
  throw new Error(`Build artifacts not found: ${err.message}. Plugin: ${pluginPath}, Map: ${mapPath}`)
84
81
  }
82
+ }
83
+
84
+ // Build TypeScript service once before all tests
85
+ test.before(async () => {
86
+ const dirs = [
87
+ resolve(import.meta.dirname, 'fixtures/sourcemap-test/service'),
88
+ resolve(import.meta.dirname, 'fixtures/sourcemap-config-test/service')
89
+ ]
90
+
91
+ for (const dir of dirs) {
92
+ await compile(dir)
93
+ }
85
94
  })
86
95
 
87
- async function createApp (t) {
88
- const configFile = resolve(import.meta.dirname, 'fixtures/sourcemap-test/platformatic.json')
96
+ async function createApp (t, config = 'fixtures/sourcemap-test/platformatic.json') {
97
+ const configFile = resolve(import.meta.dirname, config)
89
98
 
90
99
  // Ensure tmp directory exists
91
100
  const { mkdir } = await import('node:fs/promises')
@@ -108,7 +117,7 @@ async function createApp (t) {
108
117
  return { app, url }
109
118
  }
110
119
 
111
- test('sourcemaps should be initialized and profiling should work with TypeScript', async t => {
120
+ test('sourcemaps should be initialized and profiling should work with TypeScript', { skip: process.platform === 'win32' }, async t => {
112
121
  const { app, url } = await createApp(t)
113
122
 
114
123
  // Verify service is running
@@ -173,7 +182,7 @@ test('sourcemaps should be initialized and profiling should work with TypeScript
173
182
  await app.sendCommandToApplication('service', 'stopProfiling')
174
183
  })
175
184
 
176
- test('sourcemaps should work with heap profiling', async t => {
185
+ test('sourcemaps should work with heap profiling', { skip: process.platform === 'win32' }, async t => {
177
186
  const { app, url } = await createApp(t)
178
187
 
179
188
  // Start heap profiling with source maps enabled
@@ -205,3 +214,68 @@ test('sourcemaps should work with heap profiling', async t => {
205
214
 
206
215
  await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
207
216
  })
217
+
218
+ test('sourcemaps should be initialized and profiling should work with TypeScript if enabled via config file', { skip: process.platform === 'win32' }, async t => {
219
+ const { app, url } = await createApp(t, 'fixtures/sourcemap-config-test/platformatic.json')
220
+
221
+ // Verify service is running
222
+ const res = await request(`${url}/`)
223
+ const json = await res.body.json()
224
+ assert.strictEqual(res.statusCode, 200)
225
+ assert.strictEqual(json.message, 'Hello from TypeScript')
226
+
227
+ // Verify sourcemap files exist in service directory
228
+ const diagRes = await request(`${url}/diagnostic`, { headersTimeout: 10000, bodyTimeout: 10000 })
229
+ const diag = await diagRes.body.json()
230
+ assert.strictEqual(diag.pluginMapExists, true, `Sourcemap file should exist at ${diag.serviceDir}/plugin.js.map`)
231
+
232
+ // Start profiling with sufficient duration to ensure we capture samples
233
+ // Enable source maps to resolve TypeScript locations
234
+ await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 5000 })
235
+
236
+ // Wait for profiler to actually start
237
+ await waitForCondition(async () => {
238
+ const state = await app.sendCommandToApplication('service', 'getProfilingState')
239
+ return state.isProfilerRunning
240
+ }, 2000)
241
+
242
+ // Make requests spread over time to ensure continuous CPU activity during profiling
243
+ // Use shorter interval and add timeout to prevent hanging
244
+ let consecutiveFailures = 0
245
+ let intervalStopped = false
246
+ const requestInterval = setInterval(() => {
247
+ if (intervalStopped) return
248
+
249
+ request(`${url}/compute`, { headersTimeout: 30000, bodyTimeout: 30000 })
250
+ .then(() => {
251
+ consecutiveFailures = 0
252
+ })
253
+ .catch((err) => {
254
+ consecutiveFailures++
255
+
256
+ // If we get 3 consecutive connection failures, the service likely crashed
257
+ if (consecutiveFailures >= 3 && (err.message.includes('ECONNREFUSED') || err.message.includes('ECONNRESET'))) {
258
+ intervalStopped = true
259
+ clearInterval(requestInterval)
260
+ throw new Error(`Service crashed - ${consecutiveFailures} consecutive connection failures: ${err.message}`)
261
+ }
262
+ })
263
+ }, 300)
264
+
265
+ // Wait for profile to be captured
266
+ await waitForCondition(async () => {
267
+ const state = await app.sendCommandToApplication('service', 'getProfilingState')
268
+ return state.hasProfile
269
+ }, 10000)
270
+
271
+ clearInterval(requestInterval)
272
+
273
+ // Get the profile - this should succeed with SourceMapper initialized
274
+ const encodedProfile = await app.sendCommandToApplication('service', 'getLastProfile')
275
+ const profile = decodeAndValidateProfile(encodedProfile, true)
276
+
277
+ // Verify sourcemaps are working by checking for .ts file extensions
278
+ verifyTypeScriptFilesInProfile(profile)
279
+
280
+ await app.sendCommandToApplication('service', 'stopProfiling')
281
+ })