diff --git a/.gitattributes b/.gitattributes index 6c3caa3fe5..589c9da751 100644 --- a/.gitattributes +++ b/.gitattributes @@ -45,3 +45,6 @@ examples/**/* linguist-documentation src/deps/*.c linguist-vendored src/deps/brotli/** linguist-vendored + +test/js/node/test/fixtures linguist-vendored +test/js/node/test/common linguist-vendored diff --git a/.vscode/settings.json b/.vscode/settings.json index 476f38ee4c..a533a0a9f0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,9 @@ "src/bun.js/WebKit": true, "src/deps/*/**": true, "test/node.js/upstream": true, + // This will fill up your whole search history. + "test/js/node/test/fixtures": true, + "test/js/node/test/common": true, }, "search.followSymlinks": false, "search.useIgnoreFiles": true, diff --git a/test/js/node/test/.gitignore b/test/js/node/test/.gitignore new file mode 100644 index 0000000000..c08151e95f --- /dev/null +++ b/test/js/node/test/.gitignore @@ -0,0 +1,10 @@ +fixtures/wpt +fixtures/tools +fixtures/v8-coverage +fixtures/test-runner +fixtures/source-map +fixtures/snapshot +fixtures/repl* +.tmp.* +*shadow-realm* +**/fails.txt diff --git a/test/js/node/test/common/arraystream.js b/test/js/node/test/common/arraystream.js new file mode 100644 index 0000000000..c9dae0512b --- /dev/null +++ b/test/js/node/test/common/arraystream.js @@ -0,0 +1,23 @@ +'use strict'; + +const { Stream } = require('stream'); +function noop() {} + +// A stream to push an array into a REPL +function ArrayStream() { + this.run = function(data) { + data.forEach((line) => { + this.emit('data', `${line}\n`); + }); + }; +} + +Object.setPrototypeOf(ArrayStream.prototype, Stream.prototype); +Object.setPrototypeOf(ArrayStream, Stream); +ArrayStream.prototype.readable = true; +ArrayStream.prototype.writable = true; +ArrayStream.prototype.pause = noop; +ArrayStream.prototype.resume = noop; +ArrayStream.prototype.write = noop; + +module.exports = ArrayStream; diff --git a/test/js/node/test/common/assertSnapshot.js b/test/js/node/test/common/assertSnapshot.js new file mode 100644 index 0000000000..88f40281e0 --- /dev/null +++ b/test/js/node/test/common/assertSnapshot.js @@ -0,0 +1,97 @@ +'use strict'; +const common = require('.'); +const path = require('node:path'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const assert = require('node:assert/strict'); + +const stackFramesRegexp = /(?<=\n)(\s+)((.+?)\s+\()?(?:\(?(.+?):(\d+)(?::(\d+))?)\)?(\s+\{)?(\[\d+m)?(\n|$)/g; +const windowNewlineRegexp = /\r/g; + +function replaceNodeVersion(str) { + return str.replaceAll(process.version, '*'); +} + +function replaceStackTrace(str, replacement = '$1*$7$8\n') { + return str.replace(stackFramesRegexp, replacement); +} + +function replaceWindowsLineEndings(str) { + return str.replace(windowNewlineRegexp, ''); +} + +function replaceWindowsPaths(str) { + return common.isWindows ? str.replaceAll(path.win32.sep, path.posix.sep) : str; +} + +function replaceFullPaths(str) { + return str.replaceAll(process.cwd(), ''); +} + +function transform(...args) { + return (str) => args.reduce((acc, fn) => fn(acc), str); +} + +function getSnapshotPath(filename) { + const { name, dir } = path.parse(filename); + return path.resolve(dir, `${name}.snapshot`); +} + +async function assertSnapshot(actual, filename = process.argv[1]) { + const snapshot = getSnapshotPath(filename); + if (process.env.NODE_REGENERATE_SNAPSHOTS) { + await fs.writeFile(snapshot, actual); + } else { + let expected; + try { + expected = await fs.readFile(snapshot, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') { + console.log( + 'Snapshot file does not exist. You can create a new one by running the test with NODE_REGENERATE_SNAPSHOTS=1', + ); + } + throw e; + } + assert.strictEqual(actual, replaceWindowsLineEndings(expected)); + } +} + +/** + * Spawn a process and assert its output against a snapshot. + * if you want to automatically update the snapshot, run tests with NODE_REGENERATE_SNAPSHOTS=1 + * transform is a function that takes the output and returns a string that will be compared against the snapshot + * this is useful for normalizing output such as stack traces + * there are some predefined transforms in this file such as replaceStackTrace and replaceWindowsLineEndings + * both of which can be used as an example for writing your own + * compose multiple transforms by passing them as arguments to the transform function: + * assertSnapshot.transform(assertSnapshot.replaceStackTrace, assertSnapshot.replaceWindowsLineEndings) + * @param {string} filename + * @param {function(string): string} [transform] + * @param {object} [options] - control how the child process is spawned + * @param {boolean} [options.tty] - whether to spawn the process in a pseudo-tty + * @returns {Promise} + */ +async function spawnAndAssert(filename, transform = (x) => x, { tty = false, ...options } = {}) { + if (tty && common.isWindows) { + test({ skip: 'Skipping pseudo-tty tests, as pseudo terminals are not available on Windows.' }); + return; + } + const flags = common.parseTestFlags(filename); + const executable = tty ? 'tools/pseudo-tty.py' : process.execPath; + const args = tty ? [process.execPath, ...flags, filename] : [...flags, filename]; + const { stdout, stderr } = await common.spawnPromisified(executable, args, options); + await assertSnapshot(transform(`${stdout}${stderr}`), filename); +} + +module.exports = { + assertSnapshot, + getSnapshotPath, + replaceFullPaths, + replaceNodeVersion, + replaceStackTrace, + replaceWindowsLineEndings, + replaceWindowsPaths, + spawnAndAssert, + transform, +}; diff --git a/test/js/node/test/common/benchmark.js b/test/js/node/test/common/benchmark.js new file mode 100644 index 0000000000..d9c1cdc627 --- /dev/null +++ b/test/js/node/test/common/benchmark.js @@ -0,0 +1,43 @@ +'use strict'; + +const assert = require('assert'); +const fork = require('child_process').fork; +const path = require('path'); + +const runjs = path.join(__dirname, '..', '..', 'benchmark', 'run.js'); + +function runBenchmark(name, env) { + const argv = ['test']; + + argv.push(name); + + const mergedEnv = { ...process.env, ...env }; + + const child = fork(runjs, argv, { + env: mergedEnv, + stdio: ['inherit', 'pipe', 'inherit', 'ipc'], + }); + child.stdout.setEncoding('utf8'); + + let stdout = ''; + child.stdout.on('data', (line) => { + stdout += line; + }); + + child.on('exit', (code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + // This bit makes sure that each benchmark file is being sent settings such + // that the benchmark file runs just one set of options. This helps keep the + // benchmark tests from taking a long time to run. Therefore, each benchmark + // file should result in three lines of output: a blank line, a line with + // the name of the benchmark file, and a line with the only results that we + // get from testing the benchmark file. + assert.ok( + /^(?:\n.+?\n.+?\n)+$/.test(stdout), + `benchmark file not running exactly one configuration in test: ${stdout}`, + ); + }); +} + +module.exports = runBenchmark; diff --git a/test/js/node/test/common/child_process.js b/test/js/node/test/common/child_process.js new file mode 100644 index 0000000000..d555d09a94 --- /dev/null +++ b/test/js/node/test/common/child_process.js @@ -0,0 +1,146 @@ +'use strict'; + +const assert = require('assert'); +const { spawnSync, execFileSync } = require('child_process'); +const common = require('./'); +const util = require('util'); + +// Workaround for Windows Server 2008R2 +// When CMD is used to launch a process and CMD is killed too quickly, the +// process can stay behind running in suspended state, never completing. +function cleanupStaleProcess(filename) { + if (!common.isWindows) { + return; + } + process.once('beforeExit', () => { + const basename = filename.replace(/.*[/\\]/g, ''); + try { + execFileSync(`${process.env.SystemRoot}\\System32\\wbem\\WMIC.exe`, [ + 'process', + 'where', + `commandline like '%${basename}%child'`, + 'delete', + '/nointeractive', + ]); + } catch { + // Ignore failures, there might not be any stale process to clean up. + } + }); +} + +// This should keep the child process running long enough to expire +// the timeout. +const kExpiringChildRunTime = common.platformTimeout(20 * 1000); +const kExpiringParentTimer = 1; +assert(kExpiringChildRunTime > kExpiringParentTimer); + +function logAfterTime(time) { + setTimeout(() => { + // The following console statements are part of the test. + console.log('child stdout'); + console.error('child stderr'); + }, time); +} + +function checkOutput(str, check) { + if ((check instanceof RegExp && !check.test(str)) || + (typeof check === 'string' && check !== str)) { + return { passed: false, reason: `did not match ${util.inspect(check)}` }; + } + if (typeof check === 'function') { + try { + check(str); + } catch (error) { + return { + passed: false, + reason: `did not match expectation, checker throws:\n${util.inspect(error)}`, + }; + } + } + return { passed: true }; +} + +function expectSyncExit(child, { + status, + signal, + stderr: stderrCheck, + stdout: stdoutCheck, + trim = false, +}) { + const failures = []; + let stderrStr, stdoutStr; + if (status !== undefined && child.status !== status) { + failures.push(`- process terminated with status ${child.status}, expected ${status}`); + } + if (signal !== undefined && child.signal !== signal) { + failures.push(`- process terminated with signal ${child.signal}, expected ${signal}`); + } + + function logAndThrow() { + const tag = `[process ${child.pid}]:`; + console.error(`${tag} --- stderr ---`); + console.error(stderrStr === undefined ? child.stderr.toString() : stderrStr); + console.error(`${tag} --- stdout ---`); + console.error(stdoutStr === undefined ? child.stdout.toString() : stdoutStr); + console.error(`${tag} status = ${child.status}, signal = ${child.signal}`); + throw new Error(`${failures.join('\n')}`); + } + + // If status and signal are not matching expectations, fail early. + if (failures.length !== 0) { + logAndThrow(); + } + + if (stderrCheck !== undefined) { + stderrStr = child.stderr.toString(); + const { passed, reason } = checkOutput(trim ? stderrStr.trim() : stderrStr, stderrCheck); + if (!passed) { + failures.push(`- stderr ${reason}`); + } + } + if (stdoutCheck !== undefined) { + stdoutStr = child.stdout.toString(); + const { passed, reason } = checkOutput(trim ? stdoutStr.trim() : stdoutStr, stdoutCheck); + if (!passed) { + failures.push(`- stdout ${reason}`); + } + } + if (failures.length !== 0) { + logAndThrow(); + } + return { child, stderr: stderrStr, stdout: stdoutStr }; +} + +function spawnSyncAndExit(...args) { + const spawnArgs = args.slice(0, args.length - 1); + const expectations = args[args.length - 1]; + const child = spawnSync(...spawnArgs); + return expectSyncExit(child, expectations); +} + +function spawnSyncAndExitWithoutError(...args) { + return expectSyncExit(spawnSync(...args), { + status: 0, + signal: null, + }); +} + +function spawnSyncAndAssert(...args) { + const expectations = args.pop(); + const child = spawnSync(...args); + return expectSyncExit(child, { + status: 0, + signal: null, + ...expectations, + }); +} + +module.exports = { + cleanupStaleProcess, + logAfterTime, + kExpiringChildRunTime, + kExpiringParentTimer, + spawnSyncAndAssert, + spawnSyncAndExit, + spawnSyncAndExitWithoutError, +}; diff --git a/test/js/node/test/common/countdown.js b/test/js/node/test/common/countdown.js new file mode 100644 index 0000000000..4aa86b4253 --- /dev/null +++ b/test/js/node/test/common/countdown.js @@ -0,0 +1,28 @@ +'use strict'; + +const assert = require('assert'); +const kLimit = Symbol('limit'); +const kCallback = Symbol('callback'); +const common = require('./'); + +class Countdown { + constructor(limit, cb) { + assert.strictEqual(typeof limit, 'number'); + assert.strictEqual(typeof cb, 'function'); + this[kLimit] = limit; + this[kCallback] = common.mustCall(cb); + } + + dec() { + assert(this[kLimit] > 0, 'Countdown expired'); + if (--this[kLimit] === 0) + this[kCallback](); + return this[kLimit]; + } + + get remaining() { + return this[kLimit]; + } +} + +module.exports = Countdown; diff --git a/test/js/node/test/common/cpu-prof.js b/test/js/node/test/common/cpu-prof.js new file mode 100644 index 0000000000..42f55b35fe --- /dev/null +++ b/test/js/node/test/common/cpu-prof.js @@ -0,0 +1,50 @@ +'use strict'; + +require('./'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +function getCpuProfiles(dir) { + const list = fs.readdirSync(dir); + return list + .filter((file) => file.endsWith('.cpuprofile')) + .map((file) => path.join(dir, file)); +} + +function getFrames(file, suffix) { + const data = fs.readFileSync(file, 'utf8'); + const profile = JSON.parse(data); + const frames = profile.nodes.filter((i) => { + const frame = i.callFrame; + return frame.url.endsWith(suffix); + }); + return { frames, nodes: profile.nodes }; +} + +function verifyFrames(output, file, suffix) { + const { frames, nodes } = getFrames(file, suffix); + if (frames.length === 0) { + // Show native debug output and the profile for debugging. + console.log(output.stderr.toString()); + console.log(nodes); + } + assert.notDeepStrictEqual(frames, []); +} + +// We need to set --cpu-interval to a smaller value to make sure we can +// find our workload in the samples. 50us should be a small enough sampling +// interval for this. +const kCpuProfInterval = 50; +const env = { + ...process.env, + NODE_DEBUG_NATIVE: 'INSPECTOR_PROFILER', +}; + +module.exports = { + getCpuProfiles, + kCpuProfInterval, + env, + getFrames, + verifyFrames, +}; diff --git a/test/js/node/test/common/crypto.js b/test/js/node/test/common/crypto.js new file mode 100644 index 0000000000..ba47285df4 --- /dev/null +++ b/test/js/node/test/common/crypto.js @@ -0,0 +1,132 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const crypto = require('crypto'); +const { + createSign, + createVerify, + publicEncrypt, + privateDecrypt, + sign, + verify, +} = crypto; + +// The values below (modp2/modp2buf) are for a 1024 bits long prime from +// RFC 2412 E.2, see https://tools.ietf.org/html/rfc2412. */ +const modp2buf = Buffer.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, + 0xda, 0xa2, 0x21, 0x68, 0xc2, 0x34, 0xc4, 0xc6, 0x62, 0x8b, + 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, + 0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, + 0x51, 0x4a, 0x08, 0x79, 0x8e, 0x34, 0x04, 0xdd, 0xef, 0x95, + 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d, + 0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, + 0xc2, 0x45, 0xe4, 0x85, 0xb5, 0x76, 0x62, 0x5e, 0x7e, 0xc6, + 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x37, 0xed, 0x6b, 0x0b, 0xff, + 0x5c, 0xb6, 0xf4, 0x06, 0xb7, 0xed, 0xee, 0x38, 0x6b, 0xfb, + 0x5a, 0x89, 0x9f, 0xa5, 0xae, 0x9f, 0x24, 0x11, 0x7c, 0x4b, + 0x1f, 0xe6, 0x49, 0x28, 0x66, 0x51, 0xec, 0xe6, 0x53, 0x81, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, +]); + +function testDH({ publicKey: alicePublicKey, privateKey: alicePrivateKey }, + { publicKey: bobPublicKey, privateKey: bobPrivateKey }, + expectedValue) { + const buf1 = crypto.diffieHellman({ + privateKey: alicePrivateKey, + publicKey: bobPublicKey, + }); + const buf2 = crypto.diffieHellman({ + privateKey: bobPrivateKey, + publicKey: alicePublicKey, + }); + assert.deepStrictEqual(buf1, buf2); + + if (expectedValue !== undefined) + assert.deepStrictEqual(buf1, expectedValue); +} + +// Asserts that the size of the given key (in chars or bytes) is within 10% of +// the expected size. +function assertApproximateSize(key, expectedSize) { + const u = typeof key === 'string' ? 'chars' : 'bytes'; + const min = Math.floor(0.9 * expectedSize); + const max = Math.ceil(1.1 * expectedSize); + assert(key.length >= min, + `Key (${key.length} ${u}) is shorter than expected (${min} ${u})`); + assert(key.length <= max, + `Key (${key.length} ${u}) is longer than expected (${max} ${u})`); +} + +// Tests that a key pair can be used for encryption / decryption. +function testEncryptDecrypt(publicKey, privateKey) { + const message = 'Hello Node.js world!'; + const plaintext = Buffer.from(message, 'utf8'); + for (const key of [publicKey, privateKey]) { + const ciphertext = publicEncrypt(key, plaintext); + const received = privateDecrypt(privateKey, ciphertext); + assert.strictEqual(received.toString('utf8'), message); + } +} + +// Tests that a key pair can be used for signing / verification. +function testSignVerify(publicKey, privateKey) { + const message = Buffer.from('Hello Node.js world!'); + + function oldSign(algo, data, key) { + return createSign(algo).update(data).sign(key); + } + + function oldVerify(algo, data, key, signature) { + return createVerify(algo).update(data).verify(key, signature); + } + + for (const signFn of [sign, oldSign]) { + const signature = signFn('SHA256', message, privateKey); + for (const verifyFn of [verify, oldVerify]) { + for (const key of [publicKey, privateKey]) { + const okay = verifyFn('SHA256', message, key, signature); + assert(okay); + } + } + } +} + +// Constructs a regular expression for a PEM-encoded key with the given label. +function getRegExpForPEM(label, cipher) { + const head = `\\-\\-\\-\\-\\-BEGIN ${label}\\-\\-\\-\\-\\-`; + const rfc1421Header = cipher == null ? '' : + `\nProc-Type: 4,ENCRYPTED\nDEK-Info: ${cipher},[^\n]+\n`; + const body = '([a-zA-Z0-9\\+/=]{64}\n)*[a-zA-Z0-9\\+/=]{1,64}'; + const end = `\\-\\-\\-\\-\\-END ${label}\\-\\-\\-\\-\\-`; + return new RegExp(`^${head}${rfc1421Header}\n${body}\n${end}\n$`); +} + +const pkcs1PubExp = getRegExpForPEM('RSA PUBLIC KEY'); +const pkcs1PrivExp = getRegExpForPEM('RSA PRIVATE KEY'); +const pkcs1EncExp = (cipher) => getRegExpForPEM('RSA PRIVATE KEY', cipher); +const spkiExp = getRegExpForPEM('PUBLIC KEY'); +const pkcs8Exp = getRegExpForPEM('PRIVATE KEY'); +const pkcs8EncExp = getRegExpForPEM('ENCRYPTED PRIVATE KEY'); +const sec1Exp = getRegExpForPEM('EC PRIVATE KEY'); +const sec1EncExp = (cipher) => getRegExpForPEM('EC PRIVATE KEY', cipher); + +module.exports = { + modp2buf, + testDH, + assertApproximateSize, + testEncryptDecrypt, + testSignVerify, + pkcs1PubExp, + pkcs1PrivExp, + pkcs1EncExp, // used once + spkiExp, + pkcs8Exp, // used once + pkcs8EncExp, // used once + sec1Exp, + sec1EncExp, +}; diff --git a/test/js/node/test/common/debugger.js b/test/js/node/test/common/debugger.js new file mode 100644 index 0000000000..d5d77fc7c6 --- /dev/null +++ b/test/js/node/test/common/debugger.js @@ -0,0 +1,183 @@ +'use strict'; +const common = require('../common'); +const spawn = require('child_process').spawn; + +const BREAK_MESSAGE = new RegExp('(?:' + [ + 'assert', 'break', 'break on start', 'debugCommand', + 'exception', 'other', 'promiseRejection', 'step', +].join('|') + ') in', 'i'); + +let TIMEOUT = common.platformTimeout(5000); +if (common.isWindows) { + // Some of the windows machines in the CI need more time to receive + // the outputs from the client. + // https://github.com/nodejs/build/issues/3014 + TIMEOUT = common.platformTimeout(15000); +} + +function isPreBreak(output) { + return /Break on start/.test(output) && /1 \(function \(exports/.test(output); +} + +function startCLI(args, flags = [], spawnOpts = {}) { + let stderrOutput = ''; + const child = + spawn(process.execPath, [...flags, 'inspect', ...args], spawnOpts); + + const outputBuffer = []; + function bufferOutput(chunk) { + if (this === child.stderr) { + stderrOutput += chunk; + } + outputBuffer.push(chunk); + } + + function getOutput() { + return outputBuffer.join('\n').replaceAll('\b', ''); + } + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', bufferOutput); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', bufferOutput); + + if (process.env.VERBOSE === '1') { + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + } + + return { + flushOutput() { + const output = this.output; + outputBuffer.length = 0; + return output; + }, + + waitFor(pattern) { + function checkPattern(str) { + if (Array.isArray(pattern)) { + return pattern.every((p) => p.test(str)); + } + return pattern.test(str); + } + + return new Promise((resolve, reject) => { + function checkOutput() { + if (checkPattern(getOutput())) { + tearDown(); + resolve(); + } + } + + function onChildClose(code, signal) { + tearDown(); + let message = 'Child exited'; + if (code) { + message += `, code ${code}`; + } + if (signal) { + message += `, signal ${signal}`; + } + message += ` while waiting for ${pattern}; found: ${this.output}`; + if (stderrOutput) { + message += `\n STDERR: ${stderrOutput}`; + } + reject(new Error(message)); + } + + const timer = setTimeout(() => { + tearDown(); + reject(new Error([ + `Timeout (${TIMEOUT}) while waiting for ${pattern}`, + `found: ${this.output}`, + ].join('; '))); + }, TIMEOUT); + + function tearDown() { + clearTimeout(timer); + child.stdout.removeListener('data', checkOutput); + child.removeListener('close', onChildClose); + } + + child.on('close', onChildClose); + child.stdout.on('data', checkOutput); + checkOutput(); + }); + }, + + waitForPrompt() { + return this.waitFor(/>\s+$/); + }, + + async waitForInitialBreak() { + await this.waitFor(/break (?:on start )?in/i); + + if (isPreBreak(this.output)) { + await this.command('next', false); + return this.waitFor(/break in/); + } + }, + + get breakInfo() { + const output = this.output; + const breakMatch = + output.match(/(step |break (?:on start )?)in ([^\n]+):(\d+)\n/i); + + if (breakMatch === null) { + throw new Error( + `Could not find breakpoint info in ${JSON.stringify(output)}`); + } + return { filename: breakMatch[2], line: +breakMatch[3] }; + }, + + ctrlC() { + return this.command('.interrupt'); + }, + + get output() { + return getOutput(); + }, + + get rawOutput() { + return outputBuffer.join('').toString(); + }, + + parseSourceLines() { + return getOutput().split('\n') + .map((line) => line.match(/(?:\*|>)?\s*(\d+)/)) + .filter((match) => match !== null) + .map((match) => +match[1]); + }, + + writeLine(input, flush = true) { + if (flush) { + this.flushOutput(); + } + if (process.env.VERBOSE === '1') { + process.stderr.write(`< ${input}\n`); + } + child.stdin.write(input); + child.stdin.write('\n'); + }, + + command(input, flush = true) { + this.writeLine(input, flush); + return this.waitForPrompt(); + }, + + stepCommand(input) { + this.writeLine(input, true); + return this + .waitFor(BREAK_MESSAGE) + .then(() => this.waitForPrompt()); + }, + + quit() { + return new Promise((resolve) => { + child.stdin.end(); + child.on('close', resolve); + }); + }, + }; +} +module.exports = startCLI; diff --git a/test/js/node/test/common/dns.js b/test/js/node/test/common/dns.js new file mode 100644 index 0000000000..d854c73629 --- /dev/null +++ b/test/js/node/test/common/dns.js @@ -0,0 +1,341 @@ +'use strict'; + +const assert = require('assert'); +const os = require('os'); +const { isIP } = require('net'); + +const types = { + A: 1, + AAAA: 28, + NS: 2, + CNAME: 5, + SOA: 6, + PTR: 12, + MX: 15, + TXT: 16, + ANY: 255, + CAA: 257, +}; + +const classes = { + IN: 1, +}; + +// Naïve DNS parser/serializer. + +function readDomainFromPacket(buffer, offset) { + assert.ok(offset < buffer.length); + const length = buffer[offset]; + if (length === 0) { + return { nread: 1, domain: '' }; + } else if ((length & 0xC0) === 0) { + offset += 1; + const chunk = buffer.toString('ascii', offset, offset + length); + // Read the rest of the domain. + const { nread, domain } = readDomainFromPacket(buffer, offset + length); + return { + nread: 1 + length + nread, + domain: domain ? `${chunk}.${domain}` : chunk, + }; + } + // Pointer to another part of the packet. + assert.strictEqual(length & 0xC0, 0xC0); + // eslint-disable-next-line @stylistic/js/space-infix-ops, @stylistic/js/space-unary-ops + const pointeeOffset = buffer.readUInt16BE(offset) &~ 0xC000; + return { + nread: 2, + domain: readDomainFromPacket(buffer, pointeeOffset), + }; +} + +function parseDNSPacket(buffer) { + assert.ok(buffer.length > 12); + + const parsed = { + id: buffer.readUInt16BE(0), + flags: buffer.readUInt16BE(2), + }; + + const counts = [ + ['questions', buffer.readUInt16BE(4)], + ['answers', buffer.readUInt16BE(6)], + ['authorityAnswers', buffer.readUInt16BE(8)], + ['additionalRecords', buffer.readUInt16BE(10)], + ]; + + let offset = 12; + for (const [ sectionName, count ] of counts) { + parsed[sectionName] = []; + for (let i = 0; i < count; ++i) { + const { nread, domain } = readDomainFromPacket(buffer, offset); + offset += nread; + + const type = buffer.readUInt16BE(offset); + + const rr = { + domain, + cls: buffer.readUInt16BE(offset + 2), + }; + offset += 4; + + for (const name in types) { + if (types[name] === type) + rr.type = name; + } + + if (sectionName !== 'questions') { + rr.ttl = buffer.readInt32BE(offset); + const dataLength = buffer.readUInt16BE(offset); + offset += 6; + + switch (type) { + case types.A: + assert.strictEqual(dataLength, 4); + rr.address = `${buffer[offset + 0]}.${buffer[offset + 1]}.` + + `${buffer[offset + 2]}.${buffer[offset + 3]}`; + break; + case types.AAAA: + assert.strictEqual(dataLength, 16); + rr.address = buffer.toString('hex', offset, offset + 16) + .replace(/(.{4}(?!$))/g, '$1:'); + break; + case types.TXT: + { + let position = offset; + rr.entries = []; + while (position < offset + dataLength) { + const txtLength = buffer[offset]; + rr.entries.push(buffer.toString('utf8', + position + 1, + position + 1 + txtLength)); + position += 1 + txtLength; + } + assert.strictEqual(position, offset + dataLength); + break; + } + case types.MX: + { + rr.priority = buffer.readInt16BE(buffer, offset); + offset += 2; + const { nread, domain } = readDomainFromPacket(buffer, offset); + rr.exchange = domain; + assert.strictEqual(nread, dataLength); + break; + } + case types.NS: + case types.CNAME: + case types.PTR: + { + const { nread, domain } = readDomainFromPacket(buffer, offset); + rr.value = domain; + assert.strictEqual(nread, dataLength); + break; + } + case types.SOA: + { + const mname = readDomainFromPacket(buffer, offset); + const rname = readDomainFromPacket(buffer, offset + mname.nread); + rr.nsname = mname.domain; + rr.hostmaster = rname.domain; + const trailerOffset = offset + mname.nread + rname.nread; + rr.serial = buffer.readUInt32BE(trailerOffset); + rr.refresh = buffer.readUInt32BE(trailerOffset + 4); + rr.retry = buffer.readUInt32BE(trailerOffset + 8); + rr.expire = buffer.readUInt32BE(trailerOffset + 12); + rr.minttl = buffer.readUInt32BE(trailerOffset + 16); + + assert.strictEqual(trailerOffset + 20, dataLength); + break; + } + default: + throw new Error(`Unknown RR type ${rr.type}`); + } + offset += dataLength; + } + + parsed[sectionName].push(rr); + + assert.ok(offset <= buffer.length); + } + } + + assert.strictEqual(offset, buffer.length); + return parsed; +} + +function writeIPv6(ip) { + const parts = ip.replace(/^:|:$/g, '').split(':'); + const buf = Buffer.alloc(16); + + let offset = 0; + for (const part of parts) { + if (part === '') { + offset += 16 - 2 * (parts.length - 1); + } else { + buf.writeUInt16BE(parseInt(part, 16), offset); + offset += 2; + } + } + + return buf; +} + +function writeDomainName(domain) { + return Buffer.concat(domain.split('.').map((label) => { + assert(label.length < 64); + return Buffer.concat([ + Buffer.from([label.length]), + Buffer.from(label, 'ascii'), + ]); + }).concat([Buffer.alloc(1)])); +} + +function writeDNSPacket(parsed) { + const buffers = []; + const kStandardResponseFlags = 0x8180; + + buffers.push(new Uint16Array([ + parsed.id, + parsed.flags === undefined ? kStandardResponseFlags : parsed.flags, + parsed.questions && parsed.questions.length, + parsed.answers && parsed.answers.length, + parsed.authorityAnswers && parsed.authorityAnswers.length, + parsed.additionalRecords && parsed.additionalRecords.length, + ])); + + for (const q of parsed.questions) { + assert(types[q.type]); + buffers.push(writeDomainName(q.domain)); + buffers.push(new Uint16Array([ + types[q.type], + q.cls === undefined ? classes.IN : q.cls, + ])); + } + + for (const rr of [].concat(parsed.answers, + parsed.authorityAnswers, + parsed.additionalRecords)) { + if (!rr) continue; + + assert(types[rr.type]); + buffers.push(writeDomainName(rr.domain)); + buffers.push(new Uint16Array([ + types[rr.type], + rr.cls === undefined ? classes.IN : rr.cls, + ])); + buffers.push(new Int32Array([rr.ttl])); + + const rdLengthBuf = new Uint16Array(1); + buffers.push(rdLengthBuf); + + switch (rr.type) { + case 'A': + rdLengthBuf[0] = 4; + buffers.push(new Uint8Array(rr.address.split('.'))); + break; + case 'AAAA': + rdLengthBuf[0] = 16; + buffers.push(writeIPv6(rr.address)); + break; + case 'TXT': { + const total = rr.entries.map((s) => s.length).reduce((a, b) => a + b); + // Total length of all strings + 1 byte each for their lengths. + rdLengthBuf[0] = rr.entries.length + total; + for (const txt of rr.entries) { + buffers.push(new Uint8Array([Buffer.byteLength(txt)])); + buffers.push(Buffer.from(txt)); + } + break; + } + case 'MX': + rdLengthBuf[0] = 2; + buffers.push(new Uint16Array([rr.priority])); + // fall through + case 'NS': + case 'CNAME': + case 'PTR': + { + const domain = writeDomainName(rr.exchange || rr.value); + rdLengthBuf[0] += domain.length; + buffers.push(domain); + break; + } + case 'SOA': + { + const mname = writeDomainName(rr.nsname); + const rname = writeDomainName(rr.hostmaster); + rdLengthBuf[0] = mname.length + rname.length + 20; + buffers.push(mname, rname); + buffers.push(new Uint32Array([ + rr.serial, rr.refresh, rr.retry, rr.expire, rr.minttl, + ])); + break; + } + case 'CAA': + { + rdLengthBuf[0] = 5 + rr.issue.length + 2; + buffers.push(Buffer.from([Number(rr.critical)])); + buffers.push(Buffer.from([Number(5)])); + buffers.push(Buffer.from('issue' + rr.issue)); + break; + } + default: + throw new Error(`Unknown RR type ${rr.type}`); + } + } + + return Buffer.concat(buffers.map((typedArray) => { + const buf = Buffer.from(typedArray.buffer, + typedArray.byteOffset, + typedArray.byteLength); + if (os.endianness() === 'LE') { + if (typedArray.BYTES_PER_ELEMENT === 2) buf.swap16(); + if (typedArray.BYTES_PER_ELEMENT === 4) buf.swap32(); + } + return buf; + })); +} + +const mockedErrorCode = 'ENOTFOUND'; +const mockedSysCall = 'getaddrinfo'; + +function errorLookupMock(code = mockedErrorCode, syscall = mockedSysCall) { + return function lookupWithError(hostname, dnsopts, cb) { + const err = new Error(`${syscall} ${code} ${hostname}`); + err.code = code; + err.errno = code; + err.syscall = syscall; + err.hostname = hostname; + cb(err); + }; +} + +function createMockedLookup(...addresses) { + addresses = addresses.map((address) => ({ address: address, family: isIP(address) })); + + // Create a DNS server which replies with a AAAA and a A record for the same host + return function lookup(hostname, options, cb) { + if (options.all === true) { + process.nextTick(() => { + cb(null, addresses); + }); + + return; + } + + process.nextTick(() => { + cb(null, addresses[0].address, addresses[0].family); + }); + }; +} + +module.exports = { + types, + classes, + writeDNSPacket, + parseDNSPacket, + errorLookupMock, + mockedErrorCode, + mockedSysCall, + createMockedLookup, +}; diff --git a/test/js/node/test/common/duplexpair.js b/test/js/node/test/common/duplexpair.js new file mode 100644 index 0000000000..1f41ed32f1 --- /dev/null +++ b/test/js/node/test/common/duplexpair.js @@ -0,0 +1,48 @@ +'use strict'; +const { Duplex } = require('stream'); +const assert = require('assert'); + +const kCallback = Symbol('Callback'); +const kOtherSide = Symbol('Other'); + +class DuplexSocket extends Duplex { + constructor() { + super(); + this[kCallback] = null; + this[kOtherSide] = null; + } + + _read() { + const callback = this[kCallback]; + if (callback) { + this[kCallback] = null; + callback(); + } + } + + _write(chunk, encoding, callback) { + assert.notStrictEqual(this[kOtherSide], null); + assert.strictEqual(this[kOtherSide][kCallback], null); + if (chunk.length === 0) { + process.nextTick(callback); + } else { + this[kOtherSide].push(chunk); + this[kOtherSide][kCallback] = callback; + } + } + + _final(callback) { + this[kOtherSide].on('end', callback); + this[kOtherSide].push(null); + } +} + +function makeDuplexPair() { + const clientSide = new DuplexSocket(); + const serverSide = new DuplexSocket(); + clientSide[kOtherSide] = serverSide; + serverSide[kOtherSide] = clientSide; + return { clientSide, serverSide }; +} + +module.exports = makeDuplexPair; diff --git a/test/js/node/test/common/fixtures.js b/test/js/node/test/common/fixtures.js new file mode 100644 index 0000000000..75815b035b --- /dev/null +++ b/test/js/node/test/common/fixtures.js @@ -0,0 +1,59 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); + +const fixturesDir = path.join(__dirname, '..', 'fixtures'); + +function fixturesPath(...args) { + return path.join(fixturesDir, ...args); +} + +function fixturesFileURL(...args) { + return pathToFileURL(fixturesPath(...args)); +} + +function readFixtureSync(args, enc) { + if (Array.isArray(args)) + return fs.readFileSync(fixturesPath(...args), enc); + return fs.readFileSync(fixturesPath(args), enc); +} + +function readFixtureKey(name, enc) { + return fs.readFileSync(fixturesPath('keys', name), enc); +} + +function readFixtureKeys(enc, ...names) { + return names.map((name) => readFixtureKey(name, enc)); +} + +// This should be in sync with test/fixtures/utf8_test_text.txt. +// We copy them here as a string because this is supposed to be used +// in fs API tests. +const utf8TestText = '永和九年,嵗在癸丑,暮春之初,會於會稽山隂之蘭亭,脩稧事也。' + + '羣賢畢至,少長咸集。此地有崇山峻領,茂林脩竹;又有清流激湍,' + + '暎帶左右。引以為流觴曲水,列坐其次。雖無絲竹管弦之盛,一觴一詠,' + + '亦足以暢敘幽情。是日也,天朗氣清,恵風和暢;仰觀宇宙之大,' + + '俯察品類之盛;所以遊目騁懐,足以極視聽之娛,信可樂也。夫人之相與,' + + '俯仰一世,或取諸懐抱,悟言一室之內,或因寄所託,放浪形骸之外。' + + '雖趣舎萬殊,靜躁不同,當其欣扵所遇,暫得扵己,怏然自足,' + + '不知老之將至。及其所之既惓,情隨事遷,感慨係之矣。向之所欣,' + + '俛仰之閒以為陳跡,猶不能不以之興懐;況脩短隨化,終期扵盡。' + + '古人云:「死生亦大矣。」豈不痛哉!每攬昔人興感之由,若合一契,' + + '未嘗不臨文嗟悼,不能喻之扵懐。固知一死生為虛誕,齊彭殤為妄作。' + + '後之視今,亦由今之視昔,悲夫!故列敘時人,錄其所述,雖世殊事異,' + + '所以興懐,其致一也。後之攬者,亦將有感扵斯文。'; + +module.exports = { + fixturesDir, + path: fixturesPath, + fileURL: fixturesFileURL, + readSync: readFixtureSync, + readKey: readFixtureKey, + readKeys: readFixtureKeys, + utf8TestText, + get utf8TestTextPath() { + return fixturesPath('utf8_test_text.txt'); + }, +}; diff --git a/test/js/node/test/common/fixtures.mjs b/test/js/node/test/common/fixtures.mjs new file mode 100644 index 0000000000..d6f7f6c092 --- /dev/null +++ b/test/js/node/test/common/fixtures.mjs @@ -0,0 +1,17 @@ +import fixtures from './fixtures.js'; + +const { + fixturesDir, + path, + fileURL, + readSync, + readKey, +} = fixtures; + +export { + fixturesDir, + path, + fileURL, + readSync, + readKey, +}; diff --git a/test/js/node/test/common/gc.js b/test/js/node/test/common/gc.js new file mode 100644 index 0000000000..8e2c5ee5da --- /dev/null +++ b/test/js/node/test/common/gc.js @@ -0,0 +1,127 @@ +'use strict'; + +const wait = require('timers/promises').setTimeout; + +// TODO(joyeecheung): merge ongc.js and gcUntil from common/index.js +// into this. + +// This function can be used to check if an object factor leaks or not, +// but it needs to be used with care: +// 1. The test should be set up with an ideally small +// --max-old-space-size or --max-heap-size, which combined with +// the maxCount parameter can reproduce a leak of the objects +// created by fn(). +// 2. This works under the assumption that if *none* of the objects +// created by fn() can be garbage-collected, the test would crash due +// to OOM. +// 3. If *any* of the objects created by fn() can be garbage-collected, +// it is considered leak-free. The FinalizationRegistry is used to +// terminate the test early once we detect any of the object is +// garbage-collected to make the test less prone to false positives. +// This may be especially important for memory management relying on +// emphemeron GC which can be inefficient to deal with extremely fast +// heap growth. +// Note that this can still produce false positives. When the test using +// this function still crashes due to OOM, inspect the heap to confirm +// if a leak is present (e.g. using heap snapshots). +// The generateSnapshotAt parameter can be used to specify a count +// interval to create the heap snapshot which may enforce a more thorough GC. +// This can be tried for code paths that require it for the GC to catch up +// with heap growth. However this type of forced GC can be in conflict with +// other logic in V8 such as bytecode aging, and it can slow down the test +// significantly, so it should be used scarcely and only as a last resort. +async function checkIfCollectable( + fn, maxCount = 4096, generateSnapshotAt = Infinity, logEvery = 128) { + let anyFinalized = false; + let count = 0; + + const f = new FinalizationRegistry(() => { + anyFinalized = true; + }); + + async function createObject() { + const obj = await fn(); + f.register(obj); + if (count++ < maxCount && !anyFinalized) { + setImmediate(createObject, 1); + } + // This can force a more thorough GC, but can slow the test down + // significantly in a big heap. Use it with care. + if (count % generateSnapshotAt === 0) { + // XXX(joyeecheung): This itself can consume a bit of JS heap memory, + // but the other alternative writeHeapSnapshot can run into disk space + // not enough problems in the CI & be slower depending on file system. + // Just do this for now as long as it works and only invent some + // internal voodoo when we absolutely have no other choice. + require('v8').getHeapSnapshot().pause().read(); + console.log(`Generated heap snapshot at ${count}`); + } + if (count % logEvery === 0) { + console.log(`Created ${count} objects`); + } + if (anyFinalized) { + console.log(`Found finalized object at ${count}, stop testing`); + } + } + + createObject(); +} + +// Repeat an operation and give GC some breathing room at every iteration. +async function runAndBreathe(fn, repeat, waitTime = 20) { + for (let i = 0; i < repeat; i++) { + await fn(); + await wait(waitTime); + } +} + +/** + * This requires --expose-internals. + * This function can be used to check if an object factory leaks or not by + * iterating over the heap and count objects with the specified class + * (which is checked by looking up the prototype chain). + * @param {(i: number) => number} fn The factory receiving iteration count + * and returning number of objects created. The return value should be + * precise otherwise false negatives can be produced. + * @param {Function} ctor The constructor of the objects being counted. + * @param {number} count Number of iterations that this check should be done + * @param {number} waitTime Optional breathing time for GC. + */ +async function checkIfCollectableByCounting(fn, ctor, count, waitTime = 20) { + const { queryObjects } = require('v8'); + const { name } = ctor; + const initialCount = queryObjects(ctor, { format: 'count' }); + console.log(`Initial count of ${name}: ${initialCount}`); + let totalCreated = 0; + for (let i = 0; i < count; ++i) { + const created = await fn(i); + totalCreated += created; + console.log(`#${i}: created ${created} ${name}, total ${totalCreated}`); + await wait(waitTime); // give GC some breathing room. + const currentCount = queryObjects(ctor, { format: 'count' }); + const collected = totalCreated - (currentCount - initialCount); + console.log(`#${i}: counted ${currentCount} ${name}, collected ${collected}`); + if (collected > 0) { + console.log(`Detected ${collected} collected ${name}, finish early`); + return; + } + } + + await wait(waitTime); // give GC some breathing room. + const currentCount = queryObjects(ctor, { format: 'count' }); + const collected = totalCreated - (currentCount - initialCount); + console.log(`Last count: counted ${currentCount} ${name}, collected ${collected}`); + // Some objects with the prototype can be collected. + if (collected > 0) { + console.log(`Detected ${collected} collected ${name}`); + return; + } + + throw new Error(`${name} cannot be collected`); +} + +module.exports = { + checkIfCollectable, + runAndBreathe, + checkIfCollectableByCounting, +}; diff --git a/test/js/node/test/common/globals.js b/test/js/node/test/common/globals.js new file mode 100644 index 0000000000..42caece2b8 --- /dev/null +++ b/test/js/node/test/common/globals.js @@ -0,0 +1,146 @@ +'use strict'; + +const intrinsics = new Set([ + 'Object', + 'Function', + 'Array', + 'Number', + 'parseFloat', + 'parseInt', + 'Infinity', + 'NaN', + 'undefined', + 'Boolean', + 'String', + 'Symbol', + 'Date', + 'Promise', + 'RegExp', + 'Error', + 'AggregateError', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', + 'globalThis', + 'JSON', + 'Math', + 'Intl', + 'ArrayBuffer', + 'Uint8Array', + 'Int8Array', + 'Uint16Array', + 'Int16Array', + 'Uint32Array', + 'Int32Array', + 'Float32Array', + 'Float64Array', + 'Uint8ClampedArray', + 'BigUint64Array', + 'BigInt64Array', + 'DataView', + 'Map', + 'BigInt', + 'Set', + 'WeakMap', + 'WeakSet', + 'Proxy', + 'Reflect', + 'ShadowRealm', + 'FinalizationRegistry', + 'WeakRef', + 'decodeURI', + 'decodeURIComponent', + 'encodeURI', + 'encodeURIComponent', + 'escape', + 'unescape', + 'eval', + 'isFinite', + 'isNaN', + 'SharedArrayBuffer', + 'Atomics', + 'WebAssembly', + 'Iterator', +]); + +if (global.gc) { + intrinsics.add('gc'); +} + +// v8 exposes console in the global scope. +intrinsics.add('console'); + +const webIdlExposedWildcard = new Set([ + 'DOMException', + 'TextEncoder', + 'TextDecoder', + 'AbortController', + 'AbortSignal', + 'CustomEvent', + 'EventTarget', + 'Event', + 'URL', + 'URLSearchParams', + 'ReadableStream', + 'ReadableStreamDefaultReader', + 'ReadableStreamBYOBReader', + 'ReadableStreamBYOBRequest', + 'ReadableByteStreamController', + 'ReadableStreamDefaultController', + 'TransformStream', + 'TransformStreamDefaultController', + 'WritableStream', + 'WritableStreamDefaultWriter', + 'WritableStreamDefaultController', + 'ByteLengthQueuingStrategy', + 'CountQueuingStrategy', + 'TextEncoderStream', + 'TextDecoderStream', + 'CompressionStream', + 'DecompressionStream', +]); + +const webIdlExposedWindow = new Set([ + 'console', + 'BroadcastChannel', + 'queueMicrotask', + 'structuredClone', + 'MessageChannel', + 'MessagePort', + 'MessageEvent', + 'clearInterval', + 'clearTimeout', + 'setInterval', + 'setTimeout', + 'atob', + 'btoa', + 'Blob', + 'Performance', + 'performance', + 'fetch', + 'FormData', + 'Headers', + 'Request', + 'Response', + 'WebSocket', + 'EventSource', + 'CloseEvent', +]); + +const nodeGlobals = new Set([ + 'process', + 'global', + 'Buffer', + 'clearImmediate', + 'setImmediate', +]); + +module.exports = { + intrinsics, + webIdlExposedWildcard, + webIdlExposedWindow, + nodeGlobals, +}; diff --git a/test/js/node/test/common/heap.js b/test/js/node/test/common/heap.js new file mode 100644 index 0000000000..8eb36a8bfc --- /dev/null +++ b/test/js/node/test/common/heap.js @@ -0,0 +1,249 @@ +'use strict'; +const assert = require('assert'); +const util = require('util'); + +let internalBinding; +try { + internalBinding = require('internal/test/binding').internalBinding; +} catch (e) { + console.log('using `test/common/heap.js` requires `--expose-internals`'); + throw e; +} + +const { buildEmbedderGraph } = internalBinding('heap_utils'); +const { getHeapSnapshot } = require('v8'); + +function createJSHeapSnapshot(stream = getHeapSnapshot()) { + stream.pause(); + const dump = JSON.parse(stream.read()); + const meta = dump.snapshot.meta; + + const nodes = + readHeapInfo(dump.nodes, meta.node_fields, meta.node_types, dump.strings); + const edges = + readHeapInfo(dump.edges, meta.edge_fields, meta.edge_types, dump.strings); + + for (const node of nodes) { + node.incomingEdges = []; + node.outgoingEdges = []; + } + + let fromNodeIndex = 0; + let edgeIndex = 0; + for (const { type, name_or_index, to_node } of edges) { + while (edgeIndex === nodes[fromNodeIndex].edge_count) { + edgeIndex = 0; + fromNodeIndex++; + } + const toNode = nodes[to_node / meta.node_fields.length]; + const fromNode = nodes[fromNodeIndex]; + const edge = { + type, + to: toNode, + from: fromNode, + name: typeof name_or_index === 'string' ? name_or_index : null, + }; + toNode.incomingEdges.push(edge); + fromNode.outgoingEdges.push(edge); + edgeIndex++; + } + + for (const node of nodes) { + assert.strictEqual(node.edge_count, node.outgoingEdges.length, + `${node.edge_count} !== ${node.outgoingEdges.length}`); + } + return nodes; +} + +function readHeapInfo(raw, fields, types, strings) { + const items = []; + + for (let i = 0; i < raw.length; i += fields.length) { + const item = {}; + for (let j = 0; j < fields.length; j++) { + const name = fields[j]; + let type = types[j]; + if (Array.isArray(type)) { + item[name] = type[raw[i + j]]; + } else if (name === 'name_or_index') { // type === 'string_or_number' + if (item.type === 'element' || item.type === 'hidden') + type = 'number'; + else + type = 'string'; + } + + if (type === 'string') { + item[name] = strings[raw[i + j]]; + } else if (type === 'number' || type === 'node') { + item[name] = raw[i + j]; + } + } + items.push(item); + } + + return items; +} + +function inspectNode(snapshot) { + return util.inspect(snapshot, { depth: 4 }); +} + +function isEdge(edge, { node_name, edge_name }) { + if (edge_name !== undefined && edge.name !== edge_name) { + return false; + } + // From our internal embedded graph + if (edge.to.value) { + if (edge.to.value.constructor.name !== node_name) { + return false; + } + } else if (edge.to.name !== node_name) { + return false; + } + return true; +} + +class State { + constructor(stream) { + this.snapshot = createJSHeapSnapshot(stream); + this.embedderGraph = buildEmbedderGraph(); + } + + // Validate the v8 heap snapshot + validateSnapshot(rootName, expected, { loose = false } = {}) { + const rootNodes = this.snapshot.filter( + (node) => node.name === rootName && node.type !== 'string'); + if (loose) { + assert(rootNodes.length >= expected.length, + `Expect to find at least ${expected.length} '${rootName}', ` + + `found ${rootNodes.length}`); + } else { + assert.strictEqual( + rootNodes.length, expected.length, + `Expect to find ${expected.length} '${rootName}', ` + + `found ${rootNodes.length}`); + } + + for (const expectation of expected) { + if (expectation.children) { + for (const expectedEdge of expectation.children) { + const check = typeof expectedEdge === 'function' ? expectedEdge : + (edge) => (isEdge(edge, expectedEdge)); + const hasChild = rootNodes.some( + (node) => node.outgoingEdges.some(check), + ); + // Don't use assert with a custom message here. Otherwise the + // inspection in the message is done eagerly and wastes a lot of CPU + // time. + if (!hasChild) { + throw new Error( + 'expected to find child ' + + `${util.inspect(expectedEdge)} in ${inspectNode(rootNodes)}`); + } + } + } + + if (expectation.detachedness !== undefined) { + const matchedNodes = rootNodes.filter( + (node) => node.detachedness === expectation.detachedness); + if (loose) { + assert(matchedNodes.length >= rootNodes.length, + `Expect to find at least ${rootNodes.length} with ` + + `detachedness ${expectation.detachedness}, ` + + `found ${matchedNodes.length}`); + } else { + assert.strictEqual( + matchedNodes.length, rootNodes.length, + `Expect to find ${rootNodes.length} with detachedness ` + + `${expectation.detachedness}, found ${matchedNodes.length}`); + } + } + } + } + + // Validate our internal embedded graph representation + validateGraph(rootName, expected, { loose = false } = {}) { + const rootNodes = this.embedderGraph.filter( + (node) => node.name === rootName, + ); + if (loose) { + assert(rootNodes.length >= expected.length, + `Expect to find at least ${expected.length} '${rootName}', ` + + `found ${rootNodes.length}`); + } else { + assert.strictEqual( + rootNodes.length, expected.length, + `Expect to find ${expected.length} '${rootName}', ` + + `found ${rootNodes.length}`); + } + for (const expectation of expected) { + if (expectation.children) { + for (const expectedEdge of expectation.children) { + const check = typeof expectedEdge === 'function' ? expectedEdge : + (edge) => (isEdge(edge, expectedEdge)); + // Don't use assert with a custom message here. Otherwise the + // inspection in the message is done eagerly and wastes a lot of CPU + // time. + const hasChild = rootNodes.some( + (node) => node.edges.some(check), + ); + if (!hasChild) { + throw new Error( + 'expected to find child ' + + `${util.inspect(expectedEdge)} in ${inspectNode(rootNodes)}`); + } + } + } + } + } + + validateSnapshotNodes(rootName, expected, { loose = false } = {}) { + this.validateSnapshot(rootName, expected, { loose }); + this.validateGraph(rootName, expected, { loose }); + } +} + +function recordState(stream = undefined) { + return new State(stream); +} + +function validateSnapshotNodes(...args) { + return recordState().validateSnapshotNodes(...args); +} + +function getHeapSnapshotOptionTests() { + const fixtures = require('../common/fixtures'); + const cases = [ + { + options: { exposeInternals: true }, + expected: [{ + children: [ + // We don't have anything special to test here yet + // because we don't use cppgc or embedder heap tracer. + { edge_name: 'nonNumeric', node_name: 'test' }, + ], + }], + }, + { + options: { exposeNumericValues: true }, + expected: [{ + children: [ + { edge_name: 'numeric', node_name: 'smi number' }, + ], + }], + }, + ]; + return { + fixtures: fixtures.path('klass-with-fields.js'), + check(snapshot, expected) { + snapshot.validateSnapshot('Klass', expected, { loose: true }); + }, + cases, + }; +} + +module.exports = { + recordState, + validateSnapshotNodes, + getHeapSnapshotOptionTests, +}; diff --git a/test/js/node/test/common/hijackstdio.js b/test/js/node/test/common/hijackstdio.js new file mode 100644 index 0000000000..749d6aab48 --- /dev/null +++ b/test/js/node/test/common/hijackstdio.js @@ -0,0 +1,32 @@ +'use strict'; + +// Hijack stdout and stderr +const stdWrite = {}; +function hijackStdWritable(name, listener) { + const stream = process[name]; + const _write = stdWrite[name] = stream.write; + + stream.writeTimes = 0; + stream.write = function(data, callback) { + try { + listener(data); + } catch (e) { + process.nextTick(() => { throw e; }); + } + + _write.call(stream, data, callback); + stream.writeTimes++; + }; +} + +function restoreWritable(name) { + process[name].write = stdWrite[name]; + delete process[name].writeTimes; +} + +module.exports = { + hijackStdout: hijackStdWritable.bind(null, 'stdout'), + hijackStderr: hijackStdWritable.bind(null, 'stderr'), + restoreStdout: restoreWritable.bind(null, 'stdout'), + restoreStderr: restoreWritable.bind(null, 'stderr'), +}; diff --git a/test/js/node/test/common/http2.js b/test/js/node/test/common/http2.js new file mode 100644 index 0000000000..6df1c29c09 --- /dev/null +++ b/test/js/node/test/common/http2.js @@ -0,0 +1,148 @@ +'use strict'; + +// An HTTP/2 testing tool used to create mock frames for direct testing +// of HTTP/2 endpoints. + +const kFrameData = Symbol('frame-data'); +const FLAG_EOS = 0x1; +const FLAG_ACK = 0x1; +const FLAG_EOH = 0x4; +const FLAG_PADDED = 0x8; +const PADDING = Buffer.alloc(255); + +const kClientMagic = Buffer.from('505249202a20485454502f322' + + 'e300d0a0d0a534d0d0a0d0a', 'hex'); + +const kFakeRequestHeaders = Buffer.from('828684410f7777772e65' + + '78616d706c652e636f6d', 'hex'); + + +const kFakeResponseHeaders = Buffer.from('4803333032580770726976617465611d' + + '4d6f6e2c203231204f63742032303133' + + '2032303a31333a323120474d546e1768' + + '747470733a2f2f7777772e6578616d70' + + '6c652e636f6d', 'hex'); + +function isUint32(val) { + return val >>> 0 === val; +} + +function isUint24(val) { + return val >>> 0 === val && val <= 0xFFFFFF; +} + +function isUint8(val) { + return val >>> 0 === val && val <= 0xFF; +} + +function write32BE(array, pos, val) { + if (!isUint32(val)) + throw new RangeError('val is not a 32-bit number'); + array[pos++] = (val >> 24) & 0xff; + array[pos++] = (val >> 16) & 0xff; + array[pos++] = (val >> 8) & 0xff; + array[pos++] = val & 0xff; +} + +function write24BE(array, pos, val) { + if (!isUint24(val)) + throw new RangeError('val is not a 24-bit number'); + array[pos++] = (val >> 16) & 0xff; + array[pos++] = (val >> 8) & 0xff; + array[pos++] = val & 0xff; +} + +function write8(array, pos, val) { + if (!isUint8(val)) + throw new RangeError('val is not an 8-bit number'); + array[pos] = val; +} + +class Frame { + constructor(length, type, flags, id) { + this[kFrameData] = Buffer.alloc(9); + write24BE(this[kFrameData], 0, length); + write8(this[kFrameData], 3, type); + write8(this[kFrameData], 4, flags); + write32BE(this[kFrameData], 5, id); + } + + get data() { + return this[kFrameData]; + } +} + +class SettingsFrame extends Frame { + constructor(ack = false) { + let flags = 0; + if (ack) + flags |= FLAG_ACK; + super(0, 4, flags, 0); + } +} + +class DataFrame extends Frame { + constructor(id, payload, padlen = 0, final = false) { + let len = payload.length; + let flags = 0; + if (final) flags |= FLAG_EOS; + const buffers = [payload]; + if (padlen > 0) { + buffers.unshift(Buffer.from([padlen])); + buffers.push(PADDING.slice(0, padlen)); + len += padlen + 1; + flags |= FLAG_PADDED; + } + super(len, 0, flags, id); + buffers.unshift(this[kFrameData]); + this[kFrameData] = Buffer.concat(buffers); + } +} + +class HeadersFrame extends Frame { + constructor(id, payload, padlen = 0, final = false) { + let len = payload.length; + let flags = FLAG_EOH; + if (final) flags |= FLAG_EOS; + const buffers = [payload]; + if (padlen > 0) { + buffers.unshift(Buffer.from([padlen])); + buffers.push(PADDING.slice(0, padlen)); + len += padlen + 1; + flags |= FLAG_PADDED; + } + super(len, 1, flags, id); + buffers.unshift(this[kFrameData]); + this[kFrameData] = Buffer.concat(buffers); + } +} + +class PingFrame extends Frame { + constructor(ack = false) { + const buffers = [Buffer.alloc(8)]; + super(8, 6, ack ? 1 : 0, 0); + buffers.unshift(this[kFrameData]); + this[kFrameData] = Buffer.concat(buffers); + } +} + +class AltSvcFrame extends Frame { + constructor(size) { + const buffers = [Buffer.alloc(size)]; + super(size, 10, 0, 0); + buffers.unshift(this[kFrameData]); + this[kFrameData] = Buffer.concat(buffers); + } +} + +module.exports = { + Frame, + AltSvcFrame, + DataFrame, + HeadersFrame, + SettingsFrame, + PingFrame, + kFakeRequestHeaders, + kFakeResponseHeaders, + kClientMagic, +}; diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js new file mode 100644 index 0000000000..ea21d15c10 --- /dev/null +++ b/test/js/node/test/common/index.js @@ -0,0 +1,1156 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/* eslint-disable node-core/crypto-check */ +'use strict'; +const process = global.process; // Some tests tamper with the process global. + +const assert = require('assert'); +const { exec, execSync, spawn, spawnSync } = require('child_process'); +const fs = require('fs'); +const net = require('net'); +// Do not require 'os' until needed so that test-os-checked-function can +// monkey patch it. If 'os' is required here, that test will fail. +const path = require('path'); +const { inspect } = require('util'); +const { isMainThread } = require('worker_threads'); +const { isModuleNamespaceObject } = require('util/types'); + +const tmpdir = require('./tmpdir'); +const bits = ['arm64', 'loong64', 'mips', 'mipsel', 'ppc64', 'riscv64', 's390x', 'x64'] + .includes(process.arch) ? 64 : 32; +const hasIntl = !!process.config.variables.v8_enable_i18n_support; + +const { + atob, + btoa, +} = require('buffer'); + +// Some tests assume a umask of 0o022 so set that up front. Tests that need a +// different umask will set it themselves. +// +// Workers can read, but not set the umask, so check that this is the main +// thread. +if (isMainThread) + process.umask(0o022); + +const noop = () => {}; + +const hasCrypto = Boolean(process.versions.openssl) && + !process.env.NODE_SKIP_CRYPTO; + +// Synthesize OPENSSL_VERSION_NUMBER format with the layout 0xMNN00PPSL +const opensslVersionNumber = (major = 0, minor = 0, patch = 0) => { + assert(major >= 0 && major <= 0xf); + assert(minor >= 0 && minor <= 0xff); + assert(patch >= 0 && patch <= 0xff); + return (major << 28) | (minor << 20) | (patch << 4); +}; + +let OPENSSL_VERSION_NUMBER; +const hasOpenSSL = (major = 0, minor = 0, patch = 0) => { + if (!hasCrypto) return false; + if (OPENSSL_VERSION_NUMBER === undefined) { + const regexp = /(?\d+)\.(?\d+)\.(?

\d+)/; + const { m, n, p } = process.versions.openssl.match(regexp).groups; + OPENSSL_VERSION_NUMBER = opensslVersionNumber(m, n, p); + } + return OPENSSL_VERSION_NUMBER >= opensslVersionNumber(major, minor, patch); +}; + +const hasQuic = hasCrypto && !!process.config.variables.openssl_quic; + +function parseTestFlags(filename = process.argv[1]) { + // The copyright notice is relatively big and the flags could come afterwards. + const bytesToRead = 1500; + const buffer = Buffer.allocUnsafe(bytesToRead); + const fd = fs.openSync(filename, 'r'); + const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead); + fs.closeSync(fd); + const source = buffer.toString('utf8', 0, bytesRead); + + const flagStart = source.search(/\/\/ Flags:\s+--/) + 10; + + if (flagStart === 9) { + return []; + } + let flagEnd = source.indexOf('\n', flagStart); + // Normalize different EOL. + if (source[flagEnd - 1] === '\r') { + flagEnd--; + } + return source + .substring(flagStart, flagEnd) + .split(/\s+/) + .filter(Boolean); +} + +// Check for flags. Skip this for workers (both, the `cluster` module and +// `worker_threads`) and child processes. +// If the binary was built without-ssl then the crypto flags are +// invalid (bad option). The test itself should handle this case. +if (process.argv.length === 2 && + !process.env.NODE_SKIP_FLAG_CHECK && + isMainThread && + hasCrypto && + require('cluster').isPrimary && + fs.existsSync(process.argv[1])) { + const flags = parseTestFlags(); + for (const flag of flags) { + if (!process.execArgv.includes(flag) && + // If the binary is build without `intl` the inspect option is + // invalid. The test itself should handle this case. + (process.features.inspector || !flag.startsWith('--inspect'))) { + console.log( + 'NOTE: The test started as a child_process using these flags:', + inspect(flags), + 'Use NODE_SKIP_FLAG_CHECK to run the test with the original flags.', + ); + const args = [...flags, ...process.execArgv, ...process.argv.slice(1)]; + const options = { encoding: 'utf8', stdio: 'inherit' }; + const result = spawnSync(process.execPath, args, options); + if (result.signal) { + process.kill(0, result.signal); + } else { + process.exit(result.status); + } + } + } +} + +const isWindows = process.platform === 'win32'; +const isSunOS = process.platform === 'sunos'; +const isFreeBSD = process.platform === 'freebsd'; +const isOpenBSD = process.platform === 'openbsd'; +const isLinux = process.platform === 'linux'; +const isOSX = process.platform === 'darwin'; +const isASan = process.config.variables.asan === 1; +const isPi = (() => { + try { + // Normal Raspberry Pi detection is to find the `Raspberry Pi` string in + // the contents of `/sys/firmware/devicetree/base/model` but that doesn't + // work inside a container. Match the chipset model number instead. + const cpuinfo = fs.readFileSync('/proc/cpuinfo', { encoding: 'utf8' }); + const ok = /^Hardware\s*:\s*(.*)$/im.exec(cpuinfo)?.[1] === 'BCM2835'; + /^/.test(''); // Clear RegExp.$_, some tests expect it to be empty. + return ok; + } catch { + return false; + } +})(); + +const isDumbTerminal = process.env.TERM === 'dumb'; + +// When using high concurrency or in the CI we need much more time for each connection attempt +net.setDefaultAutoSelectFamilyAttemptTimeout(platformTimeout(net.getDefaultAutoSelectFamilyAttemptTimeout() * 10)); +const defaultAutoSelectFamilyAttemptTimeout = net.getDefaultAutoSelectFamilyAttemptTimeout(); + +const buildType = process.config.target_defaults ? + process.config.target_defaults.default_configuration : + 'Release'; + +// If env var is set then enable async_hook hooks for all tests. +if (process.env.NODE_TEST_WITH_ASYNC_HOOKS) { + const destroydIdsList = {}; + const destroyListList = {}; + const initHandles = {}; + const { internalBinding } = require('internal/test/binding'); + const async_wrap = internalBinding('async_wrap'); + + process.on('exit', () => { + // Iterate through handles to make sure nothing crashes + for (const k in initHandles) + inspect(initHandles[k]); + }); + + const _queueDestroyAsyncId = async_wrap.queueDestroyAsyncId; + async_wrap.queueDestroyAsyncId = function queueDestroyAsyncId(id) { + if (destroyListList[id] !== undefined) { + process._rawDebug(destroyListList[id]); + process._rawDebug(); + throw new Error(`same id added to destroy list twice (${id})`); + } + destroyListList[id] = inspect(new Error()); + _queueDestroyAsyncId(id); + }; + + require('async_hooks').createHook({ + init(id, ty, tr, resource) { + if (initHandles[id]) { + process._rawDebug( + `Is same resource: ${resource === initHandles[id].resource}`); + process._rawDebug(`Previous stack:\n${initHandles[id].stack}\n`); + throw new Error(`init called twice for same id (${id})`); + } + initHandles[id] = { + resource, + stack: inspect(new Error()).slice(6), + }; + }, + before() { }, + after() { }, + destroy(id) { + if (destroydIdsList[id] !== undefined) { + process._rawDebug(destroydIdsList[id]); + process._rawDebug(); + throw new Error(`destroy called for same id (${id})`); + } + destroydIdsList[id] = inspect(new Error()); + }, + }).enable(); +} + +let opensslCli = null; +let inFreeBSDJail = null; +let localhostIPv4 = null; + +const localIPv6Hosts = + isLinux ? [ + // Debian/Ubuntu + 'ip6-localhost', + 'ip6-loopback', + + // SUSE + 'ipv6-localhost', + 'ipv6-loopback', + + // Typically universal + 'localhost', + ] : [ 'localhost' ]; + +const PIPE = (() => { + const localRelative = path.relative(process.cwd(), `${tmpdir.path}/`); + const pipePrefix = isWindows ? '\\\\.\\pipe\\' : localRelative; + const pipeName = `node-test.${process.pid}.sock`; + return path.join(pipePrefix, pipeName); +})(); + +// Check that when running a test with +// `$node --abort-on-uncaught-exception $file child` +// the process aborts. +function childShouldThrowAndAbort() { + let testCmd = ''; + if (!isWindows) { + // Do not create core files, as it can take a lot of disk space on + // continuous testing and developers' machines + testCmd += 'ulimit -c 0 && '; + } + testCmd += `"${process.argv[0]}" --abort-on-uncaught-exception `; + testCmd += `"${process.argv[1]}" child`; + const child = exec(testCmd); + child.on('exit', function onExit(exitCode, signal) { + const errMsg = 'Test should have aborted ' + + `but instead exited with exit code ${exitCode}` + + ` and signal ${signal}`; + assert(nodeProcessAborted(exitCode, signal), errMsg); + }); +} + +function createZeroFilledFile(filename) { + const fd = fs.openSync(filename, 'w'); + fs.ftruncateSync(fd, 10 * 1024 * 1024); + fs.closeSync(fd); +} + + +const pwdCommand = isWindows ? + ['cmd.exe', ['/d', '/c', 'cd']] : + ['pwd', []]; + + +function platformTimeout(ms) { + const multipliers = typeof ms === 'bigint' ? + { two: 2n, four: 4n, seven: 7n } : { two: 2, four: 4, seven: 7 }; + + if (process.features.debug) + ms = multipliers.two * ms; + + if (exports.isAIX || exports.isIBMi) + return multipliers.two * ms; // Default localhost speed is slower on AIX + + if (isPi) + return multipliers.two * ms; // Raspberry Pi devices + + return ms; +} + +let knownGlobals = [ + AbortController, + atob, + btoa, + clearImmediate, + clearInterval, + clearTimeout, + global, + setImmediate, + setInterval, + setTimeout, + queueMicrotask, +]; + +if (global.gc) { + knownGlobals.push(global.gc); +} + +if (global.navigator) { + knownGlobals.push(global.navigator); +} + +if (global.Navigator) { + knownGlobals.push(global.Navigator); +} + +if (global.Performance) { + knownGlobals.push(global.Performance); +} +if (global.performance) { + knownGlobals.push(global.performance); +} +if (global.PerformanceMark) { + knownGlobals.push(global.PerformanceMark); +} +if (global.PerformanceMeasure) { + knownGlobals.push(global.PerformanceMeasure); +} + +// TODO(@ethan-arrowood): Similar to previous checks, this can be temporary +// until v16.x is EOL. Once all supported versions have structuredClone we +// can add this to the list above instead. +if (global.structuredClone) { + knownGlobals.push(global.structuredClone); +} + +if (global.EventSource) { + knownGlobals.push(EventSource); +} + +if (global.fetch) { + knownGlobals.push(fetch); +} +if (hasCrypto && global.crypto) { + knownGlobals.push(global.crypto); + knownGlobals.push(global.Crypto); + knownGlobals.push(global.CryptoKey); + knownGlobals.push(global.SubtleCrypto); +} +if (global.CustomEvent) { + knownGlobals.push(global.CustomEvent); +} +if (global.ReadableStream) { + knownGlobals.push( + global.ReadableStream, + global.ReadableStreamDefaultReader, + global.ReadableStreamBYOBReader, + global.ReadableStreamBYOBRequest, + global.ReadableByteStreamController, + global.ReadableStreamDefaultController, + global.TransformStream, + global.TransformStreamDefaultController, + global.WritableStream, + global.WritableStreamDefaultWriter, + global.WritableStreamDefaultController, + global.ByteLengthQueuingStrategy, + global.CountQueuingStrategy, + global.TextEncoderStream, + global.TextDecoderStream, + global.CompressionStream, + global.DecompressionStream, + ); +} + +if (global.Storage) { + knownGlobals.push( + global.localStorage, + global.sessionStorage, + global.Storage, + ); +} + +function allowGlobals(...allowlist) { + knownGlobals = knownGlobals.concat(allowlist); +} + +if (process.env.NODE_TEST_KNOWN_GLOBALS !== '0') { + if (process.env.NODE_TEST_KNOWN_GLOBALS) { + const knownFromEnv = process.env.NODE_TEST_KNOWN_GLOBALS.split(','); + allowGlobals(...knownFromEnv); + } + + function leakedGlobals() { + const leaked = []; + + for (const val in global) { + // globalThis.crypto is a getter that throws if Node.js was compiled + // without OpenSSL. + if (val !== 'crypto' && !knownGlobals.includes(global[val])) { + leaked.push(val); + } + } + + return leaked; + } + + process.on('exit', function() { + const leaked = leakedGlobals(); + if (leaked.length > 0) { + assert.fail(`Unexpected global(s) found: ${leaked.join(', ')}`); + } + }); +} + +const mustCallChecks = []; + +function runCallChecks(exitCode) { + if (exitCode !== 0) return; + + const failed = mustCallChecks.filter(function(context) { + if ('minimum' in context) { + context.messageSegment = `at least ${context.minimum}`; + return context.actual < context.minimum; + } + context.messageSegment = `exactly ${context.exact}`; + return context.actual !== context.exact; + }); + + failed.forEach(function(context) { + console.log('Mismatched %s function calls. Expected %s, actual %d.', + context.name, + context.messageSegment, + context.actual); + console.log(context.stack.split('\n').slice(2).join('\n')); + }); + + if (failed.length) process.exit(1); +} + +function mustCall(fn, exact) { + return _mustCallInner(fn, exact, 'exact'); +} + +function mustSucceed(fn, exact) { + return mustCall(function(err, ...args) { + assert.ifError(err); + if (typeof fn === 'function') + return fn.apply(this, args); + }, exact); +} + +function mustCallAtLeast(fn, minimum) { + return _mustCallInner(fn, minimum, 'minimum'); +} + +function _mustCallInner(fn, criteria = 1, field) { + if (process._exiting) + throw new Error('Cannot use common.mustCall*() in process exit handler'); + if (typeof fn === 'number') { + criteria = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + + if (typeof criteria !== 'number') + throw new TypeError(`Invalid ${field} value: ${criteria}`); + + const context = { + [field]: criteria, + actual: 0, + stack: inspect(new Error()), + name: fn.name || '', + }; + + // Add the exit listener only once to avoid listener leak warnings + if (mustCallChecks.length === 0) process.on('exit', runCallChecks); + + mustCallChecks.push(context); + + const _return = function() { // eslint-disable-line func-style + context.actual++; + return fn.apply(this, arguments); + }; + // Function instances have own properties that may be relevant. + // Let's replicate those properties to the returned function. + // Refs: https://tc39.es/ecma262/#sec-function-instances + Object.defineProperties(_return, { + name: { + value: fn.name, + writable: false, + enumerable: false, + configurable: true, + }, + length: { + value: fn.length, + writable: false, + enumerable: false, + configurable: true, + }, + }); + return _return; +} + +function hasMultiLocalhost() { + const { internalBinding } = require('internal/test/binding'); + const { TCP, constants: TCPConstants } = internalBinding('tcp_wrap'); + const t = new TCP(TCPConstants.SOCKET); + const ret = t.bind('127.0.0.2', 0); + t.close(); + return ret === 0; +} + +function skipIfEslintMissing() { + if (!fs.existsSync( + path.join(__dirname, '..', '..', 'tools', 'eslint', 'node_modules', 'eslint'), + )) { + skip('missing ESLint'); + } +} + +function canCreateSymLink() { + // On Windows, creating symlinks requires admin privileges. + // We'll only try to run symlink test if we have enough privileges. + // On other platforms, creating symlinks shouldn't need admin privileges + if (isWindows) { + // whoami.exe needs to be the one from System32 + // If unix tools are in the path, they can shadow the one we want, + // so use the full path while executing whoami + const whoamiPath = path.join(process.env.SystemRoot, + 'System32', 'whoami.exe'); + + try { + const output = execSync(`${whoamiPath} /priv`, { timeout: 1000 }); + return output.includes('SeCreateSymbolicLinkPrivilege'); + } catch { + return false; + } + } + // On non-Windows platforms, this always returns `true` + return true; +} + +function getCallSite(top) { + const originalStackFormatter = Error.prepareStackTrace; + Error.prepareStackTrace = (err, stack) => + `${stack[0].getFileName()}:${stack[0].getLineNumber()}`; + const err = new Error(); + Error.captureStackTrace(err, top); + // With the V8 Error API, the stack is not formatted until it is accessed + err.stack; // eslint-disable-line no-unused-expressions + Error.prepareStackTrace = originalStackFormatter; + return err.stack; +} + +function mustNotCall(msg) { + const callSite = getCallSite(mustNotCall); + return function mustNotCall(...args) { + const argsInfo = args.length > 0 ? + `\ncalled with arguments: ${args.map((arg) => inspect(arg)).join(', ')}` : ''; + assert.fail( + `${msg || 'function should not have been called'} at ${callSite}` + + argsInfo); + }; +} + +const _mustNotMutateObjectDeepProxies = new WeakMap(); + +function mustNotMutateObjectDeep(original) { + // Return primitives and functions directly. Primitives are immutable, and + // proxied functions are impossible to compare against originals, e.g. with + // `assert.deepEqual()`. + if (original === null || typeof original !== 'object') { + return original; + } + + const cachedProxy = _mustNotMutateObjectDeepProxies.get(original); + if (cachedProxy) { + return cachedProxy; + } + + const _mustNotMutateObjectDeepHandler = { + __proto__: null, + defineProperty(target, property, descriptor) { + assert.fail(`Expected no side effects, got ${inspect(property)} ` + + 'defined'); + }, + deleteProperty(target, property) { + assert.fail(`Expected no side effects, got ${inspect(property)} ` + + 'deleted'); + }, + get(target, prop, receiver) { + return mustNotMutateObjectDeep(Reflect.get(target, prop, receiver)); + }, + preventExtensions(target) { + assert.fail('Expected no side effects, got extensions prevented on ' + + inspect(target)); + }, + set(target, property, value, receiver) { + assert.fail(`Expected no side effects, got ${inspect(value)} ` + + `assigned to ${inspect(property)}`); + }, + setPrototypeOf(target, prototype) { + assert.fail(`Expected no side effects, got set prototype to ${prototype}`); + }, + }; + + const proxy = new Proxy(original, _mustNotMutateObjectDeepHandler); + _mustNotMutateObjectDeepProxies.set(original, proxy); + return proxy; +} + +function printSkipMessage(msg) { + console.log(`1..0 # Skipped: ${msg}`); +} + +function skip(msg) { + printSkipMessage(msg); + // In known_issues test, skipping should produce a non-zero exit code. + process.exit(require.main?.filename.startsWith(path.resolve(__dirname, '../known_issues/')) ? 1 : 0); +} + +// Returns true if the exit code "exitCode" and/or signal name "signal" +// represent the exit code and/or signal name of a node process that aborted, +// false otherwise. +function nodeProcessAborted(exitCode, signal) { + // Depending on the compiler used, node will exit with either + // exit code 132 (SIGILL), 133 (SIGTRAP) or 134 (SIGABRT). + let expectedExitCodes = [132, 133, 134]; + + // On platforms using KSH as the default shell (like SmartOS), + // when a process aborts, KSH exits with an exit code that is + // greater than 256, and thus the exit code emitted with the 'exit' + // event is null and the signal is set to either SIGILL, SIGTRAP, + // or SIGABRT (depending on the compiler). + const expectedSignals = ['SIGILL', 'SIGTRAP', 'SIGABRT']; + + // On Windows, 'aborts' are of 2 types, depending on the context: + // (i) Exception breakpoint, if --abort-on-uncaught-exception is on + // which corresponds to exit code 2147483651 (0x80000003) + // (ii) Otherwise, _exit(134) which is called in place of abort() due to + // raising SIGABRT exiting with ambiguous exit code '3' by default + if (isWindows) + expectedExitCodes = [0x80000003, 134]; + + // When using --abort-on-uncaught-exception, V8 will use + // base::OS::Abort to terminate the process. + // Depending on the compiler used, the shell or other aspects of + // the platform used to build the node binary, this will actually + // make V8 exit by aborting or by raising a signal. In any case, + // one of them (exit code or signal) needs to be set to one of + // the expected exit codes or signals. + if (signal !== null) { + return expectedSignals.includes(signal); + } + return expectedExitCodes.includes(exitCode); +} + +function isAlive(pid) { + try { + process.kill(pid, 'SIGCONT'); + return true; + } catch { + return false; + } +} + +function _expectWarning(name, expected, code) { + if (typeof expected === 'string') { + expected = [[expected, code]]; + } else if (!Array.isArray(expected)) { + expected = Object.entries(expected).map(([a, b]) => [b, a]); + } else if (expected.length !== 0 && !Array.isArray(expected[0])) { + expected = [[expected[0], expected[1]]]; + } + // Deprecation codes are mandatory, everything else is not. + if (name === 'DeprecationWarning') { + expected.forEach(([_, code]) => assert(code, `Missing deprecation code: ${expected}`)); + } + return mustCall((warning) => { + const expectedProperties = expected.shift(); + if (!expectedProperties) { + assert.fail(`Unexpected extra warning received: ${warning}`); + } + const [ message, code ] = expectedProperties; + assert.strictEqual(warning.name, name); + if (typeof message === 'string') { + assert.strictEqual(warning.message, message); + } else { + assert.match(warning.message, message); + } + assert.strictEqual(warning.code, code); + }, expected.length); +} + +let catchWarning; + +// Accepts a warning name and description or array of descriptions or a map of +// warning names to description(s) ensures a warning is generated for each +// name/description pair. +// The expected messages have to be unique per `expectWarning()` call. +function expectWarning(nameOrMap, expected, code) { + if (catchWarning === undefined) { + catchWarning = {}; + process.on('warning', (warning) => { + if (!catchWarning[warning.name]) { + throw new TypeError( + `"${warning.name}" was triggered without being expected.\n` + + inspect(warning), + ); + } + catchWarning[warning.name](warning); + }); + } + if (typeof nameOrMap === 'string') { + catchWarning[nameOrMap] = _expectWarning(nameOrMap, expected, code); + } else { + Object.keys(nameOrMap).forEach((name) => { + catchWarning[name] = _expectWarning(name, nameOrMap[name]); + }); + } +} + +// Useful for testing expected internal/error objects +function expectsError(validator, exact) { + return mustCall((...args) => { + if (args.length !== 1) { + // Do not use `assert.strictEqual()` to prevent `inspect` from + // always being called. + assert.fail(`Expected one argument, got ${inspect(args)}`); + } + const error = args.pop(); + // The error message should be non-enumerable + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(error, 'message'), false); + + assert.throws(() => { throw error; }, validator); + return true; + }, exact); +} + +function skipIfInspectorDisabled() { + if (!process.features.inspector) { + skip('V8 inspector is disabled'); + } +} + +function skipIf32Bits() { + if (bits < 64) { + skip('The tested feature is not available in 32bit builds'); + } +} + +function skipIfWorker() { + if (!isMainThread) { + skip('This test only works on a main thread'); + } +} + +function getArrayBufferViews(buf) { + const { buffer, byteOffset, byteLength } = buf; + + const out = []; + + const arrayBufferViews = [ + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, + DataView, + ]; + + for (const type of arrayBufferViews) { + const { BYTES_PER_ELEMENT = 1 } = type; + if (byteLength % BYTES_PER_ELEMENT === 0) { + out.push(new type(buffer, byteOffset, byteLength / BYTES_PER_ELEMENT)); + } + } + return out; +} + +function getBufferSources(buf) { + return [...getArrayBufferViews(buf), new Uint8Array(buf).buffer]; +} + +function getTTYfd() { + // Do our best to grab a tty fd. + const tty = require('tty'); + // Don't attempt fd 0 as it is not writable on Windows. + // Ref: ef2861961c3d9e9ed6972e1e84d969683b25cf95 + const ttyFd = [1, 2, 4, 5].find(tty.isatty); + if (ttyFd === undefined) { + try { + return fs.openSync('/dev/tty'); + } catch { + // There aren't any tty fd's available to use. + return -1; + } + } + return ttyFd; +} + +function runWithInvalidFD(func) { + let fd = 1 << 30; + // Get first known bad file descriptor. 1 << 30 is usually unlikely to + // be an valid one. + try { + while (fs.fstatSync(fd--) && fd > 0); + } catch { + return func(fd); + } + + printSkipMessage('Could not generate an invalid fd'); +} + +// A helper function to simplify checking for ERR_INVALID_ARG_TYPE output. +function invalidArgTypeHelper(input) { + if (input == null) { + return ` Received ${input}`; + } + if (typeof input === 'function') { + return ` Received function ${input.name}`; + } + if (typeof input === 'object') { + if (input.constructor?.name) { + return ` Received an instance of ${input.constructor.name}`; + } + return ` Received ${inspect(input, { depth: -1 })}`; + } + + let inspected = inspect(input, { colors: false }); + if (inspected.length > 28) { inspected = `${inspected.slice(inspected, 0, 25)}...`; } + + return ` Received type ${typeof input} (${inspected})`; +} + +function skipIfDumbTerminal() { + if (isDumbTerminal) { + skip('skipping - dumb terminal'); + } +} + +function gcUntil(name, condition) { + if (typeof name === 'function') { + condition = name; + name = undefined; + } + return new Promise((resolve, reject) => { + let count = 0; + function gcAndCheck() { + setImmediate(() => { + count++; + global.gc(); + if (condition()) { + resolve(); + } else if (count < 10) { + gcAndCheck(); + } else { + reject(name === undefined ? undefined : 'Test ' + name + ' failed'); + } + }); + } + gcAndCheck(); + }); +} + +function requireNoPackageJSONAbove(dir = __dirname) { + let possiblePackage = path.join(dir, '..', 'package.json'); + let lastPackage = null; + while (possiblePackage !== lastPackage) { + if (fs.existsSync(possiblePackage)) { + assert.fail( + 'This test shouldn\'t load properties from a package.json above ' + + `its file location. Found package.json at ${possiblePackage}.`); + } + lastPackage = possiblePackage; + possiblePackage = path.join(possiblePackage, '..', '..', 'package.json'); + } +} + +function spawnPromisified(...args) { + let stderr = ''; + let stdout = ''; + + const child = spawn(...args); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (data) => { stderr += data; }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (data) => { stdout += data; }); + + return new Promise((resolve, reject) => { + child.on('close', (code, signal) => { + resolve({ + code, + signal, + stderr, + stdout, + }); + }); + child.on('error', (code, signal) => { + reject({ + code, + signal, + stderr, + stdout, + }); + }); + }); +} + +function getPrintedStackTrace(stderr) { + const lines = stderr.split('\n'); + + let state = 'initial'; + const result = { + message: [], + nativeStack: [], + jsStack: [], + }; + for (let i = 0; i < lines.length; ++i) { + const line = lines[i].trim(); + if (line.length === 0) { + continue; // Skip empty lines. + } + + switch (state) { + case 'initial': + result.message.push(line); + if (line.includes('Native stack trace')) { + state = 'native-stack'; + } else { + result.message.push(line); + } + break; + case 'native-stack': + if (line.includes('JavaScript stack trace')) { + state = 'js-stack'; + } else { + result.nativeStack.push(line); + } + break; + case 'js-stack': + result.jsStack.push(line); + break; + } + } + return result; +} + +/** + * Check the exports of require(esm). + * TODO(joyeecheung): use it in all the test-require-module-* tests to minimize changes + * if/when we change the layout of the result returned by require(esm). + * @param {object} mod result returned by require() + * @param {object} expectation shape of expected namespace. + */ +function expectRequiredModule(mod, expectation) { + assert(isModuleNamespaceObject(mod)); + assert.deepStrictEqual({ ...mod }, { ...expectation }); +} + +const common = { + allowGlobals, + buildType, + canCreateSymLink, + childShouldThrowAndAbort, + createZeroFilledFile, + defaultAutoSelectFamilyAttemptTimeout, + expectsError, + expectRequiredModule, + expectWarning, + gcUntil, + getArrayBufferViews, + getBufferSources, + getCallSite, + getPrintedStackTrace, + getTTYfd, + hasIntl, + hasCrypto, + hasOpenSSL, + hasQuic, + hasMultiLocalhost, + invalidArgTypeHelper, + isAlive, + isASan, + isDumbTerminal, + isFreeBSD, + isLinux, + isMainThread, + isOpenBSD, + isOSX, + isPi, + isSunOS, + isWindows, + localIPv6Hosts, + mustCall, + mustCallAtLeast, + mustNotCall, + mustNotMutateObjectDeep, + mustSucceed, + nodeProcessAborted, + PIPE, + parseTestFlags, + platformTimeout, + printSkipMessage, + pwdCommand, + requireNoPackageJSONAbove, + runWithInvalidFD, + skip, + skipIf32Bits, + skipIfDumbTerminal, + skipIfEslintMissing, + skipIfInspectorDisabled, + skipIfWorker, + spawnPromisified, + + get enoughTestMem() { + return require('os').totalmem() > 0x70000000; /* 1.75 Gb */ + }, + + get hasFipsCrypto() { + return hasCrypto && require('crypto').getFips(); + }, + + get hasIPv6() { + const iFaces = require('os').networkInterfaces(); + let re; + if (isWindows) { + re = /Loopback Pseudo-Interface/; + } else if (this.isIBMi) { + re = /\*LOOPBACK/; + } else { + re = /lo/; + } + return Object.keys(iFaces).some((name) => { + return re.test(name) && + iFaces[name].some(({ family }) => family === 'IPv6'); + }); + }, + + get hasOpenSSL3() { + return hasOpenSSL(3); + }, + + get hasOpenSSL31() { + return hasOpenSSL(3, 1); + }, + + get hasOpenSSL32() { + return hasOpenSSL(3, 2); + }, + + get inFreeBSDJail() { + if (inFreeBSDJail !== null) return inFreeBSDJail; + + if (exports.isFreeBSD && + execSync('sysctl -n security.jail.jailed').toString() === '1\n') { + inFreeBSDJail = true; + } else { + inFreeBSDJail = false; + } + return inFreeBSDJail; + }, + + // On IBMi, process.platform and os.platform() both return 'aix', + // when built with Python versions earlier than 3.9. + // It is not enough to differentiate between IBMi and real AIX system. + get isAIX() { + return require('os').type() === 'AIX'; + }, + + get isIBMi() { + return require('os').type() === 'OS400'; + }, + + get isLinuxPPCBE() { + return (process.platform === 'linux') && (process.arch === 'ppc64') && + (require('os').endianness() === 'BE'); + }, + + get localhostIPv4() { + if (localhostIPv4 !== null) return localhostIPv4; + + if (this.inFreeBSDJail) { + // Jailed network interfaces are a bit special - since we need to jump + // through loops, as well as this being an exception case, assume the + // user will provide this instead. + if (process.env.LOCALHOST) { + localhostIPv4 = process.env.LOCALHOST; + } else { + console.error('Looks like we\'re in a FreeBSD Jail. ' + + 'Please provide your default interface address ' + + 'as LOCALHOST or expect some tests to fail.'); + } + } + + if (localhostIPv4 === null) localhostIPv4 = '127.0.0.1'; + + return localhostIPv4; + }, + + // opensslCli defined lazily to reduce overhead of spawnSync + get opensslCli() { + if (opensslCli !== null) return opensslCli; + + if (process.config.variables.node_shared_openssl) { + // Use external command + opensslCli = 'openssl'; + } else { + // Use command built from sources included in Node.js repository + opensslCli = path.join(path.dirname(process.execPath), 'openssl-cli'); + } + + if (exports.isWindows) opensslCli += '.exe'; + + const opensslCmd = spawnSync(opensslCli, ['version']); + if (opensslCmd.status !== 0 || opensslCmd.error !== undefined) { + // OpenSSL command cannot be executed + opensslCli = false; + } + return opensslCli; + }, + + get PORT() { + if (+process.env.TEST_PARALLEL) { + throw new Error('common.PORT cannot be used in a parallelized test'); + } + return +process.env.NODE_COMMON_PORT || 12346; + }, + + /** + * Returns the EOL character used by this Git checkout. + */ + get checkoutEOL() { + return fs.readFileSync(__filename).includes('\r\n') ? '\r\n' : '\n'; + }, +}; + +const validProperties = new Set(Object.keys(common)); +module.exports = new Proxy(common, { + get(obj, prop) { + if (!validProperties.has(prop)) + throw new Error(`Using invalid common property: '${prop}'`); + return obj[prop]; + }, +}); diff --git a/test/js/node/test/common/index.mjs b/test/js/node/test/common/index.mjs new file mode 100644 index 0000000000..430527faf8 --- /dev/null +++ b/test/js/node/test/common/index.mjs @@ -0,0 +1,110 @@ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const common = require('./index.js'); + +const { + allowGlobals, + buildType, + canCreateSymLink, + checkoutEOL, + childShouldThrowAndAbort, + createZeroFilledFile, + enoughTestMem, + expectsError, + expectWarning, + getArrayBufferViews, + getBufferSources, + getCallSite, + getTTYfd, + hasCrypto, + hasIntl, + hasIPv6, + hasMultiLocalhost, + isAIX, + isAlive, + isDumbTerminal, + isFreeBSD, + isIBMi, + isLinux, + isLinuxPPCBE, + isMainThread, + isOpenBSD, + isOSX, + isSunOS, + isWindows, + localIPv6Hosts, + mustCall, + mustCallAtLeast, + mustNotCall, + mustNotMutateObjectDeep, + mustSucceed, + nodeProcessAborted, + opensslCli, + parseTestFlags, + PIPE, + platformTimeout, + printSkipMessage, + runWithInvalidFD, + skip, + skipIf32Bits, + skipIfDumbTerminal, + skipIfEslintMissing, + skipIfInspectorDisabled, + spawnPromisified, +} = common; + +const getPort = () => common.PORT; + +export { + allowGlobals, + buildType, + canCreateSymLink, + checkoutEOL, + childShouldThrowAndAbort, + createRequire, + createZeroFilledFile, + enoughTestMem, + expectsError, + expectWarning, + getArrayBufferViews, + getBufferSources, + getCallSite, + getPort, + getTTYfd, + hasCrypto, + hasIntl, + hasIPv6, + hasMultiLocalhost, + isAIX, + isAlive, + isDumbTerminal, + isFreeBSD, + isIBMi, + isLinux, + isLinuxPPCBE, + isMainThread, + isOpenBSD, + isOSX, + isSunOS, + isWindows, + localIPv6Hosts, + mustCall, + mustCallAtLeast, + mustNotCall, + mustNotMutateObjectDeep, + mustSucceed, + nodeProcessAborted, + opensslCli, + parseTestFlags, + PIPE, + platformTimeout, + printSkipMessage, + runWithInvalidFD, + skip, + skipIf32Bits, + skipIfDumbTerminal, + skipIfEslintMissing, + skipIfInspectorDisabled, + spawnPromisified, +}; diff --git a/test/js/node/test/common/inspector-helper.js b/test/js/node/test/common/inspector-helper.js new file mode 100644 index 0000000000..2c4d4af6de --- /dev/null +++ b/test/js/node/test/common/inspector-helper.js @@ -0,0 +1,537 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const fixtures = require('../common/fixtures'); +const { spawn } = require('child_process'); +const { URL, pathToFileURL } = require('url'); +const { EventEmitter } = require('events'); + +const _MAINSCRIPT = fixtures.path('loop.js'); +const DEBUG = false; +const TIMEOUT = common.platformTimeout(15 * 1000); + +function spawnChildProcess(inspectorFlags, scriptContents, scriptFile) { + const args = [].concat(inspectorFlags); + if (scriptContents) { + args.push('-e', scriptContents); + } else { + args.push(scriptFile); + } + const child = spawn(process.execPath, args); + + const handler = tearDown.bind(null, child); + process.on('exit', handler); + process.on('uncaughtException', handler); + process.on('unhandledRejection', handler); + process.on('SIGINT', handler); + + return child; +} + +function makeBufferingDataCallback(dataCallback) { + let buffer = Buffer.alloc(0); + return (data) => { + const newData = Buffer.concat([buffer, data]); + const str = newData.toString('utf8'); + const lines = str.replace(/\r/g, '').split('\n'); + if (str.endsWith('\n')) + buffer = Buffer.alloc(0); + else + buffer = Buffer.from(lines.pop(), 'utf8'); + for (const line of lines) + dataCallback(line); + }; +} + +function tearDown(child, err) { + child.kill(); + if (err) { + console.error(err); + process.exit(1); + } +} + +function parseWSFrame(buffer) { + // Protocol described in https://tools.ietf.org/html/rfc6455#section-5 + let message = null; + if (buffer.length < 2) + return { length: 0, message }; + if (buffer[0] === 0x88 && buffer[1] === 0x00) { + return { length: 2, message, closed: true }; + } + assert.strictEqual(buffer[0], 0x81); + let dataLen = 0x7F & buffer[1]; + let bodyOffset = 2; + if (buffer.length < bodyOffset + dataLen) + return 0; + if (dataLen === 126) { + dataLen = buffer.readUInt16BE(2); + bodyOffset = 4; + } else if (dataLen === 127) { + assert(buffer[2] === 0 && buffer[3] === 0, 'Inspector message too big'); + dataLen = buffer.readUIntBE(4, 6); + bodyOffset = 10; + } + if (buffer.length < bodyOffset + dataLen) + return { length: 0, message }; + const jsonPayload = + buffer.slice(bodyOffset, bodyOffset + dataLen).toString('utf8'); + try { + message = JSON.parse(jsonPayload); + } catch (e) { + console.error(`JSON.parse() failed for: ${jsonPayload}`); + throw e; + } + if (DEBUG) + console.log('[received]', JSON.stringify(message)); + return { length: bodyOffset + dataLen, message }; +} + +function formatWSFrame(message) { + const messageBuf = Buffer.from(JSON.stringify(message)); + + const wsHeaderBuf = Buffer.allocUnsafe(16); + wsHeaderBuf.writeUInt8(0x81, 0); + let byte2 = 0x80; + const bodyLen = messageBuf.length; + + let maskOffset = 2; + if (bodyLen < 126) { + byte2 = 0x80 + bodyLen; + } else if (bodyLen < 65536) { + byte2 = 0xFE; + wsHeaderBuf.writeUInt16BE(bodyLen, 2); + maskOffset = 4; + } else { + byte2 = 0xFF; + wsHeaderBuf.writeUInt32BE(bodyLen, 2); + wsHeaderBuf.writeUInt32BE(0, 6); + maskOffset = 10; + } + wsHeaderBuf.writeUInt8(byte2, 1); + wsHeaderBuf.writeUInt32BE(0x01020408, maskOffset); + + for (let i = 0; i < messageBuf.length; i++) + messageBuf[i] = messageBuf[i] ^ (1 << (i % 4)); + + return Buffer.concat([wsHeaderBuf.slice(0, maskOffset + 4), messageBuf]); +} + +class InspectorSession { + constructor(socket, instance) { + this._instance = instance; + this._socket = socket; + this._nextId = 1; + this._commandResponsePromises = new Map(); + this._unprocessedNotifications = []; + this._notificationCallback = null; + this._scriptsIdsByUrl = new Map(); + this._pausedDetails = null; + + let buffer = Buffer.alloc(0); + socket.on('data', (data) => { + buffer = Buffer.concat([buffer, data]); + do { + const { length, message, closed } = parseWSFrame(buffer); + if (!length) + break; + + if (closed) { + socket.write(Buffer.from([0x88, 0x00])); // WS close frame + } + buffer = buffer.slice(length); + if (message) + this._onMessage(message); + } while (true); + }); + this._terminationPromise = new Promise((resolve) => { + socket.once('close', resolve); + }); + } + + + waitForServerDisconnect() { + return this._terminationPromise; + } + + async disconnect() { + this._socket.destroy(); + return this.waitForServerDisconnect(); + } + + _onMessage(message) { + if (message.id) { + const { resolve, reject } = this._commandResponsePromises.get(message.id); + this._commandResponsePromises.delete(message.id); + if (message.result) + resolve(message.result); + else + reject(message.error); + } else { + if (message.method === 'Debugger.scriptParsed') { + const { scriptId, url } = message.params; + this._scriptsIdsByUrl.set(scriptId, url); + const fileUrl = url.startsWith('file:') ? + url : pathToFileURL(url).toString(); + if (fileUrl === this.scriptURL().toString()) { + this.mainScriptId = scriptId; + } + } + if (message.method === 'Debugger.paused') + this._pausedDetails = message.params; + if (message.method === 'Debugger.resumed') + this._pausedDetails = null; + + if (this._notificationCallback) { + // In case callback needs to install another + const callback = this._notificationCallback; + this._notificationCallback = null; + callback(message); + } else { + this._unprocessedNotifications.push(message); + } + } + } + + unprocessedNotifications() { + return this._unprocessedNotifications; + } + + _sendMessage(message) { + const msg = JSON.parse(JSON.stringify(message)); // Clone! + msg.id = this._nextId++; + if (DEBUG) + console.log('[sent]', JSON.stringify(msg)); + + const responsePromise = new Promise((resolve, reject) => { + this._commandResponsePromises.set(msg.id, { resolve, reject }); + }); + + return new Promise( + (resolve) => this._socket.write(formatWSFrame(msg), resolve)) + .then(() => responsePromise); + } + + send(commands) { + if (Array.isArray(commands)) { + // Multiple commands means the response does not matter. There might even + // never be a response. + return Promise + .all(commands.map((command) => this._sendMessage(command))) + .then(() => {}); + } + return this._sendMessage(commands); + } + + waitForNotification(methodOrPredicate, description) { + const desc = description || methodOrPredicate; + const message = `Timed out waiting for matching notification (${desc})`; + return fires( + this._asyncWaitForNotification(methodOrPredicate), message, TIMEOUT); + } + + async _asyncWaitForNotification(methodOrPredicate) { + function matchMethod(notification) { + return notification.method === methodOrPredicate; + } + const predicate = + typeof methodOrPredicate === 'string' ? matchMethod : methodOrPredicate; + let notification = null; + do { + if (this._unprocessedNotifications.length) { + notification = this._unprocessedNotifications.shift(); + } else { + notification = await new Promise( + (resolve) => this._notificationCallback = resolve); + } + } while (!predicate(notification)); + return notification; + } + + _isBreakOnLineNotification(message, line, expectedScriptPath) { + if (message.method === 'Debugger.paused') { + const callFrame = message.params.callFrames[0]; + const location = callFrame.location; + const scriptPath = this._scriptsIdsByUrl.get(location.scriptId); + assert.strictEqual(scriptPath.toString(), + expectedScriptPath.toString(), + `${scriptPath} !== ${expectedScriptPath}`); + assert.strictEqual(location.lineNumber, line); + return true; + } + } + + waitForBreakOnLine(line, url) { + return this + .waitForNotification( + (notification) => + this._isBreakOnLineNotification(notification, line, url), + `break on ${url}:${line}`); + } + + pausedDetails() { + return this._pausedDetails; + } + + _matchesConsoleOutputNotification(notification, type, values) { + if (!Array.isArray(values)) + values = [ values ]; + if (notification.method === 'Runtime.consoleAPICalled') { + const params = notification.params; + if (params.type === type) { + let i = 0; + for (const value of params.args) { + if (value.value !== values[i++]) + return false; + } + return i === values.length; + } + } + } + + waitForConsoleOutput(type, values) { + const desc = `Console output matching ${JSON.stringify(values)}`; + return this.waitForNotification( + (notification) => this._matchesConsoleOutputNotification(notification, + type, values), + desc); + } + + async runToCompletion() { + console.log('[test]', 'Verify node waits for the frontend to disconnect'); + await this.send({ 'method': 'Debugger.resume' }); + await this.waitForNotification((notification) => { + if (notification.method === 'Debugger.paused') { + this.send({ 'method': 'Debugger.resume' }); + } + return notification.method === 'Runtime.executionContextDestroyed' && + notification.params.executionContextId === 1; + }); + while ((await this._instance.nextStderrString()) !== + 'Waiting for the debugger to disconnect...'); + await this.disconnect(); + } + + scriptPath() { + return this._instance.scriptPath(); + } + + script() { + return this._instance.script(); + } + + scriptURL() { + return pathToFileURL(this.scriptPath()); + } +} + +class NodeInstance extends EventEmitter { + constructor(inspectorFlags = ['--inspect-brk=0', '--expose-internals'], + scriptContents = '', + scriptFile = _MAINSCRIPT, + logger = console) { + super(); + + this._logger = logger; + this._scriptPath = scriptFile; + this._script = scriptFile ? null : scriptContents; + this._portCallback = null; + this.resetPort(); + this._process = spawnChildProcess(inspectorFlags, scriptContents, + scriptFile); + this._running = true; + this._stderrLineCallback = null; + this._unprocessedStderrLines = []; + + this._process.stdout.on('data', makeBufferingDataCallback( + (line) => { + this.emit('stdout', line); + this._logger.log('[out]', line); + })); + + this._process.stderr.on('data', makeBufferingDataCallback( + (message) => this.onStderrLine(message))); + + this._shutdownPromise = new Promise((resolve) => { + this._process.once('exit', (exitCode, signal) => { + if (signal) { + this._logger.error(`[err] child process crashed, signal ${signal}`); + } + resolve({ exitCode, signal }); + this._running = false; + }); + }); + } + + get pid() { + return this._process.pid; + } + + resetPort() { + this.portPromise = new Promise((resolve) => this._portCallback = resolve); + } + + static async startViaSignal(scriptContents) { + const instance = new NodeInstance( + ['--expose-internals', '--inspect-port=0'], + `${scriptContents}\nprocess._rawDebug('started');`, undefined); + const msg = 'Timed out waiting for process to start'; + while (await fires(instance.nextStderrString(), msg, TIMEOUT) !== 'started'); + process._debugProcess(instance._process.pid); + return instance; + } + + onStderrLine(line) { + this.emit('stderr', line); + this._logger.log('[err]', line); + if (this._portCallback) { + const matches = line.match(/Debugger listening on ws:\/\/.+:(\d+)\/.+/); + if (matches) { + this._portCallback(matches[1]); + this._portCallback = null; + } + } + if (this._stderrLineCallback) { + this._stderrLineCallback(line); + this._stderrLineCallback = null; + } else { + this._unprocessedStderrLines.push(line); + } + } + + httpGet(host, path, hostHeaderValue) { + this._logger.log('[test]', `Testing ${path}`); + const headers = hostHeaderValue ? { 'Host': hostHeaderValue } : null; + return this.portPromise.then((port) => new Promise((resolve, reject) => { + const req = http.get({ host, port, family: 4, path, headers }, (res) => { + let response = ''; + res.setEncoding('utf8'); + res + .on('data', (data) => response += data.toString()) + .on('end', () => { + resolve(response); + }); + }); + req.on('error', reject); + })).then((response) => { + try { + return JSON.parse(response); + } catch (e) { + e.body = response; + throw e; + } + }); + } + + async sendUpgradeRequest() { + const response = await this.httpGet(null, '/json/list'); + const devtoolsUrl = response[0].webSocketDebuggerUrl; + const port = await this.portPromise; + return http.get({ + port, + family: 4, + path: new URL(devtoolsUrl).pathname, + headers: { + 'Connection': 'Upgrade', + 'Upgrade': 'websocket', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Key': 'key==', + }, + }); + } + + async connectInspectorSession() { + this._logger.log('[test]', 'Connecting to a child Node process'); + const upgradeRequest = await this.sendUpgradeRequest(); + return new Promise((resolve) => { + upgradeRequest + .on('upgrade', + (message, socket) => resolve(new InspectorSession(socket, this))) + .on('response', common.mustNotCall('Upgrade was not received')); + }); + } + + async expectConnectionDeclined() { + this._logger.log('[test]', 'Checking upgrade is not possible'); + const upgradeRequest = await this.sendUpgradeRequest(); + return new Promise((resolve) => { + upgradeRequest + .on('upgrade', common.mustNotCall('Upgrade was received')) + .on('response', (response) => + response.on('data', () => {}) + .on('end', () => resolve(response.statusCode))); + }); + } + + expectShutdown() { + return this._shutdownPromise; + } + + nextStderrString() { + if (this._unprocessedStderrLines.length) + return Promise.resolve(this._unprocessedStderrLines.shift()); + return new Promise((resolve) => this._stderrLineCallback = resolve); + } + + write(message) { + this._process.stdin.write(message); + } + + kill() { + this._process.kill(); + return this.expectShutdown(); + } + + scriptPath() { + return this._scriptPath; + } + + script() { + if (this._script === null) + this._script = fs.readFileSync(this.scriptPath(), 'utf8'); + return this._script; + } +} + +function onResolvedOrRejected(promise, callback) { + return promise.then((result) => { + callback(); + return result; + }, (error) => { + callback(); + throw error; + }); +} + +function timeoutPromise(error, timeoutMs) { + let clearCallback = null; + let done = false; + const promise = onResolvedOrRejected(new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(error), timeoutMs); + clearCallback = () => { + if (done) + return; + clearTimeout(timeout); + resolve(); + }; + }), () => done = true); + promise.clear = clearCallback; + return promise; +} + +// Returns a new promise that will propagate `promise` resolution or rejection +// if that happens within the `timeoutMs` timespan, or rejects with `error` as +// a reason otherwise. +function fires(promise, error, timeoutMs) { + const timeout = timeoutPromise(error, timeoutMs); + return Promise.race([ + onResolvedOrRejected(promise, () => timeout.clear()), + timeout, + ]); +} + +module.exports = { + NodeInstance, +}; diff --git a/test/js/node/test/common/internet.js b/test/js/node/test/common/internet.js new file mode 100644 index 0000000000..51f18aeb44 --- /dev/null +++ b/test/js/node/test/common/internet.js @@ -0,0 +1,58 @@ +'use strict'; + +// Utilities for internet-related tests + +const addresses = { + // A generic host that has registered common DNS records, + // supports both IPv4 and IPv6, and provides basic HTTP/HTTPS services + INET_HOST: 'nodejs.org', + // A host that provides IPv4 services + INET4_HOST: 'nodejs.org', + // A host that provides IPv6 services + INET6_HOST: 'nodejs.org', + // An accessible IPv4 IP, + // defaults to the Google Public DNS IPv4 address + INET4_IP: '8.8.8.8', + // An accessible IPv6 IP, + // defaults to the Google Public DNS IPv6 address + INET6_IP: '2001:4860:4860::8888', + // An invalid host that cannot be resolved + // See https://tools.ietf.org/html/rfc2606#section-2 + INVALID_HOST: 'something.invalid', + // A host with MX records registered + MX_HOST: 'nodejs.org', + // On some systems, .invalid returns a server failure/try again rather than + // record not found. Use this to guarantee record not found. + NOT_FOUND: 'come.on.fhqwhgads.test', + // A host with SRV records registered + SRV_HOST: '_caldav._tcp.google.com', + // A host with PTR records registered + PTR_HOST: '8.8.8.8.in-addr.arpa', + // A host with NAPTR records registered + NAPTR_HOST: 'sip2sip.info', + // A host with SOA records registered + SOA_HOST: 'nodejs.org', + // A host with CAA record registered + CAA_HOST: 'google.com', + // A host with CNAME records registered + CNAME_HOST: 'blog.nodejs.org', + // A host with NS records registered + NS_HOST: 'nodejs.org', + // A host with TXT records registered + TXT_HOST: 'nodejs.org', + // An accessible IPv4 DNS server + DNS4_SERVER: '8.8.8.8', + // An accessible IPv4 DNS server + DNS6_SERVER: '2001:4860:4860::8888', +}; + +for (const key of Object.keys(addresses)) { + const envName = `NODE_TEST_${key}`; + if (process.env[envName]) { + addresses[key] = process.env[envName]; + } +} + +module.exports = { + addresses, +}; diff --git a/test/js/node/test/common/measure-memory.js b/test/js/node/test/common/measure-memory.js new file mode 100644 index 0000000000..ffde35f285 --- /dev/null +++ b/test/js/node/test/common/measure-memory.js @@ -0,0 +1,57 @@ +'use strict'; + +const assert = require('assert'); +const common = require('./'); + +// The formats could change when V8 is updated, then the tests should be +// updated accordingly. +function assertResultShape(result) { + assert.strictEqual(typeof result.jsMemoryEstimate, 'number'); + assert.strictEqual(typeof result.jsMemoryRange[0], 'number'); + assert.strictEqual(typeof result.jsMemoryRange[1], 'number'); +} + +function assertSummaryShape(result) { + assert.strictEqual(typeof result, 'object'); + assert.strictEqual(typeof result.total, 'object'); + assertResultShape(result.total); +} + +function assertDetailedShape(result, contexts = 0) { + assert.strictEqual(typeof result, 'object'); + assert.strictEqual(typeof result.total, 'object'); + assert.strictEqual(typeof result.current, 'object'); + assertResultShape(result.total); + assertResultShape(result.current); + if (contexts === 0) { + assert.deepStrictEqual(result.other, []); + } else { + assert.strictEqual(result.other.length, contexts); + for (const item of result.other) { + assertResultShape(item); + } + } +} + +function assertSingleDetailedShape(result) { + assert.strictEqual(typeof result, 'object'); + assert.strictEqual(typeof result.total, 'object'); + assert.strictEqual(typeof result.current, 'object'); + assert.deepStrictEqual(result.other, []); + assertResultShape(result.total); + assertResultShape(result.current); +} + +function expectExperimentalWarning() { + common.expectWarning( + 'ExperimentalWarning', + 'vm.measureMemory is an experimental feature and might change at any time', + ); +} + +module.exports = { + assertSummaryShape, + assertDetailedShape, + assertSingleDetailedShape, + expectExperimentalWarning, +}; diff --git a/test/js/node/test/common/ongc.js b/test/js/node/test/common/ongc.js new file mode 100644 index 0000000000..d361c55b51 --- /dev/null +++ b/test/js/node/test/common/ongc.js @@ -0,0 +1,32 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const gcTrackerMap = new WeakMap(); +const gcTrackerTag = 'NODE_TEST_COMMON_GC_TRACKER'; + +function onGC(obj, gcListener) { + const async_hooks = require('async_hooks'); + + const onGcAsyncHook = async_hooks.createHook({ + init: common.mustCallAtLeast(function(id, type) { + if (this.trackedId === undefined) { + assert.strictEqual(type, gcTrackerTag); + this.trackedId = id; + } + }), + destroy(id) { + assert.notStrictEqual(this.trackedId, -1); + if (id === this.trackedId) { + this.gcListener.ongc(); + onGcAsyncHook.disable(); + } + }, + }).enable(); + onGcAsyncHook.gcListener = gcListener; + + gcTrackerMap.set(obj, new async_hooks.AsyncResource(gcTrackerTag)); + obj = null; +} + +module.exports = onGC; diff --git a/test/js/node/test/common/package.json b/test/js/node/test/common/package.json new file mode 100644 index 0000000000..5bbefffbab --- /dev/null +++ b/test/js/node/test/common/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/test/js/node/test/common/prof.js b/test/js/node/test/common/prof.js new file mode 100644 index 0000000000..13047406dc --- /dev/null +++ b/test/js/node/test/common/prof.js @@ -0,0 +1,67 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +function getHeapProfiles(dir) { + const list = fs.readdirSync(dir); + return list + .filter((file) => file.endsWith('.heapprofile')) + .map((file) => path.join(dir, file)); +} + +function findFirstFrameInNode(root, func) { + const first = root.children.find( + (child) => child.callFrame.functionName === func, + ); + if (first) { + return first; + } + for (const child of root.children) { + const first = findFirstFrameInNode(child, func); + if (first) { + return first; + } + } + return undefined; +} + +function findFirstFrame(file, func) { + const data = fs.readFileSync(file, 'utf8'); + const profile = JSON.parse(data); + const first = findFirstFrameInNode(profile.head, func); + return { frame: first, roots: profile.head.children }; +} + +function verifyFrames(output, file, func) { + const { frame, roots } = findFirstFrame(file, func); + if (!frame) { + // Show native debug output and the profile for debugging. + console.log(output.stderr.toString()); + console.log(roots); + } + assert.notStrictEqual(frame, undefined); +} + +// We need to set --heap-prof-interval to a small enough value to make +// sure we can find our workload in the samples, so we need to set +// TEST_ALLOCATION > kHeapProfInterval. +const kHeapProfInterval = 128; +const TEST_ALLOCATION = kHeapProfInterval * 2; + +const env = { + ...process.env, + TEST_ALLOCATION, + NODE_DEBUG_NATIVE: 'INSPECTOR_PROFILER', +}; + +// TODO(joyeecheung): share the fixutres with v8 coverage tests +module.exports = { + getHeapProfiles, + verifyFrames, + findFirstFrame, + kHeapProfInterval, + TEST_ALLOCATION, + env, +}; diff --git a/test/js/node/test/common/report.js b/test/js/node/test/common/report.js new file mode 100644 index 0000000000..6e41561186 --- /dev/null +++ b/test/js/node/test/common/report.js @@ -0,0 +1,339 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const util = require('util'); +const cpus = os.cpus(); + +function findReports(pid, dir) { + // Default filenames are of the form + // report..