blob: 16cd9c6ee85fae90b9f9702dd0cccccb094a4c5a [file] [log] [blame]
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:async/async.dart';
import 'package:node_preamble/preamble.dart' as preamble;
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as p;
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/backend.dart'
show Compiler, Runtime, StackTraceMapper, SuitePlatform;
import 'package:test_core/src/runner/application_exception.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/configuration.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/dart2js_compiler_pool.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/load_exception.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/package_version.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/customizable_platform.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/environment.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/plugin/platform_helpers.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
import 'package:test_core/src/runner/wasm_compiler_pool.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/errors.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/package_config.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
import 'package:yaml/yaml.dart';
import '../../util/package_map.dart';
import '../executable_settings.dart';
/// A platform that loads tests in Node.js processes.
class NodePlatform extends PlatformPlugin
implements CustomizablePlatform<ExecutableSettings> {
/// The test runner configuration.
final Configuration _config;
/// The [Dart2JsCompilerPool] managing active instances of `dart2js`.
final _jsCompilers = Dart2JsCompilerPool(['-Dnode=true', '--server-mode']);
final _wasmCompilers = WasmCompilerPool(['-Dnode=true']);
/// The temporary directory in which compiled JS is emitted.
final _compiledDir = createTempDir();
/// Executable settings for [Runtime.nodeJS] and runtimes that extend
/// it.
final _settings = {
Runtime.nodeJS: ExecutableSettings(
linuxExecutable: 'node',
macOSExecutable: 'node',
windowsExecutable: 'node.exe')
};
NodePlatform() : _config = Configuration.current;
@override
ExecutableSettings parsePlatformSettings(YamlMap settings) =>
ExecutableSettings.parse(settings);
@override
ExecutableSettings mergePlatformSettings(
ExecutableSettings settings1, ExecutableSettings settings2) =>
settings1.merge(settings2);
@override
void customizePlatform(Runtime runtime, ExecutableSettings settings) {
var oldSettings = _settings[runtime] ?? _settings[runtime.root];
if (oldSettings != null) settings = oldSettings.merge(settings);
_settings[runtime] = settings;
}
@override
Future<RunnerSuite> load(String path, SuitePlatform platform,
SuiteConfiguration suiteConfig, Map<String, Object?> message) async {
if (platform.compiler != Compiler.dart2js &&
platform.compiler != Compiler.dart2wasm) {
throw StateError(
'Unsupported compiler for the Node platform ${platform.compiler}.');
}
var (channel, stackMapper) =
await _loadChannel(path, platform, suiteConfig);
var controller = deserializeSuite(path, platform, suiteConfig,
const PluginEnvironment(), channel, message);
controller.channel('test.node.mapper').sink.add(stackMapper?.serialize());
return await controller.suite;
}
/// Loads a [StreamChannel] communicating with the test suite at [path].
///
/// Returns that channel along with a [StackTraceMapper] representing the
/// source map for the compiled suite.
Future<(StreamChannel<Object?>, StackTraceMapper?)> _loadChannel(String path,
SuitePlatform platform, SuiteConfiguration suiteConfig) async {
final servers = await _loopback();
try {
var (process, stackMapper) =
await _spawnProcess(path, platform, suiteConfig, servers.first.port);
// Forward Node's standard IO to the print handler so it's associated with
// the load test.
//
// TODO(nweiz): Associate this with the current test being run, if any.
process.stdout.transform(lineSplitter).listen(print);
process.stderr.transform(lineSplitter).listen(print);
// Wait for the first connection (either over ipv4 or v6). If the proccess
// exits before it connects, throw instead of waiting for a connection
// indefinitely.
var socket = await Future.any([
StreamGroup.merge(servers).first,
process.exitCode.then((_) => null),
]);
if (socket == null) {
throw LoadException(
path, 'Node exited before connecting to the test channel.');
}
var channel = StreamChannel(socket.cast<List<int>>(), socket)
.transform(StreamChannelTransformer.fromCodec(utf8))
.transform(_chunksToLines)
.transform(jsonDocument)
.transformStream(StreamTransformer.fromHandlers(handleDone: (sink) {
process.kill();
sink.close();
}));
return (channel, stackMapper);
} finally {
unawaited(Future.wait<void>(servers.map((s) =>
s.close().then<ServerSocket?>((v) => v).onError((_, __) => null))));
}
}
/// Spawns a Node.js process that loads the Dart test suite at [path].
///
/// Returns that channel along with a [StackTraceMapper] representing the
/// source map for the compiled suite.
Future<(Process, StackTraceMapper?)> _spawnProcess(
String path,
SuitePlatform platform,
SuiteConfiguration suiteConfig,
int socketPort) async {
if (_config.suiteDefaults.precompiledPath != null) {
return _spawnPrecompiledProcess(path, platform.runtime, suiteConfig,
socketPort, _config.suiteDefaults.precompiledPath!);
} else {
return switch (platform.compiler) {
Compiler.dart2js => _spawnNormalJsProcess(
path, platform.runtime, suiteConfig, socketPort),
Compiler.dart2wasm => _spawnNormalWasmProcess(
path, platform.runtime, suiteConfig, socketPort),
_ => throw StateError('Unsupported compiler ${platform.compiler}'),
};
}
}
Future<String> _entrypointScriptForTest(
String testPath, SuiteConfiguration suiteConfig) async {
return '''
${suiteConfig.metadata.languageVersionComment ?? await rootPackageLanguageVersionComment}
import "package:test/src/bootstrap/node.dart";
import "${p.toUri(p.absolute(testPath))}" as test;
void main() {
internalBootstrapNodeTest(() => test.main);
}
''';
}
/// Compiles [testPath] with dart2js, adds the node preamble, and then spawns
/// a Node.js process that loads that Dart test suite.
Future<(Process, StackTraceMapper?)> _spawnNormalJsProcess(String testPath,
Runtime runtime, SuiteConfiguration suiteConfig, int socketPort) async {
var dir = Directory(_compiledDir).createTempSync('test_').path;
var jsPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.js');
await _jsCompilers.compile(
await _entrypointScriptForTest(testPath, suiteConfig),
jsPath,
suiteConfig,
);
// Add the Node.js preamble to ensure that the dart2js output is
// compatible. Use the minified version so the source map remains valid.
var jsFile = File(jsPath);
await jsFile.writeAsString(
preamble.getPreamble(minified: true) + await jsFile.readAsString());
StackTraceMapper? mapper;
if (!suiteConfig.jsTrace) {
var mapPath = '$jsPath.map';
mapper = JSStackTraceMapper(await File(mapPath).readAsString(),
mapUrl: p.toUri(mapPath),
sdkRoot: Uri.parse('org-dartlang-sdk:///sdk'),
packageMap: (await currentPackageConfig).toPackageMap());
}
return (await _startProcess(runtime, jsPath, socketPort), mapper);
}
/// Compiles [testPath] with dart2wasm, adds a JS entrypoint and then spawns
/// a Node.js process loading the compiled test suite.
Future<(Process, StackTraceMapper?)> _spawnNormalWasmProcess(String testPath,
Runtime runtime, SuiteConfiguration suiteConfig, int socketPort) async {
var dir = Directory(_compiledDir).createTempSync('test_').path;
// dart2wasm will emit a .wasm file and a .mjs file responsible for loading
// that file.
var wasmPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.wasm');
var loader = '${p.basename(testPath)}.node_test.dart.wasm.mjs';
// We need to create an additional entrypoint file loading the wasm module.
var jsPath = p.join(dir, '${p.basename(testPath)}.node_test.dart.js');
await _wasmCompilers.compile(
await _entrypointScriptForTest(testPath, suiteConfig),
wasmPath,
suiteConfig,
);
await File(jsPath).writeAsString('''
const { createReadStream } = require('fs');
const { once } = require('events');
const { PassThrough } = require('stream');
const main = async () => {
const { instantiate, invoke } = await import("./$loader");
const wasmContents = createReadStream("$wasmPath.wasm");
const stream = new PassThrough();
wasmContents.pipe(stream);
await once(wasmContents, 'open');
const response = new Response(
stream,
{
headers: {
"Content-Type": "application/wasm"
}
}
);
const instancePromise = WebAssembly.compileStreaming(response);
const module = await instantiate(instancePromise, {});
invoke(module);
};
main();
''');
return (await _startProcess(runtime, jsPath, socketPort), null);
}
/// Spawns a Node.js process that loads the Dart test suite at [testPath]
/// under [precompiledPath].
Future<(Process, StackTraceMapper?)> _spawnPrecompiledProcess(
String testPath,
Runtime runtime,
SuiteConfiguration suiteConfig,
int socketPort,
String precompiledPath) async {
StackTraceMapper? mapper;
var jsPath = p.join(precompiledPath, '$testPath.node_test.dart.js');
if (!suiteConfig.jsTrace) {
var mapPath = '$jsPath.map';
mapper = JSStackTraceMapper(await File(mapPath).readAsString(),
mapUrl: p.toUri(mapPath),
sdkRoot: Uri.parse('org-dartlang-sdk:///sdk'),
packageMap: (await findPackageConfig(Directory(precompiledPath)))!
.toPackageMap());
}
return (await _startProcess(runtime, jsPath, socketPort), mapper);
}
/// Starts the Node.js process for [runtime] with [jsPath].
Future<Process> _startProcess(
Runtime runtime, String jsPath, int socketPort) async {
var settings = _settings[runtime]!;
var nodeModules = p.absolute('node_modules');
var nodePath = Platform.environment['NODE_PATH'];
nodePath = nodePath == null ? nodeModules : '$nodePath:$nodeModules';
try {
return await Process.start(
settings.executable,
settings.arguments.toList()
..add(jsPath)
..add(socketPort.toString()),
environment: {'NODE_PATH': nodePath});
} catch (error, stackTrace) {
await Future<Never>.error(
ApplicationException(
'Failed to run ${runtime.name}: ${getErrorMessage(error)}'),
stackTrace);
}
}
@override
Future<void> close() => _closeMemo.runOnce(() async {
await _jsCompilers.close();
await _wasmCompilers.close();
await Directory(_compiledDir).deleteWithRetry();
});
final _closeMemo = AsyncMemoizer<void>();
}
Future<List<ServerSocket>> _loopback({int remainingRetries = 5}) async {
if (!await _supportsIPv4) {
return [await ServerSocket.bind(InternetAddress.loopbackIPv6, 0)];
}
var v4Server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
if (!await _supportsIPv6) return [v4Server];
try {
// Reuse the IPv4 server's port so that if [port] is 0, both servers use
// the same ephemeral port.
var v6Server =
await ServerSocket.bind(InternetAddress.loopbackIPv6, v4Server.port);
return [v4Server, v6Server];
} on SocketException catch (error) {
if (error.osError?.errorCode != _addressInUseErrno) rethrow;
if (remainingRetries == 0) rethrow;
// A port being available on IPv4 doesn't necessarily mean that the same
// port is available on IPv6. If it's not (which is rare in practice),
// we try again until we find one that's available on both.
unawaited(v4Server.close());
return await _loopback(remainingRetries: remainingRetries - 1);
}
}
/// Whether this computer supports binding to IPv6 addresses.
final Future<bool> _supportsIPv6 = () async {
try {
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv6, 0);
unawaited(socket.close());
return true;
} on SocketException catch (_) {
return false;
}
}();
/// Whether this computer supports binding to IPv4 addresses.
final Future<bool> _supportsIPv4 = () async {
try {
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
unawaited(socket.close());
return true;
} on SocketException catch (_) {
return false;
}
}();
/// The error code for an error caused by a port already being in use.
final int _addressInUseErrno = () {
if (Platform.isWindows) return 10048;
if (Platform.isMacOS) return 48;
assert(Platform.isLinux);
return 98;
}();
/// A [StreamChannelTransformer] that converts a chunked string channel to a
/// line-by-line channel.
///
/// Note that this is only safe for channels whose messages are guaranteed not
/// to contain newlines.
final _chunksToLines = StreamChannelTransformer<String, String>(
const LineSplitter(),
StreamSinkTransformer.fromHandlers(
handleData: (data, sink) => sink.add('$data\n')));