Skip to content

Commit

Permalink
Refactored pmtiles_server to be generic. This will allow us to spawn …
Browse files Browse the repository at this point in the history
…other server types.
  • Loading branch information
bramp committed Feb 1, 2024
1 parent deec12d commit ba82227
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 108 deletions.
4 changes: 4 additions & 0 deletions packages/pmtiles_tests/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ dev_dependencies:
path: ^1.8.3
pmtiles: ^1.2.0
stream_channel: ^2.1.2

json_serializable: ^6.7.1
json_annotation: ^4.8.1
build_runner: ^2.4.8
92 changes: 66 additions & 26 deletions packages/pmtiles_tests/test/archive_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import 'dart:io';
import 'dart:typed_data';

import 'package:pmtiles/pmtiles.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test/test.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;

import 'server_args.dart';

final client = http.Client();
late final int port;
late final String pmtilesUrl;
final sampleDir = path.join(Directory.current.path, 'samples');

/// Use the pmtiles server to get the tile, as a source of comparision
Future<http.Response> getReferenceTile(
Expand All @@ -19,11 +21,21 @@ Future<http.Response> getReferenceTile(

final basename = path.basenameWithoutExtension(archive);
final response = await client.get(
Uri.parse('http://localhost:$port/$basename/${t.z}/${t.x}/${t.y}.$ext'),
Uri.parse('$pmtilesUrl/$basename/${t.z}/${t.x}/${t.y}.$ext'),
);
return response;
}

dynamic getReferenceMetadata(String sample) async {
final basename = path.basenameWithoutExtension(sample);
return json.decoder.convert(
await http.read(
Uri.parse('$pmtilesUrl/$basename/metadata'),
),
);
}

/// Wrapper for a ReadAt, that counts how many requests/bytes are read.
class CountingReadAt implements ReadAt {
final ReadAt _inner;
int requests = 0;
Expand All @@ -49,8 +61,55 @@ class CountingReadAt implements ReadAt {
}
}

// This is very heavy handed, but we'll run a pmtiles server, and compare
// the results to our library.
String pmtilesServingToUrl(String logline) {
return logline.replaceAllMapped(
RegExp(r'(.* Serving .* port )(\d+)( .* interface )([\d.]+)(.*)'),
(Match m) => "http://${m[4]}:${m[2]}");
// ${m[4]} may be 0.0.0.0, which seems to allow us to connect to (on my
// mac), but I'm not sure that's valid everywhere. Maybe we replaced
// that with localhost.
}

/// Start a `pmtile server` instance, returning the URL its running on.
Future<String> startPmtilesServer() async {
final channel = spawnHybridUri(
'server.dart',
stayAlive: true,
message: ServerArgs(
executable: 'pmtiles',
arguments: [
'serve',
'.',

'--port', '\$port',

// Allow requests from any origin. This allows the `chrome` browser
// based tests to work.
'--cors',
'*'
],
workingDirectory: 'samples',
waitFor: 'Serving',
).toJson(),
);

addTearDown(() async {
// Tell the pmtiles server to shutdown and wait for the sink to be closed.
channel.sink.add("tearDownAll");
await channel.sink.done;
});

// Get the url pmtiles server is running on.
return pmtilesServingToUrl(await channel.stream.first);
}

// This is very heavy handed, but we'll run a `pmtiles server`, and makes 1000s
// of API calls comparing the reference results to the results to our library.
//
// There is a lot of additional complexity here, so these tests can be
// performaned from a web browser. As such, the serving of the test data is done
// from a `spawnHybridUri` isolate, and the actual test in this file is run
// in the browser.
void main() async {
final samples = [
'samples/countries.pmtiles',
Expand All @@ -60,34 +119,15 @@ void main() async {
'samples/trails.pmtiles',
];

StreamChannel? channel;

setUpAll(() async {
// pmtiles_server.dart will spawn a `pmtiles serve` process, for testing.
channel = spawnHybridUri('pmtiles_server.dart', stayAlive: true);

// Get the port the pmtiles server is running on.
port = await channel!.stream.first;
});

tearDownAll(() async {
// Tell the pmtiles server to shutdown and wait for the sink to be closed.
if (channel != null) {
channel!.sink.add("tearDownAll");
await channel!.sink.done;
}
pmtilesUrl = await startPmtilesServer();
});

group('archive', () {
for (final sample in samples) {
test('$sample metadata()', () async {
// Fetch the reference metadata from the pmtiles server.
final basename = path.basenameWithoutExtension(sample);
final expected = json.decoder.convert(
await http.read(
Uri.parse('http://localhost:$port/$basename/metadata'),
),
);
final expected = await getReferenceMetadata(sample);

// Now test our implementation
final file = File(sample);
Expand Down
12 changes: 12 additions & 0 deletions packages/pmtiles_tests/test/archive_test_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:test/test.dart';

import 'archive_test.dart';

// Quis custodiet ipsos custodes?
void main() async {
test('pmtilesServingToUrl', () {
String actual = pmtilesServingToUrl(
'2024/02/01 10:21:28 main.go:152: Serving . on port 8080 and interface 0.0.0.0 with Access-Control-Allow-Origin:');
expect(actual, "http://0.0.0.0:8080");
});
}
82 changes: 0 additions & 82 deletions packages/pmtiles_tests/test/pmtiles_server.dart

This file was deleted.

105 changes: 105 additions & 0 deletions packages/pmtiles_tests/test/server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'dart:convert';
import 'dart:io';
import 'package:stream_channel/stream_channel.dart';

import 'server_args.dart';

/// Gets a free port on the local machine.
///
/// This is racy, because we don't hold the port open, but it's good enough for
/// our purposes.
///
/// Borrowed from https://stackoverflow.com/a/14095888/88646
Future<int> getUnusedPort([InternetAddress? address]) {
return ServerSocket.bind(address ?? InternetAddress.loopbackIPv4, 0)
.then((socket) {
var port = socket.port;
socket.close();
return port;
});
}

/// Spawns a process that can be used for receiving test requests.
///
/// Use like this:
/// ```dart
/// setUpAll(() async {
/// channel = spawnHybridUri(
/// 'server.dart',
/// stayAlive: true,
/// message: ServerArgs(
/// executable: 'http-server',
/// arguments: ['.'],
/// workingDirectory: 'samples',
/// ).toJson(),
/// );
///
/// url = await channel!.stream.first;
/// // Now the server is ready
/// });
///
/// tearDownAll(() async {
/// // Tell the server to shutdown and wait for the sink to be closed.
/// if (channel != null) {
/// channel!.sink.add("tearDownAll");
/// await channel!.sink.done;
/// }
/// });
/// ```
///
hybridMain(StreamChannel channel, Object message) async {
final args = ServerArgs.fromJson(message as Map<String, dynamic>);

// Find the binary, as Process seems to require a full path.
final executableFullPath =
Process.runSync('which', [args.executable]).stdout.toString().trim();

if (executableFullPath.isEmpty) {
throw Exception('Could not find `${args.executable}` binary');
}

// Replace any `$port`, with a random port number
final arguments = await Future.wait(args.arguments.map((s) async {
while (s.contains('\$port')) {
final port = await getUnusedPort();
s = s.replaceFirst('\$port', port.toString());
}
return s;
}));

// Invoke the binary
Process process = await Process.start(
executableFullPath,
arguments,
workingDirectory: args.workingDirectory,
includeParentEnvironment: false,
);

try {
// Wait until it prints the 'available' string.
final stdout = process.stdout.transform(utf8.decoder).asBroadcastStream();
final url = await stdout.firstWhere((line) => line.contains(args.waitFor));

// Then ignore the rest
stdout.drain();

// Always print stderr
process.stderr.transform(utf8.decoder).forEach(print);

// Send the line we found
channel.sink.add(url);

// Wait for the channel to receive a message telling us to tearDown.
await channel.stream.first; // the received message should be "tearDownAll".
} finally {
// The spawned process may hang around due to
// https://github.com/dart-lang/sdk/issues/53772 :(

// Cleanup the process
process.kill();
await process.exitCode;
}

// and we are done.
channel.sink.close();
}
25 changes: 25 additions & 0 deletions packages/pmtiles_tests/test/server_args.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:json_annotation/json_annotation.dart';

part 'server_args.g.dart';

@JsonSerializable()
class ServerArgs {
final String executable;
final List<String> arguments;
final String? workingDirectory;

/// We wait for a log line that contains this string.
final String waitFor;

const ServerArgs({
required this.executable,
required this.waitFor,
this.arguments = const [],
this.workingDirectory,
}) : assert(executable != ""),
assert(waitFor != "");

factory ServerArgs.fromJson(Map<String, dynamic> json) =>
_$ServerArgsFromJson(json);
Map<String, dynamic> toJson() => _$ServerArgsToJson(this);
}
25 changes: 25 additions & 0 deletions packages/pmtiles_tests/test/server_args.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ba82227

Please sign in to comment.