Browse Source

Add options syncing (using chrome.storage.sync).

FelisCatus 11 years ago
parent
commit
a145d4f6a5

+ 2 - 0
omega-pac/src/profiles.coffee

@@ -134,6 +134,8 @@ module.exports = exports =
       result = analyze?.call(exports, profile)
       cache.analyzed = result
     return cache
+  dropCache: (profile) ->
+    exports._profileCache.drop profile
   directReferenceSet: (profile) ->
     return {} if not exports.isInclusive(profile)
     cache = exports._profileCache.get profile, {}

+ 3 - 0
omega-pac/src/utils.coffee

@@ -28,6 +28,9 @@ class AttachedCache
     value = if typeof otherwise == 'function' then otherwise() else otherwise
     @_setCache(obj, {tag: tag, value: value})
     return value
+  drop: (obj) ->
+    if obj[@prop]?
+      obj[@prop] = undefined
   _getCache: (obj) -> obj[@prop]
   _setCache: (obj, value) ->
     if not Object::hasOwnProperty.call obj, @prop

+ 12 - 0
omega-pac/test/profiles.coffee

@@ -179,6 +179,18 @@ describe 'Profiles', ->
         '+example': 'example'
         '+abc': 'abc'
       )
+    it 'should clear the reference cache if explicitly requested', ->
+      profile.revision = 'a'
+      set = Profiles.directReferenceSet(profile)
+      # Remove 'default' from references.
+      profile.defaultProfileName = 'abc'
+      Profiles.dropCache(profile)
+      newSet = Profiles.directReferenceSet(profile)
+      newSet.should.eql(
+        '+company': 'company'
+        '+example': 'example'
+        '+abc': 'abc'
+      )
   describe 'VirtualProfile', ->
     profile = Profiles.create('test', 'VirtualProfile')
     profile.defaultProfileName = 'default'

+ 8 - 1
omega-target-chromium-extension/background.coffee

@@ -132,7 +132,14 @@ actionForUrl = (url) ->
 
 storage = new OmegaTargetCurrent.Storage(chrome.storage.local, 'local')
 state = new OmegaTargetCurrent.BrowserStorage(localStorage, 'omega.local.')
-options = new OmegaTargetCurrent.Options(null, storage, state, Log)
+
+syncStorage = new OmegaTargetCurrent.Storage(chrome.storage.sync, 'sync')
+sync = new OmegaTargetCurrent.OptionsSync(syncStorage)
+if localStorage['omega.local.syncOptions'] != '"sync"'
+  sync.enabled = false
+sync.transformValue = OmegaTargetCurrent.Options.transformValueForSync
+
+options = new OmegaTargetCurrent.Options(null, storage, state, Log, sync)
 options.externalApi = new OmegaTargetCurrent.ExternalApi(options)
 options.externalApi.listen()
 

+ 27 - 3
omega-target-chromium-extension/src/storage.coffee

@@ -3,24 +3,48 @@ OmegaTarget = require('omega-target')
 Promise = OmegaTarget.Promise
 
 class ChromeStorage extends OmegaTarget.Storage
+  @parseStorageErrors: (err) ->
+    if err?.message
+      sustainedPerMinute = 'MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE'
+      if err.message.indexOf('QUOTA_BYTES_PER_ITEM') >= 0
+        err = new OmegaTarget.Storage.QuotaExceededError()
+        err.perItem = true
+      else if err.message.indexOf('QUOTA_BYTES') >= 0
+        err = new OmegaTarget.Storage.QuotaExceededError()
+      else if err.message.indexOf('MAX_ITEMS') >= 0
+        err = new OmegaTarget.Storage.QuotaExceededError()
+        err.maxItems = true
+      else if err.message.indexOf('MAX_WRITE_OPERATIONS_') >= 0
+        err = new OmegaTarget.Storage.RateLimitExceededError()
+        if err.message.indexOf('MAX_WRITE_OPERATIONS_PER_HOUR') >= 0
+          err.perHour = true
+        else if err.message.indexOf('MAX_WRITE_OPERATIONS_PER_MINUTE') >= 0
+          err.perMinute = true
+      else if err.message.indexOf(sustainedPerMinute) >= 0
+        err = new OmegaTarget.Storage.RateLimitExceededError()
+        err.perMinute = true
+        err.sustained = 10
+
+    return Promise.reject(err)
+
   constructor: (storage, @areaName) ->
     @storage = chromeApiPromisifyAll(storage)
 
   get: (keys) ->
     keys ?= null
-    @storage.getAsync(keys)
+    @storage.getAsync(keys).catch(ChromeStorage.parseStorageErrors)
 
   set: (items) ->
     if Object.keys(items).length == 0
       return Promise.resolve({})
-    @storage.setAsync(items)
+    @storage.setAsync(items).catch(ChromeStorage.parseStorageErrors)
 
   remove: (keys) ->
     if not keys?
       return @storage.clearAsync()
     if Array.isArray(keys) and keys.length == 0
       return Promise.resolve({})
-    @storage.removeAsync(keys)
+    @storage.removeAsync(keys).catch(ChromeStorage.parseStorageErrors)
 
   watch: (keys, callback) ->
     ChromeStorage.watchers[@areaName] ?= {}

+ 1 - 0
omega-target/index.coffee

@@ -3,6 +3,7 @@ module.exports =
   Storage: require('./src/storage')
   BrowserStorage: require('./src/browser_storage')
   Options: require('./src/options')
+  OptionsSync: require('./src/options_sync')
   OmegaPac: require('omega-pac')
 
 for name, value of require('./src/utils.coffee')

+ 5 - 2
omega-target/package.json

@@ -4,7 +4,7 @@
   "private": true,
   "main": "./index.js",
   "devDependencies": {
-    "chai": "~1.9.1",
+    "chai": "^1.10.0",
     "coffee-script": "^1.8.0",
     "coffeeify": "^0.7.0",
     "grunt": "^0.4.5",
@@ -14,11 +14,14 @@
     "grunt-contrib-watch": "^0.6.1",
     "grunt-mocha-test": "~0.11.0",
     "load-grunt-config": "^0.13.1",
-    "minifyify": "^4.1.1"
+    "minifyify": "^4.1.1",
+    "sinon": "^1.12.2",
+    "sinon-chai": "^2.6.0"
   },
   "dependencies": {
     "bluebird": "^2.3.2",
     "jsondiffpatch": "^0.1.8",
+    "limiter": "^1.0.5",
     "omega-pac": "../omega-pac"
   },
   "browser": {

+ 40 - 8
omega-target/src/options.coffee

@@ -31,20 +31,51 @@ class Options
 
   ready: null
 
-  ProfileNotExistError: class ProfileNotExistError extends Error
+  @ProfileNotExistError: class ProfileNotExistError extends Error
     constructor: (@profileName) ->
       super.constructor("Profile #{@profileName} does not exist!")
 
-  NoOptionsError: class NoOptionsError extends Error
+  @NoOptionsError:
+    class NoOptionsError extends Error
+      constructor: -> super
 
-  constructor: (@_options, @_storage, @_state, @log) ->
+  ###*
+  # Transform options values (especially profiles) for syncing.
+  # @param {{}} value The value to transform
+  # @param {{}} key The key of the options
+  # @returns {{}} The transformed value
+  ###
+  @transformValueForSync: (value, key) ->
+    if key[0] == '+'
+      if OmegaPac.Profiles.updateUrl(value)
+        profile = {}
+        for k, v of value
+          continue if k == 'lastUpdate' || k == 'ruleList' || k == 'pacScript'
+          profile[k] = v
+        value = profile
+    return value
+
+  constructor: (@_options, @_storage, @_state, @log, @sync) ->
     @_storage ?= Storage()
     @_state ?= Storage()
     @log ?= Log
     if @_options?
       @ready = Promise.resolve(@_options)
     else
-      @ready = @_storage.get(null)
+      @ready = if @sync?.enabled then Promise.resolve() else @_storage.get(null)
+      @ready = @ready.then (options) =>
+        return options if not @sync?
+        if options?['schemaVersion']
+          @_state.get({'syncOptions': ''}).then ({syncOptions}) =>
+            return if syncOptions
+            @_state.set({'syncOptions': 'conflict'})
+            @sync.storage.get('schemaVersion').then({schemaVersion}) =>
+              @_state.set({'syncOptions': 'pristine'}) if not schemaVersion
+          return options
+        @_state.set({'syncOptions': 'sync'})
+        @sync.watchAndPull(@_storage)
+        @sync.copyTo(@_storage).then =>
+          @_storage.get(null)
     @ready = @ready.then((options) =>
       @upgrade(options).then(([options, changes]) =>
         modified = {}
@@ -86,9 +117,10 @@ class Options
     ).then => @getAll()
 
     @ready.then =>
+      @sync.requestPush(@_options) if @sync?.enabled
+
       @_state.get({'firstRun': ''}).then ({firstRun}) =>
-        if firstRun
-          @onFirstRun(firstRun)
+        @onFirstRun(firstRun) if firstRun
 
       if @_options['-downloadInterval'] > 0
         @updateProfile()
@@ -200,7 +232,6 @@ class Options
     @_options = jsondiffpatch.patch(@_options, patch)
     # Only set the keys whose values have changed.
     changes = {}
-    removed = []
     for own key, delta of patch
       if delta.length == 3 and delta[1] == 0 and delta[2] == 0
         # [previousValue, 0, 0] indicates that the key was removed.
@@ -241,6 +272,7 @@ class Options
       else
         @_setAvailableProfiles() if profilesChanged
     if args?.persist ? true
+      @sync?.requestPush(changes) if @sync?.enabled
       for key in removed
         delete changes[key]
       @_storage.set(changes).then =>
@@ -525,7 +557,7 @@ class Options
           profile = OmegaPac.Profiles.byKey(key, @_options)
           profile.lastUpdate = new Date().toISOString()
           if OmegaPac.Profiles.update(profile, data)
-            OmegaPac.Profiles.updateRevision(profile)
+            OmegaPac.Profiles.dropCache(profile)
             changes = {}
             changes[key] = profile
             @_setOptions(changes).return(profile)

+ 197 - 0
omega-target/src/options_sync.coffee

@@ -0,0 +1,197 @@
+### @module omega-target/options_sync ###
+Promise = require 'bluebird'
+Storage = require './storage'
+Log = require './log'
+{Revision} = require 'omega-pac'
+jsondiffpatch = require 'jsondiffpatch'
+TokenBucket = require('limiter').TokenBucket
+
+class OptionsSync
+  @TokenBucket: TokenBucket
+
+  _timeout: null
+  _bucket: null
+  _waiting: false
+  _pending: {}
+
+  ###*
+  # The debounce timeout (ms) for requestPush scheduling. See requestPush.
+  # @type number
+  ###
+  debounce: 1000
+
+  ###*
+  # The throttling timeout (ms) for watchAndPull. See watchAndPull.
+  # @type number
+  ###
+  pullThrottle: 1000
+
+  ###*
+  # The remote storage of syncing.
+  # @type Storage
+  ###
+  storage: null
+
+  constructor: (@storage, @_bucket) ->
+    @_bucket ?= new TokenBucket(10, 10, 'minute', null)
+    @_bucket.clear ?= =>
+      @_bucket.tryRemoveTokens(@_bucket.content)
+
+  ###*
+  # Transform storage values for syncing. The default implementation applies no
+  # transformation, but the behavior can be altered by assigning to this field.
+  # Note: Transformation is applied before merging.
+  # @param {{}} value The value to transform
+  # @param {{}} key The key of the item
+  # @returns {{}} The transformed value
+  ###
+  transformValue: (v) -> v
+
+  ###*
+  # Merge newVal and oldVal of a given key. The default implementation choose
+  # between newVal and oldVal based on the following rules:
+  # 1. Choose oldVal if it has a revision newer than or equal to that of newVal.
+  # 2. Choose oldVal if it deeply equals newVal.
+  # 3. Otherwise, choose newVal.
+  #
+  # @param {string} key The key of the item
+  # @param {} newVal The new value
+  # @param {} oldVal The old value
+  # @returns {} The merged result
+  ###
+  merge: do ->
+    diff = jsondiffpatch.create(
+      objectHash: (obj) -> JSON.stringify(obj)
+      textDiff: minLength: 1 / 0
+    )
+    return (key, newVal, oldVal) ->
+      return oldVal if newVal == oldVal
+      if oldVal?.revision? and newVal?.revision?
+        result = Revision.compare(oldVal.revision, newVal.revision)
+        return oldVal if result >= 0
+      return oldVal unless diff.diff(oldVal, newVal)?
+      return newVal
+
+  ###*
+  # Whether syncing is enabled or not. See requestPush for the effect.
+  # @type boolean
+  ###
+  enabled: true
+
+  ###*
+  # Request pushing the changes to remote storage. The changes are cached first,
+  # and then the actual write operations are scheduled if enabled is true.
+  # The actual operation is delayed and debounced, combining continuous writes
+  # in a short period into a single write operation.
+  # @param {Object.<string, {}>} changes A map from keys to values.
+  ###
+  requestPush: (changes) ->
+    clearTimeout(@_timeout) if @_timeout?
+    for key, value of changes
+      if typeof value != 'undefined'
+        value = @transformValue(value, key)
+        continue if typeof value == 'undefined'
+      @_pending[key] = value
+    return unless @enabled
+    @_timeout = setTimeout(@_doPush.bind(this), @debounce)
+
+  ###*
+  # Returning the pending changes not written to the remote storage.
+  # @returns {Object.<string, {}>} The pending changes.
+  ###
+  pendingChanges: -> @_pending
+
+  _doPush: ->
+    @_timeout = null
+    return if @_waiting
+    @_waiting = true
+    @_bucket.removeTokens 1, =>
+      @storage.get(null).then((base) =>
+        changes = @_pending
+        @_pending = {}
+        @_waiting = false
+        Storage.operationsForChanges(changes, base: base, merge: @merge)
+      ).then ({set, remove}) =>
+        doSet =
+          if Object.keys(set).length == 0
+            Promise.resolve(0)
+          else
+            Log.log 'OptionsSync::set', set
+            @storage.set(set).return(1)
+        doSet.then((cost) =>
+          set = {}
+          if remove.length > 0
+            if @_bucket.tryRemoveTokens(cost)
+              Log.log 'OptionsSync::remove', remove
+              return @storage.remove(remove)
+            else
+              return Promise.reject('bucket')
+        ).catch (e) =>
+          # Re-submit the changes for syncing, but with lower priority.
+          for key, value of set
+            if not (key of @_pending)
+              @_pending[key] = value
+          for key in remove
+            if not (key of @_pending)
+              @_pending[key] = undefined
+
+          if e == 'bucket'
+            @_doPush()
+          else if e instanceof Storage.RateLimitExceededError
+            Log.log 'OptionsSync::rateLimitExceeded'
+            # Try to clear the @_bucket to wait more time before retrying.
+            @_bucket.clear()
+            @requestPush({})
+            return
+          else if e instanceof Storage.QuotaExceededError
+            # TODO(catus): Remove profiles that are too large and retry.
+            @_pending = {}
+          else
+            Promise.reject(e)
+
+  _logOperations: (text, operations) ->
+    if Object.keys(operations.set).length
+      Log.log(text + '::set', operations.set)
+    if operations.remove.length
+      Log.log(text + '::remove', operations.remove)
+
+  ###*
+  # Pull the remote storage for changes, and write them to local.
+  # @param {Storage} local The local storage to be written to
+  # @returns {function} Calling the returned function will stop watching.
+  ###
+  copyTo: (local) ->
+    Promise.join local.get(null), @storage.get(null), (base, changes) =>
+      local.apply(
+        changes: changes
+        base: base
+        merge: @merge
+      ).then (operations) =>
+        @_logOperations('OptionsSync::copyTo', operations)
+
+  ###*
+  # Watch the remote storage for changes, and write them to local.
+  # The actual writing is throttled by pullThrottle with initial delay.
+  # @param {Storage} local The local storage to be written to
+  # @returns {function} Calling the returned function will stop watching.
+  ###
+  watchAndPull: (local) ->
+    pullScheduled = null
+    pull = {}
+    doPull = =>
+      local.get(null).then((base) =>
+        changes = pull
+        pull = {}
+        pullScheduled = null
+        Storage.operationsForChanges(changes, base: base, merge: @merge)
+      ).then (operations) =>
+        @_logOperations('OptionsSync::pull', operations)
+        local.apply(operations)
+
+    @storage.watch null, (changes) =>
+      for key, value of changes
+        pull[key] = value
+      return if pullScheduled?
+      pullScheduled = setTimeout(doPull, @pullThrottle)
+
+module.exports = OptionsSync

+ 89 - 4
omega-target/src/storage.coffee

@@ -3,6 +3,65 @@ Promise = require 'bluebird'
 Log = require './log'
 
 class Storage
+  ###*
+  # Any operation that fails due to rate limiting should reject with an instance
+  # of RateLimitExceededError, when implemented in derived classes of Storage.
+  ###
+  @RateLimitExceededError:
+    class RateLimitExceededError extends Error
+      constructor: -> super
+
+  ###*
+  # Any operation that fails due to storage quota should reject with an instance
+  # of QuotaExceededError, when implemented in derived classes of Storage.
+  ###
+  @QuotaExceededError:
+    class QuotaExceededError extends Error
+      constructor: -> super
+
+  ###*
+  # A set of operations to be performed on a Storage.
+  # @typedef WriteOperations
+  # @type {object}
+  # @property {Object.<string, {}>} set - A map from keys to new values of the
+  # items to set
+  # @property {{}[]} remove - An array of keys to remove
+  ###
+
+  ###*
+  # Calculate the actual operations against storage that should be performed to
+  # replay the changes on a storage.
+  # @param {Object.<string, {}>} changes The changes to apply
+  # @param {?{}} args Extra arguments
+  # @param {Object.<string, {}>?} args.base The original items in the storage.
+  # @param {function(key, newVal, oldVal)} args.merge A function that merges
+  # the newVal and oldVal. oldVal is only provided if args.base is present.
+  # @returns {WriteOperations} The operations that should be performed.
+  ###
+  @operationsForChanges: (changes, {base, merge} = {}) ->
+    set = {}
+    remove = []
+    for key, newVal of changes
+      if not base?
+        newVal = if merge then merge(key, newVal) else newVal
+        if typeof newVal == 'undefined'
+          remove.push(key)
+        else
+          set[key] = newVal
+      else
+        oldVal = base[key]
+        if typeof newVal == 'undefined'
+          if typeof oldVal != 'undefined'
+            remove.push(key)
+        else if newVal != oldVal
+          if merge
+            newVal = merge(key, newVal, oldVal)
+            if newVal != oldVal
+              set[key] = newVal
+          else
+            set[key] = newVal
+    return {set: set, remove: remove}
+
   ###*
   # Get the requested values by keys from the storage.
   # @param {(string|string[]|null|Object.<string,{}>)} keys The keys to retrive,
@@ -11,16 +70,18 @@ class Storage
   ###
   get: (keys) ->
     Log.method('Storage#get', this, arguments)
+    return Promise.resolve({}) unless @_items
     if not keys?
-      keys = ['a', 'b', 'c']
+      keys = @_items
     map = {}
     if typeof keys == 'string'
-      map[keys] = 42
+      map[keys] = @_items[keys]
     else if Array.isArray(keys)
       for key in keys
-        map[key] = 42
+        map[key] = @_items[key]
     else if typeof keys == 'object'
-      map = keys
+      for key, value of keys
+        map[key] = @_items[key] ? value
     Promise.resolve(map)
 
   ###*
@@ -30,6 +91,9 @@ class Storage
   ###
   set: (items) ->
     Log.method('Storage#set', this, arguments)
+    @_items ?= {}
+    for key, value of items
+      @_items[key] = value
     Promise.resolve(items)
   
   ###*
@@ -39,6 +103,14 @@ class Storage
   ###
   remove: (keys) ->
     Log.method('Storage#remove', this, arguments)
+    if @_items?
+      if not keys?
+        @_items = {}
+      else if Array.isArray(keys)
+        for key in keys
+          delete @_items[key]
+      else
+        delete @_items[keys]
     Promise.resolve()
   
   ###*
@@ -55,5 +127,18 @@ class Storage
   watch: (keys, callback) ->
     Log.method('Storage#watch', this, arguments)
     return (-> null)
+  
+  ###*
+  # Apply WriteOperations to the storage.
+  # @param {WriteOperations|{changes: Object.<string,{}>}} operations The
+  # operations to apply, or the changes to be applied. If changes is provided,
+  # the operations are calculated by Storage.operationsForChanges, with extra
+  # fields passed through as the second argument.
+  # @returns {Promise} A promise that fulfills on operation success.
+  ###
+  apply: (operations) ->
+    if 'changes' of operations
+      operations = Storage.operationsForChanges(operations.changes, operations)
+    @set(operations.set).then(=> @remove(operations.remove)).return(operations)
 
 module.exports = Storage

+ 0 - 193
omega-target/test/conditions.coffee

@@ -1,193 +0,0 @@
-chai = require 'chai'
-should = chai.should()
-
-describe 'Conditions', ->
-  Conditions = require '../src/conditions'
-  url = require 'url'
-
-  requestFromUri = (uri) ->
-    if typeof uri == 'string'
-      uri = url.parse uri
-    req =
-      url: url.format(uri)
-      host: uri.host
-      scheme: uri.protocol.replace(':', '')
-
-  U2 = require 'uglify-js'
-  testCond = (condition, request, should_match) ->
-    o_request = request
-    should_match = !!should_match
-    if typeof request == 'string'
-      request = requestFromUri(request)
-
-    matchResult = Conditions.match(condition, request)
-    condExpr = Conditions.compile(condition, request)
-    testFunc = new U2.AST_Function(
-      argnames: [
-        new U2.AST_SymbolFunarg name: 'url'
-        new U2.AST_SymbolFunarg name: 'host'
-        new U2.AST_SymbolFunarg name: 'scheme'
-      ]
-      body: [
-        new U2.AST_Return value: condExpr
-      ]
-    )
-    testFunc = eval '(' + testFunc.print_to_string() + ')'
-    compileResult = testFunc(request.url, request.host, request.scheme)
-
-    friendlyError = (compiled) ->
-      # Try to give friendly assert messages instead of something like
-      # "expect true to be false".
-      printCond = JSON.stringify(condition)
-      printCompiled = if compiled then 'COMPILED ' else ''
-      printMatch = if should_match then 'to match' else 'not to match'
-      msg = ("expect #{printCompiled}condition #{printCond} " +
-             "#{printMatch} request #{o_request}")
-      chai.assert(false, msg)
-
-    if matchResult != should_match
-      friendlyError()
-
-    if compileResult != should_match
-      friendlyError('compiled')
-
-    return matchResult
-
-  describe 'TrueCondition', ->
-    it 'should always return true', ->
-      testCond({conditionType: 'TrueCondition'}, {}, 'match')
-  describe 'FalseCondition', ->
-    it 'should always return false', ->
-      testCond({conditionType: 'FalseCondition'}, {}, not 'match')
-  describe 'UrlRegexCondition', ->
-    cond =
-      conditionType: 'UrlRegexCondition'
-      pattern: 'example\\.com'
-    it 'should match requests based on regex pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not match requests not matching the pattern', ->
-      testCond(cond, 'http://www.example.net/', not 'match')
-    it 'should support regex meta chars', ->
-      con =
-        conditionType: 'UrlRegexCondition'
-        pattern: 'exam.*\\.com'
-      testCond(cond, 'http://www.example.com/', 'match')
-  describe 'UrlWildcardCondition', ->
-    cond =
-      conditionType: 'UrlWildcardCondition'
-      pattern: '*example.com*'
-    it 'should match requests based on wildcard pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not match requests not matching the pattern', ->
-      testCond(cond, 'http://www.example.net/', not 'match')
-    it 'should support wildcard question marks', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '*exam???.com*'
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not support regex meta chars', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '.*example.com.*'
-      testCond(cond, 'http://example.com/', not 'match')
-    it 'should support multiple patterns in one condition', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '*.example.com/*|*.example.net/*'
-      testCond(cond, 'http://a.example.com/abc', 'match')
-      testCond(cond, 'http://b.example.net/def', 'match')
-      testCond(cond, 'http://c.example.org/ghi', not 'match')
-  describe 'HostRegexCondition', ->
-    cond =
-      conditionType: 'HostRegexCondition'
-      pattern: '.*\\.example\\.com'
-    it 'should match requests based on regex pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not match requests not matching the pattern', ->
-      testCond(cond, 'http://example.com/', not 'match')
-    it 'should not match URL parts other than the host', ->
-      testCond(cond, 'http://example.net/www.example.com')
-        .should.be.false
-
-  describe 'HostWildcardCondition', ->
-    cond =
-      conditionType: 'HostWildcardCondition'
-      pattern: '*.example.com'
-    it 'should match requests based on wildcard pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should also match hostname without the optional level', ->
-      # https://github.com/FelisCatus/SwitchyOmega/wiki/Host-wildcard-condition
-      testCond(cond, 'http://example.com/', 'match')
-    it 'should allow override of the magical behavior', ->
-      con =
-        conditionType: 'HostWildcardCondition'
-        pattern: '**.example.com'
-      testCond(con, 'http://www.example.com/', 'match')
-      testCond(con, 'http://example.com/', not 'match')
-    it 'should not match URL parts other than the host', ->
-      testCond(cond, 'http://example.net/www.example.com')
-        .should.be.false
-    it 'should support multiple patterns in one condition', ->
-      cond =
-        conditionType: 'HostWildcardCondition'
-        pattern: '*.example.com|*.example.net'
-      testCond(cond, 'http://a.example.com/abc', 'match')
-      testCond(cond, 'http://example.net/def', 'match')
-      testCond(cond, 'http://c.example.org/ghi', not 'match')
-
-  describe 'BypassCondition', ->
-    # See https://developer.chrome.com/extensions/proxy#bypass_list
-    it 'should correctly support patterns containing hosts', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: '.example.com'
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://example.com/', not 'match')
-      cond.pattern = '*.example.com'
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://example.com/', not 'match')
-      cond.pattern = 'example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'http://www.example.com/', not 'match')
-      cond.pattern = '*example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://anotherexample.com/', 'match')
-    it 'should match the scheme specified in the pattern', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'https://example.com/', not 'match')
-    it 'should match the port specified in the pattern', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://example.com:8080'
-      testCond(cond, 'http://example.com:8080/', 'match')
-      testCond(cond, 'http://example.com:888/', not 'match')
-    it 'should correctly support patterns using IPv4 literals', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://127.0.0.1:8080'
-      testCond(cond, 'http://127.0.0.1:8080/', 'match')
-      testCond(cond, 'http://127.0.0.2:8080/', not 'match')
-    # TODO(felis): Not yet supported. See the code for BypassCondition.
-    it.skip 'should correctly support IPv6 canonicalization', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://[0:0::1]:8080'
-      Conditions.analyze(cond)
-      cond._analyzed().url.should.equal '999'
-      testCond(cond, 'http://[::1]:8080/', 'match')
-      testCond(cond, 'http://[1::1]:8080/', not 'match')
-
-  describe 'KeywordCondition', ->
-    cond =
-      conditionType: 'KeywordCondition'
-      pattern: 'example.com'
-    it 'should match requests based on substring', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://www.example.net/', not 'match')
-    it 'should not match HTTPS requests', ->
-      testCond(cond, 'https://example.com/', not 'match')
-      testCond(cond, 'https://example.net/', not 'match')

+ 174 - 0
omega-target/test/options_sync.coffee

@@ -0,0 +1,174 @@
+chai = require 'chai'
+should = chai.should()
+sinon = require 'sinon'
+chai.use require('sinon-chai')
+
+describe 'OptionsSync', ->
+  OptionsSync = require '../src/options_sync'
+  Storage = require '../src/storage'
+  Log = require '../src/log'
+
+  before ->
+    # Silence storage and sync logging.
+    sinon.stub(Log, 'log')
+
+  after ->
+    Log.log.restore()
+
+  # coffeelint: disable=missing_fat_arrows
+  hookPostBasic = (func, hook) -> ->
+    result = func.apply(this, arguments)
+    hook.apply(this, arguments)
+    return result
+  # coffeelint: enable=missing_fat_arrows
+
+  hookPost = (args...) ->
+    if args.length == 2
+      [func, hook] = args
+      hostPostBasic(func, hook)
+    else
+      [obj, method, hook] = args
+      obj[method] = hookPostBasic(obj[method], hook)
+
+  describe '#merge', ->
+    sync = new OptionsSync()
+    it 'should choose the one with newer revision', ->
+      newVal = {revision: '2'}
+      oldVal = {revision: '1'}
+      sync.merge('example', newVal, oldVal).should.equal(newVal)
+    it 'should favor oldVal when revisions are equal', ->
+      newVal = {revision: '1', is: 'newVal'}
+      oldVal = {revision: '1', is: 'oldVal'}
+      sync.merge('example', newVal, oldVal).should.equal(oldVal)
+    it 'should favor oldVal when newVal deeply equals oldVal', ->
+      newVal = {they: 'are', the: 'same'}
+      oldVal = {they: 'are', the: 'same'}
+      sync.merge('example', newVal, oldVal).should.equal(oldVal)
+    it 'should choose newVal when newVal is different', ->
+      newVal = {they: 'are', not: 'equal'}
+      oldVal = {they: 'are', not: 'identical'}
+      sync.merge('example', newVal, oldVal).should.equal(newVal)
+
+  describe '#requestPush', ->
+    unlimited = new OptionsSync.TokenBucket()
+
+    it 'should store pendingChanges', ->
+      sync = new OptionsSync()
+      sync.enabled = false
+      sync.requestPush({a: 1})
+      sync.pendingChanges().should.eql({a: 1})
+    it 'should schedule storage write', (done) ->
+      check = ->
+        return if storage.set.callCount == 0 or storage.remove.callCount == 0
+        storage.set.should.have.been.calledOnce.and.calledWith({b: 1})
+        storage.remove.should.have.been.calledOnce.and.calledWith(['a'])
+        done()
+
+      storage = new Storage()
+      storage.set({a: 1})
+      hookPost storage, 'set', check
+      hookPost storage, 'remove', check
+
+      sinon.spy(storage, 'set')
+      sinon.spy(storage, 'remove')
+
+      sync = new OptionsSync(storage, unlimited)
+      sync.debounce = 0
+      sync.requestPush({a: undefined, b: 1})
+
+    it 'should combine multiple write operations', (done) ->
+      check = ->
+        return if storage.set.callCount == 0 or storage.remove.callCount == 0
+        storage.set.should.have.been.calledOnce.and.calledWith({c: 1, d: 1})
+        storage.remove.should.have.been.calledOnce.and.calledWith(['a', 'b'])
+        done()
+
+      storage = new Storage()
+      storage.set({a: 1, b: 1})
+      hookPost storage, 'set', check
+      hookPost storage, 'remove', check
+
+      sinon.spy(storage, 'set')
+      sinon.spy(storage, 'remove')
+
+      sync = new OptionsSync(storage, unlimited)
+      sync.debounce = 0
+      sync.requestPush({a: undefined})
+      sync.requestPush({b: 2})
+      sync.requestPush({b: undefined})
+      sync.requestPush({c: 1})
+      sync.requestPush({d: 1})
+      sync.requestPush({e: 1})
+      sync.requestPush({e: undefined})
+
+  describe '#copyTo', ->
+    it 'should fetch all items from remote storage', (done) ->
+      remote = new Storage()
+      remote.set({a: 1, b: 2, c: 3})
+
+      storage = new Storage()
+      hookPost storage, 'set', ->
+        storage.set.should.have.been.calledOnce.and.calledWith(
+          {a: 1, b: 2, c: 3}
+        )
+        done()
+
+      sinon.spy(storage, 'set')
+
+      sync = new OptionsSync(remote)
+      sync.copyTo(storage)
+
+    it 'should merge with local as base', (done) ->
+      check = ->
+        return if storage.set.callCount == 0 or storage.remove.callCount == 0
+        storage.set.should.have.been.calledOnce.and.calledWith({b: 2, c: 3})
+        storage.remove.should.have.been.calledOnce.and.calledWith(['d'])
+        done()
+
+      remote = new Storage()
+      remote.set({a: 1, b: 2, c: 3, d: undefined})
+
+      storage = new Storage()
+      storage.set({a: 1, b: 0, d: 4})
+
+      hookPost storage, 'set', check
+      hookPost storage, 'remove', check
+
+      sinon.spy(storage, 'set')
+      sinon.spy(storage, 'remove')
+
+      sync = new OptionsSync(remote)
+      sync.copyTo(storage)
+
+  describe '#watchAndPull', ->
+    it 'should pull changes into local when remote changes', (done) ->
+      check = ->
+        return if storage.set.callCount == 0 or storage.remove.callCount == 0
+        remote.watch.should.have.been.calledOnce
+        storage.set.should.have.been.calledOnce.and.calledWith({b: 2, c: 3})
+        storage.remove.should.have.been.calledOnce.and.calledWith(['d'])
+        done()
+
+      remote = new Storage()
+      hookPost remote, 'watch', (_, callback) ->
+        setTimeout (->
+          callback({a: 1})
+          callback({b: 2})
+          callback({c: 3})
+          callback({d: undefined})
+        ), 10
+
+      sinon.spy(remote, 'watch')
+
+      storage = new Storage()
+      storage.set({a: 1, b: 0, d: 4})
+
+      hookPost storage, 'set', check
+      hookPost storage, 'remove', check
+
+      sinon.spy(storage, 'set')
+      sinon.spy(storage, 'remove')
+
+      sync = new OptionsSync(remote)
+      sync.pullThrottle = 0
+      sync.watchAndPull(storage)

+ 0 - 56
omega-target/test/pac_generator.coffee

@@ -1,56 +0,0 @@
-chai = require 'chai'
-should = chai.should()
-
-describe 'PacGenerator', ->
-  PacGenerator = require '../src/pac_generator.coffee'
-
-  options =
-    '+auto':
-      name: 'auto'
-      profileType: 'SwitchProfile'
-      revision: 'test'
-      defaultProfileName: 'direct'
-      rules: [
-        {profileName: 'proxy', condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://(www|www2)\\.example\\.com/'
-        }
-        {profileName: 'direct', condition:
-          conditionType: 'HostLevelsCondition'
-          minValue: 3
-          maxValue: 8
-        }
-        {
-          profileName: 'proxy'
-          condition: {conditionType: 'KeywordCondition', pattern: 'keyword'}
-        }
-        {profileName: 'proxy', condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'https://ssl.example.com/*'
-        }
-      ]
-    '+proxy':
-      name: 'proxy'
-      profileType: 'FixedProfile'
-      revision: 'test'
-      fallbackProxy: {scheme: 'http', host: '127.0.0.1', port: 8888}
-      bypassList: [
-        {conditionType: 'BypassCondition', pattern: '127.0.0.1:8080'}
-        {conditionType: 'BypassCondition', pattern: '127.0.0.1'}
-        {conditionType: 'BypassCondition', pattern: '<local>'}
-      ]
-
-  it 'should generate pac scripts from options', ->
-    ast = PacGenerator.script(options, 'auto')
-    pac = ast.print_to_string(beautify: true, comments: true)
-    pac.should.not.be.empty
-    func = eval("(function () { #{pac}\n return FindProxyForURL; })()")
-    result = func('http://www.example.com/', 'www.example.com')
-    result.should.equal('PROXY 127.0.0.1:8888')
-  it 'should be able to compress pac scripts', ->
-    ast = PacGenerator.script(options, 'auto')
-    pac = PacGenerator.compress(ast).print_to_string()
-    pac.should.not.be.empty
-    func = eval("(function () { #{pac}\n return FindProxyForURL; })()")
-    result = func('http://www.example.com/', 'www.example.com')
-    result.should.equal('PROXY 127.0.0.1:8888')

+ 0 - 198
omega-target/test/profiles.coffee

@@ -1,198 +0,0 @@
-chai = require 'chai'
-should = chai.should()
-
-describe 'Profiles', ->
-  Profiles = require '../src/profiles'
-  url = require 'url'
-
-  requestFromUri = (uri) ->
-    if typeof uri == 'string'
-      uri = url.parse uri
-    req =
-      url: url.format(uri)
-      host: uri.host
-      scheme: uri.protocol.replace(':', '')
-
-  U2 = require 'uglify-js'
-  testProfile = (profile, request, expected) ->
-    o_request = request
-    if typeof request == 'string'
-      request = requestFromUri(request)
-
-    matchResult = Profiles.match(profile, request)
-    compiled = Profiles.compile(profile, request)
-    compileResult = eval '(' + compiled.print_to_string() + ')'
-    if typeof compileResult == 'function'
-      compileResult = compileResult(request.url, request.host, request.scheme)
-
-    friendlyError = (compiled) ->
-      # Try to give friendly assert messages.
-      printProfile = JSON.stringify(printProfile)
-      printCompiled = if compiled then 'COMPILED ' else ''
-      printMatch = if should_match then 'to match' else 'not to match'
-      msg = ("expect #{printCompiled} #{printProfile} #{printMatch} " +
-              "request #{o_request}")
-      chai.assert(false, msg)
-
-    if expected[0] == '+' and matchResult != expected
-      friendlyError()
-
-    if compileResult != expected #TODO
-      friendlyError('compiled')
-
-    return matchResult
-
-  describe '#pacResult', ->
-    it 'should return DIRECT for no proxy', ->
-      Profiles.pacResult().should.equal("DIRECT")
-    it 'should return a valid PAC result for a proxy', ->
-      proxy = {scheme: "http", host: "127.0.0.1", port: 8888}
-      Profiles.pacResult(proxy).should.equal("PROXY 127.0.0.1:8888")
-  describe '#byName', ->
-    it 'should get profiles from builtin profiles', ->
-      profile = Profiles.byName('direct')
-      profile.should.be.an('object')
-      profile.profileType.should.equal('DirectProfile')
-    it 'should get profiles from given options', ->
-      profile = {}
-      profile = Profiles.byName('profile', {"+profile": profile})
-      profile.should.equal(profile)
-  describe 'SystemProfile', ->
-    it 'should be builtin with the name "system"', ->
-      profile = Profiles.byName('system')
-      profile.should.be.an('object')
-      profile.profileType.should.equal('SystemProfile')
-    it 'should not match request to profiles', ->
-      profile = Profiles.byName('system')
-      should.not.exist Profiles.match(profile, {})
-    it 'should throw when trying to compile', ->
-      profile = Profiles.byName('system')
-      should.throw(-> Profiles.compile(profile))
-  describe 'DirectProfile', ->
-    it 'should be builtin with the name "direct"', ->
-      profile = Profiles.byName('direct')
-      profile.should.be.an('object')
-      profile.profileType.should.equal('DirectProfile')
-    it 'should return "DIRECT" when compiled', ->
-      profile = Profiles.byName('direct')
-      testProfile(profile, {}, 'DIRECT')
-  return
-  describe 'UrlWildcardCondition', ->
-    cond =
-      conditionType: 'UrlWildcardCondition'
-      pattern: '*example.com*'
-    it 'should match requests based on wildcard pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not match requests not matching the pattern', ->
-      testCond(cond, 'http://www.example.net/', not 'match')
-    it 'should support wildcard question marks', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '*exam???.com*'
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not support regex meta chars', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '.*example.com.*'
-      testCond(cond, 'http://example.com/', not 'match')
-    it 'should support multiple patterns in one condition', ->
-      cond =
-        conditionType: 'UrlWildcardCondition'
-        pattern: '*.example.com/*|*.example.net/*'
-      testCond(cond, 'http://a.example.com/abc', 'match')
-      testCond(cond, 'http://b.example.net/def', 'match')
-      testCond(cond, 'http://c.example.org/ghi', not 'match')
-  describe 'HostRegexCondition', ->
-    cond =
-      conditionType: 'HostRegexCondition'
-      pattern: '.*\\.example\\.com'
-    it 'should match requests based on regex pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should not match requests not matching the pattern', ->
-      testCond(cond, 'http://example.com/', not 'match')
-    it 'should not match URL parts other than the host', ->
-      testCond(cond, 'http://example.net/www.example.com')
-        .should.be.false
-
-  describe 'HostWildcardCondition', ->
-    cond =
-      conditionType: 'HostWildcardCondition'
-      pattern: '*.example.com'
-    it 'should match requests based on wildcard pattern', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-    it 'should also match hostname without the optional level', ->
-      # https://github.com/FelisCatus/SwitchyOmega/wiki/Host-wildcard-condition
-      testCond(cond, 'http://example.com/', 'match')
-    it 'should allow override of the magical behavior', ->
-      con =
-        conditionType: 'HostWildcardCondition'
-        pattern: '**.example.com'
-      testCond(con, 'http://www.example.com/', 'match')
-      testCond(con, 'http://example.com/', not 'match')
-    it 'should not match URL parts other than the host', ->
-      testCond(cond, 'http://example.net/www.example.com')
-        .should.be.false
-    it 'should support multiple patterns in one condition', ->
-      cond =
-        conditionType: 'HostWildcardCondition'
-        pattern: '*.example.com|*.example.net'
-      testCond(cond, 'http://a.example.com/abc', 'match')
-      testCond(cond, 'http://example.net/def', 'match')
-      testCond(cond, 'http://c.example.org/ghi', not 'match')
-
-  describe 'BypassCondition', ->
-    # See https://developer.chrome.com/extensions/proxy#bypass_list
-    it 'should correctly support patterns containing hosts', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: '.example.com'
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://example.com/', not 'match')
-      cond.pattern = '*.example.com'
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://example.com/', not 'match')
-      cond.pattern = 'example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'http://www.example.com/', not 'match')
-      cond.pattern = '*example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://anotherexample.com/', 'match')
-    it 'should match the scheme specified in the pattern', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://example.com'
-      testCond(cond, 'http://example.com/', 'match')
-      testCond(cond, 'https://example.com/', not 'match')
-    it 'should match the port specified in the pattern', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://example.com:8080'
-      testCond(cond, 'http://example.com:8080/', 'match')
-      testCond(cond, 'http://example.com:888/', not 'match')
-    it 'should correctly support patterns using IPv4 literals', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://127.0.0.1:8080'
-      testCond(cond, 'http://127.0.0.1:8080/', 'match')
-      testCond(cond, 'http://127.0.0.2:8080/', not 'match')
-    # TODO(felis): Not yet supported. See the code for BypassCondition.
-    it.skip 'should correctly support IPv6 canonicalization', ->
-      cond =
-        conditionType: 'BypassCondition'
-        pattern: 'http://[0:0::1]:8080'
-      Conditions.analyze(cond)
-      cond._analyzed().url.should.equal '999'
-      testCond(cond, 'http://[::1]:8080/', 'match')
-      testCond(cond, 'http://[1::1]:8080/', not 'match')
-
-  describe 'KeywordCondition', ->
-    cond =
-      conditionType: 'KeywordCondition'
-      pattern: 'example.com'
-    it 'should match requests based on substring', ->
-      testCond(cond, 'http://www.example.com/', 'match')
-      testCond(cond, 'http://www.example.net/', not 'match')
-    it 'should not match HTTPS requests', ->
-      testCond(cond, 'https://example.com/', not 'match')
-      testCond(cond, 'https://example.net/', not 'match')

+ 0 - 211
omega-target/test/rule_list.coffee

@@ -1,211 +0,0 @@
-chai = require 'chai'
-should = chai.should()
-
-describe 'RuleList', ->
-  RuleList = require '../src/rule_list'
-  describe 'AutoProxy', ->
-    parse = RuleList['AutoProxy']
-    it 'should parse keyword conditions', ->
-      result = parse('example.com', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'KeywordCondition'
-          pattern: 'example.com'
-      )
-    it 'should parse keyword conditions with asterisks', ->
-      result = parse('example*.com', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'http://*example*.com*'
-      )
-    it 'should parse host conditions', ->
-      result = parse('||example.com', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'HostWildcardCondition'
-          pattern: '*.example.com'
-      )
-    it 'should parse "starts-with" conditions', ->
-      result = parse('|https://ssl.example.com', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'https://ssl.example.com*'
-      )
-    it 'should parse "starts-with" conditions for the HTTP scheme', ->
-      result = parse('|http://example.com', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'http://example.com*'
-      )
-    it 'should parse url regex conditions', ->
-      result = parse('/^https?:\\/\\/[^\\/]+example\.com/', 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^https?:\\/\\/[^\\/]+example\.com'
-      )
-    it 'should ignore comment lines', ->
-      result = parse('!example.com', 'match', 'notmatch')
-      result.should.have.length(0)
-    it 'should parse multiple lines', ->
-      result = parse 'example.com\n!comment\n||example.com', 'match', 'notmatch'
-      result.should.have.length(2)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'KeywordCondition'
-          pattern: 'example.com'
-      )
-      result[1].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'HostWildcardCondition'
-          pattern: '*.example.com'
-      )
-    it 'should put exclusive rules first', ->
-      result = parse 'example.com\n@@||example.com', 'match', 'notmatch'
-      result.should.have.length(2)
-      result[0].should.eql(
-        profileName: 'notmatch'
-        condition:
-          conditionType: 'HostWildcardCondition'
-          pattern: '*.example.com'
-      )
-      result[1].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'KeywordCondition'
-          pattern: 'example.com'
-      )
-
-  describe 'Switchy', ->
-    parse = RuleList['Switchy']
-    compose = (sections) ->
-      list = '#BEGIN\r\n\r\n'
-      for sec, rules of sections
-        list += "[#{sec}]\r\n"
-        for rule in rules
-          list += rule
-          list += '\r\n'
-      list += '\r\n\r\n#END\r\n'
-    it 'should parse empty rule lists', ->
-      list = compose {}
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(0)
-    it 'should ignore stuff before #BEGIN or after #END.', ->
-      list = compose {}
-      list += '[RegExp]\r\ntest\r\n'
-      list = '[Wildcard]\r\ntest\r\n' + list
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(0)
-    it 'should parse wildcard rules', ->
-      list = compose 'Wildcard': [
-        '*://example.com/*'
-      ]
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: '*://example.com/*'
-      )
-    it 'should parse RegExp rules', ->
-      list = compose 'RegExp': [
-        '^http://www\.example\.com/.*'
-      ]
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://www\.example\.com/.*'
-      )
-    it 'should parse exclusive rules', ->
-      list = compose 'RegExp': [
-        '!^http://www\.example\.com/.*'
-      ]
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(1)
-      result[0].should.eql(
-        profileName: 'notmatch'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://www\.example\.com/.*'
-      )
-    it 'should parse multiple rules in multiple sections', ->
-      list = compose {
-        'Wildcard': [
-          'http://www\.example\.com/*'
-          'http://example\.com/*'
-        ]
-        'RegExp': [
-          '^http://www\.example\.com/.*'
-          '^http://example\.com/.*'
-        ]
-      }
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(4)
-      result[0].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'http://www.example.com/*'
-      )
-      result[1].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'http://example.com/*'
-      )
-      result[2].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://www\.example\.com/.*'
-      )
-      result[3].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://example\.com/.*'
-      )
-    it 'should put exclusive rules first', ->
-      list = compose {
-        'Wildcard': [
-          'http://www\.example\.com/*'
-        ]
-        'RegExp': [
-          '!^http://www\.example\.com/.*'
-        ]
-      }
-      result = parse(list, 'match', 'notmatch')
-      result.should.have.length(2)
-      result[0].should.eql(
-        profileName: 'notmatch'
-        condition:
-          conditionType: 'UrlRegexCondition'
-          pattern: '^http://www.example\.com/.*'
-      )
-      result[1].should.eql(
-        profileName: 'match'
-        condition:
-          conditionType: 'UrlWildcardCondition'
-          pattern: 'http://www.example.com/*'
-      )

+ 0 - 15
omega-target/test/shexp_utils.coffee

@@ -1,15 +0,0 @@
-chai = require 'chai'
-should = chai.should()
-
-describe 'ShexpUtils', ->
-  ShexpUtils = require '../src/shexp_utils'
-  describe '#escapeSlash', ->
-    it 'should escape all forward slashes', ->
-      regex = ShexpUtils.escapeSlash '/test/'
-      regex.should.equal '\\/test\\/'
-    it 'should not escape slashes that are already escaped', ->
-      regex = ShexpUtils.escapeSlash '\\/test\\/'
-      regex.should.equal '\\/test\\/'
-    it 'should know the difference between escaped and unescaped slashes', ->
-      regex = ShexpUtils.escapeSlash '\\\\/\\/test\\/'
-      regex.should.equal '\\\\\\/\\/test\\/'