blob: 8541f499fc87e6894deb4a56c3c874c70dbcc1cf [file] [log] [blame]
// Copyright 2013 The Flutter Authors. 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:html' as html;
import 'dart:js_util' as js_util;
import 'dart:typed_data';
import 'package:quiver/testing/async.dart';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine/browser_detection.dart';
import 'package:ui/src/engine/keyboard.dart';
import 'package:ui/src/engine/services.dart';
import 'package:ui/src/engine/text_editing/text_editing.dart';
import 'package:ui/ui.dart' as ui;
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('Keyboard', () {
/// Used to save and restore [ui.window.onPlatformMessage] after each test.
ui.PlatformMessageCallback? savedCallback;
setUp(() {
savedCallback = ui.window.onPlatformMessage;
});
tearDown(() {
ui.window.onPlatformMessage = savedCallback;
});
test('initializes and disposes', () {
expect(Keyboard.instance, isNull);
Keyboard.initialize();
expect(Keyboard.instance, isA<Keyboard>());
Keyboard.instance!.dispose();
expect(Keyboard.instance, isNull);
});
test('dispatches keyup to flutter/keyevent channel', () {
Keyboard.initialize();
String? channelReceived;
Map<String, dynamic>? dataReceived;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
channelReceived = channel;
dataReceived = const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>?;
};
html.KeyboardEvent event;
event = dispatchKeyboardEvent('keyup', key: 'SomeKey', code: 'SomeCode', keyCode: 1);
expect(event.defaultPrevented, isFalse);
expect(channelReceived, 'flutter/keyevent');
expect(dataReceived, <String, dynamic>{
'type': 'keyup',
'keymap': 'web',
'code': 'SomeCode',
'location': 0,
'key': 'SomeKey',
'metaState': 0x0,
'keyCode': 1,
});
Keyboard.instance!.dispose();
},
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50815
skip: browserEngine == BrowserEngine.edge);
test('dispatches keydown to flutter/keyevent channel', () {
Keyboard.initialize();
String? channelReceived;
Map<String, dynamic>? dataReceived;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
channelReceived = channel;
dataReceived = const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>?;
};
html.KeyboardEvent event;
event =
dispatchKeyboardEvent('keydown', key: 'SomeKey', code: 'SomeCode', keyCode: 1);
expect(channelReceived, 'flutter/keyevent');
expect(dataReceived, <String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'code': 'SomeCode',
'key': 'SomeKey',
'location': 0,
'metaState': 0x0,
'keyCode': 1,
});
expect(event.defaultPrevented, isFalse);
Keyboard.instance!.dispose();
},
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50815
skip: browserEngine == BrowserEngine.edge);
test('dispatches correct meta state', () {
Keyboard.initialize();
Map<String, dynamic>? dataReceived;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
dataReceived = const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>?;
};
html.KeyboardEvent event;
event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
isControlPressed: true,
);
expect(event.defaultPrevented, isFalse);
expect(dataReceived, <String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'code': 'SomeCode',
'key': 'SomeKey',
'location': 0,
// ctrl
'metaState': 0x4,
'keyCode': 0,
});
event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
isShiftPressed: true,
isAltPressed: true,
isMetaPressed: true,
);
expect(event.defaultPrevented, isFalse);
expect(dataReceived, <String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'code': 'SomeCode',
'key': 'SomeKey',
'location': 0,
// shift alt meta
'metaState': 0x1 | 0x2 | 0x8,
'keyCode': 0,
});
Keyboard.instance!.dispose();
},
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50815
skip: browserEngine == BrowserEngine.edge);
test('dispatches repeat events', () {
Keyboard.initialize();
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
html.KeyboardEvent event;
event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
repeat: true,
);
expect(event.defaultPrevented, isFalse);
event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
repeat: true,
);
expect(event.defaultPrevented, isFalse);
event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
repeat: true,
);
expect(event.defaultPrevented, isFalse);
final Map<String, dynamic> expectedMessage = <String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'code': 'SomeCode',
'key': 'SomeKey',
'location': 0,
'metaState': 0,
'keyCode': 0,
};
expect(messages, <Map<String, dynamic>>[
expectedMessage,
expectedMessage,
expectedMessage,
]);
Keyboard.instance!.dispose();
},
// TODO(mdebbar): https://github.com/flutter/flutter/issues/50815
skip: browserEngine == BrowserEngine.edge);
test('stops dispatching events after dispose', () {
Keyboard.initialize();
int count = 0;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
count += 1;
};
dispatchKeyboardEvent('keydown');
expect(count, 1);
dispatchKeyboardEvent('keyup');
expect(count, 2);
Keyboard.instance!.dispose();
expect(Keyboard.instance, isNull);
// No more event dispatching.
dispatchKeyboardEvent('keydown');
expect(count, 2);
dispatchKeyboardEvent('keyup');
expect(count, 2);
});
test('prevents default when key is handled by the framework', () {
Keyboard.initialize();
int count = 0;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
count += 1;
final ByteData response = const JSONMessageCodec().encodeMessage(<String, dynamic>{'handled': true})!;
callback!(response);
};
final html.KeyboardEvent event = dispatchKeyboardEvent(
'keydown',
key: 'Tab',
code: 'Tab',
);
expect(event.defaultPrevented, isTrue);
expect(count, 1);
Keyboard.instance!.dispose();
});
test("Doesn't prevent default when key is not handled by the framework", () {
Keyboard.initialize();
int count = 0;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
count += 1;
final ByteData response = const JSONMessageCodec().encodeMessage(<String, dynamic>{'handled': false})!;
callback!(response);
};
final html.KeyboardEvent event = dispatchKeyboardEvent(
'keydown',
key: 'Tab',
code: 'Tab',
);
expect(event.defaultPrevented, isFalse);
expect(count, 1);
Keyboard.instance!.dispose();
});
test('keyboard events should be triggered on text fields', () {
Keyboard.initialize();
int count = 0;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
count += 1;
};
useTextEditingElement((html.Element element) {
final html.KeyboardEvent event = dispatchKeyboardEvent(
'keydown',
key: 'SomeKey',
code: 'SomeCode',
target: element,
);
expect(event.defaultPrevented, isFalse);
expect(count, 1);
});
Keyboard.instance!.dispose();
});
test('the "Tab" key should never be ignored', () {
Keyboard.initialize();
int count = 0;
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
count += 1;
final ByteData response = const JSONMessageCodec().encodeMessage(<String, dynamic>{'handled': true})!;
callback!(response);
};
useTextEditingElement((html.Element element) {
final html.KeyboardEvent event = dispatchKeyboardEvent(
'keydown',
key: 'Tab',
code: 'Tab',
target: element,
);
expect(event.defaultPrevented, isTrue);
expect(count, 1);
});
Keyboard.instance!.dispose();
});
testFakeAsync(
'On macOS, synthesize keyup when shortcut is handled by the system',
(FakeAsync async) {
// This can happen when the user clicks `cmd+alt+i` to open devtools. Here
// is the sequence we receive from the browser in such case:
//
// keydown(cmd) -> keydown(alt) -> keydown(i) -> keyup(alt) -> keyup(cmd)
//
// There's no `keyup(i)`. The web engine is expected to synthesize a
// `keyup(i)` event.
Keyboard.initialize(onMacOs: true);
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
dispatchKeyboardEvent(
'keydown',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isMetaPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'Alt',
code: 'AltLeft',
location: 1,
isMetaPressed: true,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
isMetaPressed: true,
isAltPressed: true,
);
async.elapse(const Duration(milliseconds: 10));
dispatchKeyboardEvent(
'keyup',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keyup',
key: 'Alt',
code: 'AltLeft',
location: 1,
);
// Notice no `keyup` for "i".
expect(messages, <Map<String, dynamic>>[
<String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'key': 'Meta',
'code': 'MetaLeft',
'location': 1,
// meta
'metaState': 0x8,
'keyCode': 0,
},
<String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'key': 'Alt',
'code': 'AltLeft',
'location': 1,
// alt meta
'metaState': 0x2 | 0x8,
'keyCode': 0,
},
<String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'key': 'i',
'code': 'KeyI',
'location': 0,
// alt meta
'metaState': 0x2 | 0x8,
'keyCode': 0,
},
<String, dynamic>{
'type': 'keyup',
'keymap': 'web',
'key': 'Meta',
'code': 'MetaLeft',
'location': 1,
// alt
'metaState': 0x2,
'keyCode': 0,
},
<String, dynamic>{
'type': 'keyup',
'keymap': 'web',
'key': 'Alt',
'code': 'AltLeft',
'location': 1,
'metaState': 0x0,
'keyCode': 0,
},
]);
messages.clear();
// Still too eary to synthesize a keyup event.
async.elapse(const Duration(milliseconds: 50));
expect(messages, isEmpty);
async.elapse(const Duration(seconds: 3));
expect(messages, <Map<String, dynamic>>[
<String, dynamic>{
'type': 'keyup',
'keymap': 'web',
'key': 'i',
'code': 'KeyI',
'location': 0,
'metaState': 0x0,
'keyCode': 0,
}
]);
Keyboard.instance!.dispose();
},
);
testFakeAsync(
'On macOS, do not synthesize keyup when we receive repeat events',
(FakeAsync async) {
Keyboard.initialize(onMacOs: true);
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
dispatchKeyboardEvent(
'keydown',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isMetaPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'Alt',
code: 'AltLeft',
location: 1,
isMetaPressed: true,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
isMetaPressed: true,
isAltPressed: true,
);
async.elapse(const Duration(milliseconds: 10));
dispatchKeyboardEvent(
'keyup',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keyup',
key: 'Alt',
code: 'AltLeft',
location: 1,
);
// Notice no `keyup` for "i".
messages.clear();
// Spend more than 2 seconds sending repeat events and make sure no
// keyup was synthesized.
for (int i = 0; i < 20; i++) {
async.elapse(const Duration(milliseconds: 100));
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
repeat: true,
);
}
// There should be no synthesized keyup.
expect(messages, hasLength(20));
for (int i = 0; i < 20; i++) {
expect(messages[i], <String, dynamic>{
'type': 'keydown',
'keymap': 'web',
'key': 'i',
'code': 'KeyI',
'location': 0,
'metaState': 0x0,
'keyCode': 0,
});
}
messages.clear();
Keyboard.instance!.dispose();
},
);
testFakeAsync(
'On macOS, do not synthesize keyup when keys are not affected by meta modifiers',
(FakeAsync async) {
Keyboard.initialize();
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
);
dispatchKeyboardEvent(
'keydown',
key: 'o',
code: 'KeyO',
);
messages.clear();
// Wait for a long-enough period of time and no events
// should be synthesized
async.elapse(const Duration(seconds: 3));
expect(messages, hasLength(0));
Keyboard.instance!.dispose();
},
);
testFakeAsync('On macOS, do not synthesize keyup for meta keys', (FakeAsync async) {
Keyboard.initialize(onMacOs: true);
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
dispatchKeyboardEvent(
'keydown',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isMetaPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'Alt',
code: 'AltLeft',
location: 1,
isMetaPressed: true,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
isMetaPressed: true,
isAltPressed: true,
);
async.elapse(const Duration(milliseconds: 10));
dispatchKeyboardEvent(
'keyup',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isAltPressed: true,
);
// Notice no `keyup` for "AltLeft" and "i".
messages.clear();
// There has been no repeat events for "AltLeft" nor "i". Only "i" should
// synthesize a keyup event.
async.elapse(const Duration(seconds: 3));
expect(messages, <Map<String, dynamic>>[
<String, dynamic>{
'type': 'keyup',
'keymap': 'web',
'key': 'i',
'code': 'KeyI',
'location': 0,
// alt
'metaState': 0x2,
'keyCode': 0,
}
]);
Keyboard.instance!.dispose();
});
testFakeAsync(
'On non-macOS, do not synthesize keyup for shortcuts',
(FakeAsync async) {
Keyboard.initialize(onMacOs: false);
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[];
ui.window.onPlatformMessage = (String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) {
messages.add(const JSONMessageCodec().decodeMessage(data) as Map<String, dynamic>);
};
dispatchKeyboardEvent(
'keydown',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isMetaPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'Alt',
code: 'AltLeft',
location: 1,
isMetaPressed: true,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keydown',
key: 'i',
code: 'KeyI',
isMetaPressed: true,
isAltPressed: true,
);
async.elapse(const Duration(milliseconds: 10));
dispatchKeyboardEvent(
'keyup',
key: 'Meta',
code: 'MetaLeft',
location: 1,
isAltPressed: true,
);
dispatchKeyboardEvent(
'keyup',
key: 'Alt',
code: 'AltLeft',
location: 1,
);
// Notice no `keyup` for "i".
expect(messages, hasLength(5));
messages.clear();
// Never synthesize keyup events.
async.elapse(const Duration(seconds: 3));
expect(messages, isEmpty);
Keyboard.instance!.dispose();
},
);
});
}
typedef ElementCallback = void Function(html.Element element);
void useTextEditingElement(ElementCallback callback) {
final html.InputElement input = html.InputElement();
input.classes.add(HybridTextEditing.textEditingClass);
try {
html.document.body!.append(input);
callback(input);
} finally {
input.remove();
}
}
html.KeyboardEvent dispatchKeyboardEvent(
String type, {
html.EventTarget? target,
String? key,
String? code,
int location = 0,
bool repeat = false,
bool isShiftPressed = false,
bool isAltPressed = false,
bool isControlPressed = false,
bool isMetaPressed = false,
int keyCode = 0,
}) {
target ??= html.window;
final Function jsKeyboardEvent =
js_util.getProperty<Function>(html.window, 'KeyboardEvent');
final List<dynamic> eventArgs = <dynamic>[
type,
<String, dynamic>{
'key': key,
'code': code,
'location': location,
'repeat': repeat,
'shiftKey': isShiftPressed,
'altKey': isAltPressed,
'ctrlKey': isControlPressed,
'metaKey': isMetaPressed,
'keyCode': keyCode,
'bubbles': true,
'cancelable': true,
}
];
final html.KeyboardEvent event = js_util.callConstructor<html.KeyboardEvent>(
jsKeyboardEvent,
js_util.jsify(eventArgs) as List<Object?>,
);
target.dispatchEvent(event);
return event;
}
typedef FakeAsyncTest = void Function(FakeAsync);
void testFakeAsync(String description, FakeAsyncTest fn) {
test(description, () {
FakeAsync().run(fn);
});
}