From 9ff80fcac3600401c9fed053cc8422f89c404940 Mon Sep 17 00:00:00 2001
From: Shin'ya UEOKA <ueokande@i-beam.org>
Date: Sat, 5 Oct 2019 08:52:49 +0000
Subject: Add partial blacklist item

---
 src/content/controllers/SettingController.ts |   3 +-
 src/shared/settings/Blacklist.ts             | 116 ++++++++++++++---
 test/shared/settings/Blacklist.test.ts       | 178 +++++++++++++++++++--------
 3 files changed, 227 insertions(+), 70 deletions(-)

diff --git a/src/content/controllers/SettingController.ts b/src/content/controllers/SettingController.ts
index 06273a0..e1c7f01 100644
--- a/src/content/controllers/SettingController.ts
+++ b/src/content/controllers/SettingController.ts
@@ -15,7 +15,8 @@ export default class SettingController {
   async initSettings(): Promise<void> {
     try {
       let current = await this.settingUseCase.reload();
-      let disabled = current.blacklist.includes(window.location.href);
+      let url = new URL(window.location.href);
+      let disabled = current.blacklist.includesEntireBlacklist(url);
       if (disabled) {
         this.addonEnabledUseCase.disable();
       } else {
diff --git a/src/shared/settings/Blacklist.ts b/src/shared/settings/Blacklist.ts
index a95b606..5648611 100644
--- a/src/shared/settings/Blacklist.ts
+++ b/src/shared/settings/Blacklist.ts
@@ -1,39 +1,117 @@
-export type BlacklistJSON = string[];
+export type BlacklistItemJSON = string | {
+  url: string,
+  keys: string[],
+};
+
+export type BlacklistJSON = BlacklistItemJSON[];
 
-const fromWildcard = (pattern: string): RegExp => {
+const regexFromWildcard = (pattern: string): RegExp => {
   let regexStr = '^' + pattern.replace(/\*/g, '.*') + '$';
   return new RegExp(regexStr);
 };
 
+const isArrayOfString = (raw: any): boolean => {
+  if (!Array.isArray(raw)) {
+    return false;
+  }
+  for (let x of Array.from(raw)) {
+    if (typeof x !== 'string') {
+      return false;
+    }
+  }
+  return true;
+};
+
+export class BlacklistItem {
+  public readonly pattern: string;
+
+  private regex: RegExp;
+
+  public readonly partial: boolean;
+
+  public readonly keys: string[];
+
+  private constructor(
+    pattern: string,
+    partial: boolean,
+    keys: string[]
+  ) {
+    this.pattern = pattern;
+    this.regex = regexFromWildcard(pattern);
+    this.partial = partial;
+    this.keys = keys;
+  }
+
+  static fromJSON(raw: any): BlacklistItem {
+    if (typeof raw === 'string') {
+      return new BlacklistItem(raw, false, []);
+    } else if (typeof raw === 'object' && raw !== null) {
+      if (!('url' in raw)) {
+        throw new TypeError(
+          `missing field "url" of blacklist item: ${JSON.stringify(raw)}`);
+      }
+      if (typeof raw.url !== 'string') {
+        throw new TypeError(
+          `invalid field "url" of blacklist item: ${JSON.stringify(raw)}`);
+      }
+      if (!('keys' in raw)) {
+        throw new TypeError(
+          `missing field "keys" of blacklist item: ${JSON.stringify(raw)}`);
+      }
+      if (!isArrayOfString(raw.keys)) {
+        throw new TypeError(
+          `invalid field "keys" of blacklist item: ${JSON.stringify(raw)}`);
+      }
+      return new BlacklistItem(raw.url as string, true, raw.keys as string[]);
+    }
+    throw new TypeError(
+      `invalid format of blacklist item: ${JSON.stringify(raw)}`);
+  }
+
+  toJSON(): BlacklistItemJSON {
+    if (!this.partial) {
+      return this.pattern;
+    }
+    return { url: this.pattern, keys: this.keys };
+  }
+
+  matches(url: URL): boolean {
+    return this.pattern.includes('/')
+      ? this.regex.test(url.host + url.pathname)
+      : this.regex.test(url.host);
+  }
+
+  includeKey(url: URL, keys: string): boolean {
+    if (!this.matches(url)) {
+      return false;
+    }
+    return !this.partial || this.keys.includes(keys);
+  }
+}
+
 export default class Blacklist {
   constructor(
-    private blacklist: string[],
+    private blacklist: BlacklistItem[],
   ) {
   }
 
   static fromJSON(json: any): Blacklist {
     if (!Array.isArray(json)) {
-      throw new TypeError(`"blacklist" is not an array of string`);
-    }
-    for (let x of json) {
-      if (typeof x !== 'string') {
-        throw new TypeError(`"blacklist" is not an array of string`);
-      }
+      throw new TypeError('blacklist is not an array: ' + JSON.stringify(json));
     }
-    return new Blacklist(json);
+    let items = Array.from(json).map(item => BlacklistItem.fromJSON(item));
+    return new Blacklist(items);
   }
 
   toJSON(): BlacklistJSON {
-    return this.blacklist;
+    return this.blacklist.map(item => item.toJSON());
   }
 
-  includes(url: string): boolean {
-    let u = new URL(url);
-    return this.blacklist.some((item) => {
-      if (!item.includes('/')) {
-        return fromWildcard(item).test(u.host);
-      }
-      return fromWildcard(item).test(u.host + u.pathname);
-    });
+  includesEntireBlacklist(url: URL): boolean {
+    return this.blacklist.some(item => !item.partial && item.matches(url));
+  }
+
+  includeKey(url: URL, key: string) {
+    return this.blacklist.some(item => item.includeKey(url, key));
   }
 }
diff --git a/test/shared/settings/Blacklist.test.ts b/test/shared/settings/Blacklist.test.ts
index fbacf5d..e7e1855 100644
--- a/test/shared/settings/Blacklist.test.ts
+++ b/test/shared/settings/Blacklist.test.ts
@@ -1,77 +1,155 @@
-import Blacklist from '../../../src/shared/settings/Blacklist';
+import Blacklist, { BlacklistItem } from '../../../src/shared/settings/Blacklist';
 import { expect } from 'chai';
 
-describe('Blacklist', () => {
-  describe('fromJSON', () => {
-    it('returns empty array by empty settings', () => {
-      let blacklist = Blacklist.fromJSON([]);
-      expect(blacklist.toJSON()).to.be.empty;
+describe('BlacklistItem', () => {
+  describe('#fromJSON', () => {
+    it('parses string pattern', () => {
+      let item = BlacklistItem.fromJSON('example.com');
+      expect(item.pattern).to.equal('example.com');
+      expect(item.partial).to.be.false;
     });
 
-    it('returns blacklist by valid settings', () => {
-      let blacklist = Blacklist.fromJSON([
-        'github.com',
-        'circleci.com',
-      ]);
-
-      expect(blacklist.toJSON()).to.deep.equal([
-        'github.com',
-        'circleci.com',
-      ]);
+    it('parses partial blacklist item', () => {
+      let item = BlacklistItem.fromJSON({ url: 'example.com', keys: ['j', 'k']});
+      expect(item.pattern).to.equal('example.com');
+      expect(item.partial).to.be.true;
+      expect(item.keys).to.deep.equal(['j', 'k']);
     });
 
-    it('throws a TypeError by invalid settings', () => {
-      expect(() => Blacklist.fromJSON(null)).to.throw(TypeError);
-      expect(() => Blacklist.fromJSON({})).to.throw(TypeError);
-      expect(() => Blacklist.fromJSON([1,2,3])).to.throw(TypeError);
+    it('throws a TypeError', () => {
+      expect(() => BlacklistItem.fromJSON(null)).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON(100)).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON({})).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON({url: 'google.com'})).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON({keys: ['a']})).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON({url: 'google.com', keys: 10})).to.throw(TypeError);
+      expect(() => BlacklistItem.fromJSON({url: 'google.com', keys: ['a', 'b', 3]})).to.throw(TypeError);
     });
   });
 
-  describe('#includes', () => {
-    it('matches by *', () => {
-      let blacklist = new Blacklist(['*']);
-
-      expect(blacklist.includes('https://github.com/abc')).to.be.true;
+  describe('#matches', () => {
+    it('matches by "*"', () => {
+      let item = BlacklistItem.fromJSON('*');
+      expect(item.matches(new URL('https://github.com/abc'))).to.be.true;
     });
 
     it('matches by hostname', () => {
-      let blacklist = new Blacklist(['github.com']);
-
-      expect(blacklist.includes('https://github.com')).to.be.true;
-      expect(blacklist.includes('https://gist.github.com')).to.be.false;
-      expect(blacklist.includes('https://github.com/ueokande')).to.be.true;
-      expect(blacklist.includes('https://github.org')).to.be.false;
-      expect(blacklist.includes('https://google.com/search?q=github.org')).to.be.false;
+      let item = BlacklistItem.fromJSON('github.com');
+      expect(item.matches(new URL('https://github.com'))).to.be.true;
+      expect(item.matches(new URL('https://gist.github.com'))).to.be.false;
+      expect(item.matches(new URL('https://github.com/ueokande'))).to.be.true;
+      expect(item.matches(new URL('https://github.org'))).to.be.false;
+      expect(item.matches(new URL('https://google.com/search?q=github.org'))).to.be.false;
     });
 
     it('matches by hostname with wildcard', () => {
-      let blacklist = new Blacklist(['*.github.com']);
+      let item = BlacklistItem.fromJSON('*.github.com');
 
-      expect(blacklist.includes('https://github.com')).to.be.false;
-      expect(blacklist.includes('https://gist.github.com')).to.be.true;
-    })
+      expect(item.matches(new URL('https://github.com'))).to.be.false;
+      expect(item.matches(new URL('https://gist.github.com'))).to.be.true;
+    });
 
     it('matches by path', () => {
-      let blacklist = new Blacklist(['github.com/abc']);
+      let item = BlacklistItem.fromJSON('github.com/abc');
 
-      expect(blacklist.includes('https://github.com/abc')).to.be.true;
-      expect(blacklist.includes('https://github.com/abcdef')).to.be.false;
-      expect(blacklist.includes('https://gist.github.com/abc')).to.be.false;
-    })
+      expect(item.matches(new URL('https://github.com/abc'))).to.be.true;
+      expect(item.matches(new URL('https://github.com/abcdef'))).to.be.false;
+      expect(item.matches(new URL('https://gist.github.com/abc'))).to.be.false;
+    });
 
     it('matches by path with wildcard', () => {
-      let blacklist = new Blacklist(['github.com/abc*']);
+      let item = BlacklistItem.fromJSON('github.com/abc*');
 
-      expect(blacklist.includes('https://github.com/abc')).to.be.true;
-      expect(blacklist.includes('https://github.com/abcdef')).to.be.true;
-      expect(blacklist.includes('https://gist.github.com/abc')).to.be.false;
-    })
+      expect(item.matches(new URL('https://github.com/abc'))).to.be.true;
+      expect(item.matches(new URL('https://github.com/abcdef'))).to.be.true;
+      expect(item.matches(new URL('https://gist.github.com/abc'))).to.be.false;
+    });
 
     it('matches address and port', () => {
-      let blacklist = new Blacklist(['127.0.0.1:8888']);
+      let item = BlacklistItem.fromJSON('127.0.0.1:8888');
+
+      expect(item.matches(new URL('http://127.0.0.1:8888/'))).to.be.true;
+      expect(item.matches(new URL('http://127.0.0.1:8888/hello'))).to.be.true;
+    });
 
-      expect(blacklist.includes('http://127.0.0.1:8888/')).to.be.true;
-      expect(blacklist.includes('http://127.0.0.1:8888/hello')).to.be.true;
+    it('matches with partial blacklist', () => {
+      let item = BlacklistItem.fromJSON({ url: 'google.com', keys: ['j', 'k'] });
+
+      expect(item.matches(new URL('https://google.com'))).to.be.true;
+      expect(item.matches(new URL('https://yahoo.com'))).to.be.false;
     })
-  })
+  });
+
+  describe('#includesPartialKeys', () => {
+    it('matches with partial keys', () => {
+      let item = BlacklistItem.fromJSON({url: 'google.com', keys: ['j', 'k']});
+
+      expect(item.includeKey(new URL('http://google.com/maps'), 'j')).to.be.true;
+      expect(item.includeKey(new URL('http://google.com/maps'), 'z')).to.be.false;
+      expect(item.includeKey(new URL('http://maps.google.com/'), 'j')).to.be.false;
+    })
+  });
+});
+
+describe('Blacklist', () => {
+  describe('#fromJSON', () => {
+    it('parses string list', () => {
+      let blacklist = Blacklist.fromJSON(['example.com', 'example.org']);
+      expect(blacklist.toJSON()).to.deep.equals([
+        'example.com', 'example.org',
+      ]);
+    });
+
+    it('parses mixed blacklist', () => {
+      let blacklist = Blacklist.fromJSON([
+        { url: 'example.com', keys: ['j', 'k']},
+        'example.org',
+      ]);
+      expect(blacklist.toJSON()).to.deep.equals([
+        { url: 'example.com', keys: ['j', 'k']},
+        'example.org',
+      ]);
+    });
+
+    it('parses empty blacklist', () => {
+      let blacklist = Blacklist.fromJSON([]);
+      expect(blacklist.toJSON()).to.deep.equals([]);
+    });
+
+    it('throws a TypeError', () => {
+      expect(() => Blacklist.fromJSON(null)).to.throw(TypeError);
+      expect(() => Blacklist.fromJSON(100)).to.throw(TypeError);
+      expect(() => Blacklist.fromJSON({})).to.throw(TypeError);
+      expect(() => Blacklist.fromJSON([100])).to.throw(TypeError);
+      expect(() => Blacklist.fromJSON([{}])).to.throw(TypeError);
+    })
+  });
+
+  describe('#includesEntireBlacklist', () => {
+    it('matches a url with entire blacklist', () => {
+      let blacklist = Blacklist.fromJSON(['google.com', '*.github.com']);
+      expect(blacklist.includesEntireBlacklist(new URL('https://google.com'))).to.be.true;
+      expect(blacklist.includesEntireBlacklist(new URL('https://github.com'))).to.be.false;
+      expect(blacklist.includesEntireBlacklist(new URL('https://gist.github.com'))).to.be.true;
+    });
+
+    it('does not matches with partial blacklist', () => {
+      let blacklist = Blacklist.fromJSON(['google.com', { url: 'yahoo.com', keys: ['j', 'k'] }]);
+      expect(blacklist.includesEntireBlacklist(new URL('https://google.com'))).to.be.true;
+      expect(blacklist.includesEntireBlacklist(new URL('https://yahoo.com'))).to.be.false;
+    });
+  });
+
+  describe('#includesKeys', () => {
+    it('matches with entire blacklist or keys in the partial blacklist', () => {
+      let blacklist = Blacklist.fromJSON([
+        'google.com',
+        { url: 'github.com', keys: ['j', 'k'] },
+      ]);
+
+      expect(blacklist.includeKey(new URL('https://google.com'), 'j')).to.be.true;
+      expect(blacklist.includeKey(new URL('https://github.com'), 'j')).to.be.true;
+      expect(blacklist.includeKey(new URL('https://github.com'), 'a')).to.be.false;
+    });
+  });
 });
-- 
cgit v1.2.3


From 7528fe831fa4e17e5c427e89025ac76b078a9313 Mon Sep 17 00:00:00 2001
From: Shin'ya UEOKA <ueokande@i-beam.org>
Date: Sun, 6 Oct 2019 12:39:56 +0000
Subject: Ignore keys on partial blacklist

---
 src/content/di.ts                             |   2 +
 src/content/repositories/AddressRepository.ts |   9 ++
 src/content/usecases/KeymapUseCase.ts         |  15 +++
 test/content/usecases/KeymapUseCase.test.ts   | 133 ++++++++++++++++++++++++++
 4 files changed, 159 insertions(+)
 create mode 100644 src/content/repositories/AddressRepository.ts
 create mode 100644 test/content/usecases/KeymapUseCase.test.ts

diff --git a/src/content/di.ts b/src/content/di.ts
index e18806a..63103a1 100644
--- a/src/content/di.ts
+++ b/src/content/di.ts
@@ -2,6 +2,7 @@
 
 import { AddonEnabledRepositoryImpl } from './repositories/AddonEnabledRepository';
 import { AddonIndicatorClientImpl } from './client/AddonIndicatorClient';
+import { AddressRepositoryImpl } from './repositories/AddressRepository';
 import { ClipboardRepositoryImpl } from './repositories/ClipboardRepository';
 import { ConsoleClientImpl } from './client/ConsoleClient';
 import { ConsoleFramePresenterImpl } from './presenters/ConsoleFramePresenter';
@@ -31,6 +32,7 @@ import { container } from 'tsyringe';
 container.register('FollowMasterClient', { useValue: new FollowMasterClientImpl(window.top) });
 container.register('AddonEnabledRepository', { useClass: AddonEnabledRepositoryImpl });
 container.register('AddonIndicatorClient', { useClass: AddonIndicatorClientImpl });
+container.register('AddressRepository', { useClass: AddressRepositoryImpl });
 container.register('ClipboardRepository', { useClass: ClipboardRepositoryImpl });
 container.register('ConsoleClient', { useClass: ConsoleClientImpl });
 container.register('ConsoleFramePresenter', { useClass: ConsoleFramePresenterImpl });
diff --git a/src/content/repositories/AddressRepository.ts b/src/content/repositories/AddressRepository.ts
new file mode 100644
index 0000000..6f9487b
--- /dev/null
+++ b/src/content/repositories/AddressRepository.ts
@@ -0,0 +1,9 @@
+export default interface AddressRepository {
+  getCurrentURL(): URL
+}
+
+export class AddressRepositoryImpl implements AddressRepository {
+  getCurrentURL(): URL {
+    return new URL(window.location.href);
+  }
+}
diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts
index 495f6d0..67d667d 100644
--- a/src/content/usecases/KeymapUseCase.ts
+++ b/src/content/usecases/KeymapUseCase.ts
@@ -6,6 +6,7 @@ import * as operations from '../../shared/operations';
 import Keymaps from '../../shared/settings/Keymaps';
 import Key from '../../shared/settings/Key';
 import KeySequence from '../../shared/settings/KeySequence';
+import AddressRepository from '../repositories/AddressRepository';
 
 type KeymapEntityMap = Map<KeySequence, operations.Operation>;
 
@@ -25,11 +26,19 @@ export default class KeymapUseCase {
 
     @inject('AddonEnabledRepository')
     private addonEnabledRepository: AddonEnabledRepository,
+
+    @inject('AddressRepository')
+    private addressRepository: AddressRepository,
   ) {
   }
 
   nextOp(key: Key): operations.Operation | null {
     let sequence = this.repository.enqueueKey(key);
+    if (sequence.length() === 1 && this.blacklistKey(key)) {
+      // ignore if the input starts with black list keys
+      this.repository.clear();
+      return null;
+    }
 
     let keymaps = this.keymapEntityMap();
     let matched = Array.from(keymaps.keys()).filter(
@@ -71,4 +80,10 @@ export default class KeymapUseCase {
     ) as [KeySequence, operations.Operation][];
     return new Map<KeySequence, operations.Operation>(entries);
   }
+
+  private blacklistKey(key: Key): boolean {
+    let url = this.addressRepository.getCurrentURL();
+    let blacklist = this.settingRepository.get().blacklist;
+    return blacklist.includeKey(url, key);
+  }
 }
diff --git a/test/content/usecases/KeymapUseCase.test.ts b/test/content/usecases/KeymapUseCase.test.ts
new file mode 100644
index 0000000..5f2feba
--- /dev/null
+++ b/test/content/usecases/KeymapUseCase.test.ts
@@ -0,0 +1,133 @@
+import KeymapUseCase from '../../../src/content/usecases/KeymapUseCase';
+import {expect} from 'chai';
+import SettingRepository from "../../../src/content/repositories/SettingRepository";
+import Settings from "../../../src/shared/settings/Settings";
+import AddonEnabledRepository from "../../../src/content/repositories/AddonEnabledRepository";
+import {KeymapRepositoryImpl} from "../../../src/content/repositories/KeymapRepository";
+import Key from "../../../src/shared/settings/Key";
+import AddressRepository from "../../../src/content/repositories/AddressRepository";
+
+class MockSettingRepository implements SettingRepository {
+  constructor(
+    private readonly settings: Settings,
+  ) {
+  }
+
+  get(): Settings {
+    return this.settings;
+  }
+
+  set(_setting: Settings): void {
+    throw new Error('TODO');
+  }
+}
+
+class MockAddonEnabledRepository implements AddonEnabledRepository {
+  constructor(
+    private readonly enabled: boolean,
+  ) {
+  }
+
+  get(): boolean {
+    return this.enabled;
+  }
+
+  set(_on: boolean): void {
+    throw new Error('TODO');
+  }
+}
+
+class MockAddressRepository implements AddressRepository {
+  constructor(
+    private url: URL,
+  ) {
+  }
+
+  getCurrentURL(): URL {
+    return this.url;
+  }
+}
+
+
+describe('KeymapUseCase', () => {
+  it('returns matched operation', () => {
+    let settings = Settings.fromJSON({
+      keymaps: {
+        k: {type: 'scroll.vertically', count: -1},
+        j: {type: 'scroll.vertically', count: 1},
+        gg: {type: 'scroll.top'},
+      },
+    });
+    let sut = new KeymapUseCase(
+      new KeymapRepositoryImpl(),
+      new MockSettingRepository(settings),
+      new MockAddonEnabledRepository(true),
+      new MockAddressRepository(new URL('https://example.com')),
+    );
+
+    expect(sut.nextOp(Key.fromMapKey('k'))).to.deep.equal({type: 'scroll.vertically', count: -1});
+    expect(sut.nextOp(Key.fromMapKey('j'))).to.deep.equal({type: 'scroll.vertically', count: 1});
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null;
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.deep.equal({type: 'scroll.top'});
+    expect(sut.nextOp(Key.fromMapKey('z'))).to.be.null;
+  });
+
+  it('returns only ADDON_ENABLE and ADDON_TOGGLE_ENABLED operation', () => {
+    let settings = Settings.fromJSON({
+      keymaps: {
+        k: {type: 'scroll.vertically', count: -1},
+        a: {type: 'addon.enable'},
+        b: {type: 'addon.toggle.enabled'},
+      },
+    });
+    let sut = new KeymapUseCase(
+      new KeymapRepositoryImpl(),
+      new MockSettingRepository(settings),
+      new MockAddonEnabledRepository(false),
+      new MockAddressRepository(new URL('https://example.com')),
+    );
+
+    expect(sut.nextOp(Key.fromMapKey('k'))).to.be.null;
+    expect(sut.nextOp(Key.fromMapKey('a'))).to.deep.equal({type: 'addon.enable'});
+    expect(sut.nextOp(Key.fromMapKey('b'))).to.deep.equal({type: 'addon.toggle.enabled'});
+  });
+
+  it('blocks keys in the partial blacklist', () => {
+    let settings = Settings.fromJSON({
+      keymaps: {
+        k: {type: 'scroll.vertically', count: -1},
+        j: {type: 'scroll.vertically', count: 1},
+        gg: {"type": "scroll.top"},
+        G: {"type": "scroll.bottom"},
+      },
+      blacklist: [
+        { url: "example.com", keys: ['g'] },
+        { url: "example.org", keys: ['<S-G>'] }
+      ],
+    });
+
+    let sut = new KeymapUseCase(
+      new KeymapRepositoryImpl(),
+      new MockSettingRepository(settings),
+      new MockAddonEnabledRepository(true),
+      new MockAddressRepository(new URL('https://example.com')),
+    );
+
+    expect(sut.nextOp(Key.fromMapKey('k'))).to.deep.equal({type: 'scroll.vertically', count: -1});
+    expect(sut.nextOp(Key.fromMapKey('j'))).to.deep.equal({type: 'scroll.vertically', count: 1});
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null;
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null;
+    expect(sut.nextOp(Key.fromMapKey('G'))).to.deep.equal({type: 'scroll.bottom'});
+
+    sut = new KeymapUseCase(
+      new KeymapRepositoryImpl(),
+      new MockSettingRepository(settings),
+      new MockAddonEnabledRepository(true),
+      new MockAddressRepository(new URL('https://example.org')),
+    );
+
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.be.null;
+    expect(sut.nextOp(Key.fromMapKey('g'))).to.deep.equal({type: 'scroll.top'});
+    expect(sut.nextOp(Key.fromMapKey('G'))).to.be.null;
+  });
+});
-- 
cgit v1.2.3


From fa6dfb0395826041349c604edcbcbaa316fc95d8 Mon Sep 17 00:00:00 2001
From: Shin'ya UEOKA <ueokande@i-beam.org>
Date: Sun, 6 Oct 2019 12:51:43 +0000
Subject: Add partial blacklist form

---
 src/settings/components/form/BlacklistForm.tsx     | 52 +++++++-------
 .../components/form/PartialBlacklistForm.scss      | 28 ++++++++
 .../components/form/PartialBlacklistForm.tsx       | 79 ++++++++++++++++++++++
 src/settings/components/index.tsx                  | 16 +++--
 src/shared/settings/Blacklist.ts                   | 24 ++++---
 src/shared/settings/KeySequence.ts                 |  2 +-
 .../components/form/BlacklistForm.test.tsx         | 25 ++++---
 test/shared/settings/Blacklist.test.ts             | 17 +++--
 8 files changed, 185 insertions(+), 58 deletions(-)
 create mode 100644 src/settings/components/form/PartialBlacklistForm.scss
 create mode 100644 src/settings/components/form/PartialBlacklistForm.tsx

diff --git a/src/settings/components/form/BlacklistForm.tsx b/src/settings/components/form/BlacklistForm.tsx
index f352e41..4e96cbf 100644
--- a/src/settings/components/form/BlacklistForm.tsx
+++ b/src/settings/components/form/BlacklistForm.tsx
@@ -2,17 +2,17 @@ import './BlacklistForm.scss';
 import AddButton from '../ui/AddButton';
 import DeleteButton from '../ui/DeleteButton';
 import React from 'react';
-import { BlacklistJSON } from '../../../shared/settings/Blacklist';
+import Blacklist, { BlacklistItem } from '../../../shared/settings/Blacklist';
 
 interface Props {
-  value: BlacklistJSON;
-  onChange: (value: BlacklistJSON) => void;
+  value: Blacklist;
+  onChange: (value: Blacklist) => void;
   onBlur: () => void;
 }
 
 class BlacklistForm extends React.Component<Props> {
   public static defaultProps: Props = {
-    value: [],
+    value: new Blacklist([]),
     onChange: () => {},
     onBlur: () => {},
   };
@@ -20,24 +20,22 @@ class BlacklistForm extends React.Component<Props> {
   render() {
     return <div className='form-blacklist-form'>
       {
-        this.props.value
-          .map((item, index) => {
-            if (typeof item !== 'string') {
-              // TODO support partial blacklist;
-              return null;
-            }
-            return <div key={index} className='form-blacklist-form-row'>
-              <input data-index={index} type='text' name='url'
-                className='column-url' value={item}
-                onChange={this.bindValue.bind(this)}
-                onBlur={this.props.onBlur}
-              />
-              <DeleteButton data-index={index} name='delete'
-                onClick={this.bindValue.bind(this)}
-                onBlur={this.props.onBlur}
-              />
-            </div>;
-          })
+        this.props.value.items.map((item, index) => {
+          if (item.partial) {
+            return null;
+          }
+          return <div key={index} className='form-blacklist-form-row'>
+            <input data-index={index} type='text' name='url'
+              className='column-url' value={item.pattern}
+              onChange={this.bindValue.bind(this)}
+              onBlur={this.props.onBlur}
+            />
+            <DeleteButton data-index={index} name='delete'
+              onClick={this.bindValue.bind(this)}
+              onBlur={this.props.onBlur}
+            />
+          </div>;
+        })
       }
       <AddButton name='add' style={{ float: 'right' }}
         onClick={this.bindValue.bind(this)} />
@@ -47,17 +45,17 @@ class BlacklistForm extends React.Component<Props> {
   bindValue(e: any) {
     let name = e.target.name;
     let index = e.target.getAttribute('data-index');
-    let next = this.props.value.slice();
+    let items = this.props.value.items;
 
     if (name === 'url') {
-      next[index] = e.target.value;
+      items[index] = new BlacklistItem(e.target.value, false, []);
     } else if (name === 'add') {
-      next.push('');
+      items.push(new BlacklistItem('', false, []));
     } else if (name === 'delete') {
-      next.splice(index, 1);
+      items.splice(index, 1);
     }
 
-    this.props.onChange(next);
+    this.props.onChange(new Blacklist(items));
     if (name === 'delete') {
       this.props.onBlur();
     }
diff --git a/src/settings/components/form/PartialBlacklistForm.scss b/src/settings/components/form/PartialBlacklistForm.scss
new file mode 100644
index 0000000..caf6f93
--- /dev/null
+++ b/src/settings/components/form/PartialBlacklistForm.scss
@@ -0,0 +1,28 @@
+.form-partial-blacklist-form {
+  @mixin row-base {
+    display: flex;
+
+    .column-url {
+      flex: 5;
+      min-width: 0;
+    }
+    .column-keys {
+      flex: 1;
+      min-width: 0;
+    }
+    .column-delete {
+      flex: 1;
+      min-width: 0;
+    }
+  }
+
+  &-header {
+    @include row-base;
+
+    font-weight: bold;
+  }
+
+  &-row {
+    @include row-base;
+  }
+}
diff --git a/src/settings/components/form/PartialBlacklistForm.tsx b/src/settings/components/form/PartialBlacklistForm.tsx
new file mode 100644
index 0000000..0702913
--- /dev/null
+++ b/src/settings/components/form/PartialBlacklistForm.tsx
@@ -0,0 +1,79 @@
+import './PartialBlacklistForm.scss';
+import AddButton from '../ui/AddButton';
+import DeleteButton from '../ui/DeleteButton';
+import React from 'react';
+import Blacklist, { BlacklistItem } from '../../../shared/settings/Blacklist';
+
+interface Props {
+  value: Blacklist;
+  onChange: (value: Blacklist) => void;
+  onBlur: () => void;
+}
+
+class PartialBlacklistForm extends React.Component<Props> {
+  public static defaultProps: Props = {
+    value: new Blacklist([]),
+    onChange: () => {},
+    onBlur: () => {},
+  };
+
+  render() {
+    return <div className='form-partial-blacklist-form'>
+      <div className='form-partial-blacklist-form-header'>
+        <div className='column-url'>URL</div>
+        <div className='column-keys'>Keys</div>
+      </div>
+      {
+        this.props.value.items.map((item, index) => {
+          if (!item.partial) {
+            return null;
+          }
+          return <div key={index} className='form-partial-blacklist-form-row'>
+            <input data-index={index} type='text' name='url'
+              className='column-url' value={item.pattern}
+              onChange={this.bindValue.bind(this)}
+              onBlur={this.props.onBlur}
+            />
+            <input data-index={index} type='text' name='keys'
+              className='column-keys' value={item.keys.join(',')}
+              onChange={this.bindValue.bind(this)}
+              onBlur={this.props.onBlur}
+            />
+            <DeleteButton data-index={index} name='delete'
+              onClick={this.bindValue.bind(this)}
+              onBlur={this.props.onBlur}
+            />
+          </div>;
+        })
+      }
+      <AddButton name='add' style={{ float: 'right' }}
+        onClick={this.bindValue.bind(this)} />
+    </div>;
+  }
+
+  bindValue(e: any) {
+    let name = e.target.name;
+    let index = e.target.getAttribute('data-index');
+    let items = this.props.value.items;
+
+    if (name === 'url') {
+      let current = items[index];
+      items[index] = new BlacklistItem(e.target.value, true, current.keys);
+    } else if (name === 'keys') {
+      let current = items[index];
+      items[index] = new BlacklistItem(
+        current.pattern, true, e.target.value.split(','));
+    } else if (name === 'add') {
+      items.push(new BlacklistItem('', true, []));
+    } else if (name === 'delete') {
+      items.splice(index, 1);
+    }
+
+    this.props.onChange(new Blacklist(items));
+    if (name === 'delete') {
+      this.props.onBlur();
+    }
+  }
+}
+
+export default PartialBlacklistForm;
diff --git a/src/settings/components/index.tsx b/src/settings/components/index.tsx
index 160dd9c..3eb2dbe 100644
--- a/src/settings/components/index.tsx
+++ b/src/settings/components/index.tsx
@@ -6,6 +6,7 @@ import SearchForm from './form/SearchForm';
 import KeymapsForm from './form/KeymapsForm';
 import BlacklistForm from './form/BlacklistForm';
 import PropertiesForm from './form/PropertiesForm';
+import PartialBlacklistForm from './form/PartialBlacklistForm';
 import * as settingActions from '../../settings/actions/setting';
 import SettingData, {
   FormKeymaps, FormSearch, FormSettings, JSONTextSettings,
@@ -53,7 +54,15 @@ class SettingsComponent extends React.Component<Props> {
       <fieldset>
         <legend>Blacklist</legend>
         <BlacklistForm
-          value={form.blacklist.toJSON()}
+          value={form.blacklist}
+          onChange={this.bindBlacklistForm.bind(this)}
+          onBlur={this.save.bind(this)}
+        />
+      </fieldset>
+      <fieldset>
+        <legend>Partial blacklist</legend>
+        <PartialBlacklistForm
+          value={form.blacklist}
           onChange={this.bindBlacklistForm.bind(this)}
           onBlur={this.save.bind(this)}
         />
@@ -138,11 +147,10 @@ class SettingsComponent extends React.Component<Props> {
     this.props.dispatch(settingActions.set(data));
   }
 
-  bindBlacklistForm(value: any) {
+  bindBlacklistForm(blacklist: Blacklist) {
     let data = new SettingData({
       source: this.props.source,
-      form: (this.props.form as FormSettings).buildWithBlacklist(
-        Blacklist.fromJSON(value)),
+      form: (this.props.form as FormSettings).buildWithBlacklist(blacklist),
     });
     this.props.dispatch(settingActions.set(data));
   }
diff --git a/src/shared/settings/Blacklist.ts b/src/shared/settings/Blacklist.ts
index 5648611..0cfbd71 100644
--- a/src/shared/settings/Blacklist.ts
+++ b/src/shared/settings/Blacklist.ts
@@ -1,3 +1,5 @@
+import Key from './Key';
+
 export type BlacklistItemJSON = string | {
   url: string,
   keys: string[],
@@ -31,7 +33,9 @@ export class BlacklistItem {
 
   public readonly keys: string[];
 
-  private constructor(
+  private readonly keyEntities: Key[];
+
+  constructor(
     pattern: string,
     partial: boolean,
     keys: string[]
@@ -40,6 +44,7 @@ export class BlacklistItem {
     this.regex = regexFromWildcard(pattern);
     this.partial = partial;
     this.keys = keys;
+    this.keyEntities = this.keys.map(Key.fromMapKey);
   }
 
   static fromJSON(raw: any): BlacklistItem {
@@ -81,17 +86,20 @@ export class BlacklistItem {
       : this.regex.test(url.host);
   }
 
-  includeKey(url: URL, keys: string): boolean {
+  includeKey(url: URL, key: Key): boolean {
     if (!this.matches(url)) {
       return false;
     }
-    return !this.partial || this.keys.includes(keys);
+    if (!this.partial) {
+      return true;
+    }
+    return this.keyEntities.some(k => k.equals(key));
   }
 }
 
 export default class Blacklist {
   constructor(
-    private blacklist: BlacklistItem[],
+    public readonly items: BlacklistItem[],
   ) {
   }
 
@@ -104,14 +112,14 @@ export default class Blacklist {
   }
 
   toJSON(): BlacklistJSON {
-    return this.blacklist.map(item => item.toJSON());
+    return this.items.map(item => item.toJSON());
   }
 
   includesEntireBlacklist(url: URL): boolean {
-    return this.blacklist.some(item => !item.partial && item.matches(url));
+    return this.items.some(item => !item.partial && item.matches(url));
   }
 
-  includeKey(url: URL, key: string) {
-    return this.blacklist.some(item => item.includeKey(url, key));
+  includeKey(url: URL, key: Key) {
+    return this.items.some(item => item.includeKey(url, key));
   }
 }
diff --git a/src/shared/settings/KeySequence.ts b/src/shared/settings/KeySequence.ts
index 4955583..abae61a 100644
--- a/src/shared/settings/KeySequence.ts
+++ b/src/shared/settings/KeySequence.ts
@@ -1,4 +1,4 @@
-import Key from '../../shared/settings/Key';
+import Key from './Key';
 
 export default class KeySequence {
   constructor(
diff --git a/test/settings/components/form/BlacklistForm.test.tsx b/test/settings/components/form/BlacklistForm.test.tsx
index 2be5d96..7daf513 100644
--- a/test/settings/components/form/BlacklistForm.test.tsx
+++ b/test/settings/components/form/BlacklistForm.test.tsx
@@ -2,13 +2,16 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactTestRenderer from 'react-test-renderer';
 import ReactTestUtils from 'react-dom/test-utils';
-import BlacklistForm from 'settings/components/form/BlacklistForm'
+import { expect } from 'chai'
+
+import BlacklistForm from '../../../../src/settings/components/form/BlacklistForm'
+import Blacklist from '../../../../src/shared/settings/Blacklist';
 
 describe("settings/form/BlacklistForm", () => {
   describe('render', () => {
     it('renders BlacklistForm', () => {
       let root = ReactTestRenderer.create(
-        <BlacklistForm value={['*.slack.com', 'www.google.com/maps']} />,
+        <BlacklistForm value={Blacklist.fromJSON(['*.slack.com', 'www.google.com/maps'])} />,
       ).root;
 
       let children = root.children[0].children;
@@ -43,10 +46,10 @@ describe("settings/form/BlacklistForm", () => {
     it('invokes onChange event on edit', (done) => {
       ReactTestUtils.act(() => {
         ReactDOM.render(<BlacklistForm
-          value={['*.slack.com', 'www.google.com/maps*']}
+          value={Blacklist.fromJSON(['*.slack.com', 'www.google.com/maps*'])}
           onChange={value => {
-            expect(value).to.have.lengthOf(2);
-            expect(value).to.have.members(['gitter.im', 'www.google.com/maps*']);
+            let urls = value.items.map(item => item.pattern);
+            expect(urls).to.have.members(['gitter.im', 'www.google.com/maps*']);
             done();
           }}
         />, container)
@@ -60,10 +63,10 @@ describe("settings/form/BlacklistForm", () => {
     it('invokes onChange event on delete', (done) => {
       ReactTestUtils.act(() => {
         ReactDOM.render(<BlacklistForm
-          value={['*.slack.com', 'www.google.com/maps*']}
+          value={Blacklist.fromJSON(['*.slack.com', 'www.google.com/maps*'])}
           onChange={value => {
-            expect(value).to.have.lengthOf(1);
-            expect(value).to.have.members(['www.google.com/maps*']);
+            let urls = value.items.map(item => item.pattern);
+            expect(urls).to.have.members(['www.google.com/maps*']);
             done();
           }}
         />, container)
@@ -76,10 +79,10 @@ describe("settings/form/BlacklistForm", () => {
     it('invokes onChange event on add', (done) => {
       ReactTestUtils.act(() => {
         ReactDOM.render(<BlacklistForm
-          value={['*.slack.com']}
+          value={Blacklist.fromJSON(['*.slack.com'])}
           onChange={value => {
-            expect(value).to.have.lengthOf(2);
-            expect(value).to.have.members(['*.slack.com', '']);
+            let urls = value.items.map(item => item.pattern);
+            expect(urls).to.have.members(['*.slack.com', '']);
             done();
           }}
         />, container);
diff --git a/test/shared/settings/Blacklist.test.ts b/test/shared/settings/Blacklist.test.ts
index e7e1855..133112c 100644
--- a/test/shared/settings/Blacklist.test.ts
+++ b/test/shared/settings/Blacklist.test.ts
@@ -1,5 +1,6 @@
 import Blacklist, { BlacklistItem } from '../../../src/shared/settings/Blacklist';
 import { expect } from 'chai';
+import Key from '../../../src/shared/settings/Key';
 
 describe('BlacklistItem', () => {
   describe('#fromJSON', () => {
@@ -82,11 +83,13 @@ describe('BlacklistItem', () => {
 
   describe('#includesPartialKeys', () => {
     it('matches with partial keys', () => {
-      let item = BlacklistItem.fromJSON({url: 'google.com', keys: ['j', 'k']});
+      let item = BlacklistItem.fromJSON({url: 'google.com', keys: ['j', 'k', '<C-U>']});
 
-      expect(item.includeKey(new URL('http://google.com/maps'), 'j')).to.be.true;
-      expect(item.includeKey(new URL('http://google.com/maps'), 'z')).to.be.false;
-      expect(item.includeKey(new URL('http://maps.google.com/'), 'j')).to.be.false;
+      expect(item.includeKey(new URL('http://google.com/maps'), Key.fromMapKey('j'))).to.be.true;
+      expect(item.includeKey(new URL('http://google.com/maps'), Key.fromMapKey('<C-U>'))).to.be.true;
+      expect(item.includeKey(new URL('http://google.com/maps'), Key.fromMapKey('z'))).to.be.false;
+      expect(item.includeKey(new URL('http://google.com/maps'), Key.fromMapKey('u'))).to.be.false;
+      expect(item.includeKey(new URL('http://maps.google.com/'), Key.fromMapKey('j'))).to.be.false;
     })
   });
 });
@@ -147,9 +150,9 @@ describe('Blacklist', () => {
         { url: 'github.com', keys: ['j', 'k'] },
       ]);
 
-      expect(blacklist.includeKey(new URL('https://google.com'), 'j')).to.be.true;
-      expect(blacklist.includeKey(new URL('https://github.com'), 'j')).to.be.true;
-      expect(blacklist.includeKey(new URL('https://github.com'), 'a')).to.be.false;
+      expect(blacklist.includeKey(new URL('https://google.com'), Key.fromMapKey('j'))).to.be.true;
+      expect(blacklist.includeKey(new URL('https://github.com'), Key.fromMapKey('j'))).to.be.true;
+      expect(blacklist.includeKey(new URL('https://github.com'), Key.fromMapKey('a'))).to.be.false;
     });
   });
 });
-- 
cgit v1.2.3


From f59a2dd8c7ac41798e077a795ea88f3bd580e81c Mon Sep 17 00:00:00 2001
From: Shin'ya UEOKA <ueokande@i-beam.org>
Date: Tue, 8 Oct 2019 03:35:26 +0000
Subject: Add e2e test for partial blacklist

---
 e2e/partial_blacklist.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 61 insertions(+)
 create mode 100644 e2e/partial_blacklist.test.ts

diff --git a/e2e/partial_blacklist.test.ts b/e2e/partial_blacklist.test.ts
new file mode 100644
index 0000000..e938dc6
--- /dev/null
+++ b/e2e/partial_blacklist.test.ts
@@ -0,0 +1,61 @@
+import * as path from 'path';
+import * as assert from 'assert';
+
+import TestServer from './lib/TestServer';
+import { Builder, Lanthan } from 'lanthan';
+import { WebDriver } from 'selenium-webdriver';
+import Page from './lib/Page';
+
+describe("partial blacklist test", () => {
+  let server = new TestServer().receiveContent('/*',
+    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`,
+  );
+  let lanthan: Lanthan;
+  let webdriver: WebDriver;
+  let browser: any;
+
+  before(async() => {
+    lanthan = await Builder
+      .forBrowser('firefox')
+      .spyAddon(path.join(__dirname, '..'))
+      .build();
+    webdriver = lanthan.getWebDriver();
+    browser = lanthan.getWebExtBrowser();
+    await server.start();
+
+    let url = server.url().replace('http://', '');
+    await browser.storage.local.set({
+      settings: {
+        source: 'json',
+        json: `{
+        "keymaps": {
+          "j": { "type": "scroll.vertically", "count": 1 },
+          "k": { "type": "scroll.vertically", "count": -1 }
+        },
+        "blacklist": [
+          { "url": "${url}", "keys": ["k"] }
+        ]
+      }`,
+      },
+    });
+  });
+
+  after(async() => {
+    await server.stop();
+    if (lanthan) {
+      await lanthan.quit();
+    }
+  });
+
+  it('should disable keys in the partial blacklist', async () => {
+    let page = await Page.navigateTo(webdriver, server.url('/'));
+
+    await page.sendKeys('j')
+    let scrollY = await page.getScrollY();
+    assert.strictEqual(scrollY, 64);
+
+    await page.sendKeys('k')
+    scrollY = await page.getScrollY();
+    assert.strictEqual(scrollY, 64);
+  });
+});
-- 
cgit v1.2.3


From 68f6211aac4177f3a70a40031dabbd1b61840071 Mon Sep 17 00:00:00 2001
From: Shin'ya UEOKA <ueokande@i-beam.org>
Date: Tue, 8 Oct 2019 13:08:29 +0000
Subject: Clean e2e tests

---
 e2e/blacklist.test.ts           | 21 +++++-----
 e2e/clipboard.test.ts           | 16 +++++---
 e2e/command_addbookmark.test.ts |  2 +-
 e2e/command_bdelete.test.ts     |  8 ++--
 e2e/command_buffer.test.ts      |  2 +-
 e2e/command_help.test.ts        |  2 +-
 e2e/command_open.test.ts        | 30 ++++++++------
 e2e/command_tabopen.test.ts     | 20 ++++++----
 e2e/command_winopen.test.ts     | 19 ++++++---
 e2e/completion.test.ts          | 21 ++++------
 e2e/completion_buffers.test.ts  | 25 +++++-------
 e2e/completion_open.test.ts     | 55 +++++++-------------------
 e2e/completion_set.test.ts      | 13 ++-----
 e2e/console.test.ts             |  4 +-
 e2e/follow.test.ts              | 18 ++++-----
 e2e/follow_properties.test.ts   |  2 +-
 e2e/lib/SettingRepository.ts    | 18 +++++++++
 e2e/mark.test.ts                |  2 +-
 e2e/navigate.test.ts            | 14 +++----
 e2e/options.test.ts             | 14 +++----
 e2e/options_form.test.ts        | 16 ++++----
 e2e/partial_blacklist.test.ts   | 27 ++++++-------
 e2e/settings.ts                 | 86 -----------------------------------------
 e2e/zoom.test.ts                |  2 +-
 24 files changed, 171 insertions(+), 266 deletions(-)
 create mode 100644 e2e/lib/SettingRepository.ts
 delete mode 100644 e2e/settings.ts

diff --git a/e2e/blacklist.test.ts b/e2e/blacklist.test.ts
index 8bf1bd8..dec9d99 100644
--- a/e2e/blacklist.test.ts
+++ b/e2e/blacklist.test.ts
@@ -5,10 +5,12 @@ import TestServer from './lib/TestServer';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
+import Settings from "../src/shared/settings/Settings";
 
 describe("blacklist test", () => {
   let server = new TestServer().receiveContent('/*',
-    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html">`,
+    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`,
   );
   let lanthan: Lanthan;
   let webdriver: WebDriver;
@@ -24,17 +26,12 @@ describe("blacklist test", () => {
     await server.start();
 
     let url = server.url('/a').replace('http://', '');
-    await browser.storage.local.set({
-      settings: {
-        source: 'json',
-        json: `{
-        "keymaps": {
-          "j": { "type": "scroll.vertically", "count": 1 }
-        },
-        "blacklist": [ "${url}" ]
-      }`,
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      keymaps: {
+        j: { type: "scroll.vertically", count: 1 },
       },
-    });
+      blacklist: [ url ],
+    }));
   });
 
   after(async() => {
@@ -46,7 +43,7 @@ describe("blacklist test", () => {
 
   it('should disable add-on if the URL is in the blacklist', async () => {
     let page = await Page.navigateTo(webdriver, server.url('/a'));
-    await page.sendKeys('j')
+    await page.sendKeys('j');
 
     let scrollY = await page.getScrollY();
     assert.strictEqual(scrollY, 0);
diff --git a/e2e/clipboard.test.ts b/e2e/clipboard.test.ts
index 2b71ade..3f2b289 100644
--- a/e2e/clipboard.test.ts
+++ b/e2e/clipboard.test.ts
@@ -4,10 +4,11 @@ import * as path from 'path';
 import TestServer from './lib/TestServer';
 import eventually from './eventually';
 import * as clipboard from './lib/clipboard';
-import settings from './settings';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver, Key } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
+import Settings from "../src/shared/settings/Settings";
 
 describe("clipboard test", () => {
   let server = new TestServer(12321).receiveContent('/happy', 'ok');
@@ -23,9 +24,14 @@ describe("clipboard test", () => {
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
 
-    await browser.storage.local.set({
-      settings,
-    });
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      search: {
+        default: "google",
+        engines: {
+          "google": "http://127.0.0.1:12321/google?q={}",
+        },
+      },
+    }));
 
     await server.start();
   });
@@ -42,7 +48,7 @@ describe("clipboard test", () => {
     for (let tab of tabs.slice(1)) {
       await browser.tabs.remove(tab.id);
     }
-  })
+  });
 
   it('should copy current URL by y', async () => {
     let page = await Page.navigateTo(webdriver, server.url('/#should_copy_url'));
diff --git a/e2e/command_addbookmark.test.ts b/e2e/command_addbookmark.test.ts
index bcc75ac..5344292 100644
--- a/e2e/command_addbookmark.test.ts
+++ b/e2e/command_addbookmark.test.ts
@@ -10,7 +10,7 @@ import Page from './lib/Page';
 describe('addbookmark command test', () => {
   let server = new TestServer().receiveContent('/happy', `
       <!DOCTYPE html>
-      <html lang="en"><head><title>how to be happy</title></head></html">`,
+      <html lang="en"><head><title>how to be happy</title></head></html>`,
   );
   let lanthan: Lanthan;
   let webdriver: WebDriver;
diff --git a/e2e/command_bdelete.test.ts b/e2e/command_bdelete.test.ts
index c96034d..239074e 100644
--- a/e2e/command_bdelete.test.ts
+++ b/e2e/command_bdelete.test.ts
@@ -36,10 +36,10 @@ describe('bdelete/bdeletes command test', () => {
       await browser.tabs.remove(tab.id);
     }
     await browser.tabs.update(tabs[0].id, { url: server.url('/site1'), pinned: true });
-    await browser.tabs.create({ url: server.url('/site2'), pinned: true })
-    await browser.tabs.create({ url: server.url('/site3'), pinned: true })
-    await browser.tabs.create({ url: server.url('/site4'), })
-    await browser.tabs.create({ url: server.url('/site5'), })
+    await browser.tabs.create({ url: server.url('/site2'), pinned: true });
+    await browser.tabs.create({ url: server.url('/site3'), pinned: true });
+    await browser.tabs.create({ url: server.url('/site4'), });
+    await browser.tabs.create({ url: server.url('/site5'), });
 
     await eventually(async() => {
       let handles = await webdriver.getAllWindowHandles();
diff --git a/e2e/command_buffer.test.ts b/e2e/command_buffer.test.ts
index 0036839..472502b 100644
--- a/e2e/command_buffer.test.ts
+++ b/e2e/command_buffer.test.ts
@@ -16,7 +16,7 @@ describe('buffer command test', () => {
         <head>
           <title>my_${req.path.slice(1)}</title>
         </head>
-      </html">`);
+      </html>`);
   });
   let lanthan: Lanthan;
   let webdriver: WebDriver;
diff --git a/e2e/command_help.test.ts b/e2e/command_help.test.ts
index 9269d49..20035fd 100644
--- a/e2e/command_help.test.ts
+++ b/e2e/command_help.test.ts
@@ -34,7 +34,7 @@ describe("help command test", () => {
 
   beforeEach(async() => {
     page = await Page.navigateTo(webdriver, server.url());
-  })
+  });
 
   it('should open help page by help command ', async() => {
     let console = await page.showConsole();
diff --git a/e2e/command_open.test.ts b/e2e/command_open.test.ts
index 6fb2645..ba9c51e 100644
--- a/e2e/command_open.test.ts
+++ b/e2e/command_open.test.ts
@@ -2,14 +2,15 @@ import * as path from 'path';
 import * as assert from 'assert';
 
 import TestServer from './lib/TestServer';
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
+import Settings from "../src/shared/settings/Settings";
 
 describe("open command test", () => {
-  let server = new TestServer(12321)
+  let server = new TestServer()
     .receiveContent('/google', 'google')
     .receiveContent('/yahoo', 'yahoo');
   let lanthan: Lanthan;
@@ -25,11 +26,16 @@ describe("open command test", () => {
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
 
-    await browser.storage.local.set({
-      settings,
-    });
-
     await server.start();
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      search: {
+        default: "google",
+        engines: {
+          "google": server.url('/google?q={}'),
+          "yahoo": server.url('/yahoo?q={}'),
+        },
+      },
+    }));
   });
 
   after(async() => {
@@ -42,7 +48,7 @@ describe("open command test", () => {
   beforeEach(async() => {
     await webdriver.switchTo().defaultContent();
     page = await Page.navigateTo(webdriver, server.url());
-  })
+  });
 
   it('should open default search for keywords by open command ', async() => {
     let console = await page.showConsole();
@@ -60,7 +66,7 @@ describe("open command test", () => {
     await console.execCommand('open yahoo an apple');
 
     await eventually(async() => {
-      let tabs = await browser.tabs.query({ active: true })
+      let tabs = await browser.tabs.query({ active: true });
       let url = new URL(tabs[0].url);
       assert.strictEqual(url.href, server.url('/yahoo?q=an%20apple'))
     });
@@ -71,7 +77,7 @@ describe("open command test", () => {
     await console.execCommand('open');
 
     await eventually(async() => {
-      let tabs = await browser.tabs.query({ active: true })
+      let tabs = await browser.tabs.query({ active: true });
       let url = new URL(tabs[0].url);
       assert.strictEqual(url.href, server.url('/google?q='))
     });
@@ -82,7 +88,7 @@ describe("open command test", () => {
     await console.execCommand('open yahoo');
 
     await eventually(async() => {
-      let tabs = await browser.tabs.query({ active: true })
+      let tabs = await browser.tabs.query({ active: true });
       let url = new URL(tabs[0].url);
       assert.strictEqual(url.href, server.url('/yahoo?q='))
     });
@@ -93,7 +99,7 @@ describe("open command test", () => {
     await console.execCommand('open example.com');
 
     await eventually(async() => {
-      let tabs = await browser.tabs.query({ active: true })
+      let tabs = await browser.tabs.query({ active: true });
       let url = new URL(tabs[0].url);
       assert.strictEqual(url.href, 'http://example.com/')
     });
@@ -104,7 +110,7 @@ describe("open command test", () => {
     await console.execCommand('open https://example.com/');
 
     await eventually(async() => {
-      let tabs = await browser.tabs.query({ active: true })
+      let tabs = await browser.tabs.query({ active: true });
       let url = new URL(tabs[0].url);
       assert.strictEqual(url.href, 'https://example.com/')
     });
diff --git a/e2e/command_tabopen.test.ts b/e2e/command_tabopen.test.ts
index 9d3da9a..b5533e6 100644
--- a/e2e/command_tabopen.test.ts
+++ b/e2e/command_tabopen.test.ts
@@ -2,14 +2,15 @@ import * as path from 'path';
 import * as assert from 'assert';
 
 import TestServer from './lib/TestServer';
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
+import Settings from "../src/shared/settings/Settings";
 
 describe("tabopen command test", () => {
-  let server = new TestServer(12321)
+  let server = new TestServer()
     .receiveContent('/google', 'google')
     .receiveContent('/yahoo', 'yahoo');
   let lanthan: Lanthan;
@@ -25,11 +26,16 @@ describe("tabopen command test", () => {
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
 
-    await browser.storage.local.set({
-      settings,
-    });
-
     await server.start();
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      search: {
+        default: "google",
+        engines: {
+          "google": server.url('/google?q={}'),
+          "yahoo": server.url('/yahoo?q={}'),
+        },
+      },
+    }));
   });
 
   after(async() => {
@@ -46,7 +52,7 @@ describe("tabopen command test", () => {
     }
 
     page = await Page.navigateTo(webdriver, server.url());
-  })
+  });
 
   it('should open default search for keywords by tabopen command ', async() => {
     let console = await page.showConsole();
diff --git a/e2e/command_winopen.test.ts b/e2e/command_winopen.test.ts
index 95a0b6a..fb1348d 100644
--- a/e2e/command_winopen.test.ts
+++ b/e2e/command_winopen.test.ts
@@ -2,14 +2,15 @@ import * as path from 'path';
 import * as assert from 'assert';
 
 import TestServer from './lib/TestServer';
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
+import Settings from "../src/shared/settings/Settings";
 
 describe("winopen command test", () => {
-  let server = new TestServer(12321)
+  let server = new TestServer()
     .receiveContent('/google', 'google')
     .receiveContent('/yahoo', 'yahoo');
   let lanthan: Lanthan;
@@ -24,11 +25,17 @@ describe("winopen command test", () => {
       .build();
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
-    await browser.storage.local.set({
-      settings,
-    });
 
     await server.start();
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      search: {
+        default: "google",
+        engines: {
+          "google": server.url('/google?q={}'),
+          "yahoo": server.url('/yahoo?q={}'),
+        },
+      },
+    }));
   });
 
   after(async() => {
@@ -45,7 +52,7 @@ describe("winopen command test", () => {
     }
 
     page = await Page.navigateTo(webdriver, server.url());
-  })
+  });
 
   it('should open default search for keywords by winopen command ', async() => {
     let console = await page.showConsole();
diff --git a/e2e/completion.test.ts b/e2e/completion.test.ts
index afa4432..e98e1c2 100644
--- a/e2e/completion.test.ts
+++ b/e2e/completion.test.ts
@@ -2,7 +2,6 @@ import * as path from 'path';
 import * as assert from 'assert';
 
 import eventually from './eventually';
-import settings from './settings';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver, Key } from 'selenium-webdriver';
 import Page from './lib/Page';
@@ -10,7 +9,6 @@ import Page from './lib/Page';
 describe("general completion test", () => {
   let lanthan: Lanthan;
   let webdriver: WebDriver;
-  let browser: any;
   let page: Page;
 
   before(async() => {
@@ -19,11 +17,6 @@ describe("general completion test", () => {
       .spyAddon(path.join(__dirname, '..'))
       .build();
     webdriver = lanthan.getWebDriver();
-    browser = lanthan.getWebExtBrowser();
-
-    await browser.storage.local.set({
-      settings,
-    });
   });
 
   after(async() => {
@@ -42,8 +35,8 @@ describe("general completion test", () => {
     let items = await console.getCompletions();
     assert.strictEqual(items.length, 11);
     assert.deepStrictEqual(items[0], { type: 'title', text: 'Console Command' });
-    assert.ok(items[1].text.startsWith('set'))
-    assert.ok(items[2].text.startsWith('open'))
+    assert.ok(items[1].text.startsWith('set'));
+    assert.ok(items[2].text.startsWith('open'));
     assert.ok(items[3].text.startsWith('tabopen'))
   });
 
@@ -54,8 +47,8 @@ describe("general completion test", () => {
     let items = await console.getCompletions();
     assert.strictEqual(items.length, 4);
     assert.deepStrictEqual(items[0], { type: 'title', text: 'Console Command' });
-    assert.ok(items[1].text.startsWith('buffer'))
-    assert.ok(items[2].text.startsWith('bdelete'))
+    assert.ok(items[1].text.startsWith('buffer'));
+    assert.ok(items[2].text.startsWith('bdelete'));
     assert.ok(items[3].text.startsWith('bdeletes'))
   });
 
@@ -74,14 +67,14 @@ describe("general completion test", () => {
     await console.sendKeys(Key.TAB);
     await eventually(async() => {
       let items = await console.getCompletions();
-      assert.ok(items[1].highlight)
+      assert.ok(items[1].highlight);
       assert.strictEqual(await console.currentValue(), 'buffer');
     });
 
     await console.sendKeys(Key.TAB, Key.TAB);
     await eventually(async() => {
       let items = await console.getCompletions();
-      assert.ok(items[3].highlight)
+      assert.ok(items[3].highlight);
       assert.strictEqual(await console.currentValue(), 'bdeletes');
     });
 
@@ -93,7 +86,7 @@ describe("general completion test", () => {
     await console.sendKeys(Key.SHIFT, Key.TAB);
     await eventually(async() => {
       let items = await console.getCompletions();
-      assert.ok(items[3].highlight)
+      assert.ok(items[3].highlight);
       assert.strictEqual(await console.currentValue(), 'bdeletes');
     });
   });
diff --git a/e2e/completion_buffers.test.ts b/e2e/completion_buffers.test.ts
index b2d4201..b6e7de0 100644
--- a/e2e/completion_buffers.test.ts
+++ b/e2e/completion_buffers.test.ts
@@ -3,7 +3,6 @@ import * as path from 'path';
 
 import { Request, Response } from 'express'
 import TestServer from './lib/TestServer';
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
@@ -17,7 +16,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
         <head>
           <title>title_${req.path.slice(1)}</title>
         </head>
-      </html">`);
+      </html>`);
   });
   let lanthan: Lanthan;
   let webdriver: WebDriver;
@@ -32,10 +31,6 @@ describe("completion on buffer/bdelete/bdeletes", () => {
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
 
-    await browser.storage.local.set({
-      settings,
-    });
-
     await server.start();
   });
 
@@ -53,7 +48,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
     }
 
     await browser.tabs.update(tabs[0].id, { url: server.url('/site1'), pinned: true });
-    await browser.tabs.create({ url:server.url('/site2'), pinned: true })
+    await browser.tabs.create({ url:server.url('/site2'), pinned: true });
     for (let i = 3; i <= 5; ++i) {
       await browser.tabs.create({ url: server.url('/site' + i) });
     }
@@ -84,7 +79,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[3].text.includes('%'));
       assert.ok(items[5].text.includes('#'));
     });
-  })
+  });
 
   it('should filter items with URLs by keywords on "buffer" command', async() => {
     let console = await page.showConsole();
@@ -97,7 +92,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[1].text.includes('title_site2'));
       assert.ok(items[1].text.includes(server.url('/site2')));
     });
-  })
+  });
 
   it('should filter items with titles by keywords on "buffer" command', async() => {
     let console = await page.showConsole();
@@ -108,7 +103,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.deepStrictEqual(items[0], { type: 'title', text: 'Buffers' });
       assert.ok(items[1].text.startsWith('2:'));
     });
-  })
+  });
 
   it('should show one item by number on "buffer" command', async() => {
     let console = await page.showConsole();
@@ -120,7 +115,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.deepStrictEqual(items[0], { type: 'title', text: 'Buffers' });
       assert.ok(items[1].text.startsWith('2:'));
     });
-  })
+  });
 
   it('should show unpinned tabs "bdelete" command', async() => {
     let console = await page.showConsole();
@@ -133,7 +128,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[2].text.includes('site4'));
       assert.ok(items[3].text.includes('site5'));
     });
-  })
+  });
 
   it('should show unpinned tabs "bdeletes" command', async() => {
     let console = await page.showConsole();
@@ -146,7 +141,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[2].text.includes('site4'));
       assert.ok(items[3].text.includes('site5'));
     });
-  })
+  });
 
   it('should show both pinned and unpinned tabs "bdelete!" command', async() => {
     let console = await page.showConsole();
@@ -161,7 +156,7 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[4].text.includes('site4'));
       assert.ok(items[5].text.includes('site5'));
     });
-  })
+  });
 
   it('should show both pinned and unpinned tabs "bdeletes!" command', async() => {
     let console = await page.showConsole();
@@ -176,5 +171,5 @@ describe("completion on buffer/bdelete/bdeletes", () => {
       assert.ok(items[4].text.includes('site4'));
       assert.ok(items[5].text.includes('site5'));
     });
-  })
+  });
 });
diff --git a/e2e/completion_open.test.ts b/e2e/completion_open.test.ts
index c957e2e..5f8bd11 100644
--- a/e2e/completion_open.test.ts
+++ b/e2e/completion_open.test.ts
@@ -1,12 +1,13 @@
 import * as path from 'path';
 import * as assert from 'assert';
 
+import Settings from "../src/shared/settings/Settings";
 import TestServer from './lib/TestServer';
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import SettingRepository from "./lib/SettingRepository";
 
 describe("completion on open/tabopen/winopen commands", () => {
   let server = new TestServer().receiveContent('/*', 'ok');
@@ -25,10 +26,6 @@ describe("completion on open/tabopen/winopen commands", () => {
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
 
-    await browser.storage.local.set({
-      settings,
-    });
-    
     // Add item into hitories
     await webdriver.navigate().to(('https://i-beam.org/404'));
   });
@@ -65,7 +62,7 @@ describe("completion on open/tabopen/winopen commands", () => {
       let items = completions.filter(x => x.type === 'item').map(x => x.text);
       assert.ok(items.every(x => x.includes('https://')));
     });
-  })
+  });
 
   it('should filter items with titles by keywords on "open" command', async() => {
     let console = await page.showConsole();
@@ -76,7 +73,7 @@ describe("completion on open/tabopen/winopen commands", () => {
       let items = completions.filter(x => x.type === 'item').map(x => x.text);
       assert.ok(items.every(x => x.toLowerCase().includes('getting')));
     });
-  })
+  });
 
   it('should filter items with titles by keywords on "tabopen" command', async() => {
     let console = await page.showConsole();
@@ -87,7 +84,7 @@ describe("completion on open/tabopen/winopen commands", () => {
       let items = completions.filter(x => x.type === 'item').map(x => x.text);
       assert.ok(items.every(x => x.includes('https://')));
     });
-  })
+  });
 
   it('should filter items with titles by keywords on "winopen" command', async() => {
     let console = await page.showConsole();
@@ -98,7 +95,7 @@ describe("completion on open/tabopen/winopen commands", () => {
       let items = completions.filter(x => x.type === 'item').map(x => x.text);
       assert.ok(items.every(x => x.includes('https://')));
     });
-  })
+  });
 
   it('should display only specified items in "complete" property by set command', async() => {
     let console = await page.showConsole();
@@ -127,24 +124,12 @@ describe("completion on open/tabopen/winopen commands", () => {
       let titles = completions.filter(x => x.type === 'title').map(x => x.text);
       assert.deepStrictEqual(titles, ['Bookmarks', 'Search Engines', 'Search Engines'])
     });
-  })
+  });
 
   it('should display only specified items in "complete" property by setting', async() => {
-    await browser.storage.local.set({ settings: {
-      source: 'json',
-      json: `{
-        "keymaps": {
-          ":": { "type": "command.show" }
-        },
-        "search": {
-          "default": "google",
-          "engines": { "google": "https://google.com/search?q={}" }
-        },
-        "properties": {
-          "complete": "sbh"
-        }
-      }`,
-    }});
+    new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      properties: { complete: "sbh" },
+    }));
 
     let console = await page.showConsole();
     await console.inputKeys('open ');
@@ -158,21 +143,9 @@ describe("completion on open/tabopen/winopen commands", () => {
     await console.close();
     await (webdriver.switchTo() as any).parentFrame();
 
-    await browser.storage.local.set({ settings: {
-      source: 'json',
-      json: `{
-        "keymaps": {
-          ":": { "type": "command.show" }
-        },
-        "search": {
-          "default": "google",
-          "engines": { "google": "https://google.com/search?q={}" }
-        },
-        "properties": {
-          "complete": "bss"
-        }
-      }`,
-    }});
+    new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      properties: { complete: "bss" },
+    }));
 
     console = await page.showConsole();
     await console.inputKeys('open ');
@@ -182,5 +155,5 @@ describe("completion on open/tabopen/winopen commands", () => {
       let titles = completions.filter(x => x.type === 'title').map(x => x.text);
       assert.deepStrictEqual(titles, ['Bookmarks', 'Search Engines', 'Search Engines'])
     });
-  })
+  });
 });
diff --git a/e2e/completion_set.test.ts b/e2e/completion_set.test.ts
index 2a14b2c..facf991 100644
--- a/e2e/completion_set.test.ts
+++ b/e2e/completion_set.test.ts
@@ -1,7 +1,6 @@
 import * as path from 'path';
 import * as assert from 'assert';
 
-import settings from './settings';
 import eventually from './eventually';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
@@ -10,7 +9,6 @@ import Page from './lib/Page';
 describe("completion on set commands", () => {
   let lanthan: Lanthan;
   let webdriver: WebDriver;
-  let browser: any;
   let page: Page;
 
   before(async() => {
@@ -19,11 +17,6 @@ describe("completion on set commands", () => {
       .spyAddon(path.join(__dirname, '..'))
       .build();
     webdriver = lanthan.getWebDriver();
-    browser = lanthan.getWebExtBrowser();
-
-    await browser.storage.local.set({
-      settings,
-    });
   });
 
   after(async() => {
@@ -44,9 +37,9 @@ describe("completion on set commands", () => {
       let items = await console.getCompletions();
       assert.strictEqual(items.length, 5);
       assert.deepStrictEqual(items[0], { type: 'title', text: 'Properties' });
-      assert.ok(items[1].text.startsWith('hintchars'))
-      assert.ok(items[2].text.startsWith('smoothscroll'))
-      assert.ok(items[3].text.startsWith('nosmoothscroll'))
+      assert.ok(items[1].text.startsWith('hintchars'));
+      assert.ok(items[2].text.startsWith('smoothscroll'));
+      assert.ok(items[3].text.startsWith('nosmoothscroll'));
       assert.ok(items[4].text.startsWith('complete'))
     });
   });
diff --git a/e2e/console.test.ts b/e2e/console.test.ts
index 583580a..faaf695 100644
--- a/e2e/console.test.ts
+++ b/e2e/console.test.ts
@@ -7,8 +7,8 @@ import { WebDriver, Key } from 'selenium-webdriver';
 import Page from './lib/Page';
 
 describe("console test", () => {
-  let server = new TestServer().receiveContent('/', 
-    `<!DOCTYPE html><html lang="en"><head><title>Hello, world!</title></head></html">`,
+  let server = new TestServer().receiveContent('/',
+    `<!DOCTYPE html><html lang="en"><head><title>Hello, world!</title></head></html>`,
   );
   let lanthan: Lanthan;
   let webdriver: WebDriver;
diff --git a/e2e/follow.test.ts b/e2e/follow.test.ts
index fd741ef..ce3f565 100644
--- a/e2e/follow.test.ts
+++ b/e2e/follow.test.ts
@@ -14,13 +14,13 @@ const newApp = () => {
     <!DOCTYPE html>
     <html lang="en"><body>
       <a href="hello">hello</a>
-    </body></html">`);
+    </body></html>`);
 
   server.receiveContent('/follow-input', `
     <!DOCTYPE html>
     <html lang="en"><body>
       <input>
-    </body></html">`);
+    </body></html>`);
 
   server.receiveContent('/area', `
     <!DOCTYPE html>
@@ -34,8 +34,8 @@ const newApp = () => {
         <area shape="rect" coords="64,64,64,64" href="/">
         <area shape="rect" coords="128,128,64,64" href="/">
       </map>
-    </body></html">`);
-  
+    </body></html>`);
+
   /*
    * test case: link2 is out of the viewport
    * +-----------------+
@@ -52,7 +52,7 @@ const newApp = () => {
       <div><a href="link1">link1</a></div>
       <div style="min-height:3000px"></div>
       <div><a href="link2">link2</a></div>
-    </body></html">`);
+    </body></html>`);
 
 /*
  * test case 2: link2 and link3 are out of window of the frame
@@ -69,14 +69,14 @@ const newApp = () => {
     <!DOCTYPE html>
     <html lang="en"><body>
       <iframe height="5000" src='/test2-frame'>
-    </body></html">`);
+    </body></html>`);
   server.receiveContent('/test2-frame', `
     <!DOCTYPE html>
     <html lang="en"><body>
       <div><a href="link1">link1</a></div>
       <div style="min-height:3000px"></div>
       <div><a href="link2">link2</a></div>
-    </body></html">`);
+    </body></html>`);
 
 /* test case 3: link2 is out of window of the frame
  * +-----------------+
@@ -92,14 +92,14 @@ const newApp = () => {
     <!DOCTYPE html>
     <html lang="en"><body>
       <iframe src='/test3-frame'>
-    </body></html">`);
+    </body></html>`);
   server.receiveContent('/test3-frame', `
     <!DOCTYPE html>
     <html lang="en"><body>
       <div><a href="link1">link1</a></div>
       <div style="min-height:3000px"></div>
       <div><a href="link2">link2</a></div>
-    </body></html">`);
+    </body></html>`);
 
   return server;
 };
diff --git a/e2e/follow_properties.test.ts b/e2e/follow_properties.test.ts
index 75a1d77..eaa38e2 100644
--- a/e2e/follow_properties.test.ts
+++ b/e2e/follow_properties.test.ts
@@ -16,7 +16,7 @@ describe('follow properties test', () => {
       <a href="/">link3</a>
       <a href="/">link4</a>
       <a href="/">link5</a>
-    </body></html">`);
+    </body></html>`);
 
   let lanthan: Lanthan;
   let webdriver: WebDriver;
diff --git a/e2e/lib/SettingRepository.ts b/e2e/lib/SettingRepository.ts
new file mode 100644
index 0000000..9d5d5aa
--- /dev/null
+++ b/e2e/lib/SettingRepository.ts
@@ -0,0 +1,18 @@
+import { JSONTextSettings, SettingSource } from '../../src/shared/SettingData';
+import Settings from '../../src/shared/settings/Settings';
+
+export default class SettingRepository {
+  constructor(
+    private readonly browser: any,
+  ) {
+  }
+
+  async saveJSON(settings: Settings): Promise<void> {
+    await this.browser.storage.local.set({
+      settings: {
+        source: SettingSource.JSON,
+        json:  JSONTextSettings.fromSettings(settings).toJSONText(),
+      }
+    });
+  }
+}
diff --git a/e2e/mark.test.ts b/e2e/mark.test.ts
index 57a8fa6..f9f372b 100644
--- a/e2e/mark.test.ts
+++ b/e2e/mark.test.ts
@@ -9,7 +9,7 @@ import Page from './lib/Page';
 
 describe("mark test", () => {
   let server = new TestServer().receiveContent('/',
-    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html">`,
+    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`,
   );
   let lanthan: Lanthan;
   let webdriver: WebDriver;
diff --git a/e2e/navigate.test.ts b/e2e/navigate.test.ts
index 8ee209b..15c5a31 100644
--- a/e2e/navigate.test.ts
+++ b/e2e/navigate.test.ts
@@ -16,7 +16,7 @@ const newApp = () => {
       <html lang="en">
         <a href="/pagenation-a/${Number(req.params.page) - 1}">prev</a>
         <a href="/pagenation-a/${Number(req.params.page) + 1}">next</a>
-      </html">`);
+      </html>`);
   });
 
   server.handle('/pagenation-link/:page', (req, res) => {
@@ -27,7 +27,7 @@ const newApp = () => {
           <link rel="prev" href="/pagenation-link/${Number(req.params.page) - 1}"></link>
           <link rel="next" href="/pagenation-link/${Number(req.params.page) + 1}"></link>
         </head>
-      </html">`);
+      </html>`);
   });
   server.receiveContent('/reload', `
     <!DOCTYPE html>
@@ -36,7 +36,7 @@ const newApp = () => {
         <script>window.location.hash = Date.now()</script>
       </head>
       <body style="width:10000px; height:10000px"></body>
-    </html">`);
+    </html>`);
 
   server.receiveContent('/*', `ok`);
 
@@ -75,7 +75,7 @@ describe("navigate test", () => {
     for (let tab of tabs.slice(1)) {
       await browser.tabs.remove(tab.id);
     }
-  })
+  });
 
   it('should go to parent path without hash by gu', async () => {
     let page = await Page.navigateTo(webdriver, server.url('/a/b/c'));
@@ -95,7 +95,7 @@ describe("navigate test", () => {
     await eventually(async() => {
       let tab = (await browser.tabs.query({}))[0];
       let url = new URL(tab.url);
-      assert.strictEqual(url.hash, '')
+      assert.strictEqual(url.hash, '');
       assert.strictEqual(url.pathname, `/a/b/c`)
     });
   });
@@ -213,7 +213,7 @@ describe("navigate test", () => {
 
     await page.sendKeys('r');
 
-    let after
+    let after;
     await eventually(async() => {
       let tab = (await browser.tabs.query({}))[0];
       after = Number(new URL(tab.url).hash.split('#')[1]);
@@ -239,7 +239,7 @@ describe("navigate test", () => {
 
     await page.sendKeys(Key.SHIFT, 'R');
 
-    let after
+    let after;
     await eventually(async() => {
       let tab = (await browser.tabs.query({}))[0];
       after = Number(new URL(tab.url).hash.split('#')[1]);
diff --git a/e2e/options.test.ts b/e2e/options.test.ts
index 8d5023f..f418dc3 100644
--- a/e2e/options.test.ts
+++ b/e2e/options.test.ts
@@ -10,7 +10,7 @@ import OptionPage from './lib/OptionPage';
 
 describe("options page", () => {
   let server = new TestServer().receiveContent('/',
-    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html">`,
+    `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`,
   );
   let lanthan: Lanthan;
   let webdriver: WebDriver;
@@ -39,22 +39,22 @@ describe("options page", () => {
     for (let tab of tabs.slice(1)) {
       await browser.tabs.remove(tab.id);
     }
-  })
+  });
 
   it('saves current config on blur', async () => {
     let page = await OptionPage.open(lanthan);
     let jsonPage = await page.asJSONOptionPage();
-    await jsonPage.updateSettings(`{ "blacklist": [ "https://example.com" ] }`)
+    await jsonPage.updateSettings(`{ "blacklist": [ "https://example.com" ] }`);
 
     let { settings } = await browser.storage.local.get('settings');
-    assert.strictEqual(settings.source, 'json')
-    assert.strictEqual(settings.json, '{ "blacklist": [ "https://example.com" ] } ')
+    assert.strictEqual(settings.source, 'json');
+    assert.strictEqual(settings.json, '{ "blacklist": [ "https://example.com" ] } ');
 
     await jsonPage.updateSettings(`invalid json`);
 
     settings = (await browser.storage.local.get('settings')).settings;
-    assert.strictEqual(settings.source, 'json')
-    assert.strictEqual(settings.json, '{ "blacklist": [ "https://example.com" ] } ')
+    assert.strictEqual(settings.source, 'json');
+    assert.strictEqual(settings.json, '{ "blacklist": [ "https://example.com" ] } ');
 
     let message = await jsonPage.getErrorMessage();
     assert.ok(message.startsWith('SyntaxError:'))
diff --git a/e2e/options_form.test.ts b/e2e/options_form.test.ts
index af53791..6023f9c 100644
--- a/e2e/options_form.test.ts
+++ b/e2e/options_form.test.ts
@@ -19,13 +19,13 @@ describe("options form page", () => {
     for (let tab of tabs.slice(1)) {
       await browser.tabs.remove(tab.id);
     }
-  })
+  });
 
   afterEach(async() => {
     if (lanthan) {
       await lanthan.quit();
     }
-  })
+  });
 
   it('switch to form settings', async () => {
     let page = await OptionPage.open(lanthan);
@@ -33,7 +33,7 @@ describe("options form page", () => {
 
     let { settings } = await browser.storage.local.get('settings');
     assert.strictEqual(settings.source, 'form')
-  })
+  });
 
   it('add blacklist', async () => {
     let page = await OptionPage.open(lanthan);
@@ -43,20 +43,20 @@ describe("options form page", () => {
 
     // assert default
     let settings = (await browser.storage.local.get('settings')).settings;
-    assert.deepStrictEqual(settings.form.blacklist, [])
+    assert.deepStrictEqual(settings.form.blacklist, []);
 
     // add blacklist items
     await forms.addBlacklist();
-    await forms.setBlacklist(0, 'google.com')
+    await forms.setBlacklist(0, 'google.com');
 
     settings = (await browser.storage.local.get('settings')).settings;
-    assert.deepStrictEqual(settings.form.blacklist, ['google.com'])
+    assert.deepStrictEqual(settings.form.blacklist, ['google.com']);
 
     await forms.addBlacklist();
-    await forms.setBlacklist(1, 'yahoo.com')
+    await forms.setBlacklist(1, 'yahoo.com');
 
     settings = (await browser.storage.local.get('settings')).settings;
-    assert.deepStrictEqual(settings.form.blacklist, ['google.com', 'yahoo.com'])
+    assert.deepStrictEqual(settings.form.blacklist, ['google.com', 'yahoo.com']);
 
     // delete first item
     await forms.removeBlackList(0);
diff --git a/e2e/partial_blacklist.test.ts b/e2e/partial_blacklist.test.ts
index e938dc6..950bb39 100644
--- a/e2e/partial_blacklist.test.ts
+++ b/e2e/partial_blacklist.test.ts
@@ -5,6 +5,8 @@ import TestServer from './lib/TestServer';
 import { Builder, Lanthan } from 'lanthan';
 import { WebDriver } from 'selenium-webdriver';
 import Page from './lib/Page';
+import Settings from '../src/shared/settings/Settings';
+import SettingRepository from './lib/SettingRepository';
 
 describe("partial blacklist test", () => {
   let server = new TestServer().receiveContent('/*',
@@ -24,20 +26,15 @@ describe("partial blacklist test", () => {
     await server.start();
 
     let url = server.url().replace('http://', '');
-    await browser.storage.local.set({
-      settings: {
-        source: 'json',
-        json: `{
-        "keymaps": {
-          "j": { "type": "scroll.vertically", "count": 1 },
-          "k": { "type": "scroll.vertically", "count": -1 }
-        },
-        "blacklist": [
-          { "url": "${url}", "keys": ["k"] }
-        ]
-      }`,
+    await new SettingRepository(browser).saveJSON(Settings.fromJSON({
+      keymaps: {
+        j: { type: 'scroll.vertically', count: 1 },
+        k: { type: 'scroll.vertically', count: -1 },
       },
-    });
+      blacklist: [
+        { 'url': url, 'keys': ['k'] }
+      ]
+    }));
   });
 
   after(async() => {
@@ -50,11 +47,11 @@ describe("partial blacklist test", () => {
   it('should disable keys in the partial blacklist', async () => {
     let page = await Page.navigateTo(webdriver, server.url('/'));
 
-    await page.sendKeys('j')
+    await page.sendKeys('j');
     let scrollY = await page.getScrollY();
     assert.strictEqual(scrollY, 64);
 
-    await page.sendKeys('k')
+    await page.sendKeys('k');
     scrollY = await page.getScrollY();
     assert.strictEqual(scrollY, 64);
   });
diff --git a/e2e/settings.ts b/e2e/settings.ts
deleted file mode 100644
index b88caf0..0000000
--- a/e2e/settings.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-export default {
-  source: 'json',
-  json: `{
-  "keymaps": {
-    "0": { "type": "scroll.home" },
-    ":": { "type": "command.show" },
-    "o": { "type": "command.show.open", "alter": false },
-    "O": { "type": "command.show.open", "alter": true },
-    "t": { "type": "command.show.tabopen", "alter": false },
-    "T": { "type": "command.show.tabopen", "alter": true },
-    "w": { "type": "command.show.winopen", "alter": false },
-    "W": { "type": "command.show.winopen", "alter": true },
-    "b": { "type": "command.show.buffer" },
-    "a": { "type": "command.show.addbookmark", "alter": true },
-    "k": { "type": "scroll.vertically", "count": -1 },
-    "j": { "type": "scroll.vertically", "count": 1 },
-    "h": { "type": "scroll.horizonally", "count": -1 },
-    "l": { "type": "scroll.horizonally", "count": 1 },
-    "<C-U>": { "type": "scroll.pages", "count": -0.5 },
-    "<C-D>": { "type": "scroll.pages", "count": 0.5 },
-    "<C-B>": { "type": "scroll.pages", "count": -1 },
-    "<C-F>": { "type": "scroll.pages", "count": 1 },
-    "gg": { "type": "scroll.top" },
-    "G": { "type": "scroll.bottom" },
-    "$": { "type": "scroll.end" },
-    "d": { "type": "tabs.close" },
-    "D": { "type": "tabs.close", "select": "left" },
-    "x$": { "type": "tabs.close.right" },
-    "!d": { "type": "tabs.close.force" },
-    "u": { "type": "tabs.reopen" },
-    "K": { "type": "tabs.prev", "count": 1 },
-    "J": { "type": "tabs.next", "count": 1 },
-    "gT": { "type": "tabs.prev", "count": 1 },
-    "gt": { "type": "tabs.next", "count": 1 },
-    "g0": { "type": "tabs.first" },
-    "g$": { "type": "tabs.last" },
-    "<C-6>": { "type": "tabs.prevsel" },
-    "r": { "type": "tabs.reload", "cache": false },
-    "R": { "type": "tabs.reload", "cache": true },
-    "zp": { "type": "tabs.pin.toggle" },
-    "zd": { "type": "tabs.duplicate" },
-    "zi": { "type": "zoom.in" },
-    "zo": { "type": "zoom.out" },
-    "zz": { "type": "zoom.neutral" },
-    "f": { "type": "follow.start", "newTab": false },
-    "F": { "type": "follow.start", "newTab": true, "background": false },
-    "m": { "type": "mark.set.prefix" },
-    "'": { "type": "mark.jump.prefix" },
-    "H": { "type": "navigate.history.prev" },
-    "L": { "type": "navigate.history.next" },
-    "[[": { "type": "navigate.link.prev" },
-    "]]": { "type": "navigate.link.next" },
-    "gu": { "type": "navigate.parent" },
-    "gU": { "type": "navigate.root" },
-    "gi": { "type": "focus.input" },
-    "gf": { "type": "page.source" },
-    "gh": { "type": "page.home" },
-    "gH": { "type": "page.home", "newTab": true },
-    "y": { "type": "urls.yank" },
-    "p": { "type": "urls.paste", "newTab": false },
-    "P": { "type": "urls.paste", "newTab": true },
-    "/": { "type": "find.start" },
-    "n": { "type": "find.next" },
-    "N": { "type": "find.prev" },
-    "<S-Esc>": { "type": "addon.toggle.enabled" }
-  },
-  "search": {
-    "default": "google",
-    "engines": {
-      "google": "http://127.0.0.1:12321/google?q={}",
-      "yahoo": "http://127.0.0.1:12321/yahoo?q={}",
-      "bing": "http://127.0.0.1:12321/bind?q={}",
-      "duckduckgo": "http://127.0.0.1:12321/duplicate?q={}",
-      "twitter": "http://127.0.0.1:12321/twitter?q={}",
-      "wikipedia": "http://127.0.0.1:12321/wikipedia?q={}"
-    }
-  },
-  "properties": {
-    "hintchars": "abcdefghijklmnopqrstuvwxyz",
-    "smoothscroll": false,
-    "complete": "sbh"
-  },
-  "blacklist": [
-  ]
-}`,
-};
diff --git a/e2e/zoom.test.ts b/e2e/zoom.test.ts
index 396ddd2..af5cc68 100644
--- a/e2e/zoom.test.ts
+++ b/e2e/zoom.test.ts
@@ -20,7 +20,7 @@ describe("zoom test", () => {
       .build();
     webdriver = lanthan.getWebDriver();
     browser = lanthan.getWebExtBrowser();
-    tab = (await browser.tabs.query({}))[0]
+    tab = (await browser.tabs.query({}))[0];
     page = await Page.currentContext(webdriver);
   });
 
-- 
cgit v1.2.3