mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 16:34:05 +01:00
Add the desktop wrapper, and stop the test suite eating BACKLOG.md
The desktop app is a browser now, so it cannot write files. This adds the other half: a loopback HTTP server that serves the web build and owns the two files the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and the todo MCP server read, and a disk copy of the note log so a wiped Chrome profile is not a total-loss event for unsynced notes. Notable details: - Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home with no authentication, so a routable bind would let anything on the network rewrite the backlog. - Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything else and the app then renders nothing at all. - Mirroring to disk is debounced. The store persists on every keystroke, so mirroring eagerly meant an HTTP request plus a whole-log write per character; IndexedDB still gets every keystroke, only the durability copy waits. Separately, this fixes a pre-existing destructive bug found while testing: `settings_screen_test` did real file I/O and overwrote the user's canonical `~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It went unnoticed because the app rewrites the file whenever it launches. The export destination is now injectable and the test redirects it to a temp dir; verified that a full suite run leaves the real file byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66143bdfe3
commit
c37a9ef118
76
bin/todo_desktop.dart
Normal file
76
bin/todo_desktop.dart
Normal file
@ -0,0 +1,76 @@
|
||||
// coverage:ignore-file
|
||||
// Entry point for the desktop wrapper: resolves real paths, starts the server,
|
||||
// and launches the browser. The serving logic it delegates to is covered by
|
||||
// test/wrapper_server_test.dart.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
/// Port must stay fixed: the browser keys `localStorage` (the GitHub token) and
|
||||
/// IndexedDB (the notes) by origin, so a different port looks like a different
|
||||
/// app with no token and no notes. Kept in step with lib/sync/desktop_wrapper.
|
||||
const _port = 8730;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null) {
|
||||
stderr.writeln('HOME is not set; cannot resolve the backlog path.');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Assets sit next to this executable when installed; --web-root overrides for
|
||||
// development runs straight out of the repo.
|
||||
final webRoot = _argValue(args, '--web-root') ??
|
||||
p.join(p.dirname(Platform.resolvedExecutable), 'web');
|
||||
if (!Directory(webRoot).existsSync()) {
|
||||
stderr.writeln('web assets not found at $webRoot');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
final server = WrapperServer(
|
||||
webRoot: webRoot,
|
||||
backlogPath: p.join(home, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(home, '.local', 'share', 'todo-desktop', 'todo_notes.json'),
|
||||
);
|
||||
await server.start(_port);
|
||||
stdout.writeln('todo desktop serving on http://localhost:$_port');
|
||||
|
||||
if (_argValue(args, '--no-browser') == null) {
|
||||
await _launchBrowser(home);
|
||||
// The browser owns the session: when its window closes, so does the app.
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches the app in a Chrome-family browser with a **stable** profile
|
||||
/// directory, since the token and notes live in that profile.
|
||||
Future<void> _launchBrowser(String home) async {
|
||||
const candidates = [
|
||||
'/opt/google/chrome/chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
];
|
||||
final browser = candidates.firstWhere(
|
||||
(path) => File(path).existsSync(),
|
||||
orElse: () => '',
|
||||
);
|
||||
if (browser.isEmpty) {
|
||||
stderr.writeln('No Chrome-family browser found; open '
|
||||
'http://localhost:$_port manually.');
|
||||
return;
|
||||
}
|
||||
final profile = p.join(home, '.local', 'share', 'todo-desktop', 'profile');
|
||||
final process = await Process.start(browser, [
|
||||
'--app=http://localhost:$_port',
|
||||
'--user-data-dir=$profile',
|
||||
'--no-first-run',
|
||||
]);
|
||||
await process.exitCode;
|
||||
}
|
||||
|
||||
String? _argValue(List<String> args, String name) {
|
||||
final index = args.indexOf(name);
|
||||
if (index == -1 || index + 1 >= args.length) return null;
|
||||
return args[index + 1];
|
||||
}
|
||||
@ -18,14 +18,27 @@ import 'package:todo/data/desktop_backup_client.dart';
|
||||
/// absent (for instance when the same build is opened in a plain browser tab).
|
||||
class WebLogPersistence implements LogPersistence {
|
||||
/// Creates a persistence over [database], mirroring writes to [backup].
|
||||
WebLogPersistence({required Database database, this.backup})
|
||||
: _db = database;
|
||||
WebLogPersistence({
|
||||
required Database database,
|
||||
this.backup,
|
||||
this.mirrorDebounce = const Duration(seconds: 2),
|
||||
}) : _db = database;
|
||||
|
||||
final Database _db;
|
||||
|
||||
/// Wrapper client that mirrors writes to disk, or null when absent.
|
||||
final DesktopBackupClient? backup;
|
||||
|
||||
/// How long to coalesce disk mirroring after the last write.
|
||||
///
|
||||
/// The store persists on *every* keystroke, so mirroring eagerly would mean
|
||||
/// an HTTP request and a whole-log disk write per character. IndexedDB is
|
||||
/// still written synchronously with each keystroke; only the durability copy
|
||||
/// is debounced.
|
||||
final Duration mirrorDebounce;
|
||||
|
||||
Timer? _mirrorTimer;
|
||||
|
||||
/// Object store holding a single record: the serialised log.
|
||||
static const storeName = 'log';
|
||||
|
||||
@ -64,6 +77,18 @@ class WebLogPersistence implements LogPersistence {
|
||||
final txn = _db.transaction(storeName, idbModeReadWrite);
|
||||
await txn.objectStore(storeName).put(text, recordKey);
|
||||
await txn.completed;
|
||||
unawaited(backup?.writeLog(text) ?? Future<void>.value());
|
||||
_scheduleMirror(text);
|
||||
}
|
||||
|
||||
void _scheduleMirror(String text) {
|
||||
final client = backup;
|
||||
if (client == null) return;
|
||||
_mirrorTimer?.cancel();
|
||||
_mirrorTimer = Timer(mirrorDebounce, () {
|
||||
unawaited(client.writeLog(text));
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancels any pending disk mirror. Call when tearing the store down.
|
||||
void dispose() => _mirrorTimer?.cancel();
|
||||
}
|
||||
|
||||
163
lib/desktop/wrapper_server.dart
Normal file
163
lib/desktop/wrapper_server.dart
Normal file
@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Local HTTP server backing the desktop app.
|
||||
///
|
||||
/// The desktop app is a Flutter **web** build (Flutter's Linux embedder manages
|
||||
/// only ~20fps at 4K — see `docs/desktop-performance-findings.md`), so it runs
|
||||
/// in a browser and cannot touch the filesystem. This process is the other half
|
||||
/// of the desktop app: it serves the build and owns the two files a browser
|
||||
/// cannot write.
|
||||
///
|
||||
/// * `~/todo/BACKLOG.md` — the canonical backlog path the user's tooling and
|
||||
/// the `todo` MCP server read.
|
||||
/// * a copy of the note log — so a wiped Chrome profile is not a total-loss
|
||||
/// event for notes that have not yet synced to GitHub.
|
||||
///
|
||||
/// Binds to loopback only. The endpoints read and overwrite files in the user's
|
||||
/// home directory with no authentication, so exposing them on a routable
|
||||
/// address would let anything on the network rewrite the backlog.
|
||||
class WrapperServer {
|
||||
/// Creates a server serving [webRoot] and persisting under [backlogPath] and
|
||||
/// [logPath].
|
||||
WrapperServer({
|
||||
required this.webRoot,
|
||||
required this.backlogPath,
|
||||
required this.logPath,
|
||||
});
|
||||
|
||||
/// Directory holding the built Flutter web assets.
|
||||
final String webRoot;
|
||||
|
||||
/// Absolute path of the Markdown backlog export.
|
||||
final String backlogPath;
|
||||
|
||||
/// Absolute path of the on-disk note-log copy.
|
||||
final String logPath;
|
||||
|
||||
HttpServer? _server;
|
||||
|
||||
/// Port the server is listening on, once [start] has completed.
|
||||
int get port => _server!.port;
|
||||
|
||||
/// Binds to loopback on [requestedPort] and begins serving.
|
||||
///
|
||||
/// Pass 0 to let the OS choose a port (tests do this); the desktop launcher
|
||||
/// passes the fixed port, because the browser keys `localStorage` (the GitHub
|
||||
/// token) and IndexedDB (the notes) by origin — a changing port would
|
||||
/// silently hide both.
|
||||
Future<void> start(int requestedPort) async {
|
||||
_server = await HttpServer.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
requestedPort,
|
||||
);
|
||||
unawaited(_serve(_server!));
|
||||
}
|
||||
|
||||
/// Stops serving and releases the port.
|
||||
Future<void> stop() async => _server?.close(force: true);
|
||||
|
||||
Future<void> _serve(HttpServer server) async {
|
||||
await for (final request in server) {
|
||||
try {
|
||||
await _handle(request);
|
||||
} on Exception {
|
||||
request.response.statusCode = HttpStatus.internalServerError;
|
||||
}
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest request) async {
|
||||
final path = request.uri.path;
|
||||
if (path == '/backup/backlog') {
|
||||
return _file(request, backlogPath);
|
||||
}
|
||||
if (path == '/backup/log') {
|
||||
return _file(request, logPath);
|
||||
}
|
||||
return _static(request, path);
|
||||
}
|
||||
|
||||
/// GET returns the file's contents (404 when absent); POST overwrites it.
|
||||
Future<void> _file(HttpRequest request, String filePath) async {
|
||||
final file = File(filePath);
|
||||
if (request.method == 'POST') {
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(await utf8.decodeStream(request));
|
||||
request.response.statusCode = HttpStatus.noContent;
|
||||
return;
|
||||
}
|
||||
if (request.method != 'GET') {
|
||||
request.response.statusCode = HttpStatus.methodNotAllowed;
|
||||
return;
|
||||
}
|
||||
if (!file.existsSync()) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
request.response.headers.contentType = ContentType.text;
|
||||
request.response.write(await file.readAsString());
|
||||
}
|
||||
|
||||
Future<void> _static(HttpRequest request, String path) async {
|
||||
final relative = path == '/' ? 'index.html' : path.substring(1);
|
||||
// Reject traversal before touching the filesystem: the served root sits
|
||||
// next to the user's files, so `../` must not escape it.
|
||||
final resolved = p.normalize(p.join(webRoot, relative));
|
||||
// coverage:ignore-start
|
||||
// Defence in depth, and currently unreachable: Dart's HttpServer decodes
|
||||
// and normalises the path before a handler runs, so even `%2e%2e` arrives
|
||||
// already collapsed (verified in wrapper_server_test). Kept so the
|
||||
// guarantee does not depend on that implementation detail holding.
|
||||
if (!p.isWithin(webRoot, resolved) && resolved != p.normalize(webRoot)) {
|
||||
request.response.statusCode = HttpStatus.forbidden;
|
||||
return;
|
||||
}
|
||||
// coverage:ignore-end
|
||||
final file = File(resolved);
|
||||
if (!file.existsSync()) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
request.response.headers.contentType = contentTypeFor(resolved);
|
||||
await request.response.addStream(file.openRead());
|
||||
}
|
||||
|
||||
/// Content type for [filePath].
|
||||
///
|
||||
/// Flutter web is strict here: CanvasKit refuses to instantiate a `.wasm`
|
||||
/// served as anything but `application/wasm`, and the app silently fails to
|
||||
/// render if the bootstrap `.js` is mislabelled.
|
||||
static ContentType contentTypeFor(String filePath) {
|
||||
switch (p.extension(filePath).toLowerCase()) {
|
||||
case '.html':
|
||||
return ContentType.html;
|
||||
case '.js' || '.mjs':
|
||||
return ContentType('text', 'javascript', charset: 'utf-8');
|
||||
case '.json':
|
||||
return ContentType.json;
|
||||
case '.wasm':
|
||||
return ContentType('application', 'wasm');
|
||||
case '.css':
|
||||
return ContentType('text', 'css', charset: 'utf-8');
|
||||
case '.png':
|
||||
return ContentType('image', 'png');
|
||||
case '.svg':
|
||||
return ContentType('image', 'svg+xml');
|
||||
case '.ttf':
|
||||
return ContentType('font', 'ttf');
|
||||
case '.otf':
|
||||
return ContentType('font', 'otf');
|
||||
case '.woff2':
|
||||
return ContentType('font', 'woff2');
|
||||
case '.bin' || '.symbols':
|
||||
return ContentType.binary;
|
||||
default:
|
||||
return ContentType.binary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,14 @@ import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// Resolves the home directory the desktop export writes under.
|
||||
///
|
||||
/// Overridable **so the test suite never writes to the real `~/todo`**. This is
|
||||
/// not hypothetical: before this seam existed, running `flutter test` exported
|
||||
/// a fake note over the user's canonical `~/todo/BACKLOG.md` every time.
|
||||
String Function() resolveExportHome =
|
||||
() => Platform.environment['HOME'] ?? Directory.current.path;
|
||||
|
||||
/// Writes the Markdown export somewhere the user can get at it, and returns a
|
||||
/// human-readable description of where it went.
|
||||
///
|
||||
@ -26,7 +34,7 @@ Future<String> exportBacklog(String markdown, int noteCount) async {
|
||||
return 'Shared $noteCount notes';
|
||||
}
|
||||
// coverage:ignore-end
|
||||
final home = Platform.environment['HOME'] ?? Directory.current.path;
|
||||
final home = resolveExportHome();
|
||||
final dir = Directory('$home/todo');
|
||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||
final file = File('${dir.path}/BACKLOG.md');
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||
@ -8,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/sync/backlog_export_io.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
@ -275,7 +277,26 @@ void main() {
|
||||
expect(find.textContaining('sync failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('the default export home is the real HOME', () {
|
||||
// Guards the production default, which every other test overrides so the
|
||||
// suite cannot write to the user's ~/todo.
|
||||
expect(resolveExportHome(), Platform.environment['HOME']);
|
||||
});
|
||||
|
||||
testWidgets('Export notes writes the backlog file (desktop)', (tester) async {
|
||||
// Redirect HOME: this test does real file I/O, and without the override it
|
||||
// overwrites the user's canonical ~/todo/BACKLOG.md with the fake note
|
||||
// below on every test run.
|
||||
// Sync creation: awaiting real file I/O under the widget tester's fake
|
||||
// clock never completes.
|
||||
final home = Directory.systemTemp.createTempSync('todo_export_home');
|
||||
resolveExportHome = () => home.path;
|
||||
addTearDown(() {
|
||||
resolveExportHome =
|
||||
() => Platform.environment['HOME'] ?? Directory.current.path;
|
||||
home.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
await pumpSettings(
|
||||
tester,
|
||||
seed: [
|
||||
|
||||
153
test/wrapper_server_test.dart
Normal file
153
test/wrapper_server_test.dart
Normal file
@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
late WrapperServer server;
|
||||
late String base;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('wrapper');
|
||||
final webRoot = Directory(p.join(root.path, 'web'))..createSync();
|
||||
File(p.join(webRoot.path, 'index.html')).writeAsStringSync('<h1>todo</h1>');
|
||||
File(p.join(webRoot.path, 'main.dart.js')).writeAsStringSync('console;');
|
||||
File(p.join(webRoot.path, 'canvaskit.wasm')).writeAsStringSync('binary');
|
||||
|
||||
server = WrapperServer(
|
||||
webRoot: webRoot.path,
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
);
|
||||
// Port 0 lets the OS pick, so tests never collide with a running app.
|
||||
await server.start(0);
|
||||
base = 'http://localhost:${server.port}';
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
root.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('serves index.html at the root', () async {
|
||||
final response = await http.get(Uri.parse('$base/'));
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.body, contains('todo'));
|
||||
});
|
||||
|
||||
test('serves nested assets and 404s unknown paths', () async {
|
||||
expect((await http.get(Uri.parse('$base/main.dart.js'))).statusCode, 200);
|
||||
expect((await http.get(Uri.parse('$base/nope.js'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('refuses to serve outside the web root', () async {
|
||||
// The served directory sits beside the user's files, so traversal must not
|
||||
// be able to read them. Sent over a raw socket because http.get normalises
|
||||
// `..` away client-side, so the server would never see the attack.
|
||||
File(p.join(root.path, 'secret')).writeAsStringSync('do not serve me');
|
||||
|
||||
// Percent-encoded, because both the http client and Dart's HttpServer
|
||||
// collapse a literal `..` before any handler sees it; `%2e%2e` survives
|
||||
// that and is decoded into the path afterwards.
|
||||
final socket = await Socket.connect('localhost', server.port);
|
||||
socket.write('GET /%2e%2e/secret HTTP/1.1\r\nHost: localhost\r\n\r\n');
|
||||
await socket.flush();
|
||||
final response = await utf8.decoder.bind(socket).first;
|
||||
await socket.close();
|
||||
|
||||
// Dart's HttpServer decodes and then normalises, so traversal is already
|
||||
// collapsed before any handler runs and this comes back 404 rather than
|
||||
// 403. What matters is the property, not which code rejected it: the file
|
||||
// outside the web root is never served.
|
||||
expect(response, isNot(contains('do not serve me')));
|
||||
expect(response, anyOf(contains('403'), contains('404')));
|
||||
});
|
||||
|
||||
test('a failing write reports a server error rather than crashing', () async {
|
||||
// Parent path is a regular file, so creating the directory for the backup
|
||||
// throws. The serve loop must survive it and keep answering requests.
|
||||
final blocked = Directory(p.join(root.path, 'blocked'))..createSync();
|
||||
File(p.join(blocked.path, 'wall')).writeAsStringSync('');
|
||||
final wedged = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(blocked.path, 'wall', 'nested', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
);
|
||||
await wedged.start(0);
|
||||
addTearDown(wedged.stop);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('http://localhost:${wedged.port}/backup/backlog'),
|
||||
body: 'x',
|
||||
);
|
||||
expect(response.statusCode, 500);
|
||||
|
||||
// Still alive afterwards.
|
||||
final after = await http.get(
|
||||
Uri.parse('http://localhost:${wedged.port}/'),
|
||||
);
|
||||
expect(after.statusCode, 200);
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the backlog', () async {
|
||||
final posted = await http.post(
|
||||
Uri.parse('$base/backup/backlog'),
|
||||
body: '# backlog\n\nnote one\n',
|
||||
);
|
||||
expect(posted.statusCode, 204);
|
||||
|
||||
// Written to the real path, which is what the user's tooling reads.
|
||||
final onDisk = File(p.join(root.path, 'todo', 'BACKLOG.md'));
|
||||
expect(onDisk.existsSync(), isTrue);
|
||||
expect(onDisk.readAsStringSync(), contains('note one'));
|
||||
|
||||
final fetched = await http.get(Uri.parse('$base/backup/backlog'));
|
||||
expect(fetched.statusCode, 200);
|
||||
expect(fetched.body, contains('note one'));
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the note log', () async {
|
||||
await http.post(Uri.parse('$base/backup/log'), body: '{"a":1}');
|
||||
final fetched = await http.get(Uri.parse('$base/backup/log'));
|
||||
expect(fetched.body, '{"a":1}');
|
||||
});
|
||||
|
||||
test('GET on a backup that does not exist yet 404s', () async {
|
||||
expect((await http.get(Uri.parse('$base/backup/log'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('rejects unsupported methods on a backup path', () async {
|
||||
final response = await http.delete(Uri.parse('$base/backup/log'));
|
||||
expect(response.statusCode, 405);
|
||||
});
|
||||
|
||||
test('labels wasm and js correctly', () async {
|
||||
// CanvasKit refuses a .wasm served as anything else, and the app then
|
||||
// renders nothing at all.
|
||||
final wasm = await http.get(Uri.parse('$base/canvaskit.wasm'));
|
||||
expect(wasm.headers['content-type'], contains('application/wasm'));
|
||||
final js = await http.get(Uri.parse('$base/main.dart.js'));
|
||||
expect(js.headers['content-type'], contains('javascript'));
|
||||
});
|
||||
|
||||
test('contentTypeFor covers the asset kinds the build emits', () {
|
||||
String kind(String name) =>
|
||||
WrapperServer.contentTypeFor(name).mimeType;
|
||||
|
||||
expect(kind('a.html'), 'text/html');
|
||||
expect(kind('a.json'), 'application/json');
|
||||
expect(kind('a.css'), 'text/css');
|
||||
expect(kind('a.png'), 'image/png');
|
||||
expect(kind('a.svg'), 'image/svg+xml');
|
||||
expect(kind('a.ttf'), 'font/ttf');
|
||||
expect(kind('a.otf'), 'font/otf');
|
||||
expect(kind('a.woff2'), 'font/woff2');
|
||||
expect(kind('a.mjs'), 'text/javascript');
|
||||
expect(kind('a.bin'), 'application/octet-stream');
|
||||
expect(kind('a.unknown'), 'application/octet-stream');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user