Unverified Commit b3382b99 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Reland support flutter test on platform chrome (#33859)

parent 75486143
targets: targets:
$default: $default:
builders:
build_web_compilers|entrypoint:
enabled: false
sources: sources:
exclude: exclude:
- "test/data/**" - "test/data/**"
...@@ -12,17 +12,21 @@ import 'package:build_modules/src/platform.dart'; ...@@ -12,17 +12,21 @@ import 'package:build_modules/src/platform.dart';
import 'package:build_runner_core/build_runner_core.dart' as core; import 'package:build_runner_core/build_runner_core.dart' as core;
import 'package:build_runner_core/src/generate/build_impl.dart'; import 'package:build_runner_core/src/generate/build_impl.dart';
import 'package:build_runner_core/src/generate/options.dart'; import 'package:build_runner_core/src/generate/options.dart';
import 'package:build_test/builder.dart';
import 'package:build_test/src/debug_test_builder.dart';
import 'package:build_web_compilers/build_web_compilers.dart'; import 'package:build_web_compilers/build_web_compilers.dart';
import 'package:build_web_compilers/builders.dart'; import 'package:build_web_compilers/builders.dart';
import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart'; import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:test_core/backend.dart';
import 'package:watcher/watcher.dart'; import 'package:watcher/watcher.dart';
import '../artifacts.dart'; import '../artifacts.dart';
import '../base/file_system.dart'; import '../base/file_system.dart';
import '../base/logger.dart'; import '../base/logger.dart';
import '../base/platform.dart';
import '../compile.dart'; import '../compile.dart';
import '../dart/package_map.dart'; import '../dart/package_map.dart';
import '../globals.dart'; import '../globals.dart';
...@@ -65,6 +69,20 @@ final DartPlatform flutterWebPlatform = ...@@ -65,6 +69,20 @@ final DartPlatform flutterWebPlatform =
/// The build application to compile a flutter application to the web. /// The build application to compile a flutter application to the web.
final List<core.BuilderApplication> builders = <core.BuilderApplication>[ final List<core.BuilderApplication> builders = <core.BuilderApplication>[
core.apply(
'flutter_tools|test_bootstrap',
<BuilderFactory>[
(BuilderOptions options) => const DebugTestBuilder(),
(BuilderOptions options) => const FlutterWebTestBootstrapBuilder(),
],
core.toRoot(),
hideOutput: true,
defaultGenerateFor: const InputSet(
include: <String>[
'test/**',
],
),
),
core.apply( core.apply(
'flutter_tools|module_library', 'flutter_tools|module_library',
<Builder Function(BuilderOptions)>[moduleLibraryBuilder], <Builder Function(BuilderOptions)>[moduleLibraryBuilder],
...@@ -109,7 +127,7 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[ ...@@ -109,7 +127,7 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[
'flutter_tools|entrypoint', 'flutter_tools|entrypoint',
<BuilderFactory>[ <BuilderFactory>[
(BuilderOptions options) => FlutterWebEntrypointBuilder( (BuilderOptions options) => FlutterWebEntrypointBuilder(
options.config['target'] ?? 'lib/main.dart'), options.config['targets'] ?? <String>['lib/main.dart']),
], ],
core.toRoot(), core.toRoot(),
hideOutput: true, hideOutput: true,
...@@ -117,6 +135,7 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[ ...@@ -117,6 +135,7 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[
include: <String>[ include: <String>[
'lib/**', 'lib/**',
'web/**', 'web/**',
'test/**_test.dart.browser_test.dart',
], ],
), ),
), ),
...@@ -135,13 +154,14 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy { ...@@ -135,13 +154,14 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
@override @override
Future<void> initialize({ Future<void> initialize({
@required Directory projectDirectory, @required Directory projectDirectory,
@required String target, @required List<String> targets,
String testOutputDir,
}) async { }) async {
// Override the generated output directory so this does not conflict with // Override the generated output directory so this does not conflict with
// other build_runner output. // other build_runner output.
core.overrideGeneratedOutputDirectory('flutter_web'); core.overrideGeneratedOutputDirectory('flutter_web');
_packageUriMapper = PackageUriMapper( _packageUriMapper = PackageUriMapper(
path.absolute(target), PackageMap.globalPackagesPath, null, null); path.absolute('lib/main.dart'), PackageMap.globalPackagesPath, null, null);
_packageGraph = core.PackageGraph.forPath(projectDirectory.path); _packageGraph = core.PackageGraph.forPath(projectDirectory.path);
final core.BuildEnvironment buildEnvironment = core.OverrideableEnvironment( final core.BuildEnvironment buildEnvironment = core.OverrideableEnvironment(
core.IOEnvironment(_packageGraph), onLog: (LogRecord record) { core.IOEnvironment(_packageGraph), onLog: (LogRecord record) {
...@@ -163,8 +183,18 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy { ...@@ -163,8 +183,18 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
trackPerformance: false, trackPerformance: false,
deleteFilesByDefault: true, deleteFilesByDefault: true,
); );
final Set<core.BuildDirectory> buildDirs = <core.BuildDirectory>{
if (testOutputDir != null)
core.BuildDirectory(
'test',
outputLocation: core.OutputLocation(
testOutputDir,
useSymlinks: !platform.isWindows,
),
),
};
final Status status = final Status status =
logger.startProgress('Compiling $target for the Web...', timeout: null); logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null);
try { try {
_builder = await BuildImpl.create( _builder = await BuildImpl.create(
buildOptions, buildOptions,
...@@ -172,12 +202,12 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy { ...@@ -172,12 +202,12 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
builders, builders,
<String, Map<String, dynamic>>{ <String, Map<String, dynamic>>{
'flutter_tools|entrypoint': <String, dynamic>{ 'flutter_tools|entrypoint': <String, dynamic>{
'target': target, 'targets': targets,
} }
}, },
isReleaseBuild: false, isReleaseBuild: false,
); );
await _builder.run(const <AssetId, ChangeType>{}); await _builder.run(const <AssetId, ChangeType>{}, buildDirs: buildDirs);
} finally { } finally {
status.stop(); status.stop();
} }
...@@ -205,9 +235,9 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy { ...@@ -205,9 +235,9 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
/// A ddc-only entrypoint builder that respects the Flutter target flag. /// A ddc-only entrypoint builder that respects the Flutter target flag.
class FlutterWebEntrypointBuilder implements Builder { class FlutterWebEntrypointBuilder implements Builder {
const FlutterWebEntrypointBuilder(this.target); const FlutterWebEntrypointBuilder(this.targets);
final String target; final List<String> targets;
@override @override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{ Map<String, List<String>> get buildExtensions => const <String, List<String>>{
...@@ -222,10 +252,123 @@ class FlutterWebEntrypointBuilder implements Builder { ...@@ -222,10 +252,123 @@ class FlutterWebEntrypointBuilder implements Builder {
@override @override
Future<void> build(BuildStep buildStep) async { Future<void> build(BuildStep buildStep) async {
if (!buildStep.inputId.path.contains(target)) { bool matches = false;
for (String target in targets) {
if (buildStep.inputId.path.contains(target)) {
matches = true;
break;
}
}
if (!matches) {
return; return;
} }
log.info('building for target ${buildStep.inputId.path}'); log.info('building for target ${buildStep.inputId.path}');
await bootstrapDdc(buildStep, platform: flutterWebPlatform); await bootstrapDdc(buildStep, platform: flutterWebPlatform);
} }
} }
class FlutterWebTestBootstrapBuilder implements Builder {
const FlutterWebTestBootstrapBuilder();
@override
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
'_test.dart': <String>[
'_test.dart.browser_test.dart',
]
};
@override
Future<void> build(BuildStep buildStep) async {
final AssetId id = buildStep.inputId;
final String contents = await buildStep.readAsString(id);
final String assetPath = id.pathSegments.first == 'lib'
? path.url.join('packages', id.package, id.path)
: id.path;
final Metadata metadata = parseMetadata(
assetPath, contents, Runtime.builtIn.map((Runtime runtime) => runtime.name).toSet());
if (metadata.testOn.evaluate(SuitePlatform(Runtime.chrome))) {
await buildStep.writeAsString(id.addExtension('.browser_test.dart'), '''
import 'dart:ui' as ui;
import 'dart:html';
import 'dart:js';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/src/backend/stack_trace_formatter.dart'; // ignore: implementation_imports
import 'package:test_api/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
import 'package:test_api/src/remote_listener.dart'; // ignore: implementation_imports
import 'package:test_api/src/suite_channel_manager.dart'; // ignore: implementation_imports
import "${path.url.basename(id.path)}" as test;
Future<void> main() async {
// Extra initialization for flutter_web.
// The following parameters are hard-coded in Flutter's test embedder. Since
// we don't have an embedder yet this is the lowest-most layer we can put
// this stuff in.
await ui.webOnlyInitializeEngine();
internalBootstrapBrowserTest(() => test.main);
}
void internalBootstrapBrowserTest(Function getMain()) {
var channel =
serializeSuite(getMain, hidePrints: false, beforeLoad: () async {
var serialized =
await suiteChannel("test.browser.mapper").stream.first as Map;
if (serialized == null) return;
});
postMessageChannel().pipe(channel);
}
StreamChannel serializeSuite(Function getMain(),
{bool hidePrints = true, Future beforeLoad()}) =>
RemoteListener.start(getMain,
hidePrints: hidePrints, beforeLoad: beforeLoad);
StreamChannel suiteChannel(String name) {
var manager = SuiteChannelManager.current;
if (manager == null) {
throw StateError('suiteChannel() may only be called within a test worker.');
}
return manager.connectOut(name);
}
StreamChannel postMessageChannel() {
var controller = StreamChannelController(sync: true);
window.onMessage.firstWhere((message) {
return message.origin == window.location.origin && message.data == "port";
}).then((message) {
var port = message.ports.first;
var portSubscription = port.onMessage.listen((message) {
controller.local.sink.add(message.data);
});
controller.local.stream.listen((data) {
port.postMessage({"data": data});
}, onDone: () {
port.postMessage({"event": "done"});
portSubscription.cancel();
});
});
context['parent'].callMethod('postMessage', [
JsObject.jsify({"href": window.location.href, "ready": true}),
window.location.origin,
]);
return controller.foreign;
}
void setStackTraceMapper(StackTraceMapper mapper) {
var formatter = StackTraceFormatter.current;
if (formatter == null) {
throw StateError(
'setStackTraceMapper() may only be called within a test worker.');
}
formatter.configure(mapper: mapper);
}
''');
}
}
}
...@@ -99,6 +99,11 @@ class TestCommand extends FastFlutterCommand { ...@@ -99,6 +99,11 @@ class TestCommand extends FastFlutterCommand {
negatable: true, negatable: true,
help: 'Whether to build the assets bundle for testing.\n' help: 'Whether to build the assets bundle for testing.\n'
'Consider using --no-test-assets if assets are not required.', 'Consider using --no-test-assets if assets are not required.',
)
..addOption('platform',
allowed: const <String>['tester', 'chrome'],
defaultsTo: 'tester',
help: 'The platform to run the unit tests on. Defaults to "tester".'
); );
} }
...@@ -166,6 +171,16 @@ class TestCommand extends FastFlutterCommand { ...@@ -166,6 +171,16 @@ class TestCommand extends FastFlutterCommand {
'Test files must be in that directory and end with the pattern "_test.dart".' 'Test files must be in that directory and end with the pattern "_test.dart".'
); );
} }
} else {
final List<String> fileCopy = <String>[];
for (String file in files) {
if (file.endsWith(platform.pathSeparator)) {
fileCopy.addAll(_findTests(fs.directory(file)));
} else {
fileCopy.add(file);
}
}
files = fileCopy;
} }
CoverageCollector collector; CoverageCollector collector;
...@@ -222,6 +237,7 @@ class TestCommand extends FastFlutterCommand { ...@@ -222,6 +237,7 @@ class TestCommand extends FastFlutterCommand {
concurrency: jobs, concurrency: jobs,
buildTestAssets: buildTestAssets, buildTestAssets: buildTestAssets,
flutterProject: flutterProject, flutterProject: flutterProject,
web: argResults['platform'] == 'chrome',
); );
if (collector != null) { if (collector != null) {
......
...@@ -120,7 +120,7 @@ class ResidentWebRunner extends ResidentRunner { ...@@ -120,7 +120,7 @@ class ResidentWebRunner extends ResidentRunner {
// Start the web compiler and build the assets. // Start the web compiler and build the assets.
await webCompilationProxy.initialize( await webCompilationProxy.initialize(
projectDirectory: currentProject.directory, projectDirectory: currentProject.directory,
target: target, targets: <String>[target],
); );
_lastCompiled = DateTime.now(); _lastCompiled = DateTime.now();
final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle(); final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
......
This diff is collapsed.
...@@ -5,7 +5,9 @@ ...@@ -5,7 +5,9 @@
import 'dart:async'; import 'dart:async';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:test_api/backend.dart';
import 'package:test_core/src/executable.dart' as test; // ignore: implementation_imports import 'package:test_core/src/executable.dart' as test; // ignore: implementation_imports
import 'package:test_core/src/runner/hack_register_platform.dart' as hack; // ignore: implementation_imports
import '../artifacts.dart'; import '../artifacts.dart';
import '../base/common.dart'; import '../base/common.dart';
...@@ -16,7 +18,9 @@ import '../base/terminal.dart'; ...@@ -16,7 +18,9 @@ import '../base/terminal.dart';
import '../dart/package_map.dart'; import '../dart/package_map.dart';
import '../globals.dart'; import '../globals.dart';
import '../project.dart'; import '../project.dart';
import '../web/compile.dart';
import 'flutter_platform.dart' as loader; import 'flutter_platform.dart' as loader;
import 'flutter_web_platform.dart';
import 'watcher.dart'; import 'watcher.dart';
/// Runs tests using package:test and the Flutter engine. /// Runs tests using package:test and the Flutter engine.
...@@ -40,6 +44,7 @@ Future<int> runTests( ...@@ -40,6 +44,7 @@ Future<int> runTests(
FlutterProject flutterProject, FlutterProject flutterProject,
String icudtlPath, String icudtlPath,
Directory coverageDirectory, Directory coverageDirectory,
bool web = false,
}) async { }) async {
// Compute the command-line arguments for package:test. // Compute the command-line arguments for package:test.
final List<String> testArgs = <String>[]; final List<String> testArgs = <String>[];
...@@ -62,6 +67,32 @@ Future<int> runTests( ...@@ -62,6 +67,32 @@ Future<int> runTests(
for (String plainName in plainNames) { for (String plainName in plainNames) {
testArgs..add('--plain-name')..add(plainName); testArgs..add('--plain-name')..add(plainName);
} }
if (web) {
final String tempBuildDir = fs.systemTempDirectory
.createTempSync('_flutter_test')
.absolute
.uri
.toFilePath();
await webCompilationProxy.initialize(
projectDirectory: flutterProject.directory,
testOutputDir: tempBuildDir,
targets: testFiles.map((String testFile) {
return fs.path.relative(testFile, from: flutterProject.directory.path);
}).toList(),
);
testArgs.add('--platform=chrome');
testArgs.add('--precompiled=$tempBuildDir');
testArgs.add('--');
testArgs.addAll(testFiles);
hack.registerPlatformPlugin(
<Runtime>[Runtime.chrome],
() {
return FlutterWebPlatform.start(flutterProject.directory.path);
}
);
await test.main(testArgs);
return exitCode;
}
testArgs.add('--'); testArgs.add('--');
testArgs.addAll(testFiles); testArgs.addAll(testFiles);
......
...@@ -73,7 +73,10 @@ class ChromeLauncher { ...@@ -73,7 +73,10 @@ class ChromeLauncher {
static final Completer<Chrome> _currentCompleter = Completer<Chrome>(); static final Completer<Chrome> _currentCompleter = Completer<Chrome>();
/// Launch the chrome browser to a particular `host` page. /// Launch the chrome browser to a particular `host` page.
Future<Chrome> launch(String url) async { ///
/// `headless` defaults to false, and controls whether we open a headless or
/// a `headfull` browser.
Future<Chrome> launch(String url, { bool headless = false }) async {
final String chromeExecutable = findChromeExecutable(); final String chromeExecutable = findChromeExecutable();
final Directory dataDir = fs.systemTempDirectory.createTempSync(); final Directory dataDir = fs.systemTempDirectory.createTempSync();
final int port = await os.findFreePort(); final int port = await os.findFreePort();
...@@ -94,6 +97,8 @@ class ChromeLauncher { ...@@ -94,6 +97,8 @@ class ChromeLauncher {
'--no-default-browser-check', '--no-default-browser-check',
'--disable-default-apps', '--disable-default-apps',
'--disable-translate', '--disable-translate',
if (headless)
...<String>['--headless', '--disable-gpu'],
url, url,
]; ];
final Process process = await processManager.start(args); final Process process = await processManager.start(args);
...@@ -107,12 +112,14 @@ class ChromeLauncher { ...@@ -107,12 +112,14 @@ class ChromeLauncher {
throwToolExit('Unable to connect to Chrome DevTools.'); throwToolExit('Unable to connect to Chrome DevTools.');
return null; return null;
}); });
final Uri remoteDebuggerUri = await _getRemoteDebuggerUrl(Uri.parse('http://localhost:$port'));
return _connect(Chrome._( return _connect(Chrome._(
port, port,
ChromeConnection('localhost', port), ChromeConnection('localhost', port),
process: process, process: process,
dataDir: dataDir, dataDir: dataDir,
remoteDebuggerUri: remoteDebuggerUri,
)); ));
} }
...@@ -138,15 +145,36 @@ class ChromeLauncher { ...@@ -138,15 +145,36 @@ class ChromeLauncher {
_connect(Chrome._(port, ChromeConnection('localhost', port))); _connect(Chrome._(port, ChromeConnection('localhost', port)));
static Future<Chrome> get connectedInstance => _currentCompleter.future; static Future<Chrome> get connectedInstance => _currentCompleter.future;
/// Returns the full URL of the Chrome remote debugger for the main page.
///
/// This takes the [base] remote debugger URL (which points to a browser-wide
/// page) and uses its JSON API to find the resolved URL for debugging the host
/// page.
Future<Uri> _getRemoteDebuggerUrl(Uri base) async {
try {
final HttpClient client = HttpClient();
final HttpClientRequest request = await client.getUrl(base.resolve('/json/list'));
final HttpClientResponse response = await request.close();
final List<dynamic> jsonObject = await json.fuse(utf8).decoder.bind(response).single;
return base.resolve(jsonObject.first['devtoolsFrontendUrl']);
} catch (_) {
// If we fail to talk to the remote debugger protocol, give up and return
// the raw URL rather than crashing.
return base;
}
}
} }
/// A class for managing an instance of Chrome. /// A class for managing an instance of Chrome.
class Chrome { class Chrome {
const Chrome._( Chrome._(
this.debugPort, this.debugPort,
this.chromeConnection, { this.chromeConnection, {
Process process, Process process,
Directory dataDir, Directory dataDir,
this.remoteDebuggerUri,
}) : _process = process, }) : _process = process,
_dataDir = dataDir; _dataDir = dataDir;
...@@ -154,15 +182,18 @@ class Chrome { ...@@ -154,15 +182,18 @@ class Chrome {
final Process _process; final Process _process;
final Directory _dataDir; final Directory _dataDir;
final ChromeConnection chromeConnection; final ChromeConnection chromeConnection;
final Uri remoteDebuggerUri;
static Completer<Chrome> _currentCompleter = Completer<Chrome>(); static Completer<Chrome> _currentCompleter = Completer<Chrome>();
Future<void> get onExit => _currentCompleter.future;
Future<void> close() async { Future<void> close() async {
if (_currentCompleter.isCompleted) { if (_currentCompleter.isCompleted) {
_currentCompleter = Completer<Chrome>(); _currentCompleter = Completer<Chrome>();
} }
chromeConnection.close(); chromeConnection.close();
_process?.kill(ProcessSignal.SIGKILL); _process?.kill();
await _process?.exitCode; await _process?.exitCode;
try { try {
// Chrome starts another process as soon as it dies that modifies the // Chrome starts another process as soon as it dies that modifies the
......
...@@ -91,7 +91,8 @@ class WebCompilationProxy { ...@@ -91,7 +91,8 @@ class WebCompilationProxy {
/// `projectDirectory`. /// `projectDirectory`.
Future<void> initialize({ Future<void> initialize({
@required Directory projectDirectory, @required Directory projectDirectory,
@required String target, @required List<String> targets,
String testOutputDir,
}) async { }) async {
throw UnimplementedError(); throw UnimplementedError();
} }
......
<!DOCTYPE html>
<html>
<head>
<title>test Browser Host</title>
</head>
<body>
<svg id="dart" version="1.1" x="0px" y="0px" width="400px" height="400px" viewBox="0 0 400 400">
<path id="right-flank" fill="#0083C9" d="M249.379,226.486l-6.676,15.572L166.174,166h58.82c0,0,2.807-0.409,3.645,1.966L249.379,226.486z"/>
<path id="right-ear" fill="#00D2B8" d="M201.84,141.906L166.174,166h58.82c0,0,2.168-0.25,2.645,0.566l-2.694-8.848l-15.024-14.68C207.555,140.329,203.578,140.744,201.84,141.906z"/>
<path id="left-flank" fill="#00D2B8" d="M242.616,241.856l-15.022,6.799l-60.493-21.429c-1.035-0.395-1.101-3.696-1.101-3.696v-57.932L242.616,241.856z"/>
<path id="left-paw" fill="#55DECA" d="M167.003,227.098l60.636,21.558l15.064-6.799L237.224,259h-43.856c0,0-14.077-13.929-18.141-17.993C171.162,236.943,169.162,233.989,167.003,227.098z"/>
<path id="right-paw" fill="#00A4E4" d="M227.676,166.365c0.963,1.401,1.361,2.473,1.361,2.473l20.352,57.648l-6.711,15.37L259,236.463v-44.854c0,0-13.678-13.965-17.741-17.882C237.193,169.811,231.466,166.319,227.676,166.365z"/>
<path id="left-ear" fill="#0083C9" d="M166.769,227.098c0,0-0.769-1.104-0.769-4.355v-57.144l-23.115,34.877c-1.626,1.774-1.567,6.538,1.595,9.755l13.636,13.892L166.769,227.098z"/>
</svg>
<div id="dark"></div>
<svg id="play" version="1.1" x="0px" y="0px" width="80px" height="80px" viewBox="0 0 25 25">
<defs><filter id="blur"><feGaussianBlur stdDeviation="0.3" id="feGaussianBlur5097" /></filter></defs>
<path d="M 3.777014,1.3715789 A 1.1838119,1.1838119 0 0 0 2.693923,2.5488509 V 22.444746 a 1.1838119,1.1838119 0 0 0 1.765908,1.035999 l 17.235259,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.459831,1.5128519 A 1.1838119,1.1838119 0 0 0 3.777014,1.3715789 z" style="opacity:0.5;stroke:#000000;stroke-width:1;filter:url(#blur)" />
<path style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.32722104" d="M 3.4770491,1.0714664 A 1.1838119,1.1838119 0 0 0 2.3939589,2.2487382 V 22.144633 a 1.1838119,1.1838119 0 0 0 1.7659079,1.035999 l 17.2352602,-9.95972 a 1.1838119,1.1838119 0 0 0 0,-2.071998 L 4.1598668,1.2127389 A 1.1838119,1.1838119 0 0 0 3.4770491,1.0714664 z" />
</svg>
<script src="host.dart.js"></script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment