Skip to content

Commit

Permalink
Support dart2wasm in node.js tests (#2259)
Browse files Browse the repository at this point in the history
Support `dart2wasm` as a compiler for tests running in Node.js.
`dart2wasm` emits a `.mjs` file exporting definitions to load generated
wasm modules, the only additional thing we have to do is wrap that in a
simple entrypoint file compiling the wasm module and invoking the
startup wrapper.

There's no support for stack trace maps yet. We also don't support
precompiled node wasm tests yet (`dart2wasm` is also not currently
supported by `build_web_compilers`, so we're blocked on that either
way).

In the test runtime, I had to migrate off `require` as that function is
not in the global context for `.mjs` files. It looks like we can use
`await import` instead though. If we need to support ancient Node
versions that lack `import` support, I can adapt that to still use
`require` when compiled with `dart2js` for compatibility.
  • Loading branch information
simolus3 authored Aug 27, 2024
1 parent 90481cf commit 032ef1d
Show file tree
Hide file tree
Showing 20 changed files with 356 additions and 151 deletions.
162 changes: 81 additions & 81 deletions .github/workflows/dart.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion integration_tests/regression/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: regression_tests
publish_to: none
environment:
sdk: ^3.5.0-259.0.dev
sdk: ^3.5.0-311.0.dev
resolution: workspace
dependencies:
test: any
2 changes: 1 addition & 1 deletion integration_tests/spawn_hybrid/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: spawn_hybrid
publish_to: none
environment:
sdk: ^3.5.0-259.0.dev
sdk: ^3.5.0-311.0.dev
resolution: workspace
dependencies:
async: ^2.9.0
Expand Down
3 changes: 3 additions & 0 deletions integration_tests/wasm/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
platforms: [chrome, firefox]
# Node doesn't work because the version available in the current Ubuntu GitHub runners is too
# old to support WASM+GC, which would be required to run Dart tests.
#platforms: [chrome, firefox, node]
compilers: [dart2wasm]
2 changes: 1 addition & 1 deletion integration_tests/wasm/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: wasm_tests
publish_to: none
environment:
sdk: ^3.5.0-259.0.dev
sdk: ^3.5.0-311.0.dev
resolution: workspace
dev_dependencies:
test: any
2 changes: 1 addition & 1 deletion pkgs/checks/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ repository: https://github.com/dart-lang/test/tree/master/pkgs/checks
resolution: workspace

environment:
sdk: ^3.5.0-259.0.dev
sdk: ^3.5.0-311.0.dev

dependencies:
async: ^2.8.0
Expand Down
3 changes: 2 additions & 1 deletion pkgs/test/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
## 1.25.9-wip

* Fix dart2wasm tests on windows.
* Increase SDK constraint to ^3.5.0-259.0.dev.
* Increase SDK constraint to ^3.5.0-311.0.dev.
* Support running Node.js tests compiled with dart2wasm.

## 1.25.8

Expand Down
2 changes: 1 addition & 1 deletion pkgs/test/lib/src/bootstrap/node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ void internalBootstrapNodeTest(Function Function() getMain) {
if (serialized is! Map) return;
setStackTraceMapper(JSStackTraceMapper.deserialize(serialized)!);
});
socketChannel().pipe(channel);
socketChannel().then((socket) => socket.pipe(channel));
}
147 changes: 115 additions & 32 deletions pkgs/test/lib/src/runner/node/platform.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ import 'package:test_api/backend.dart'
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/pair.dart'; // ignore: implementation_imports
import 'package:test_core/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
import 'package:yaml/yaml.dart';

Expand All @@ -40,7 +41,8 @@ class NodePlatform extends PlatformPlugin
final Configuration _config;

/// The [Dart2JsCompilerPool] managing active instances of `dart2js`.
final _compilers = Dart2JsCompilerPool(['-Dnode=true', '--server-mode']);
final _jsCompilers = Dart2JsCompilerPool(['-Dnode=true', '--server-mode']);
final _wasmCompilers = WasmCompilerPool(['-Dnode=true']);

/// The temporary directory in which compiled JS is emitted.
final _compiledDir = createTempDir();
Expand Down Expand Up @@ -75,15 +77,17 @@ class NodePlatform extends PlatformPlugin
@override
Future<RunnerSuite> load(String path, SuitePlatform platform,
SuiteConfiguration suiteConfig, Map<String, Object?> message) async {
if (platform.compiler != Compiler.dart2js) {
if (platform.compiler != Compiler.dart2js &&
platform.compiler != Compiler.dart2wasm) {
throw StateError(
'Unsupported compiler for the Node platform ${platform.compiler}.');
}
var pair = await _loadChannel(path, platform, suiteConfig);
var (channel, stackMapper) =
await _loadChannel(path, platform, suiteConfig);
var controller = deserializeSuite(path, platform, suiteConfig,
const PluginEnvironment(), pair.first, message);
const PluginEnvironment(), channel, message);

controller.channel('test.node.mapper').sink.add(pair.last?.serialize());
controller.channel('test.node.mapper').sink.add(stackMapper?.serialize());

return await controller.suite;
}
Expand All @@ -92,16 +96,13 @@ class NodePlatform extends PlatformPlugin
///
/// Returns that channel along with a [StackTraceMapper] representing the
/// source map for the compiled suite.
Future<Pair<StreamChannel<Object?>, StackTraceMapper?>> _loadChannel(
String path,
SuitePlatform platform,
SuiteConfiguration suiteConfig) async {
Future<(StreamChannel<Object?>, StackTraceMapper?)> _loadChannel(String path,
SuitePlatform platform, SuiteConfiguration suiteConfig) async {
final servers = await _loopback();

try {
var pair = await _spawnProcess(
path, platform.runtime, suiteConfig, servers.first.port);
var process = pair.first;
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.
Expand All @@ -110,7 +111,19 @@ class NodePlatform extends PlatformPlugin
process.stdout.transform(lineSplitter).listen(print);
process.stderr.transform(lineSplitter).listen(print);

var socket = await StreamGroup.merge(servers).first;
// 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)
Expand All @@ -120,7 +133,7 @@ class NodePlatform extends PlatformPlugin
sink.close();
}));

return Pair(channel, pair.last);
return (channel, stackMapper);
} finally {
unawaited(Future.wait<void>(servers.map((s) =>
s.close().then<ServerSocket?>((v) => v).onError((_, __) => null))));
Expand All @@ -131,23 +144,28 @@ class NodePlatform extends PlatformPlugin
///
/// Returns that channel along with a [StackTraceMapper] representing the
/// source map for the compiled suite.
Future<Pair<Process, StackTraceMapper?>> _spawnProcess(String path,
Runtime runtime, SuiteConfiguration suiteConfig, int socketPort) async {
Future<(Process, StackTraceMapper?)> _spawnProcess(
String path,
SuitePlatform platform,
SuiteConfiguration suiteConfig,
int socketPort) async {
if (_config.suiteDefaults.precompiledPath != null) {
return _spawnPrecompiledProcess(path, runtime, suiteConfig, socketPort,
_config.suiteDefaults.precompiledPath!);
return _spawnPrecompiledProcess(path, platform.runtime, suiteConfig,
socketPort, _config.suiteDefaults.precompiledPath!);
} else {
return _spawnNormalProcess(path, runtime, suiteConfig, socketPort);
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}'),
};
}
}

/// Compiles [testPath] with dart2js, adds the node preamble, and then spawns
/// a Node.js process that loads that Dart test suite.
Future<Pair<Process, StackTraceMapper?>> _spawnNormalProcess(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 _compilers.compile('''
Future<String> _entrypointScriptForTest(
String testPath, SuiteConfiguration suiteConfig) async {
return '''
${suiteConfig.metadata.languageVersionComment ?? await rootPackageLanguageVersionComment}
import "package:test/src/bootstrap/node.dart";
Expand All @@ -156,7 +174,20 @@ class NodePlatform extends PlatformPlugin
void main() {
internalBootstrapNodeTest(() => test.main);
}
''', jsPath, suiteConfig);
''';
}

/// 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.
Expand All @@ -173,12 +204,63 @@ class NodePlatform extends PlatformPlugin
packageMap: (await currentPackageConfig).toPackageMap());
}

return Pair(await _startProcess(runtime, jsPath, socketPort), mapper);
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<Pair<Process, StackTraceMapper?>> _spawnPrecompiledProcess(
Future<(Process, StackTraceMapper?)> _spawnPrecompiledProcess(
String testPath,
Runtime runtime,
SuiteConfiguration suiteConfig,
Expand All @@ -195,7 +277,7 @@ class NodePlatform extends PlatformPlugin
.toPackageMap());
}

return Pair(await _startProcess(runtime, jsPath, socketPort), mapper);
return (await _startProcess(runtime, jsPath, socketPort), mapper);
}

/// Starts the Node.js process for [runtime] with [jsPath].
Expand Down Expand Up @@ -224,7 +306,8 @@ class NodePlatform extends PlatformPlugin

@override
Future<void> close() => _closeMemo.runOnce(() async {
await _compilers.close();
await _jsCompilers.close();
await _wasmCompilers.close();
await Directory(_compiledDir).deleteWithRetry();
});
final _closeMemo = AsyncMemoizer<void>();
Expand Down
39 changes: 17 additions & 22 deletions pkgs/test/lib/src/runner/node/socket_channel.dart
Original file line number Diff line number Diff line change
@@ -1,46 +1,41 @@
// 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.

@JS()
library;

import 'dart:async';
import 'dart:convert';
import 'dart:js_interop';

import 'package:js/js.dart';
import 'package:stream_channel/stream_channel.dart';

@JS('require')
external _Net _require(String module);

@JS('process.argv')
external List<String> get _args;
external JSArray<JSString> get _args;

@JS()
class _Net {
extension type _Net._(JSObject _) {
external _Socket connect(int port);
}

@JS()
class _Socket {
external void setEncoding(String encoding);
external void on(String event, void Function(String chunk) callback);
external void write(String data);
extension type _Socket._(JSObject _) {
external void setEncoding(JSString encoding);
external void on(JSString event, JSFunction callback);
external void write(JSString data);
}

/// Returns a [StreamChannel] of JSON-encodable objects that communicates over a
/// socket whose port is given by `process.argv[2]`.
StreamChannel<Object?> socketChannel() {
var net = _require('net');
var socket = net.connect(int.parse(_args[2]));
socket.setEncoding('utf8');
Future<StreamChannel<Object?>> socketChannel() async {
final net = (await importModule('node:net'.toJS).toDart) as _Net;

var socket = net.connect(int.parse(_args.toDart[2].toDart));
socket.setEncoding('utf8'.toJS);

var socketSink = StreamController<Object?>(sync: true)
..stream.listen((event) => socket.write('${jsonEncode(event)}\n'));
..stream.listen((event) => socket.write('${jsonEncode(event)}\n'.toJS));

var socketStream = StreamController<String>(sync: true);
socket.on('data', allowInterop(socketStream.add));
socket.on(
'data'.toJS,
((JSString chunk) => socketStream.add(chunk.toDart)).toJS,
);

return StreamChannel.withCloseGuarantee(
socketStream.stream.transform(const LineSplitter()).map(jsonDecode),
Expand Down
2 changes: 1 addition & 1 deletion pkgs/test/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ repository: https://github.com/dart-lang/test/tree/master/pkgs/test
resolution: workspace

environment:
sdk: ^3.5.0-259.0.dev
sdk: ^3.5.0-311.0.dev

dependencies:
analyzer: '>=5.12.0 <7.0.0'
Expand Down
Loading

0 comments on commit 032ef1d

Please sign in to comment.