Files
FlClash/test/common/task_test.dart
T
chen08209 5984c161c5 fix(core): keep very large profiles from crashing the app
Changelog: Large profiles no longer crash; downloads trust core CAs
2026-10-16 22:54:53 +08:00

851 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:yaml/yaml.dart';
void main() {
test('encoding helpers round-trip structured data', () async {
final encoded = jsonEncode({
'name': 'FlClash',
'values': [1, true, null],
});
final decoded = await decodeJSONTask<Map<String, dynamic>>(encoded);
expect(decoded['name'], 'FlClash');
expect(decoded['values'], [1, true, null]);
expect(await encodeYamlTask({'enabled': true}), contains('enabled: true'));
});
test('a proxy name holding control characters stays readable YAML', () {
const name = 'HK \x1b[31m\x00\x7f\r
';
final encoded = yaml.encode({
'proxies': [
{'name': name},
],
'proxy-groups': [
{
'name': 'G',
'proxies': [name],
},
],
});
expect(encoded, isNot(contains('\x1b')));
final config = loadYaml(encoded) as YamlMap;
expect(config['proxies'].first['name'], name);
expect(config['proxy-groups'].first['proxies'], [name]);
});
test('toGroupsTask converts, selects, and sorts core proxy data', () async {
final proxies = <String, dynamic>{
'Selector': {
'name': 'Selector',
'type': 'Selector',
'now': 'Beta',
'all': ['Zulu', 'Beta', 'missing'],
},
'Direct': {'name': 'Direct', 'type': 'Direct'},
'Zulu': {'name': 'Zulu', 'type': 'Direct'},
'Beta': {'name': 'Beta', 'type': 'Direct'},
};
final groups = await toGroupsTask(
ComputeGroupsState(
proxiesData: ProxiesData(
all: const ['Selector', 'Direct'],
proxies: proxies,
),
sortType: ProxiesSortType.name,
delayMap: const {},
selectedMap: const {'Selector': 'Beta'},
defaultTestUrl: 'https://example.com/generate_204',
),
);
expect(groups, hasLength(1));
expect(groups.single.name, 'Selector');
expect(groups.single.all.map((proxy) => proxy.name), ['Beta', 'Zulu']);
});
test(
'clashConfigTask parses core config data off the main isolate',
() async {
final configMap = <String, dynamic>{
'proxies': [
{'name': 'Alpha', 'type': 'ss'},
{'name': 'Beta', 'type': 'vmess'},
],
'proxy-groups': [
{
'name': 'Auto',
'type': 'url-test',
'proxies': ['Alpha', 'Beta'],
},
],
'rules': ['DOMAIN,example.com,Auto'],
'proxy-providers': {
'provider': {'type': 'http'},
},
'rule-providers': {
'ruleSet': {'type': 'http'},
},
'sub-rules': {'nested': []},
};
final clashConfig = await clashConfigTask(configMap);
expect(clashConfig.proxies.map((item) => item.name), ['Alpha', 'Beta']);
expect(clashConfig.proxyGroups.single.type, GroupType.URLTest);
expect(clashConfig.rules.single.ruleTarget, 'Auto');
expect(clashConfig.rules.single.content, 'example.com');
expect(clashConfig.proxyProviders, ['provider']);
expect(clashConfig.ruleProviders, ['ruleSet']);
expect(clashConfig.subRules, ['nested']);
},
);
test('toGroupsTask leaves the source proxy map untouched', () async {
final proxies = <String, dynamic>{
'Selector': {
'name': 'Selector',
'type': 'Selector',
'all': ['Beta'],
},
'Beta': {'name': 'Beta', 'type': 'Direct'},
};
final state = ComputeGroupsState(
proxiesData: ProxiesData(all: const ['Selector'], proxies: proxies),
sortType: ProxiesSortType.none,
delayMap: const {},
selectedMap: const {},
defaultTestUrl: '',
);
await buildGroups(state);
await buildGroups(state);
expect(proxies['Selector']['all'], ['Beta']);
});
test('toGroupsTask returns empty data without proxies', () async {
final groups = await toGroupsTask(
const ComputeGroupsState(
proxiesData: ProxiesData(proxies: {}, all: []),
sortType: ProxiesSortType.none,
delayMap: {},
selectedMap: {},
defaultTestUrl: '',
),
);
expect(groups, isEmpty);
});
test(
'makeRealProfileTask normalizes runtime config and added rules',
() async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({
'dns': {
'enable': true,
'nameserver': ['1.1.1.1'],
},
'sniffer': {
'sniff': {
'HTTP': {
'ports': [80, '443'],
},
},
},
'proxy-providers': {
'remote': {'type': 'http', 'url': 'https://example.com/proxy.yaml'},
'file': {'type': 'file', 'path': './local.yaml'},
},
'rule-providers': {
'remote': {'type': 'http', 'url': 'https://example.com/rule.yaml'},
},
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
}),
);
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 7,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(
mixedPort: 7893,
port: 7890,
socksPort: 7891,
redirPort: 7892,
tproxyPort: 7894,
allowLan: true,
ipv6: true,
hosts: {'router.local': '192.168.1.1,192.168.1.2'},
),
appendSystemDns: true,
proxyGroups: const [],
rules: const [],
addedRules: const [
Rule(
ruleAction: RuleAction.DOMAIN_SUFFIX,
content: 'added.example',
ruleTarget: 'MATCH',
),
],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(result.md5, hasLength(32));
expect(config['mixed-port'], 7893);
expect(config['allow-lan'], true);
expect(config['global-ua'], 'FlClash-Test');
expect(config['profile']['store-selected'], false);
expect(
config['dns']['nameserver'],
containsAll(['1.1.1.1', 'system://']),
);
expect(config['hosts']['router.local'], ['192.168.1.1', '192.168.1.2']);
expect(config['sniffer']['sniff']['HTTP']['ports'], ['80', '443']);
expect(
config['proxy-providers']['remote']['path'],
startsWith('/profiles/providers/7/proxies/'),
);
expect(
config['rule-providers']['remote']['path'],
startsWith('/profiles/providers/7/rules/'),
);
expect(config['rules'], [
'DOMAIN-SUFFIX,added.example,Original',
'DOMAIN,existing.example,DIRECT',
'MATCH,Original',
]);
expect(result.counts, (groups: 0, proxies: 0, rules: 3));
},
);
test('lastMatchTarget reads the last MATCH rule that names a target', () {
expect(
lastMatchTarget(['MATCH,A', 'DOMAIN,x.test,B', 'match,C', 'MATCH,']),
'C',
);
expect(lastMatchTarget(['DOMAIN,x.test,B']), isNull);
});
test(
'makeRealProfileTask routes MATCH placeholders to matchTarget',
() async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({
'proxies': [],
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
}),
);
final state = MakeRealProfileState(
profilesPath: '/profiles',
profileId: 7,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [
Rule(
ruleAction: RuleAction.DOMAIN_SUFFIX,
content: 'added.example',
ruleTarget: 'MATCH',
),
],
defaultUA: 'FlClash-Test',
matchTarget: 'HK',
);
final overridden = await makeRealProfileTask(state);
expect((loadYaml(overridden.yaml) as YamlMap)['rules'], [
'DOMAIN-SUFFIX,added.example,HK',
'DOMAIN,existing.example,DIRECT',
'MATCH,Original',
]);
final blank = await makeRealProfileTask(state.copyWith(matchTarget: ' '));
expect(
(loadYaml(blank.yaml) as YamlMap)['rules'].first,
'DOMAIN-SUFFIX,added.example,Original',
);
},
);
// The core re-reads these two keys out of the generated config on every
// profile apply and adopts whatever it finds, so a subscription that ships
// them would otherwise decide whether GEO databases auto-update — including
// switching the updater back on after the user turned it off.
test(
'makeRealProfileTask lets the app setting own the geo updater',
() async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({'geo-auto-update': true, 'geo-update-interval': 6}),
);
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 11,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(
geoAutoUpdate: false,
geoUpdateInterval: 48,
),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(config['geo-auto-update'], false);
expect(config['geo-update-interval'], 48);
},
);
// A profile-shipped loopback skip-auth-prefixes would bypass the credentials.
test('makeRealProfileTask lets the app own local authentication', () async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({
'authentication': ['subscription:injected'],
'skip-auth-prefixes': ['127.0.0.1/32'],
}),
);
final state = MakeRealProfileState(
profilesPath: '/profiles',
profileId: 12,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
authentication: const ['user:pass'],
);
final enabled = await makeRealProfileTask(state);
final enabledConfig = loadYaml(enabled.yaml) as YamlMap;
expect(enabledConfig['authentication'], ['user:pass']);
expect(enabledConfig['skip-auth-prefixes'], isEmpty);
final disabled = await makeRealProfileTask(
state.copyWith(authentication: const []),
);
final disabledConfig = loadYaml(disabled.yaml) as YamlMap;
expect(disabledConfig['authentication'], isEmpty);
expect(disabledConfig['skip-auth-prefixes'], isEmpty);
});
test('makeRealProfileTask overrides DNS and explicit custom data', () async {
final result = await makeRealProfileTask(
const MakeRealProfileState(
profilesPath: '/profiles',
profileId: 9,
rawConfig: {},
realPatchConfig: PatchClashConfig(),
appendSystemDns: false,
proxyGroups: [
ProxyGroup(
id: 1,
name: 'Select',
type: GroupType.Selector,
proxies: ['DIRECT'],
),
],
rules: [
Rule(
ruleAction: RuleAction.DOMAIN,
content: 'custom.example',
ruleTarget: 'DIRECT',
),
],
addedRules: [],
defaultUA: 'Fallback-UA',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(config['dns']['enable'], true);
expect(config['dns']['nameserver'], isNot(contains('system://')));
expect(config['proxy-groups'], hasLength(1));
expect(config['proxy-groups'][0].keys, ['name', 'type', 'proxies']);
expect(config['proxy-groups'][0]['type'], 'select');
expect(config['rules'], ['DOMAIN,custom.example,DIRECT']);
});
test('makeRealProfileTask keeps the DNS keys it cannot edit', () async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({
'dns': {
'enable': false,
'direct-nameserver': ['223.5.5.5'],
'proxy-server-nameserver-policy': {
'www.example.com': ['8.8.8.8'],
},
'nameserver': ['9.9.9.9'],
},
'proxy-providers': {
'first': {'type': 'http', 'url': 'https://example.com/shared.yaml'},
'second': {'type': 'http', 'url': 'https://example.com/shared.yaml'},
},
}),
);
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 13,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(config['dns']['direct-nameserver'], ['223.5.5.5']);
expect(config['dns']['proxy-server-nameserver-policy'], {
'www.example.com': ['8.8.8.8'],
});
expect(config['dns']['nameserver'], isNot(contains('system://')));
expect(
config['proxy-providers']['first']['path'],
isNot(config['proxy-providers']['second']['path']),
);
});
test('makeRealProfileTask keeps safe mode off the host', () async {
Future<YamlMap> build({required bool safeMode}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {
'dns': {'enable': true, 'listen': '0.0.0.0:53'},
'ntp': {'enable': true, 'write-to-system': true},
},
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
safeMode: safeMode,
),
);
return loadYaml(result.yaml) as YamlMap;
}
final normal = await build(safeMode: false);
expect(normal['dns']['listen'], '0.0.0.0:53');
expect(normal['ntp']['write-to-system'], true);
final safe = await build(safeMode: true);
expect(safe['dns']['listen'], '');
expect(safe['ntp']['write-to-system'], false);
expect(safe['ntp']['enable'], true);
});
test(
'makeRealProfileTask does not let a profile open a controller',
() async {
for (final safeMode in [false, true]) {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {
'external-controller': '0.0.0.0:9090',
'secret': 'from-the-profile',
'external-controller-tls': '0.0.0.0:9443',
'external-controller-unix': '/tmp/victim',
'external-controller-pipe': r'\\.\pipe\mihomo',
},
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
safeMode: safeMode,
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(config['external-controller-tls'], '');
expect(config['external-controller-unix'], '');
expect(config['external-controller-pipe'], '');
expect(config['external-controller'], '');
expect(config['secret'], '');
}
},
);
test(
'makeRealProfileTask writes the app secret over the profile one',
() async {
Future<YamlMap> build(PatchClashConfig realPatchConfig) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {'secret': 'from-the-profile'},
realPatchConfig: realPatchConfig,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
return loadYaml(result.yaml) as YamlMap;
}
final open = await build(
const PatchClashConfig(
externalController: ExternalControllerStatus.open,
secret: 'from-the-app',
),
);
expect(open['external-controller'], '127.0.0.1:9090');
expect(open['secret'], 'from-the-app');
final closed = await build(const PatchClashConfig());
expect(closed['secret'], '');
final missingSecret = await build(
const PatchClashConfig(
externalController: ExternalControllerStatus.open,
),
);
expect(missingSecret['external-controller'], '');
},
);
test(
'profile listeners and host changes are disabled only in safe mode',
() async {
for (final safeMode in [false, true]) {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {
'listeners': [
{'type': 'mixed', 'name': 'extra', 'port': 1234},
{'type': 'tun', 'name': 'extra-tun', 'auto-route': true},
],
'tunnels': [
{
'network': ['tcp'],
'address': '0.0.0.0:1234',
'target': 'example.test:443',
},
],
'ss-config': 'ss://cipher:password@:1234',
'vmess-config': 'vmess://uuid@:1234',
'tuic-server': {'enable': true, 'listen': '0.0.0.0:1234'},
'iptables': {'enable': true, 'dns-redirect': true},
'dns': {'enable': true, 'listen': '0.0.0.0:53'},
'ntp': {'enable': true, 'write-to-system': true},
},
realPatchConfig: const PatchClashConfig(
externalController: ExternalControllerStatus.open,
secret: 'from-the-app',
),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
safeMode: safeMode,
),
);
final config = loadYaml(result.yaml) as YamlMap;
if (safeMode) {
expect(config['listeners'], isEmpty);
expect(config['tunnels'], isEmpty);
expect(config['ss-config'], '');
expect(config['vmess-config'], '');
expect(config['tuic-server']['enable'], false);
expect(config['iptables']['enable'], false);
expect(config['dns']['listen'], '');
expect(config['ntp']['write-to-system'], false);
} else {
expect(config['listeners'], [
{'type': 'mixed', 'name': 'extra', 'port': 1234},
{'type': 'tun', 'name': 'extra-tun', 'auto-route': true},
]);
expect(config['tunnels'], [
{
'network': ['tcp'],
'address': '0.0.0.0:1234',
'target': 'example.test:443',
},
]);
expect(config['ss-config'], 'ss://cipher:password@:1234');
expect(config['vmess-config'], 'vmess://uuid@:1234');
expect(config['tuic-server'], {
'enable': true,
'listen': '0.0.0.0:1234',
});
expect(config['iptables'], {'enable': true, 'dns-redirect': true});
expect(config['dns']['listen'], '0.0.0.0:53');
expect(config['ntp']['write-to-system'], true);
}
expect(config['external-controller'], safeMode ? '' : '127.0.0.1:9090');
}
},
);
test('iptables interface names cannot add command arguments', () async {
for (final interfaceName in ['br-lan.10', 'lo -M /tmp/extra']) {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: {
'iptables': {
'enable': true,
'inbound-interface': interfaceName,
'bypass': ['192.168.0.0/16'],
},
},
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(
config['iptables'],
interfaceName == 'br-lan.10'
? {
'enable': true,
'inbound-interface': interfaceName,
'bypass': ['192.168.0.0/16'],
}
: {'enable': false},
);
}
});
group('makeRealProfileTask interface-name mode', () {
Future<YamlMap> runWith(PatchClashConfig realPatchConfig) async {
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
jsonEncode({'interface-name': 'en0'}),
);
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 13,
rawConfig: rawConfig,
realPatchConfig: realPatchConfig,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
return loadYaml(result.yaml) as YamlMap;
}
// Default mode, so a subscription value must not survive into the
// generated config.
test('clear forces interface-name empty', () async {
final config = await runWith(const PatchClashConfig());
expect(config['interface-name'], '');
});
test('follow leaves the profile value untouched', () async {
final config = await runWith(
const PatchClashConfig(interfaceNameMode: InterfaceNameMode.follow),
);
expect(config['interface-name'], 'en0');
});
test('custom writes the configured interface name', () async {
final config = await runWith(
const PatchClashConfig(
interfaceNameMode: InterfaceNameMode.custom,
interfaceName: 'eth0',
),
);
expect(config['interface-name'], 'eth0');
});
});
group('makeRealProfileTask network settings', () {
Future<YamlMap> runWith(
PatchClashConfig realPatchConfig,
Map<String, dynamic> rawConfig,
) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: await decodeJSONTask<Map<String, dynamic>>(
jsonEncode(rawConfig),
),
realPatchConfig: realPatchConfig,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
return loadYaml(result.yaml) as YamlMap;
}
test('tun settings replace the profile ones and leave the rest', () async {
final config = await runWith(
const PatchClashConfig(tun: Tun(mtu: 1400, strictRoute: true)),
{
'tun': {'mtu': 9000, 'strict-route': false, 'udp-timeout': 60},
},
);
final tun = config['tun'] as YamlMap;
expect(tun['mtu'], 1400);
expect(tun['strict-route'], true);
expect(tun['congestion-controller'], 'cubic');
expect(tun['udp-timeout'], 60);
});
test('outbound settings start at the core defaults', () async {
final config = await runWith(const PatchClashConfig(), {
'routing-mark': 1,
});
expect(config['keep-alive-idle'], 15);
expect(config['routing-mark'], 0);
});
test('set outbound values replace the profile ones', () async {
final config = await runWith(
const PatchClashConfig(routingMark: 6666, disableKeepAlive: true),
{'routing-mark': 1, 'disable-keep-alive': false},
);
expect(config['routing-mark'], 6666);
expect(config['disable-keep-alive'], true);
});
});
group('makeRealProfileTask legacy provider file migration', () {
late Directory tempDir;
const url = 'https://example.com/proxy.yaml';
const name = 'remote';
setUp(() {
tempDir = Directory.systemTemp.createTempSync('task_test_providers');
});
tearDown(() {
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
Future<({String legacyPath, String newPath})> runMigration() async {
final providerDir = join(
tempDir.path,
providersDirectoryName,
'17',
proxiesProviderDirectoryName,
);
final legacyPath = join(providerDir, url.toMd5());
final newPath = join(providerDir, '$name@$url'.toMd5());
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: tempDir.path,
profileId: 17,
rawConfig: {
'proxy-providers': {
name: {'type': 'http', 'url': url},
},
},
realPatchConfig: const PatchClashConfig(),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
expect(config['proxy-providers'][name]['path'], newPath);
return (legacyPath: legacyPath, newPath: newPath);
}
test('renames a file cached under the legacy url-only key', () async {
final providerDir = join(
tempDir.path,
providersDirectoryName,
'17',
proxiesProviderDirectoryName,
);
await Directory(providerDir).create(recursive: true);
final legacyFile = File(join(providerDir, url.toMd5()));
await legacyFile.writeAsString('cached-provider-data');
final paths = await runMigration();
expect(File(paths.newPath).existsSync(), isTrue);
expect(await File(paths.newPath).readAsString(), 'cached-provider-data');
expect(File(paths.legacyPath).existsSync(), isFalse);
});
test('is a no-op when no legacy file exists', () async {
final paths = await runMigration();
expect(File(paths.legacyPath).existsSync(), isFalse);
expect(File(paths.newPath).existsSync(), isFalse);
});
});
test('log and list tasks produce stable mapped output', () async {
final logs = [
const Log(
logLevel: LogLevel.info,
payload: 'first',
dateTime: '2026-07-26 10:00:00',
),
const Log(
logLevel: LogLevel.error,
payload: 'second',
dateTime: '2026-07-26 10:00:01',
),
];
final encoded = await encodeLogsTask(logs);
expect(encoded, contains('first'));
expect(encoded, contains('\n'));
});
}