├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.d.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── alias │ └── alias.js ├── constants │ └── index.js ├── index.js ├── listener.js ├── serialization.js ├── store │ ├── Store.js │ └── applyMiddleware.js ├── strategies │ ├── constants.js │ ├── deepDiff │ │ ├── arrayDiff │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── diff │ │ │ │ ├── apply.js │ │ │ │ ├── diff.js │ │ │ │ ├── lcs.js │ │ │ │ ├── patch.js │ │ │ │ └── same.js │ │ │ └── index.js │ │ ├── diff.js │ │ ├── makeDiff.js │ │ └── patch.js │ └── shallowDiff │ │ ├── diff.js │ │ └── patch.js ├── util.js └── wrap-store │ └── wrapStore.js └── test ├── .eslintrc ├── Store.test.js ├── alias.test.js ├── applyMiddleware.test.js ├── arrayDiff ├── apply.test.js ├── diff.test.js ├── index.test.js ├── patch.test.js └── same.test.js ├── deepDiff.test.js ├── listener.test.js ├── serialization.test.js ├── shallowDiff.test.js ├── util.test.js └── wrapStore.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-async-to-generator"], 3 | "presets": ["@babel/preset-env"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended"], 3 | "env": { 4 | "es6": true, 5 | "browser": true, 6 | "commonjs": true, 7 | "jquery": true 8 | }, 9 | "globals": { 10 | "chrome": true 11 | }, 12 | "parser": "babel-eslint", 13 | "rules": { 14 | "arrow-spacing": "error", 15 | "block-spacing": "error", 16 | "curly": "error", 17 | "default-case": "error", 18 | "indent": [2, 2, {"SwitchCase": 1, "VariableDeclarator": {"var": 2, "let": 2, "const": 3}}], 19 | "newline-after-var": ["error", "always"], 20 | "no-console": 0, 21 | "no-debugger": "error", 22 | "no-else-return": "error", 23 | "no-extra-bind": "error", 24 | "no-implicit-coercion": "error", 25 | "no-multi-spaces": ["error", { "exceptions": { "VariableDeclarator": true, "AssignmentExpression": true } }], 26 | "no-template-curly-in-string": "error", 27 | "no-trailing-spaces": "warn", 28 | "no-undef-init": "error", 29 | "no-unused-vars": "error", 30 | "no-var": 1, 31 | "object-shorthand": "error", 32 | "prefer-arrow-callback": "error", 33 | "prefer-const": "error", 34 | "prefer-reflect": "error", 35 | "require-await": "error", 36 | "semi": "error", 37 | "space-before-function-paren": ["error", { 38 | "anonymous": "always", 39 | "named": "never", 40 | "asyncArrow": "ignore" 41 | }], 42 | "spaced-comment": 1, 43 | "strict": "error" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm i 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # text editor artifacts 7 | *.swp 8 | *.DS_Store 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directory 31 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 32 | node_modules 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | lib 41 | /dist/ 42 | 43 | .idea 44 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .idea/ 3 | test/ 4 | .babelrc 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tyler Shaddix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebExt Redux 2 | A set of utilities for building Redux applications in web extensions. This package was originally named `react-chrome-redux`. 3 | 4 | [![NPM Version][npm-image]][npm-url] 5 | [![NPM Downloads][downloads-image]][downloads-url] 6 | 7 | ## Installation 8 | 9 | This package is available on [npm](https://www.npmjs.com/package/webext-redux): 10 | 11 | ``` 12 | npm install webext-redux 13 | ``` 14 | 15 | ## Overview 16 | 17 | `webext-redux` allows you to build your Web Extension like a Redux-powered webapp. The background page holds the Redux store, while Popovers and Content-Scripts act as UI Components, passing actions and state updates between themselves and the background store. At the end of the day, you have a single source of truth (your Redux store) that describes the entire state of your extension. 18 | 19 | All UI Components follow the same basic flow: 20 | 21 | 1. UI Component dispatches action to a Proxy Store. 22 | 2. Proxy Store passes action to background script. 23 | 3. Redux Store on the background script updates its state and sends it back to UI Component. 24 | 4. UI Component is updated with updated state. 25 | 26 | ![Architecture](https://cloud.githubusercontent.com/assets/603426/18599404/329ca9ca-7c0d-11e6-9a02-5718a0fba8db.png) 27 | 28 | ## Basic Usage ([full docs here](https://github.com/tshaddix/webext-redux/wiki)) 29 | 30 | As described in the [introduction](https://github.com/tshaddix/webext-redux/wiki/Introduction#webext-redux), there are two pieces to a basic implementation of this package. 31 | 32 | ### 1. Add the *Proxy Store* to a UI Component, such as a popup 33 | 34 | ```js 35 | // popover.js 36 | 37 | import React from 'react'; 38 | import {render} from 'react-dom'; 39 | import {Provider} from 'react-redux'; 40 | import {Store} from 'webext-redux'; 41 | 42 | import App from './components/app/App'; 43 | 44 | const store = new Store(); 45 | 46 | // wait for the store to connect to the background page 47 | store.ready().then(() => { 48 | // The store implements the same interface as Redux's store 49 | // so you can use tools like `react-redux` no problem! 50 | render( 51 | 52 | 53 | 54 | , document.getElementById('app')); 55 | }); 56 | ``` 57 | 58 | ### 2. Wrap your Redux store in the background page with `wrapStore()` 59 | 60 | ```js 61 | // background.js 62 | 63 | import {createWrapStore} from 'webext-redux'; 64 | 65 | const store; // a normal Redux store 66 | 67 | const wrapStore = createWrapStore() 68 | wrapStore(store); 69 | ``` 70 | 71 | That's it! The dispatches called from UI component will find their way to the background page no problem. The new state from your background page will make sure to find its way back to the UI components. 72 | 73 | > [!NOTE] 74 | > `createWrapStore()` ensures webext-redux can handle events when the service worker restarts. It must be called statically in the global scope of the service worker. In other words, it shouldn't be nested in async functions, just like [any other Chrome event listeners](https://developer.chrome.com/docs/extensions/get-started/tutorial/service-worker-events#step-5). 75 | 76 | 77 | ### 3. Optional: Apply any redux middleware to your *Proxy Store* with `applyMiddleware()` 78 | 79 | 80 | Just like a regular Redux store, you can apply Redux middlewares to the Proxy store by using the library provided applyMiddleware function. This can be useful for doing things such as dispatching thunks to handle async control flow. 81 | 82 | ```js 83 | // content.js 84 | import {Store, applyMiddleware} from 'webext-redux'; 85 | import thunkMiddleware from 'redux-thunk'; 86 | 87 | // Proxy store 88 | const store = new Store(); 89 | 90 | // Apply middleware to proxy store 91 | const middleware = [thunkMiddleware]; 92 | const storeWithMiddleware = applyMiddleware(store, ...middleware); 93 | 94 | // You can now dispatch a function from the proxy store 95 | storeWithMiddleware.dispatch((dispatch, getState) => { 96 | // Regular dispatches will still be routed to the background 97 | dispatch({ type: 'start-async-action' }); 98 | setTimeout(() => { 99 | dispatch({ type: 'complete-async-action' }); 100 | }, 0); 101 | }); 102 | ``` 103 | 104 | 105 | 106 | ### 4. Optional: Implement actions whose logic only happens in the background script (we call them aliases) 107 | 108 | 109 | Sometimes you'll want to make sure the logic of your action creators happen in the background script. In this case, you will want to create an alias so that the alias is proxied from the UI component and the action creator logic executes in the background script. 110 | 111 | ```js 112 | // background.js 113 | 114 | import { applyMiddleware, createStore } from 'redux'; 115 | import { alias } from 'webext-redux'; 116 | 117 | const aliases = { 118 | // this key is the name of the action to proxy, the value is the action 119 | // creator that gets executed when the proxied action is received in the 120 | // background 121 | 'user-clicked-alias': () => { 122 | // this call can only be made in the background script 123 | browser.notifications.create(...); 124 | 125 | }; 126 | }; 127 | 128 | const store = createStore(rootReducer, 129 | applyMiddleware( 130 | alias(aliases) 131 | ) 132 | ); 133 | ``` 134 | 135 | ```js 136 | // content.js 137 | 138 | import { Component } from 'react'; 139 | 140 | const store = ...; // a proxy store 141 | 142 | class ContentApp extends Component { 143 | render() { 144 | return ( 145 | 146 | ); 147 | } 148 | 149 | dispatchClickedAlias() { 150 | store.dispatch({ type: 'user-clicked-alias' }); 151 | } 152 | } 153 | ``` 154 | 155 | ### 5. Optional: Retrieve information about the initiator of the action 156 | 157 | There are probably going to be times where you are going to want to know who sent you a message. For example, maybe you have a UI Component that lives in a tab and you want to have it send information to a store that is managed by the background script and you want your background script to know which tab sent the information to it. You can retrieve this information by using the `_sender` property of the action. Let's look at an example of what this would look like. 158 | 159 | ```js 160 | // actions.js 161 | 162 | export const MY_ACTION = 'MY_ACTION'; 163 | 164 | export function myAction(data) { 165 | return { 166 | type: MY_ACTION, 167 | data: data, 168 | }; 169 | } 170 | ``` 171 | 172 | ```js 173 | // reducer.js 174 | 175 | import {MY_ACTION} from 'actions.js'; 176 | 177 | export function rootReducer(state = ..., action) { 178 | switch (action.type) { 179 | case MY_ACTION: 180 | return Object.assign({}, ...state, { 181 | lastTabId: action._sender.tab.id 182 | }); 183 | default: 184 | return state; 185 | } 186 | } 187 | ``` 188 | 189 | No changes are required to your actions, webext-redux automatically adds this information for you when you use a wrapped store. 190 | 191 | ## Migrating from regular Redux 192 | 193 | ### 1. dispatch 194 | 195 | Contrary to regular Redux, **all** dispatches are asynchronous and return a `Promise`. 196 | It is inevitable since proxy stores and the main store communicate via browser messaging, which is inherently asynchronous. 197 | 198 | In pure Redux, dispatches are synchronous 199 | (which may not be true with some middlewares such as `redux-thunk`). 200 | 201 | Consider this piece of code: 202 | ```js 203 | store.dispatch({ type: MODIFY_FOO_BAR, value: 'new value'}); 204 | console.log(store.getState().fooBar); 205 | ``` 206 | 207 | You can rely that `console.log` in the code above will display the modified value. 208 | 209 | In `webext-redux` on the Proxy Store side you will need to 210 | explicitly wait for the dispatch to complete: 211 | 212 | ```js 213 | store.dispatch({ type: MODIFY_FOO_BAR, value: 'new value'}).then(() => 214 | console.log(store.getState().fooBar) 215 | ); 216 | ``` 217 | or, using async/await syntax: 218 | 219 | ```js 220 | await store.dispatch({ type: MODIFY_FOO_BAR, value: 'new value'}); 221 | console.log(store.getState().fooBar); 222 | ``` 223 | 224 | ### 2. dispatch / React component updates 225 | 226 | This case is relatively rare. 227 | 228 | On the Proxy Store side, React component updates with `webext-redux` 229 | are more likely to take place after a dispatch is started and before it completes. 230 | 231 | While the code below might work (luckily?) in classical Redux, 232 | it does not anymore since the component has been updated before the `deletePost` is fully completed 233 | and `post` object is not accessible anymore in the promise handler: 234 | ```js 235 | class PostRemovePanel extends React.Component { 236 | (...) 237 | 238 | handleRemoveButtonClicked() { 239 | this.props.deletePost(this.props.post) 240 | .then(() => { 241 | this.setState({ message: `Post titled ${this.props.post.title} has just been deleted` }); 242 | }); 243 | } 244 | } 245 | ``` 246 | On the other hand, this piece of code is safe: 247 | 248 | ```js 249 | handleRemoveButtonClicked() { 250 | const post = this.props.post; 251 | this.props.deletePost(post); 252 | .then(() => { 253 | this.setState({ message: `Post titled ${post.title} has just been deleted` }); 254 | }); 255 | } 256 | } 257 | ``` 258 | 259 | ### Other 260 | 261 | If you spot any more surprises that are worth watching out for, make sure to let us know! 262 | 263 | ## Custom Serialization 264 | 265 | You may wish to implement custom serialization and deserialization logic for communication between the background store and your proxy store(s). Web Extension's message passing (which is used to implement this library) automatically serializes messages when they are sent and deserializes them when they are received. In the case that you have non-JSON-ifiable information in your Redux state, like a circular reference or a `Date` object, you will lose information between the background store and the proxy store(s). To manage this, both `wrapStore` and `Store` accept `serializer` and `deserializer` options. These should be functions that take a single parameter, the payload of a message, and return a serialized and deserialized form, respectively. The `serializer` function will be called every time a message is sent, and the `deserializer` function will be called every time a message is received. Note that, in addition to state updates, action creators being passed from your content script(s) to your background page will be serialized and deserialized as well. 266 | 267 | ### Example 268 | For example, consider the following `state` in your background page: 269 | 270 | ```js 271 | {todos: [ 272 | { 273 | id: 1, 274 | text: 'Write a Web extension', 275 | created: new Date(2018, 0, 1) 276 | } 277 | ]} 278 | ``` 279 | 280 | With no custom serialization, the `state` in your proxy store will look like this: 281 | 282 | ```js 283 | {todos: [ 284 | { 285 | id: 1, 286 | text: 'Write a Web extension', 287 | created: {} 288 | } 289 | ]} 290 | ``` 291 | 292 | As you can see, Web Extension's message passing has caused your date to disappear. You can pass a custom `serializer` and `deserializer` to both `wrapStore` and `Store` to make sure your dates get preserved: 293 | 294 | ```js 295 | // background.js 296 | 297 | import {createWrapStore} from 'webext-redux'; 298 | 299 | const wrapStore = createWrapStore(); 300 | const store; // a normal Redux store 301 | 302 | wrapStore(store, { 303 | serializer: payload => JSON.stringify(payload, dateReplacer), 304 | deserializer: payload => JSON.parse(payload, dateReviver) 305 | }); 306 | ``` 307 | 308 | ```js 309 | // content.js 310 | 311 | import {Store} from 'webext-redux'; 312 | 313 | const store = new Store({ 314 | serializer: payload => JSON.stringify(payload, dateReplacer), 315 | deserializer: payload => JSON.parse(payload, dateReviver) 316 | }); 317 | ``` 318 | 319 | In this example, `dateReplacer` and `dateReviver` are a custom JSON [replacer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) and [reviver](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) function, respectively. They are defined as such: 320 | 321 | ```js 322 | function dateReplacer (key, value) { 323 | // Put a custom flag on dates instead of relying on JSON's native 324 | // stringification, which would force us to use a regex on the other end 325 | return this[key] instanceof Date ? {"_RECOVER_DATE": this[key].getTime()} : value 326 | }; 327 | 328 | function dateReviver (key, value) { 329 | // Look for the custom flag and revive the date 330 | return value && value["_RECOVER_DATE"] ? new Date(value["_RECOVER_DATE"]) : value 331 | }; 332 | 333 | const stringified = JSON.stringify(state, dateReplacer) 334 | //"{"todos":[{"id":1,"text":"Write a Web extension","created":{"_RECOVER_DATE":1514793600000}}]}" 335 | 336 | JSON.parse(stringified, dateReviver) 337 | // {todos: [{ id: 1, text: 'Write a Web extension', created: new Date(2018, 0, 1) }]} 338 | ``` 339 | 340 | ## Custom Diffing and Patching Strategies 341 | 342 | On each state update, `webext-redux` generates a patch based on the difference between the old state and the new state. The patch is sent to each proxy store, where it is used to update the proxy store's state. This is more efficient than sending the entire state to each proxy store on every update. 343 | If you find that the default patching behavior is not sufficient, you can fine-tune `webext-redux` using custom diffing and patching strategies. 344 | 345 | ### Deep Diff Strategy 346 | 347 | By default, `webext-redux` uses a shallow diffing strategy to generate patches. If the identity of any of the store's top-level keys changes, their values are patched wholesale. Most of the time, this strategy will work just fine. However, in cases where a store's state is highly nested, or where many items are stored by key under a single slice of state, it can start to affect performance. Consider, for example, the following `state`: 348 | 349 | ```js 350 | { 351 | items: { 352 | "a": { ... }, 353 | "b": { ... }, 354 | "c": { ... }, 355 | "d": { ... }, 356 | // ... 357 | }, 358 | // ... 359 | } 360 | ``` 361 | 362 | If any of the individual keys under `state.items` is updated, `state.items` will become a new object (by standard Redux convention). As a result, the default diffing strategy will send then entire `state.items` object to every proxy store for patching. Since this involves serialization and deserialization of the entire object, having large objects - or many proxy stores - can create a noticeable slowdown. To mitigate this, `webext-redux` also provides a deep diffing strategy, which will traverse down the state tree until it reaches non-object values, keeping track of only the updated keys at each level of state. So, for the example above, if the object under `state.items.b` is updated, the patch will only contain those keys under `state.items.b` whose values actually changed. The deep diffing strategy can be used like so: 363 | 364 | ```js 365 | // background.js 366 | 367 | import {createWrapStore} from 'webext-redux'; 368 | import deepDiff from 'webext-redux/lib/strategies/deepDiff/diff'; 369 | 370 | const wrapStore = createWrapStore(); 371 | const store; // a normal Redux store 372 | 373 | wrapStore(store, { 374 | diffStrategy: deepDiff 375 | }); 376 | ``` 377 | 378 | ```js 379 | // content.js 380 | 381 | import {Store} from 'webext-redux'; 382 | import patchDeepDiff from 'webext-redux/lib/strategies/deepDiff/patch'; 383 | 384 | const store = new Store({ 385 | patchStrategy: patchDeepDiff 386 | }); 387 | ``` 388 | 389 | Note that the deep diffing strategy currently diffs arrays shallowly, and patches item changes based on typed equality. 390 | 391 | #### Custom Deep Diff Strategy 392 | 393 | `webext-redux` also provides a `makeDiff` function to customize the deep diffing strategy. It takes a `shouldContinue` function, which is called during diffing just after each state tree traversal, and should return a boolean indicating whether or not to continue down the tree, or to just treat the current object as a value. It is called with the old state, the new state, and the current position in the state tree (provided as a list of keys so far). Continuing the example from above, say you wanted to treat all of the individual items under `state.items` as values, rather than traversing into each one to compare its properties: 394 | 395 | ```js 396 | // background.js 397 | 398 | import {createWrapStore} from 'webext-redux'; 399 | import makeDiff from 'webext-redux/lib/strategies/deepDiff/makeDiff'; 400 | 401 | const wrapStore = createWrapStore(); 402 | const store; // a normal Redux store 403 | 404 | const shouldContinue = (oldState, newState, context) => { 405 | // If we've just traversed into a key under state.items, 406 | // stop traversing down the tree and treat this as a changed value. 407 | if (context.length === 2 && context[0] === 'items') { 408 | return false; 409 | } 410 | // Otherwise, continue down the tree. 411 | return true; 412 | } 413 | // Make the custom deep diff using the shouldContinue function 414 | const customDeepDiff = makeDiff(shouldContinue); 415 | 416 | wrapStore(store, { 417 | diffStrategy: customDeepDiff // Use the custom deep diff 418 | }); 419 | ``` 420 | 421 | Now, for each key under `state.items`, `webext-redux` will treat it as a value and patch it wholesale, rather than comparing each of its individual properties. 422 | 423 | A `shouldContinue` function of the form `(oldObj, newObj, context) => context.length === 0` is equivalent to `webext-redux`'s default shallow diffing strategy, since it will only check the top-level keys (when `context` is an empty list) and treat everything under them as changed values. 424 | 425 | ### Custom `diffStrategy` and `patchStrategy` functions 426 | 427 | You can also provide your own diffing and patching strategies, using the `diffStrategy` parameter in `wrapStore` and the `patchStrategy` parameter in `Store`, respectively. A diffing strategy should be a function that takes two arguments - the old state and the new state - and returns a patch, which can be of any form. A patch strategy is a function that takes two arguments - the old state and a patch - and returns the new state. 428 | When using a custom diffing and patching strategy, you are responsible for making sure that they function as expected; that is, that `patchStrategy(oldState, diffStrategy(oldState, newState))` is equal to `newState`. 429 | 430 | Aside from being able to fine-tune `webext-redux`'s performance, custom diffing and patching strategies allow you to use `webext-redux` with Redux stores whose states are not vanilla Javascript objects. For example, you could implement diffing and patching strategies - along with corresponding custom serialization and deserialization functions - that allow you to handle [Immutable.js](https://github.com/facebook/immutable-js) collections. 431 | 432 | ## Docs 433 | 434 | * [Introduction](https://github.com/tshaddix/webext-redux/wiki/Introduction) 435 | * [Getting Started](https://github.com/tshaddix/webext-redux/wiki/Getting-Started) 436 | * [Advanced Usage](https://github.com/tshaddix/webext-redux/wiki/Advanced-Usage) 437 | 438 | ## Who's using this? 439 | 440 | [![Loom][loom-image]][loom-url] 441 | 442 | [![GoGuardian][goguardian-image]][goguardian-url] 443 | 444 | [![Chrome IG Story][chrome-ig-story-image]][chrome-ig-story-url] 445 | 446 | [][mabl-url] 447 | 448 | [![Storyful][storyful-image]][storyful-url] 449 | 450 | Using `webext-redux` in your project? We'd love to hear about it! Just [open an issue](https://github.com/tshaddix/webext-redux/issues) and let us know. 451 | 452 | 453 | [npm-image]: https://img.shields.io/npm/v/webext-redux.svg 454 | [npm-url]: https://npmjs.org/package/webext-redux 455 | [downloads-image]: https://img.shields.io/npm/dm/webext-redux.svg 456 | [downloads-url]: https://npmjs.org/package/webext-redux 457 | [loom-image]: https://cloud.githubusercontent.com/assets/603426/22037715/28c653aa-dcad-11e6-814d-d7a418d5670f.png 458 | [loom-url]: https://www.useloom.com 459 | [goguardian-image]: https://cloud.githubusercontent.com/assets/2173532/17540959/c6749bdc-5e6f-11e6-979c-c0e0da51fc63.png 460 | [goguardian-url]: https://goguardian.com 461 | [chrome-ig-story-image]: https://user-images.githubusercontent.com/2003684/34464412-895af814-ee32-11e7-86e4-b602bf58cdbc.png 462 | [chrome-ig-story-url]: https://chrome.google.com/webstore/detail/chrome-ig-story/bojgejgifofondahckoaahkilneffhmf 463 | [mabl-url]: https://www.mabl.com 464 | [storyful-image]: https://user-images.githubusercontent.com/702227/140521240-be12e5ba-4f4e-4593-80a0-352f1acfe039.jpeg 465 | [storyful-url]: https://storyful.com 466 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as redux from "redux"; 2 | 3 | export type DiffStrategy = (oldObj: any, newObj: any) => any; 4 | export type PatchStrategy = (oldObj: any, patch: any) => any; 5 | 6 | export class Store { 7 | /** 8 | * Creates a new Proxy store 9 | * @param {object} options 10 | * @param {string} options.channelName The name of the channel for this store. 11 | * @param {object} options.state The initial state of the store (default 12 | * `{}`). 13 | * @param {function} options.serializer A function to serialize outgoing 14 | * messages (default is passthrough). 15 | * @param {function} options.deserializer A function to deserialize incoming 16 | * messages (default is passthrough). 17 | * @param {function} options.patchStrategy A function to patch the state with 18 | * incoming messages. Use one of the included patching strategies or a custom 19 | * patching function. (default is shallow diff). 20 | */ 21 | constructor(options?: { 22 | channelName?: string; 23 | state?: any; 24 | serializer?: Function; 25 | deserializer?: Function; 26 | patchStrategy?: PatchStrategy; 27 | }); 28 | 29 | /** 30 | * Returns a promise that resolves when the store is ready. 31 | * @return promise A promise that resolves when the store has established a connection with the background page. 32 | */ 33 | ready(): Promise; 34 | 35 | /** 36 | * Returns a promise that resolves when the store is ready. 37 | * @param callback An callback that will fire when the store is ready. 38 | * @return promise A promise that resolves when the store has established a connection with the background page. 39 | */ 40 | ready(cb: () => S): Promise; 41 | 42 | /** 43 | * Subscribes a listener function for all state changes 44 | * @param listener A listener function to be called when store state changes 45 | * @return An unsubscribe function which can be called to remove the listener from state updates 46 | */ 47 | subscribe(listener: () => void): () => void; 48 | 49 | /** 50 | * Replace the current state with a new state. Notifies all listeners of state change. 51 | * @param state The new state for the store 52 | */ 53 | replaceState(state: S): void; 54 | 55 | /** 56 | * Replaces the state for only the keys in the updated state. Notifies all listeners of state change. 57 | * @param difference the new (partial) redux state 58 | */ 59 | patchState(difference: Array): void; 60 | 61 | /** 62 | * Stub function to stay consistent with Redux Store API. No-op. 63 | * @param nextReducer The reducer for the store to use instead. 64 | */ 65 | replaceReducer(nextReducer: redux.Reducer): void; 66 | 67 | /** 68 | * Get the current state of the store 69 | * @return the current store state 70 | */ 71 | getState(): S; 72 | 73 | /** 74 | * Dispatch an action to the background using messaging passing 75 | * @param data The action data to dispatch 76 | * 77 | * Note: Although the return type is specified as the action, react-chrome-redux will 78 | * wrap the result in a responsePromise that will resolve/reject based on the 79 | * action response from the background page 80 | */ 81 | dispatch(data: A): A; 82 | 83 | /** 84 | * Interoperability point for observable/reactive libraries. 85 | * @returns {observable} A minimal observable of state changes. 86 | * For more information, see the observable proposal: 87 | * https://github.com/tc39/proposal-observable 88 | */ 89 | [Symbol.observable](): Observable; 90 | } 91 | 92 | type WrapStore = ( 93 | store: redux.Store, 94 | configuration?: { 95 | dispatchResponder?( 96 | dispatchResult: any, 97 | send: (response: any) => void 98 | ): void; 99 | serializer?: Function; 100 | deserializer?: Function; 101 | diffStrategy?: DiffStrategy; 102 | } 103 | ) => void; 104 | 105 | export function createWrapStore< 106 | S, 107 | A extends redux.Action = redux.AnyAction 108 | >(configuration?: { 109 | channelName?: string; 110 | }): WrapStore; 111 | 112 | export function alias(aliases: { 113 | [key: string]: (action: any) => any; 114 | }): redux.Middleware; 115 | 116 | export function applyMiddleware( 117 | store: Store, 118 | ...middleware: redux.Middleware[] 119 | ): Store; 120 | 121 | /** 122 | * Function to remove listener added by `Store.subscribe()`. 123 | */ 124 | export interface Unsubscribe { 125 | (): void; 126 | } 127 | 128 | /** 129 | * A minimal observable of state changes. 130 | * For more information, see the observable proposal: 131 | * https://github.com/tc39/proposal-observable 132 | */ 133 | export type Observable = { 134 | /** 135 | * The minimal observable subscription method. 136 | * @param {Object} observer Any object that can be used as an observer. 137 | * The observer object should have a `next` method. 138 | * @returns {subscription} An object with an `unsubscribe` method that can 139 | * be used to unsubscribe the observable from the store, and prevent further 140 | * emission of values from the observable. 141 | */ 142 | subscribe: (observer: Observer) => { unsubscribe: Unsubscribe }; 143 | [Symbol.observable](): Observable; 144 | }; 145 | 146 | /** 147 | * An Observer is used to receive data from an Observable, and is supplied as 148 | * an argument to subscribe. 149 | */ 150 | export type Observer = { 151 | next?(value: T): void; 152 | }; 153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-redux", 3 | "version": "4.0.0", 4 | "description": "A set of utilities for building Redux applications in Web Extensions.", 5 | "main": "lib/index.js", 6 | "typings": "./index.d.ts", 7 | "scripts": { 8 | "umd-build": "rollup -c", 9 | "build": "babel src --out-dir lib && npm run umd-build", 10 | "lint-src": "eslint src/{**/,}*.js", 11 | "lint-test": "eslint test/{**/,}*.js", 12 | "lint": "npm run lint-src && npm run lint-test", 13 | "prepublishOnly": "npm run build", 14 | "pretest": "babel src --out-dir lib", 15 | "test-run": "mocha --require @babel/register --recursive", 16 | "test": "npm run lint && npm run test-run" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/tshaddix/webext-redux.git" 21 | }, 22 | "author": "Tyler Shaddix", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/tshaddix/webext-redux/issues" 26 | }, 27 | "homepage": "https://github.com/tshaddix/webext-redux#readme", 28 | "dependencies": { 29 | "lodash.assignin": "^4.2.0", 30 | "lodash.clonedeep": "^4.5.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.2.3", 34 | "@babel/core": "^7.3.3", 35 | "@babel/plugin-transform-async-to-generator": "^7.2.0", 36 | "@babel/polyfill": "^7.2.5", 37 | "@babel/preset-env": "^7.3.1", 38 | "@babel/register": "^7.0.0", 39 | "babel-eslint": "^7.2.0", 40 | "eslint": "^4.18.2", 41 | "mocha": "^5.2.0", 42 | "redux": "5.0.1", 43 | "rollup": "^1.22.0", 44 | "rollup-plugin-babel": "^4.3.3", 45 | "rollup-plugin-commonjs": "^10.1.0", 46 | "rollup-plugin-node-resolve": "^5.2.0", 47 | "rollup-plugin-terser": "^5.1.2", 48 | "should": "^13.2.1", 49 | "sinon": "^6.0.0" 50 | }, 51 | "peerDependencies": { 52 | "redux": ">= 3 <= 5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | 6 | export default [ 7 | // UMD Development 8 | { 9 | input: 'src/index.js', 10 | output: { 11 | file: 'dist/webext-redux.js', 12 | format: 'umd', 13 | name: 'WebextRedux', 14 | indent: false 15 | }, 16 | plugins: [ 17 | nodeResolve(), 18 | commonjs(), 19 | babel({ 20 | exclude: 'node_modules/**' 21 | }), 22 | ], 23 | }, 24 | 25 | // UMD Production 26 | { 27 | input: 'src/index.js', 28 | output: { 29 | file: 'dist/webext-redux.min.js', 30 | format: 'umd', 31 | name: 'WebextRedux', 32 | indent: false 33 | }, 34 | plugins: [ 35 | nodeResolve(), 36 | commonjs(), 37 | babel({ 38 | exclude: 'node_modules/**' 39 | }), 40 | terser({ 41 | compress: { 42 | pure_getters: true, 43 | unsafe: true, 44 | unsafe_comps: true, 45 | warnings: false 46 | }, 47 | }), 48 | ], 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /src/alias/alias.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple middleware intercepts actions and replaces with 3 | * another by calling an alias function with the original action 4 | * @type {object} aliases an object that maps action types (keys) to alias functions (values) (e.g. { SOME_ACTION: newActionAliasFunc }) 5 | */ 6 | export default aliases => () => next => action => { 7 | const alias = aliases[action.type]; 8 | 9 | if (alias) { 10 | return next(alias(action)); 11 | } 12 | 13 | return next(action); 14 | }; 15 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | // Message type used for dispatch events 2 | // from the Proxy Stores to background 3 | export const DISPATCH_TYPE = "webext.dispatch"; 4 | 5 | // Message type for fetching current state from 6 | // background to Proxy Stores 7 | export const FETCH_STATE_TYPE = "webext.fetch_state"; 8 | 9 | // Message type for state update events from 10 | // background to Proxy Stores 11 | export const STATE_TYPE = "webext.state"; 12 | 13 | // Message type for state patch events from 14 | // background to Proxy Stores 15 | export const PATCH_STATE_TYPE = "webext.patch_state"; 16 | 17 | // The default name for the store channel 18 | export const DEFAULT_CHANNEL_NAME = "webext.channel"; 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Store from "./store/Store"; 2 | import applyMiddleware from "./store/applyMiddleware"; 3 | import createWrapStore from "./wrap-store/wrapStore"; 4 | import alias from "./alias/alias"; 5 | 6 | export { Store, applyMiddleware, createWrapStore, alias }; 7 | -------------------------------------------------------------------------------- /src/listener.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a function that can be passed as a listener callback to a browser 3 | * API. The listener will queue events until setListener is called. 4 | * 5 | * @param {Function} filter - A function that filters messages to be handled by 6 | * the listener. This is important to avoid telling the browser to expect an 7 | * async response when the message is not intended for this listener. 8 | * 9 | * @example 10 | * const filter = (message, sender, sendResponse) => { 11 | * return message.type === "my_type" 12 | * } 13 | * 14 | * const { listener, setListener } = createDeferredListener(filter); 15 | * chrome.runtime.onMessage.addListener(listener); 16 | * 17 | * // Later, define the listener to handle messages. Messages received 18 | * // before this point are queued. 19 | * setListener((message, sender, sendResponse) => { 20 | * console.log(message); 21 | * }); 22 | */ 23 | export const createDeferredListener = (filter) => { 24 | let resolve = () => {}; 25 | const fnPromise = new Promise((resolve_) => (resolve = resolve_)); 26 | 27 | const listener = (message, sender, sendResponse) => { 28 | if (!filter(message, sender, sendResponse)) { 29 | return; 30 | } 31 | 32 | fnPromise.then((fn) => { 33 | fn(message, sender, sendResponse); 34 | }); 35 | 36 | // Allow response to be async 37 | return true; 38 | }; 39 | 40 | return { setListener: resolve, listener }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/serialization.js: -------------------------------------------------------------------------------- 1 | export const noop = (payload) => payload; 2 | 3 | const transformPayload = (message, transformer = noop) => ({ 4 | ...message, 5 | // If the message has a payload, transform it. Otherwise, 6 | // just return a copy of the message. 7 | // We return a copy rather than the original message so that we're not 8 | // mutating the original action object. 9 | ...(message.payload ? {payload: transformer(message.payload)} : {}) 10 | }); 11 | 12 | const deserializeListener = (listener, deserializer = noop, shouldDeserialize) => { 13 | // If a shouldDeserialize function is passed, return a function that uses it 14 | // to check if any given message payload should be deserialized 15 | if (shouldDeserialize) { 16 | return (message, ...args) => { 17 | if (shouldDeserialize(message, ...args)) { 18 | return listener(transformPayload(message, deserializer), ...args); 19 | } 20 | return listener(message, ...args); 21 | }; 22 | } 23 | // Otherwise, return a function that tries to deserialize on every message 24 | return (message, ...args) => listener(transformPayload(message, deserializer), ...args); 25 | }; 26 | 27 | /** 28 | * A function returned from withDeserializer that, when called, wraps addListenerFn with the 29 | * deserializer passed to withDeserializer. 30 | * @name AddListenerDeserializer 31 | * @function 32 | * @param {Function} addListenerFn The add listener function to wrap. 33 | * @returns {DeserializedAddListener} 34 | */ 35 | 36 | /** 37 | * A wrapped add listener function that registers the given listener. 38 | * @name DeserializedAddListener 39 | * @function 40 | * @param {Function} listener The listener function to register. It should expect the (optionally) 41 | * deserialized message as its first argument. 42 | * @param {Function} [shouldDeserialize] A function that takes the arguments passed to the listener 43 | * and returns whether the message payload should be deserialized. Not all messages (notably, messages 44 | * this listener doesn't care about) should be attempted to be deserialized. 45 | */ 46 | 47 | /** 48 | * Given a deserializer, returns an AddListenerDeserializer function that that takes an add listener 49 | * function and returns a DeserializedAddListener that automatically deserializes message payloads. 50 | * Each message listener is expected to take the message as its first argument. 51 | * @param {Function} deserializer A function that deserializes a message payload. 52 | * @returns {AddListenerDeserializer} 53 | * Example Usage: 54 | * const withJsonDeserializer = withDeserializer(payload => JSON.parse(payload)); 55 | * const deserializedChromeListener = withJsonDeserializer(chrome.runtime.onMessage.addListener); 56 | * const shouldDeserialize = (message) => message.type === 'DESERIALIZE_ME'; 57 | * deserializedChromeListener(message => console.log("Payload:", message.payload), shouldDeserialize); 58 | * chrome.runtime.sendMessage("{'type:'DESERIALIZE_ME','payload':{'prop':4}}"); 59 | * //Payload: { prop: 4 }; 60 | * chrome.runtime.sendMessage("{'payload':{'prop':4}}"); 61 | * //Payload: "{'prop':4}"; 62 | */ 63 | export const withDeserializer = (deserializer = noop) => 64 | (addListenerFn) => 65 | (listener, shouldDeserialize) => 66 | addListenerFn(deserializeListener(listener, deserializer, shouldDeserialize)); 67 | 68 | /** 69 | * Given a serializer, returns a function that takes a message sending 70 | * function as its sole argument and returns a wrapped message sender that 71 | * automaticaly serializes message payloads. The message sender 72 | * is expected to take the message as its first argument, unless messageArgIndex 73 | * is nonzero, in which case it is expected in the position specified by messageArgIndex. 74 | * @param {Function} serializer A function that serializes a message payload 75 | * Example Usage: 76 | * const withJsonSerializer = withSerializer(payload => JSON.stringify(payload)) 77 | * const serializedChromeSender = withJsonSerializer(chrome.runtime.sendMessage) 78 | * chrome.runtime.addListener(message => console.log("Payload:", message.payload)) 79 | * serializedChromeSender({ payload: { prop: 4 }}) 80 | * //Payload: "{'prop':4}" 81 | */ 82 | export const withSerializer = (serializer = noop) => 83 | (sendMessageFn, messageArgIndex = 0) => { 84 | return (...args) => { 85 | if (args.length <= messageArgIndex) { 86 | throw new Error(`Message in request could not be serialized. ` + 87 | `Expected message in position ${messageArgIndex} but only received ${args.length} args.`); 88 | } 89 | args[messageArgIndex] = transformPayload(args[messageArgIndex], serializer); 90 | return sendMessageFn(...args); 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/store/Store.js: -------------------------------------------------------------------------------- 1 | import assignIn from 'lodash.assignin'; 2 | 3 | import { 4 | DISPATCH_TYPE, 5 | FETCH_STATE_TYPE, 6 | STATE_TYPE, 7 | PATCH_STATE_TYPE, 8 | DEFAULT_CHANNEL_NAME 9 | } from '../constants'; 10 | import { withSerializer, withDeserializer, noop } from "../serialization"; 11 | import shallowDiff from '../strategies/shallowDiff/patch'; 12 | import {getBrowserAPI} from '../util'; 13 | 14 | const backgroundErrPrefix = '\nLooks like there is an error in the background page. ' + 15 | 'You might want to inspect your background page for more details.\n'; 16 | 17 | 18 | const defaultOpts = { 19 | channelName: DEFAULT_CHANNEL_NAME, 20 | state: {}, 21 | serializer: noop, 22 | deserializer: noop, 23 | patchStrategy: shallowDiff 24 | }; 25 | 26 | class Store { 27 | /** 28 | * Creates a new Proxy store 29 | * @param {object} options 30 | * @param {string} options.channelName The name of the channel for this store. 31 | * @param {object} options.state The initial state of the store (default 32 | * `{}`). 33 | * @param {function} options.serializer A function to serialize outgoing 34 | * messages (default is passthrough). 35 | * @param {function} options.deserializer A function to deserialize incoming 36 | * messages (default is passthrough). 37 | * @param {function} options.patchStrategy A function to patch the state with 38 | * incoming messages. Use one of the included patching strategies or a custom 39 | * patching function. (default is shallow diff). 40 | */ 41 | constructor({channelName = defaultOpts.channelName, state = defaultOpts.state, serializer = defaultOpts.serializer, deserializer = defaultOpts.deserializer, patchStrategy = defaultOpts.patchStrategy} = defaultOpts) { 42 | if (!channelName) { 43 | throw new Error('channelName is required in options'); 44 | } 45 | if (typeof serializer !== 'function') { 46 | throw new Error('serializer must be a function'); 47 | } 48 | if (typeof deserializer !== 'function') { 49 | throw new Error('deserializer must be a function'); 50 | } 51 | if (typeof patchStrategy !== 'function') { 52 | throw new Error('patchStrategy must be one of the included patching strategies or a custom patching function'); 53 | } 54 | 55 | this.channelName = channelName; 56 | this.readyResolved = false; 57 | this.readyPromise = new Promise(resolve => this.readyResolve = resolve); 58 | 59 | this.browserAPI = getBrowserAPI(); 60 | this.initializeStore = this.initializeStore.bind(this); 61 | 62 | // We request the latest available state data to initialise our store 63 | this.browserAPI.runtime.sendMessage( 64 | { type: FETCH_STATE_TYPE, channelName }, undefined, this.initializeStore 65 | ); 66 | 67 | this.deserializer = deserializer; 68 | this.serializedPortListener = withDeserializer(deserializer)((...args) => this.browserAPI.runtime.onMessage.addListener(...args)); 69 | this.serializedMessageSender = withSerializer(serializer)((...args) => this.browserAPI.runtime.sendMessage(...args)); 70 | this.listeners = []; 71 | this.state = state; 72 | this.patchStrategy = patchStrategy; 73 | 74 | /** 75 | * Determine if the message should be run through the deserializer. We want 76 | * to skip processing messages that probably didn't come from this library. 77 | * Note that the listener below is still called for each message so it needs 78 | * its own guard, the shouldDeserialize predicate only skips _deserializing_ 79 | * the message. 80 | */ 81 | const shouldDeserialize = (message) => { 82 | return ( 83 | Boolean(message) && 84 | typeof message.type === "string" && 85 | message.channelName === this.channelName 86 | ); 87 | }; 88 | 89 | this.serializedPortListener(message => { 90 | if (!message || message.channelName !== this.channelName) { 91 | return; 92 | } 93 | 94 | switch (message.type) { 95 | case STATE_TYPE: 96 | this.replaceState(message.payload); 97 | 98 | if (!this.readyResolved) { 99 | this.readyResolved = true; 100 | this.readyResolve(); 101 | } 102 | break; 103 | 104 | case PATCH_STATE_TYPE: 105 | this.patchState(message.payload); 106 | break; 107 | 108 | default: 109 | // do nothing 110 | } 111 | }, shouldDeserialize); 112 | 113 | this.dispatch = this.dispatch.bind(this); // add this context to dispatch 114 | this.getState = this.getState.bind(this); // add this context to getState 115 | this.subscribe = this.subscribe.bind(this); // add this context to subscribe 116 | } 117 | 118 | /** 119 | * Returns a promise that resolves when the store is ready. Optionally a callback may be passed in instead. 120 | * @param [function] callback An optional callback that may be passed in and will fire when the store is ready. 121 | * @return {object} promise A promise that resolves when the store has established a connection with the background page. 122 | */ 123 | ready(cb = null) { 124 | if (cb !== null) { 125 | return this.readyPromise.then(cb); 126 | } 127 | 128 | return this.readyPromise; 129 | } 130 | 131 | /** 132 | * Subscribes a listener function for all state changes 133 | * @param {function} listener A listener function to be called when store state changes 134 | * @return {function} An unsubscribe function which can be called to remove the listener from state updates 135 | */ 136 | subscribe(listener) { 137 | this.listeners.push(listener); 138 | 139 | return () => { 140 | this.listeners = this.listeners.filter((l) => l !== listener); 141 | }; 142 | } 143 | 144 | /** 145 | * Replaces the state for only the keys in the updated state. Notifies all listeners of state change. 146 | * @param {object} state the new (partial) redux state 147 | */ 148 | patchState(difference) { 149 | this.state = this.patchStrategy(this.state, difference); 150 | this.listeners.forEach((l) => l()); 151 | } 152 | 153 | /** 154 | * Replace the current state with a new state. Notifies all listeners of state change. 155 | * @param {object} state The new state for the store 156 | */ 157 | replaceState(state) { 158 | this.state = state; 159 | 160 | this.listeners.forEach((l) => l()); 161 | } 162 | 163 | /** 164 | * Get the current state of the store 165 | * @return {object} the current store state 166 | */ 167 | getState() { 168 | return this.state; 169 | } 170 | 171 | /** 172 | * Stub function to stay consistent with Redux Store API. No-op. 173 | */ 174 | replaceReducer() { 175 | return; 176 | } 177 | 178 | /** 179 | * Dispatch an action to the background using messaging passing 180 | * @param {object} data The action data to dispatch 181 | * @return {Promise} Promise that will resolve/reject based on the action response from the background 182 | */ 183 | dispatch(data) { 184 | return new Promise((resolve, reject) => { 185 | this.serializedMessageSender( 186 | { 187 | type: DISPATCH_TYPE, 188 | channelName: this.channelName, 189 | payload: data 190 | }, null, (resp) => { 191 | if (!resp) { 192 | const error = this.browserAPI.runtime.lastError; 193 | const bgErr = new Error(`${backgroundErrPrefix}${error}`); 194 | 195 | reject(assignIn(bgErr, error)); 196 | return; 197 | } 198 | 199 | const {error, value} = resp; 200 | 201 | if (error) { 202 | const bgErr = new Error(`${backgroundErrPrefix}${error}`); 203 | 204 | reject(assignIn(bgErr, error)); 205 | } else { 206 | resolve(value && value.payload); 207 | } 208 | }); 209 | }); 210 | } 211 | 212 | initializeStore(message) { 213 | if (message && message.type === FETCH_STATE_TYPE) { 214 | this.replaceState(message.payload); 215 | 216 | // Resolve if readyPromise has not been resolved. 217 | if (!this.readyResolved) { 218 | this.readyResolved = true; 219 | this.readyResolve(); 220 | } 221 | } 222 | } 223 | } 224 | 225 | export default Store; 226 | -------------------------------------------------------------------------------- /src/store/applyMiddleware.js: -------------------------------------------------------------------------------- 1 | // Function taken from redux source 2 | // https://github.com/reactjs/redux/blob/master/src/compose.js 3 | function compose(...funcs) { 4 | if (funcs.length === 0) { 5 | return arg => arg; 6 | } 7 | 8 | if (funcs.length === 1) { 9 | return funcs[0]; 10 | } 11 | 12 | return funcs.reduce((a, b) => (...args) => a(b(...args))); 13 | } 14 | 15 | // Based on redux implementation of applyMiddleware to support all standard 16 | // redux middlewares 17 | export default function applyMiddleware(store, ...middlewares) { 18 | let dispatch = () => { 19 | throw new Error( 20 | 'Dispatching while constructing your middleware is not allowed. '+ 21 | 'Other middleware would not be applied to this dispatch.' 22 | ); 23 | }; 24 | 25 | const middlewareAPI = { 26 | getState: store.getState.bind(store), 27 | dispatch: (...args) => dispatch(...args) 28 | }; 29 | 30 | middlewares = (middlewares || []).map(middleware => middleware(middlewareAPI)); 31 | 32 | dispatch = compose(...middlewares)(store.dispatch); 33 | store.dispatch = dispatch; 34 | 35 | return store; 36 | } 37 | -------------------------------------------------------------------------------- /src/strategies/constants.js: -------------------------------------------------------------------------------- 1 | // The `change` value for updated or inserted fields resulting from shallow diff 2 | export const DIFF_STATUS_UPDATED = 'updated'; 3 | 4 | // The `change` value for removed fields resulting from shallow diff 5 | export const DIFF_STATUS_REMOVED = 'removed'; 6 | 7 | export const DIFF_STATUS_KEYS_UPDATED = 'updated_keys'; 8 | 9 | export const DIFF_STATUS_ARRAY_UPDATED = 'updated_array'; -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Yu Jianrong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/README.md: -------------------------------------------------------------------------------- 1 | fast-array-diff 2 | ====================== 3 | [![MIT Licence](https://badges.frapsoft.com/os/mit/mit.svg?v=103)](https://opensource.org/licenses/mit-license.php) 4 | 5 | This implementation was ported to JavaScript from Typescript base on version0.2.0 of 6 | [YuJianrong's fast-array-diff package](https://github.com/YuJianrong/fast-array-diff). 7 | 8 | ```fast-array-diff``` is a npm module to find the common or different parts of two array, it based on the solution of LCS (Longest common subsequence) problems, widely used in diff/patch of two arrays (like diff/patch feature in git). 9 | 10 | The algorithm of this module is implemented based on the paper "An O(ND) Difference Algorithm and its Variations" by Eugene Myers, Algorithm Vol. 1 No. 2, 1986, pp. 251-266. The difference of this implementation to the implementation of npm module [diff](https://www.npmjs.com/package/diff) is: the space complexity of this implementation is O(N), while the implementation of ```diff``` is O(ND), so this implementation will cost less memory on large data set. Note: although the time complexity of the implementations are both O(ND), this implementation run slower than the ```diff```. 11 | 12 | API 13 | ---------------------- 14 | * `same(arrayOld, arrayNew, compareFunc?)` - Get the LCS of the two arrays. 15 | 16 | Return a list of the common subsequence. Like: ```[1,2,3]``` 17 | 18 | *Note: The parameter `compareFunc` is optional, `===` will be used if no compare function supplied.* 19 | 20 | * `diff(arrayOld, arrayNew, compareFunc?)` - Get the difference the two array. 21 | 22 | Return an object of the difference. Like this: 23 | 24 | ``` 25 | { 26 | removed: [1,2,3], 27 | added: [2,3,4] 28 | } 29 | ``` 30 | 31 | * `getPatch(arrayOld, arrayNew, compareFunc?)` - Get the patch array which transform from old array to the new. 32 | 33 | Return an array of edit action. Like this: 34 | 35 | ``` 36 | [ 37 | { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 38 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 39 | ] 40 | ``` 41 | 42 | 43 | * `applyPatch(arrayOld, patchArray)` - Thansform the old array to the new from the input patch array 44 | 45 | Return the new Array. The input value format can be same of return value of ```getPatch```, and for the ```remove``` type, 46 | the ```items``` can be replaced to ```length``` value which is number. 47 | 48 | ``` 49 | [ 50 | { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 51 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 52 | { type: "remove", oldPos: 5, newPos: 3, length: 3 }, 53 | ] 54 | ``` 55 | 56 | Examples 57 | ---------------------- 58 | 59 | Example for ```same``` on array of number: 60 | 61 | ```js 62 | var diff = require("fast-array-diff"); 63 | 64 | console.log( diff.same([1, 2, 3, 4], [2, 1, 4])); 65 | // Output: [2, 4] 66 | ``` 67 | 68 | Example for ```diff``` on array of Object with a compare function 69 | 70 | ```js 71 | function compare(personA, personB) { 72 | return personA.firstName === personB.firstName && personA.lastName === personB.lastName; 73 | } 74 | 75 | var result = diff.diff([ 76 | { firstName: "Foo", lastName: "Bar" }, 77 | { firstName: "Apple", lastName: "Banana" }, 78 | { firstName: "Foo", lastName: "Bar" } 79 | ], [ 80 | { firstName: "Apple", lastName: "Banana" }, 81 | { firstName: "Square", lastName: "Triangle" } 82 | ], 83 | compare 84 | ); 85 | 86 | // Result is : 87 | // { 88 | // removed:[ 89 | // { firstName: 'Foo', lastName: 'Bar' }, 90 | // { firstName: 'Foo', lastName: 'Bar' } 91 | // ], 92 | // added: [ { firstName: 'Square', lastName: 'Triangle' } ] 93 | // } 94 | ``` 95 | 96 | Example for ```getPatch``` on array of number: 97 | 98 | ```js 99 | var es = diff.getPatch([1, 2, 3], [2, 3, 4]); 100 | 101 | // Result is: 102 | // [ 103 | // { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 104 | // { type: "add", oldPos: 3, newPos: 2, items: [4] }, 105 | // ] 106 | ``` 107 | 108 | Example for ```applyPatch```: 109 | 110 | ```js 111 | var arr = diff.applyPatch([1, 2, 3], [ 112 | { type: "remove", oldPos: 0, newPos: 0, length: 1 }, 113 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 114 | ]); 115 | 116 | // Result is: 117 | // [2, 3, 4] 118 | ``` 119 | 120 | 121 | ## License 122 | 123 | This module is licensed under MIT. 124 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/diff/apply.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Patches an array based on a patch description returning the patched array. 3 | * 4 | * @param a the array of items to patch 5 | * @param patch the patch to be applied 6 | * @return {*[]} the patched array 7 | */ 8 | export function applyPatch(a, patch) { 9 | const segments = []; 10 | 11 | let sameStart = 0; 12 | 13 | for (let i = 0; i < patch.length; ++i) { 14 | const patchItem = patch[i]; 15 | 16 | sameStart !== patchItem.oldPos && segments.push(a.slice(sameStart, patchItem.oldPos)); 17 | if (patchItem.type === "add") { 18 | segments.push(patchItem.items); 19 | sameStart = patchItem.oldPos; 20 | } else if (patchItem.items) { 21 | sameStart = patchItem.oldPos + patchItem.items.length; 22 | } else { 23 | sameStart = patchItem.oldPos + patchItem.length; 24 | } 25 | } 26 | sameStart !== a.length && segments.push(a.slice(sameStart)); 27 | 28 | // eslint-disable-next-line prefer-reflect 29 | return [].concat.apply([], segments); 30 | } 31 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/diff/diff.js: -------------------------------------------------------------------------------- 1 | import bestSubSequence from "./lcs"; 2 | 3 | /** 4 | * Computes the differences between the two arrays. 5 | * 6 | * @param {array} a the base array 7 | * @param {array} b the target array 8 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 9 | * @return {object} the difference between the arrays 10 | */ 11 | export function diff( 12 | a, b, 13 | compareFunc = (ia, ib) => ia === ib 14 | ) { 15 | const ret = { 16 | removed: [], 17 | added: [], 18 | }; 19 | 20 | bestSubSequence( 21 | a, b, compareFunc, 22 | (type, oldArr, oldStart, oldEnd, newArr, newStart, newEnd) => { 23 | if (type === "add") { 24 | for (let i = newStart; i < newEnd; ++i) { 25 | ret.added.push(newArr[i]); 26 | } 27 | } else if (type === "remove") { 28 | for (let i = oldStart; i < oldEnd; ++i) { 29 | ret.removed.push(oldArr[i]); 30 | } 31 | } 32 | } 33 | ); 34 | return ret; 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/diff/lcs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | 3 | /** 4 | * Longest common subsequence 5 | * 6 | * @param a the base array 7 | * @param b the target array 8 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 9 | * @return {number} 10 | */ 11 | function lcs(a, b, compareFunc) { 12 | const M = a.length, N = b.length; 13 | const MAX = M + N; 14 | 15 | const v = { 1: 0 }; 16 | 17 | for (let d = 0; d <= MAX; ++d) { 18 | for (let k = -d; k <= d; k += 2) { 19 | let x; 20 | 21 | if (k === -d || k !== d && v[k - 1] + 1 < v[k + 1]) { 22 | x = v[k + 1]; 23 | } else { 24 | x = v[k - 1] + 1; 25 | } 26 | let y = x - k; 27 | 28 | while (x < M && y < N && compareFunc(a[x] , b[y])) { 29 | x++; 30 | y++; 31 | } 32 | if (x === M && y === N) { 33 | return d; 34 | } 35 | v[k] = x; 36 | } 37 | } 38 | return -1; // never reach 39 | } 40 | 41 | const Direct = { 42 | none: 0, 43 | horizontal: 1, 44 | vertical: 1 << 1, 45 | diagonal: 1 << 2 46 | }; 47 | 48 | Direct.all = Direct.horizontal | Direct.vertical | Direct.diagonal; 49 | 50 | /** 51 | * 52 | * @param a 53 | * @param aStart 54 | * @param aEnd 55 | * @param b 56 | * @param bStart 57 | * @param bEnd 58 | * @param d 59 | * @param startDirect 60 | * @param endDirect 61 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 62 | * @param elementsChanged 63 | */ 64 | function getSolution( 65 | a, aStart, aEnd, 66 | b, bStart, bEnd, 67 | d, 68 | startDirect, endDirect, 69 | compareFunc, 70 | elementsChanged 71 | ) { 72 | if (d === 0) { 73 | elementsChanged("same", a, aStart, aEnd, b, bStart, bEnd); 74 | return; 75 | } else if (d === (aEnd - aStart) + (bEnd - bStart)) { 76 | const removeFirst = ((startDirect & Direct.horizontal) ? 1 : 0 ) + ((endDirect & Direct.vertical) ? 1 : 0 ); 77 | const addFirst = ((startDirect & Direct.vertical) ? 1 : 0 ) + ((endDirect & Direct.horizontal) ? 1 : 0 ); 78 | 79 | if (removeFirst >= addFirst) { 80 | aStart !== aEnd && elementsChanged("remove", a, aStart, aEnd, b, bStart, bStart); 81 | bStart !== bEnd && elementsChanged("add", a, aEnd, aEnd, b, bStart, bEnd); 82 | } else { 83 | bStart !== bEnd && elementsChanged("add", a, aStart, aStart, b, bStart, bEnd); 84 | aStart !== aEnd && elementsChanged("remove", a, aStart, aEnd, b, bEnd, bEnd); 85 | } 86 | return; 87 | } 88 | 89 | const M = aEnd - aStart, N = bEnd - bStart; 90 | let HALF = Math.floor(N / 2); 91 | 92 | let now = {}; 93 | 94 | for (let k = -d - 1; k <= d + 1; ++k) { 95 | now[k] = {d: Infinity, segments: 0, direct: Direct.none}; 96 | } 97 | let preview = { 98 | [-d - 1]: {d: Infinity, segments: 0, direct: Direct.none}, 99 | [d + 1]: {d: Infinity, segments: 0, direct: Direct.none}, 100 | }; 101 | 102 | for (let y = 0; y <= HALF; ++y) { 103 | [now, preview] = [preview, now]; 104 | for (let k = -d; k <= d; ++k) { 105 | const x = y + k; 106 | 107 | if (y === 0 && x === 0) { 108 | now[k] = { 109 | d: 0, 110 | segments: 0, 111 | direct: startDirect, 112 | }; 113 | continue; 114 | } 115 | 116 | const currentPoints = [{ 117 | direct: Direct.horizontal, 118 | d: now[k - 1].d + 1, 119 | segments: now[k - 1].segments + (now[k - 1].direct & Direct.horizontal ? 0 : 1), 120 | }, { 121 | direct: Direct.vertical, 122 | d: preview[k + 1].d + 1, 123 | segments: preview[k + 1].segments + (preview[k + 1].direct & Direct.vertical ? 0 : 1), 124 | }]; 125 | 126 | if (x > 0 && x <= M && y > 0 && y <= N && compareFunc(a[aStart + x - 1], b[bStart + y - 1])) { 127 | currentPoints.push({ 128 | direct: Direct.diagonal, 129 | d: preview[k].d, 130 | segments: preview[k].segments + (preview[k].direct & Direct.diagonal ? 0 : 1), 131 | }); 132 | } 133 | 134 | const bestValue = currentPoints.reduce((best, info) => { 135 | if (best.d > info.d) { 136 | return info; 137 | } else if (best.d === info.d && best.segments > info.segments) { 138 | return info; 139 | } 140 | return best; 141 | }); 142 | 143 | currentPoints.forEach(info => { 144 | if (bestValue.d === info.d && bestValue.segments === info.segments) { 145 | bestValue.direct |= info.direct; 146 | } 147 | }); 148 | now[k] = bestValue; 149 | } 150 | } 151 | 152 | let now2 = {}; 153 | 154 | for (let k = -d - 1; k <= d + 1; ++k) { 155 | now2[k] = {d: Infinity, segments: 0, direct: Direct.none}; 156 | } 157 | let preview2 = { 158 | [-d - 1]: {d: Infinity, segments: 0, direct: Direct.none}, 159 | [d + 1]: {d: Infinity, segments: 0, direct: Direct.none}, 160 | }; 161 | 162 | for (let y = N; y >= HALF; --y) { 163 | [now2, preview2] = [preview2, now2]; 164 | for (let k = d; k >= -d; --k) { 165 | const x = y + k; 166 | 167 | if (y === N && x === M) { 168 | now2[k] = { 169 | d: 0, 170 | segments: 0, 171 | direct: endDirect, 172 | }; 173 | continue; 174 | } 175 | 176 | const currentPoints = [{ 177 | direct: Direct.horizontal, 178 | d: now2[k + 1].d + 1, 179 | segments: now2[k + 1].segments + (now2[k + 1].direct & Direct.horizontal ? 0 : 1), 180 | }, { 181 | direct: Direct.vertical, 182 | d: preview2[k - 1].d + 1, 183 | segments: preview2[k - 1].segments + (preview2[k - 1].direct & Direct.vertical ? 0 : 1), 184 | }]; 185 | 186 | if (x >= 0 && x < M && y >= 0 && y < N && compareFunc(a[aStart + x], b[bStart + y])) { 187 | currentPoints.push({ 188 | direct: Direct.diagonal, 189 | d: preview2[k].d, 190 | segments: preview2[k].segments + (preview2[k].direct & Direct.diagonal ? 0 : 1), 191 | }); 192 | } 193 | 194 | const bestValue = currentPoints.reduce((best, info) => { 195 | if (best.d > info.d) { 196 | return info; 197 | } else if (best.d === info.d && best.segments > info.segments) { 198 | return info; 199 | } 200 | return best; 201 | }); 202 | 203 | currentPoints.forEach(info => { 204 | if (bestValue.d === info.d && bestValue.segments === info.segments) { 205 | bestValue.direct |= info.direct; 206 | } 207 | }); 208 | now2[k] = bestValue; 209 | } 210 | } 211 | const best = { 212 | k: -1, 213 | d: Infinity, 214 | segments: 0, 215 | direct: Direct.none, 216 | }; 217 | 218 | for (let k = -d; k <= d; ++ k) { 219 | const dSum = now[k].d + now2[k].d; 220 | 221 | if (dSum < best.d) { 222 | best.k = k; 223 | best.d = dSum; 224 | best.segments = now[k].segments + now2[k].segments + (now[k].segments & now2[k].segments ? 0 : 1); 225 | best.direct = now2[k].direct; 226 | } else if (dSum === best.d) { 227 | const segments = now[k].segments + now2[k].segments + (now[k].segments & now2[k].segments ? 0 : 1); 228 | 229 | if (segments < best.segments) { 230 | best.k = k; 231 | best.d = dSum; 232 | best.segments = segments; 233 | best.direct = now2[k].direct; 234 | } else if (segments === best.segments && !(best.direct & Direct.diagonal) && (now2[k].direct & Direct.diagonal)) { 235 | best.k = k; 236 | best.d = dSum; 237 | best.segments = segments; 238 | best.direct = now2[k].direct; 239 | } 240 | } 241 | } 242 | 243 | if (HALF + best.k === 0 && HALF === 0) { 244 | HALF++; 245 | now[best.k].direct = now2[best.k].direct; 246 | now2[best.k].direct = preview2[best.k].direct; 247 | } 248 | 249 | getSolution(a, aStart, aStart + HALF + best.k, b, bStart, bStart + HALF, 250 | now[best.k].d, startDirect, now2[best.k].direct, compareFunc, elementsChanged); 251 | getSolution(a, aStart + HALF + best.k, aEnd, b, bStart + HALF, bEnd, 252 | now2[best.k].d, now[best.k].direct, endDirect, compareFunc, elementsChanged); 253 | } 254 | 255 | /** 256 | * 257 | * @param a 258 | * @param b 259 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 260 | * @param elementsChanged 261 | */ 262 | export default function bestSubSequence( 263 | a, b, compareFunc, 264 | elementsChanged 265 | ) { 266 | const d = lcs(a, b, compareFunc); 267 | 268 | getSolution(a, 0, a.length, b, 0, b.length, d, Direct.diagonal, Direct.all, compareFunc, elementsChanged); 269 | } 270 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/diff/patch.js: -------------------------------------------------------------------------------- 1 | import bestSubSequence from "./lcs"; 2 | 3 | /** 4 | * Computes the patch necessary to turn array a into array b. 5 | * 6 | * @param a the base array 7 | * @param b the target array 8 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 9 | * @return {object} the computed patch 10 | */ 11 | export function getPatch( 12 | a, b, 13 | compareFunc = (ia, ib) => ia === ib) { 14 | const patch = []; 15 | let lastAdd = null; 16 | let lastRemove = null; 17 | 18 | /** 19 | * 20 | * @param {string} type "add" | "remove" | "same" 21 | * @param {array} oldArr the old array 22 | * @param {number} oldStart the old start 23 | * @param {number} oldEnd the old end 24 | * @param {array} newArr the new array 25 | * @param {number} newStart the new start 26 | * @param {number} newEnd the new end 27 | */ 28 | function pushChange( 29 | type, 30 | oldArr, oldStart, oldEnd, 31 | newArr, newStart, newEnd) { 32 | if (type === "same") { 33 | if (lastRemove) { 34 | patch.push(lastRemove); 35 | } 36 | if (lastAdd) { 37 | patch.push(lastAdd); 38 | } 39 | lastRemove = null; 40 | lastAdd = null; 41 | } else if (type === "remove") { 42 | if (!lastRemove) { 43 | lastRemove = { 44 | type: "remove", 45 | oldPos: oldStart, 46 | newPos: newStart, 47 | items: [], 48 | }; 49 | } 50 | for (let i = oldStart; i < oldEnd; ++i) { 51 | lastRemove.items.push(oldArr[i]); 52 | } 53 | if (lastAdd) { 54 | lastAdd.oldPos += oldEnd - oldStart; 55 | if (lastRemove.oldPos === oldStart) { 56 | lastRemove.newPos -= oldEnd - oldStart; 57 | } 58 | } 59 | } else if (type === "add") { 60 | if (!lastAdd) { 61 | lastAdd = { 62 | type: "add", 63 | oldPos: oldStart, 64 | newPos: newStart, 65 | items: [], 66 | }; 67 | } 68 | for (let i = newStart; i < newEnd; ++i) { 69 | lastAdd.items.push(newArr[i]); 70 | } 71 | } 72 | } 73 | 74 | bestSubSequence(a, b, compareFunc, pushChange); 75 | 76 | pushChange("same", [], 0, 0, [], 0, 0); 77 | 78 | return patch; 79 | } 80 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/diff/same.js: -------------------------------------------------------------------------------- 1 | import bestSubSequence from "./lcs"; 2 | 3 | /** 4 | * 5 | * @param a the base array 6 | * @param b the target array 7 | * @param compareFunc the comparison function used to determine equality (a, b) => boolean 8 | * @return {array} 9 | */ 10 | export default function ( 11 | a, b, 12 | compareFunc = (ia, ib) => ia === ib 13 | ) { 14 | const ret = []; 15 | 16 | bestSubSequence( 17 | a, b, compareFunc, 18 | (type, oldArr, oldStart, oldEnd) => { 19 | if (type === "same") { 20 | for (let i = oldStart; i < oldEnd; ++i) { 21 | ret.push(oldArr[i]); 22 | } 23 | } 24 | } 25 | ); 26 | return ret; 27 | } 28 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/arrayDiff/index.js: -------------------------------------------------------------------------------- 1 | import same from "./diff/same"; 2 | 3 | export * from "./diff/diff"; 4 | export { 5 | same 6 | }; 7 | 8 | export * from "./diff/patch"; 9 | 10 | export * from "./diff/apply"; 11 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/diff.js: -------------------------------------------------------------------------------- 1 | import { 2 | DIFF_STATUS_ARRAY_UPDATED, 3 | DIFF_STATUS_KEYS_UPDATED, 4 | DIFF_STATUS_REMOVED, 5 | DIFF_STATUS_UPDATED 6 | } from '../constants'; 7 | import { getPatch as getArrayPatch } from './arrayDiff'; 8 | 9 | const objectConstructor = ({}).constructor; 10 | 11 | function isObject(o) { 12 | return typeof o === "object" && o !== null && o.constructor === objectConstructor; 13 | } 14 | 15 | function shouldTreatAsValue(oldObj, newObj) { 16 | const bothAreArrays = Array.isArray(oldObj) && Array.isArray(newObj); 17 | 18 | return (!isObject(newObj) && !bothAreArrays) || typeof newObj !== typeof oldObj; 19 | } 20 | 21 | function diffValues(oldObj, newObj, shouldContinue, context) { 22 | // If it's null, use the current value 23 | if (oldObj === null) { 24 | return { change: DIFF_STATUS_UPDATED, value: newObj }; 25 | } 26 | 27 | // If it's a non-object, or if the type is changing, or if it's an array, 28 | // just go with the current value. 29 | if (shouldTreatAsValue(oldObj, newObj) || !shouldContinue(oldObj, newObj, context)) { 30 | return { change: DIFF_STATUS_UPDATED, value: newObj }; 31 | } 32 | 33 | if (Array.isArray(oldObj) && Array.isArray(newObj)) { 34 | return { change: DIFF_STATUS_ARRAY_UPDATED, value: getArrayPatch(oldObj, newObj) }; 35 | } 36 | 37 | // If it's an object, compute the differences for each key. 38 | return { change: DIFF_STATUS_KEYS_UPDATED, value: diffObjects(oldObj, newObj, shouldContinue, context) }; 39 | } 40 | 41 | /** 42 | * Performs a deep diff on two objects, created a nested list of patches. For objects, each key is compared. 43 | * If keys are not equal by reference, diffing continues on the key's corresponding values in the old and new 44 | * objects. If keys have been removed, they are recorded as such. 45 | * Non-object, non-array values that are not equal are recorded as updated values. Arrays are diffed shallowly. 46 | * The shouldContinue function is called on every potential value comparison with the current and previous objects 47 | * (at the present state in the tree) and the current path through the tree as an additional `context` parameter. 48 | * Returning false from this function will treat the current value as an updated value, regardless of whether or 49 | * not it is actually an object. 50 | * @param {Object} oldObj The old object 51 | * @param {Object} newObj The new object 52 | * @param {Function} shouldContinue Called with oldObj, newObj, and context, which is the current object path 53 | * Return false to stop diffing and treat everything under the current key as an updated value 54 | * @param {*} context 55 | */ 56 | export default function diffObjects(oldObj, newObj, shouldContinue = () => true, context = []) { 57 | const difference = []; 58 | 59 | // For each key in the current state, 60 | // get the differences in values. 61 | Object.keys(newObj).forEach((key) => { 62 | if (oldObj[key] !== newObj[key]) { 63 | difference.push({ 64 | key, 65 | ...diffValues(oldObj[key], newObj[key], shouldContinue, context.concat(key)) 66 | }); 67 | } 68 | }); 69 | 70 | // For each key previously present, 71 | // record its deletion. 72 | Object.keys(oldObj).forEach(key => { 73 | if (!newObj.hasOwnProperty(key)) { 74 | difference.push({ 75 | key, change: DIFF_STATUS_REMOVED 76 | }); 77 | } 78 | }); 79 | 80 | return difference; 81 | } 82 | -------------------------------------------------------------------------------- /src/strategies/deepDiff/makeDiff.js: -------------------------------------------------------------------------------- 1 | import diffObjects from './diff'; 2 | 3 | /** 4 | * A higher order function that takes a `shouldContinue` function 5 | * and returns a custom deep diff function that uses the provided 6 | * `shouldContinue` function to decide when to stop traversing 7 | * the state tree. 8 | * @param {Function} shouldContinue A function, called during 9 | * diffing just after each state tree traversal, which should 10 | * return a boolean indicating whether or not to continue down 11 | * the tree, or to just treat the current object as a value. It 12 | * is called with the old state, the new state, and the current 13 | * position in the state tree (provided as a list of keys so far). 14 | */ 15 | export default function makeDiff(shouldContinue) { 16 | return function (oldObj, newObj) { 17 | return diffObjects(oldObj, newObj, shouldContinue); 18 | }; 19 | } -------------------------------------------------------------------------------- /src/strategies/deepDiff/patch.js: -------------------------------------------------------------------------------- 1 | import { 2 | DIFF_STATUS_ARRAY_UPDATED, 3 | DIFF_STATUS_KEYS_UPDATED, 4 | DIFF_STATUS_REMOVED, 5 | DIFF_STATUS_UPDATED 6 | } from '../constants'; 7 | import { applyPatch as applyArrayPatch } from './arrayDiff'; 8 | 9 | /** 10 | * Patches the given object according to the specified list of patches. 11 | * @param {Object} obj The object to patch 12 | * @param {Array} difference The array of differences generated from diffing 13 | */ 14 | export default function patchObject(obj, difference) { 15 | if (!difference.length) { 16 | return obj; 17 | } 18 | 19 | // Start with a shallow copy of the object. 20 | const newObject = { ...obj }; 21 | 22 | // Iterate through the patches. 23 | difference.forEach(patch => { 24 | // If the value is an object whose keys are being updated, 25 | // then recursively patch the object. 26 | if (patch.change === DIFF_STATUS_KEYS_UPDATED) { 27 | newObject[patch.key] = patchObject(newObject[patch.key], patch.value); 28 | } 29 | // If the key has been deleted, delete it. 30 | else if (patch.change === DIFF_STATUS_REMOVED) { 31 | Reflect.deleteProperty(newObject, patch.key); 32 | } 33 | // If the key has been updated to a new value, update it. 34 | else if (patch.change === DIFF_STATUS_UPDATED) { 35 | newObject[patch.key] = patch.value; 36 | } 37 | // If the value is an array, update it 38 | else if (patch.change === DIFF_STATUS_ARRAY_UPDATED) { 39 | newObject[patch.key] = applyArrayPatch(newObject[patch.key], patch.value); 40 | } 41 | }); 42 | return newObject; 43 | } -------------------------------------------------------------------------------- /src/strategies/shallowDiff/diff.js: -------------------------------------------------------------------------------- 1 | import { 2 | DIFF_STATUS_UPDATED, 3 | DIFF_STATUS_REMOVED 4 | } from '../constants'; 5 | 6 | /** 7 | * Returns a new Object containing only the fields in `new` that differ from `old` 8 | * 9 | * @param {Object} old 10 | * @param {Object} new 11 | * @return {Array} An array of changes. The changes have a `key`, `value`, and `change`. 12 | * The change is either `updated`, which is if the value has changed or been added, 13 | * or `removed`. 14 | */ 15 | export default function shallowDiff(oldObj, newObj) { 16 | const difference = []; 17 | 18 | Object.keys(newObj).forEach((key) => { 19 | if (oldObj[key] !== newObj[key]) { 20 | difference.push({ 21 | key, 22 | value: newObj[key], 23 | change: DIFF_STATUS_UPDATED, 24 | }); 25 | } 26 | }); 27 | 28 | Object.keys(oldObj).forEach(key => { 29 | if (!newObj.hasOwnProperty(key)) { 30 | difference.push({ 31 | key, 32 | change: DIFF_STATUS_REMOVED, 33 | }); 34 | } 35 | }); 36 | 37 | return difference; 38 | } 39 | -------------------------------------------------------------------------------- /src/strategies/shallowDiff/patch.js: -------------------------------------------------------------------------------- 1 | import { DIFF_STATUS_UPDATED, DIFF_STATUS_REMOVED } from "../constants"; 2 | 3 | export default function (obj, difference) { 4 | const newObj = Object.assign({}, obj); 5 | 6 | difference.forEach(({change, key, value}) => { 7 | switch (change) { 8 | case DIFF_STATUS_UPDATED: 9 | newObj[key] = value; 10 | break; 11 | 12 | case DIFF_STATUS_REMOVED: 13 | Reflect.deleteProperty(newObj, key); 14 | break; 15 | 16 | default: 17 | // do nothing 18 | } 19 | }); 20 | 21 | return newObj; 22 | } -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Looks for a global browser api, first checking the chrome namespace and then 3 | * checking the browser namespace. If no appropriate namespace is present, this 4 | * function will throw an error. 5 | */ 6 | export function getBrowserAPI() { 7 | let api; 8 | 9 | try { 10 | // eslint-disable-next-line no-undef 11 | api = self.chrome || self.browser || browser; 12 | } catch (error) { 13 | // eslint-disable-next-line no-undef 14 | api = browser; 15 | } 16 | 17 | if (!api) { 18 | throw new Error("Browser API is not present"); 19 | } 20 | 21 | return api; 22 | } 23 | -------------------------------------------------------------------------------- /src/wrap-store/wrapStore.js: -------------------------------------------------------------------------------- 1 | import { 2 | DISPATCH_TYPE, 3 | FETCH_STATE_TYPE, 4 | STATE_TYPE, 5 | PATCH_STATE_TYPE, 6 | DEFAULT_CHANNEL_NAME, 7 | } from "../constants"; 8 | import { withSerializer, withDeserializer, noop } from "../serialization"; 9 | import { getBrowserAPI } from "../util"; 10 | import shallowDiff from "../strategies/shallowDiff/diff"; 11 | import { createDeferredListener } from "../listener"; 12 | 13 | /** 14 | * Responder for promisified results 15 | * @param {object} dispatchResult The result from `store.dispatch()` 16 | * @param {function} send The function used to respond to original message 17 | * @return {undefined} 18 | */ 19 | const promiseResponder = (dispatchResult, send) => { 20 | Promise.resolve(dispatchResult) 21 | .then((res) => { 22 | send({ 23 | error: null, 24 | value: res, 25 | }); 26 | }) 27 | .catch((err) => { 28 | console.error("error dispatching result:", err); 29 | send({ 30 | error: err.message, 31 | value: null, 32 | }); 33 | }); 34 | }; 35 | 36 | const defaultOpts = { 37 | channelName: DEFAULT_CHANNEL_NAME, 38 | dispatchResponder: promiseResponder, 39 | serializer: noop, 40 | deserializer: noop, 41 | diffStrategy: shallowDiff, 42 | }; 43 | 44 | /** 45 | * @typedef {function} WrapStore 46 | * @param {Object} store A Redux store 47 | * @param {Object} options 48 | * @param {function} options.dispatchResponder A function that takes the result 49 | * of a store dispatch and optionally implements custom logic for responding to 50 | * the original dispatch message. 51 | * @param {function} options.serializer A function to serialize outgoing message 52 | * payloads (default is passthrough). 53 | * @param {function} options.deserializer A function to deserialize incoming 54 | * message payloads (default is passthrough). 55 | * @param {function} options.diffStrategy A function to diff the previous state 56 | * and the new state (default is shallow diff). 57 | */ 58 | 59 | /** 60 | * Wraps a Redux store so that proxy stores can connect to it. This function 61 | * must be called synchronously when the extension loads to avoid dropping 62 | * messages that woke the service worker. 63 | * @param {Object} options 64 | * @param {string} options.channelName The name of the channel for this store. 65 | * @return {WrapStore} The wrapStore function that accepts a Redux store and 66 | * options. See {@link WrapStore}. 67 | */ 68 | export default ({ channelName = defaultOpts.channelName } = defaultOpts) => { 69 | const browserAPI = getBrowserAPI(); 70 | 71 | const filterStateMessages = (message) => 72 | message.type === FETCH_STATE_TYPE && message.channelName === channelName; 73 | 74 | const filterActionMessages = (message) => 75 | message.type === DISPATCH_TYPE && message.channelName === channelName; 76 | 77 | // Setup message listeners synchronously to avoid dropping messages if the 78 | // extension is woken by a message. 79 | const stateProviderListener = createDeferredListener(filterStateMessages); 80 | const actionListener = createDeferredListener(filterActionMessages); 81 | 82 | browserAPI.runtime.onMessage.addListener(stateProviderListener.listener); 83 | browserAPI.runtime.onMessage.addListener(actionListener.listener); 84 | 85 | return ( 86 | store, 87 | { 88 | dispatchResponder = defaultOpts.dispatchResponder, 89 | serializer = defaultOpts.serializer, 90 | deserializer = defaultOpts.deserializer, 91 | diffStrategy = defaultOpts.diffStrategy, 92 | } = defaultOpts 93 | ) => { 94 | if (typeof serializer !== "function") { 95 | throw new Error("serializer must be a function"); 96 | } 97 | if (typeof deserializer !== "function") { 98 | throw new Error("deserializer must be a function"); 99 | } 100 | if (typeof diffStrategy !== "function") { 101 | throw new Error( 102 | "diffStrategy must be one of the included diffing strategies or a custom diff function" 103 | ); 104 | } 105 | 106 | /** 107 | * Respond to dispatches from UI components 108 | */ 109 | const dispatchResponse = (request, sender, sendResponse) => { 110 | // Only called with messages that pass the filterActionMessages filter. 111 | const action = Object.assign({}, request.payload, { 112 | _sender: sender, 113 | }); 114 | 115 | let dispatchResult = null; 116 | 117 | try { 118 | dispatchResult = store.dispatch(action); 119 | } catch (e) { 120 | dispatchResult = Promise.reject(e.message); 121 | console.error(e); 122 | } 123 | 124 | dispatchResponder(dispatchResult, sendResponse); 125 | }; 126 | 127 | /** 128 | * Setup for state updates 129 | */ 130 | const serializedMessagePoster = withSerializer(serializer)((...args) => { 131 | const onErrorCallback = () => { 132 | if (browserAPI.runtime.lastError) { 133 | // do nothing - errors can be present 134 | // if no content script exists on receiver 135 | } 136 | }; 137 | 138 | browserAPI.runtime.sendMessage(...args, onErrorCallback); 139 | // We will broadcast state changes to all tabs to sync state across content scripts 140 | return browserAPI.tabs.query({}, (tabs) => { 141 | for (const tab of tabs) { 142 | browserAPI.tabs.sendMessage(tab.id, ...args, onErrorCallback); 143 | } 144 | }); 145 | }); 146 | 147 | let currentState = store.getState(); 148 | 149 | const patchState = () => { 150 | const newState = store.getState(); 151 | const diff = diffStrategy(currentState, newState); 152 | 153 | if (diff.length) { 154 | currentState = newState; 155 | 156 | serializedMessagePoster({ 157 | type: PATCH_STATE_TYPE, 158 | payload: diff, 159 | channelName, // Notifying what store is broadcasting the state changes 160 | }); 161 | } 162 | }; 163 | 164 | // Send patched state to listeners on every redux store state change 165 | store.subscribe(patchState); 166 | 167 | // Send store's initial state 168 | serializedMessagePoster({ 169 | type: STATE_TYPE, 170 | payload: currentState, 171 | channelName, // Notifying what store is broadcasting the state changes 172 | }); 173 | 174 | /** 175 | * State provider for content-script initialization 176 | */ 177 | stateProviderListener.setListener((request, sender, sendResponse) => { 178 | // This listener is only called with messages that pass filterStateMessages 179 | const state = store.getState(); 180 | 181 | sendResponse({ 182 | type: FETCH_STATE_TYPE, 183 | payload: state, 184 | }); 185 | }); 186 | 187 | /** 188 | * Setup action handler 189 | */ 190 | const withPayloadDeserializer = withDeserializer(deserializer); 191 | 192 | withPayloadDeserializer(actionListener.setListener)( 193 | dispatchResponse, 194 | filterActionMessages 195 | ); 196 | }; 197 | }; 198 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/Store.test.js: -------------------------------------------------------------------------------- 1 | import "@babel/polyfill"; 2 | 3 | import should from "should"; 4 | import sinon from "sinon"; 5 | 6 | import { Store } from "../src"; 7 | import { DISPATCH_TYPE, FETCH_STATE_TYPE, STATE_TYPE } from "../src/constants"; 8 | import { 9 | DIFF_STATUS_UPDATED, 10 | DIFF_STATUS_REMOVED, 11 | } from "../src/strategies/constants"; 12 | 13 | describe("Store", function () { 14 | const channelName = "test"; 15 | 16 | beforeEach(function () { 17 | global.self = {}; 18 | 19 | // Mock chrome.runtime API 20 | self.chrome = { 21 | runtime: { 22 | connect() { 23 | return { 24 | onMessage: { 25 | addListener() {}, 26 | }, 27 | }; 28 | }, 29 | sendMessage(data, options, cb) { 30 | cb(); 31 | }, 32 | onMessage: { 33 | addListener: () => {}, 34 | }, 35 | }, 36 | }; 37 | }); 38 | 39 | describe("#new Store()", function () { 40 | let listeners; 41 | 42 | beforeEach(function () { 43 | // mock connect.onMessage listeners array 44 | listeners = []; 45 | 46 | // override mock chrome API for this test 47 | self.chrome.runtime = { 48 | sendMessage: () => {}, 49 | onMessage: { 50 | addListener: (listener) => { 51 | listeners.push(listener); 52 | }, 53 | }, 54 | }; 55 | }); 56 | 57 | it("should setup a listener on the channel defined by the channelName option", function () { 58 | const spy = (self.chrome.runtime.sendMessage = sinon.spy()); 59 | 60 | new Store({ channelName }); 61 | 62 | spy.calledOnce.should.eql(true); 63 | spy 64 | .alwaysCalledWith({ 65 | type: FETCH_STATE_TYPE, 66 | channelName, 67 | }) 68 | .should.eql(true); 69 | }); 70 | 71 | it("should call replaceState on new state messages", function () { 72 | const store = new Store({ channelName }); 73 | 74 | // make replaceState() a spy function 75 | store.replaceState = sinon.spy(); 76 | 77 | const [l] = listeners; 78 | 79 | const payload = { 80 | a: 1, 81 | }; 82 | 83 | // send one state type message 84 | l({ 85 | type: STATE_TYPE, 86 | payload, 87 | channelName, 88 | }); 89 | 90 | // send one non-state type message 91 | l({ 92 | type: `NOT_${STATE_TYPE}`, 93 | payload: { 94 | a: 2, 95 | }, 96 | }); 97 | 98 | // make sure replace state was only called once 99 | store.replaceState.calledOnce.should.equal(true); 100 | store.replaceState.firstCall.args[0].should.eql(payload); 101 | }); 102 | 103 | it("should deserialize incoming messages", function () { 104 | const deserializer = sinon.spy(JSON.parse); 105 | const store = new Store({ channelName, deserializer }); 106 | 107 | // make replaceState() a spy function 108 | store.replaceState = sinon.spy(); 109 | 110 | const [l] = listeners; 111 | 112 | const payload = { 113 | a: 1, 114 | }; 115 | 116 | // send one state type message 117 | l({ 118 | type: STATE_TYPE, 119 | payload: JSON.stringify(payload), 120 | channelName, 121 | }); 122 | 123 | // send one non-state type message 124 | l({ 125 | type: `NOT_${STATE_TYPE}`, 126 | payload: JSON.stringify({ 127 | a: 2, 128 | }), 129 | }); 130 | 131 | // make sure replace state was called with the deserialized payload 132 | store.replaceState.firstCall.args[0].should.eql(payload); 133 | }); 134 | 135 | it("should set the initial state to empty object by default", function () { 136 | const store = new Store({ channelName }); 137 | 138 | store.getState().should.eql({}); 139 | }); 140 | 141 | it("should set the initial state to opts.state if available", function () { 142 | const store = new Store({ channelName, state: { a: "a" } }); 143 | 144 | store.getState().should.eql({ a: "a" }); 145 | }); 146 | 147 | it("should setup a initializeStore listener", function () { 148 | // mock onMessage listeners array 149 | const initializeStoreListener = []; 150 | 151 | // override mock chrome API for this test 152 | self.chrome.runtime.sendMessage = (message, options, listener) => { 153 | initializeStoreListener.push(listener); 154 | }; 155 | 156 | const store = new Store({ channelName }); 157 | 158 | initializeStoreListener.length.should.equal(1); 159 | 160 | const [l] = initializeStoreListener; 161 | 162 | // make readyResolve() a spy function 163 | store.readyResolve = sinon.spy(); 164 | 165 | const payload = { 166 | a: 1, 167 | }; 168 | 169 | // Receive message response 170 | l({ type: FETCH_STATE_TYPE, payload }); 171 | 172 | store.readyResolved.should.eql(true); 173 | store.readyResolve.calledOnce.should.equal(true); 174 | }); 175 | 176 | it("should listen only to channelName state changes", function () { 177 | // mock onMessage listeners array 178 | const stateChangesListener = []; 179 | 180 | // override mock chrome API for this test 181 | self.chrome.runtime = { 182 | onMessage: { 183 | addListener: (listener) => { 184 | stateChangesListener.push(listener); 185 | }, 186 | }, 187 | sendMessage: () => {} 188 | }; 189 | 190 | const store = new Store({ channelName }); 191 | const channelName2 = "test2"; 192 | const store2 = new Store({ channelName: channelName2 }); 193 | 194 | stateChangesListener.length.should.equal(2); 195 | 196 | const [l1, l2] = stateChangesListener; 197 | 198 | // make readyResolve() a spy function 199 | store.readyResolve = sinon.spy(); 200 | store2.readyResolve = sinon.spy(); 201 | 202 | // send message for channel 1 203 | l1({ type: STATE_TYPE, channelName, payload: [{ change: "updated", key: "a", value: "1" }] }); 204 | l2({ type: STATE_TYPE, channelName, payload: [{ change: "updated", key: "b", value: "2" }] }); 205 | 206 | stateChangesListener.length.should.equal(2); 207 | 208 | store.readyResolved.should.eql(true); 209 | store.readyResolve.calledOnce.should.equal(true); 210 | store2.readyResolved.should.eql(false); 211 | store2.readyResolve.calledOnce.should.equal(false); 212 | 213 | // send message for channel 2 214 | l1({ type: STATE_TYPE, channelName: channelName2, payload: [{ change: "updated", key: "a", value: "1" }] }); 215 | l2({ type: STATE_TYPE, channelName: channelName2, payload: [{ change: "updated", key: "b", value: "2" }] }); 216 | stateChangesListener.length.should.equal(2); 217 | store.readyResolved.should.eql(true); 218 | store.readyResolve.calledOnce.should.equal(true); 219 | store2.readyResolved.should.eql(true); 220 | store2.readyResolve.calledOnce.should.equal(true); 221 | }); 222 | }); 223 | 224 | describe("#patchState()", function () { 225 | it("should patch the state of the store", function () { 226 | const store = new Store({ channelName, state: { b: 1 } }); 227 | 228 | store.getState().should.eql({ b: 1 }); 229 | 230 | store.patchState([ 231 | { key: "a", value: 123, change: DIFF_STATUS_UPDATED }, 232 | { key: "b", change: DIFF_STATUS_REMOVED }, 233 | ]); 234 | 235 | store.getState().should.eql({ a: 123 }); 236 | }); 237 | 238 | it("should use the provided patch strategy to patch the state", function () { 239 | // Create a fake patch strategy 240 | const patchStrategy = sinon.spy((state) => ({ 241 | ...state, 242 | a: state.a + 1, 243 | })); 244 | // Initialize the store 245 | const store = new Store({ 246 | channelName, 247 | state: { a: 1, b: 5 }, 248 | patchStrategy, 249 | }); 250 | 251 | store.getState().should.eql({ a: 1, b: 5 }); 252 | 253 | // Patch the state 254 | store.patchState([]); 255 | 256 | const expectedState = { a: 2, b: 5 }; 257 | 258 | // make sure the patch strategy was used 259 | patchStrategy.callCount.should.eql(1); 260 | // make sure the state got patched 261 | store.state.should.eql(expectedState); 262 | }); 263 | }); 264 | 265 | describe("#replaceState()", function () { 266 | it("should replace the state of the store", function () { 267 | const store = new Store({ channelName }); 268 | 269 | store.getState().should.eql({}); 270 | 271 | store.replaceState({ a: "a" }); 272 | 273 | store.getState().should.eql({ a: "a" }); 274 | }); 275 | }); 276 | 277 | describe("#getState()", function () { 278 | it("should get the current state of the Store", function () { 279 | const store = new Store({ channelName, state: { a: "a" } }); 280 | 281 | store.getState().should.eql({ a: "a" }); 282 | 283 | store.replaceState({ b: "b" }); 284 | 285 | store.getState().should.eql({ b: "b" }); 286 | }); 287 | }); 288 | 289 | describe("#subscribe()", function () { 290 | it("should register a listener for state changes", function () { 291 | const store = new Store({ channelName }), 292 | newState = { b: "b" }; 293 | 294 | let callCount = 0; 295 | 296 | store.subscribe(() => { 297 | callCount += 1; 298 | store.getState().should.eql(newState); 299 | }); 300 | 301 | store.replaceState(newState); 302 | 303 | callCount.should.eql(1); 304 | }); 305 | 306 | it("should return a function which will unsubscribe the listener", function () { 307 | const store = new Store({ channelName }), 308 | listener = sinon.spy(), 309 | unsub = store.subscribe(listener); 310 | 311 | store.replaceState({ b: "b" }); 312 | 313 | listener.calledOnce.should.eql(true); 314 | 315 | unsub(); 316 | 317 | store.replaceState({ c: "c" }); 318 | 319 | listener.calledOnce.should.eql(true); 320 | }); 321 | }); 322 | 323 | describe("#dispatch()", function () { 324 | it("should send a message with the correct dispatch type and payload", function () { 325 | const spy = (self.chrome.runtime.sendMessage = sinon.spy()), 326 | store = new Store({ channelName }); 327 | 328 | store.dispatch({ a: "a" }); 329 | 330 | spy.callCount.should.eql(2); 331 | 332 | spy.args[0][0].should.eql({ type: FETCH_STATE_TYPE, channelName: "test" }); 333 | spy.args[1][0].should.eql({ type: DISPATCH_TYPE, channelName: "test", payload: { a: "a" } }); 334 | }); 335 | 336 | it("should serialize payloads before sending", function () { 337 | const spy = (self.chrome.runtime.sendMessage = sinon.spy()), 338 | serializer = sinon.spy(JSON.stringify), 339 | store = new Store({ channelName, serializer }); 340 | 341 | store.dispatch({ a: "a" }); 342 | 343 | 344 | spy.callCount.should.eql(2); 345 | 346 | spy.args[0][0].should.eql({ type: FETCH_STATE_TYPE, channelName: "test" }); 347 | spy.args[1][0].should.eql({ type: DISPATCH_TYPE, channelName: "test", payload: JSON.stringify({ a: "a" }) }); 348 | }); 349 | 350 | it("should return a promise that resolves with successful action", function () { 351 | self.chrome.runtime.sendMessage = (data, options, cb) => { 352 | cb({ value: { payload: "hello" } }); 353 | }; 354 | 355 | const store = new Store({ channelName }), 356 | p = store.dispatch({ a: "a" }); 357 | 358 | return p.should.be.fulfilledWith("hello"); 359 | }); 360 | 361 | it("should return a promise that rejects with an action error", function () { 362 | self.chrome.runtime.sendMessage = (data, options, cb) => { 363 | cb({ value: { payload: "hello" }, error: { extraMsg: "test" } }); 364 | }; 365 | 366 | const store = new Store({ channelName }), 367 | p = store.dispatch({ a: "a" }); 368 | 369 | return p.should.be.rejectedWith(Error, { extraMsg: "test" }); 370 | }); 371 | 372 | it("should return a promise that resolves with undefined for an undefined return value", function () { 373 | self.chrome.runtime.sendMessage = (data, options, cb) => { 374 | cb({ value: undefined }); 375 | }; 376 | 377 | const store = new Store({ channelName }), 378 | p = store.dispatch({ a: "a" }); 379 | 380 | return p.should.be.fulfilledWith(undefined); 381 | }); 382 | }); 383 | 384 | describe("when validating options", function () { 385 | it("should use defaults if no options present", function () { 386 | should.doesNotThrow(() => new Store()); 387 | }); 388 | 389 | it("should throw an error if serializer is not a function", function () { 390 | should.throws(() => { 391 | new Store({ channelName, serializer: "abc" }); 392 | }, Error); 393 | }); 394 | 395 | it("should throw an error if deserializer is not a function", function () { 396 | should.throws(() => { 397 | new Store({ channelName, deserializer: "abc" }); 398 | }, Error); 399 | }); 400 | 401 | it("should throw an error if patchStrategy is not a function", function () { 402 | should.throws(() => { 403 | new Store({ channelName, patchStrategy: "abc" }); 404 | }, Error); 405 | }); 406 | }); 407 | }); 408 | -------------------------------------------------------------------------------- /test/alias.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | import sinon from 'sinon'; 3 | 4 | import { alias } from '../src'; 5 | 6 | const getSessionAction = { 7 | type: 'GET_SESSION', 8 | payload: { 9 | withUser: true 10 | } 11 | }; 12 | 13 | describe('#alias()', function () { 14 | const getSessionAlias = sinon.stub().returns({ 15 | type: 'GET_SESSION_ALIAS', 16 | payload: { 17 | withUser: true, 18 | alias: true 19 | } 20 | }), 21 | aliases = alias({ 22 | GET_SESSION: getSessionAlias 23 | }); 24 | 25 | it('should call an alias when matching action type', function () { 26 | const next = sinon.spy(); 27 | 28 | aliases()(next)(getSessionAction); 29 | 30 | should.exist(next.args[0][0]); 31 | should(next.args[0][0].type).eql('GET_SESSION_ALIAS'); 32 | should(next.args[0][0].payload).eql({ 33 | withUser: true, 34 | alias: true 35 | }); 36 | }); 37 | 38 | it('should call original action if no matching alias', function () { 39 | const next = sinon.spy(); 40 | 41 | aliases()(next)({ 42 | type: 'ACTION_2', 43 | payload: { 44 | actionStuff: true 45 | } 46 | }); 47 | 48 | should.exist(next.args[0][0]); 49 | should(next.args[0][0].type).eql('ACTION_2'); 50 | should(next.args[0][0].payload).eql({ 51 | actionStuff: true 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/applyMiddleware.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | import sinon from 'sinon'; 3 | import { Store, applyMiddleware } from '../src'; 4 | 5 | // Adapt tests from applyMiddleware spec from Redux 6 | describe('applyMiddleware', function () { 7 | const channelName = 'test'; 8 | // simulates redux-thunk middleware 9 | const thunk = ({ dispatch, getState }) => next => action => 10 | typeof action === 'function' ? action(dispatch, getState) : next(action); 11 | 12 | beforeEach(function () { 13 | global.self = {}; 14 | 15 | // Mock chrome.runtime API 16 | self.chrome = { 17 | runtime: { 18 | sendMessage: () => {}, 19 | onMessage: { 20 | addListener: () => {} 21 | } 22 | } 23 | }; 24 | }); 25 | 26 | it('warns when dispatching during middleware setup', () => { 27 | function dispatchingMiddleware(store) { 28 | store.dispatch({type:'anything'}); 29 | return next => action => next(action); 30 | } 31 | const middleware = [dispatchingMiddleware]; 32 | 33 | should.throws(() => { 34 | applyMiddleware(new Store({channelName, state: {a: 'a'}}), ...middleware); 35 | }, Error); 36 | }); 37 | 38 | it('wraps dispatch method with middleware once', () => { 39 | function test(spyOnMethods) { 40 | return methods => { 41 | spyOnMethods(methods); 42 | return next => action => next(action); 43 | }; 44 | } 45 | 46 | const spy = sinon.spy(); 47 | const store = applyMiddleware(new Store({channelName}), test(spy), thunk); 48 | 49 | store.dispatch(() => ({a: 'a'})); 50 | 51 | spy.calledOnce.should.eql(true); 52 | 53 | spy.args[0][0].should.have.property('getState'); 54 | spy.args[0][0].should.have.property('dispatch'); 55 | }); 56 | 57 | it('passes recursive dispatches through the middleware chain', () => { 58 | self.chrome.runtime.sendMessage = (data, options, cb) => { 59 | cb(data.payload); 60 | }; 61 | function test(spyOnMethods) { 62 | return () => next => action => { 63 | spyOnMethods(action); 64 | return next(action); 65 | }; 66 | } 67 | function asyncActionCreator(data) { 68 | return dispatch => 69 | new Promise((resolve) => 70 | setTimeout(() => { 71 | dispatch(() => data); 72 | resolve(); 73 | }, 0) 74 | ); 75 | } 76 | 77 | const spy = sinon.spy(); 78 | const store = applyMiddleware(new Store({channelName}), test(spy), thunk); 79 | 80 | return store.dispatch(asyncActionCreator({a: 'a'})) 81 | .then(() => { 82 | spy.args.length.should.eql(2); 83 | }); 84 | }); 85 | 86 | it('passes through all arguments of dispatch calls from within middleware', () => { 87 | const spy = sinon.spy(); 88 | const testCallArgs = ['test']; 89 | 90 | function multiArgMiddleware() { 91 | return next => (action, callArgs) => { 92 | if (Array.isArray(callArgs)) { 93 | return action(...callArgs); 94 | } 95 | return next(action); 96 | }; 97 | } 98 | 99 | function dummyMiddleware({ dispatch }) { 100 | return next => action => { // eslint-disable-line no-unused-vars 101 | return dispatch(action, testCallArgs); 102 | }; 103 | } 104 | 105 | const store = applyMiddleware(new Store({channelName}), multiArgMiddleware, dummyMiddleware); 106 | 107 | store.dispatch(spy); 108 | spy.args[0].should.eql(testCallArgs); 109 | }); 110 | 111 | it('should be able to access getState from thunk', function () { 112 | const middleware = [thunk]; 113 | const store = applyMiddleware(new Store({channelName, state: {a: 'a'}}), ...middleware); 114 | 115 | store.getState().should.eql({a: 'a'}); 116 | store.dispatch((dispatch, getState) => { 117 | getState().should.eql({a: 'a'}); 118 | }); 119 | }); 120 | }); -------------------------------------------------------------------------------- /test/arrayDiff/apply.test.js: -------------------------------------------------------------------------------- 1 | import * as diff from "../../src/strategies/deepDiff/arrayDiff/diff/apply"; 2 | import * as assert from "assert"; 3 | 4 | /** 5 | * Test for same function 6 | */ 7 | describe("Apply Patch", () => { 8 | 9 | it("Array not modified by function", () => { 10 | const a = [1, 2, 3]; 11 | 12 | diff.applyPatch(a, [ 13 | { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 14 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 15 | ]); 16 | assert.deepStrictEqual(a, [1, 2, 3], "input array changed!"); 17 | }); 18 | 19 | it("Functional test", () => { 20 | function add(oldPos, newPos, str) { 21 | return { 22 | type: "add", 23 | oldPos, 24 | newPos, 25 | items: str.split(""), 26 | }; 27 | } 28 | function remove(oldPos, newPos, str) { 29 | return { 30 | type: "remove", 31 | oldPos, 32 | newPos, 33 | items: str.split(""), 34 | }; 35 | } 36 | function apply_str(a, b, script, msg) { 37 | assert.deepStrictEqual( 38 | diff.applyPatch(a.split(""), script), 39 | b.split(""), 40 | msg 41 | ); 42 | } 43 | 44 | apply_str("", "", [], "empty"); 45 | apply_str("a", "", [remove(0, 0, "a")], "remove a"); 46 | apply_str("", "b", [add(0, 0, "b")], "add b"); 47 | apply_str("abcd", "e", [remove(0, 0, "abcd"), add(4, 0, "e")], "for abcd-e"); 48 | apply_str("abc", "abc", [], "same abc"); 49 | apply_str("abcd", "obce", [remove(0, 0, "a"), add(1, 0, "o"), remove(3, 3, "d"), add(4, 3, "e")], "abcd->obce"); 50 | apply_str("abc", "ab", [remove(2, 2, "c")], "abc->ac"); 51 | apply_str("cab", "ab", [remove(0, 0, "c")], "cab->ab"); 52 | apply_str("abcde", "zbodf", [remove(0, 0, "a"), add(1, 0, "z"), 53 | remove(2, 2, "c"), add(3, 2, "o"), 54 | remove(4, 4, "e"), add(5, 4, "f"), 55 | ], "abcde->cbodf"); 56 | apply_str("bcd", "bod", [remove(1, 1, "c"), add(2, 1, "o")], "bcd->bod"); 57 | apply_str("a", "aa", [add(1, 1, "a")], "a -> aa"); 58 | apply_str("aa", "aaaa", [add(2, 2, "aa")], "aa -> aaaa"); 59 | apply_str("aaaa", "aa", [remove(2, 2, "aa")], "aaaa -> aa"); 60 | apply_str("TGGT", "GG", [remove(0, 0, "T"), remove(3, 2, "T")], "TGGT -> GG"); 61 | // debugger; 62 | apply_str( 63 | "G", "AGG", [ 64 | add(0, 0, "AG"), 65 | ]); 66 | 67 | apply_str( 68 | "GTCGTTCGGAATGCCGTTGCTCTGTAAA", "ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", [ 69 | add(0, 0, "ACCG"), 70 | add(3, 7, "GA"), 71 | remove(5, 13, "T"), 72 | add(6, 11, "GCG"), 73 | remove(11, 19, "T"), 74 | remove(16, 23, "TT"), 75 | remove(20, 25, "T"), 76 | remove(22, 26, "T"), 77 | remove(24, 27, "TA"), 78 | ], "GTCGTTCGGAATGCCGTTGCTCTGTAAA"); 79 | 80 | apply_str( 81 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ABCDEFGHIJKL12345678901234567890MNOPQRSTUVWXYZ", [ 82 | add(12, 12, "12345678901234567890") ], "remove 12345678901234567890"); 83 | 84 | }); 85 | 86 | it("Functional test on different input style", () => { 87 | function add(oldPos, newPos, str) { 88 | return { 89 | type: "add", 90 | oldPos, 91 | newPos, 92 | items: str.split(""), 93 | }; 94 | } 95 | function remove(oldPos, newPos, str) { 96 | return { 97 | type: "remove", 98 | oldPos, 99 | newPos, 100 | length: str.length, 101 | }; 102 | } 103 | function apply_str(a, b, script, msg) { 104 | assert.deepStrictEqual( 105 | diff.applyPatch(a.split(""), script), 106 | b.split(""), 107 | msg 108 | ); 109 | } 110 | 111 | apply_str("", "", [], "empty"); 112 | apply_str("a", "", [remove(0, 0, "a")], "remove a"); 113 | apply_str("", "b", [add(0, 0, "b")], "add b"); 114 | apply_str("abcd", "e", [remove(0, 0, "abcd"), add(4, 0, "e")], "for abcd-e"); 115 | apply_str("abc", "abc", [], "same abc"); 116 | apply_str("abcd", "obce", [remove(0, 0, "a"), add(1, 0, "o"), remove(3, 3, "d"), add(4, 3, "e")], "abcd->obce"); 117 | apply_str("abc", "ab", [remove(2, 2, "c")], "abc->ac"); 118 | apply_str("cab", "ab", [remove(0, 0, "c")], "cab->ab"); 119 | apply_str("abcde", "zbodf", [remove(0, 0, "a"), add(1, 0, "z"), 120 | remove(2, 2, "c"), add(3, 2, "o"), 121 | remove(4, 4, "e"), add(5, 4, "f"), 122 | ], "abcde->cbodf"); 123 | apply_str("bcd", "bod", [remove(1, 1, "c"), add(2, 1, "o")], "bcd->bod"); 124 | apply_str("a", "aa", [add(1, 1, "a")], "a -> aa"); 125 | apply_str("aa", "aaaa", [add(2, 2, "aa")], "aa -> aaaa"); 126 | apply_str("aaaa", "aa", [remove(2, 2, "aa")], "aaaa -> aa"); 127 | apply_str("TGGT", "GG", [remove(0, 0, "T"), remove(3, 2, "T")], "TGGT -> GG"); 128 | // debugger; 129 | apply_str( 130 | "G", "AGG", [ 131 | add(0, 0, "AG"), 132 | ]); 133 | 134 | apply_str( 135 | "GTCGTTCGGAATGCCGTTGCTCTGTAAA", "ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", [ 136 | add(0, 0, "ACCG"), 137 | add(3, 7, "GA"), 138 | remove(5, 13, "T"), 139 | add(6, 11, "GCG"), 140 | remove(11, 19, "T"), 141 | remove(16, 23, "TT"), 142 | remove(20, 25, "T"), 143 | remove(22, 26, "T"), 144 | remove(24, 27, "TA"), 145 | ], "GTCGTTCGGAATGCCGTTGCTCTGTAAA"); 146 | 147 | apply_str( 148 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ABCDEFGHIJKL12345678901234567890MNOPQRSTUVWXYZ", [ 149 | add(12, 12, "12345678901234567890") ], "remove 12345678901234567890"); 150 | 151 | }); 152 | 153 | }); 154 | -------------------------------------------------------------------------------- /test/arrayDiff/diff.test.js: -------------------------------------------------------------------------------- 1 | import * as diff from "../../src/strategies/deepDiff/arrayDiff/diff/diff"; 2 | import * as assert from "assert"; 3 | 4 | /** 5 | * Test for diff function 6 | */ 7 | describe("Diff", () => { 8 | it("Array should not modified by function", () => { 9 | const a = [1, 2, 3], b = [2, 3, 4]; 10 | 11 | diff.diff(a, b); 12 | assert.deepStrictEqual(a, [1, 2, 3], "input array changed!"); 13 | assert.deepStrictEqual(b, [2, 3, 4], "input array changed!"); 14 | }); 15 | 16 | it("Functional test", () => { 17 | function diff_str(a, b, added, removed) { 18 | assert.deepStrictEqual( 19 | diff.diff(a.split(""), b.split("")), 20 | { 21 | added: added.split(""), 22 | removed: removed.split(""), 23 | } 24 | ); 25 | } 26 | 27 | diff_str("", "", "", ""); 28 | diff_str("a", "", "", "a"); 29 | diff_str("", "b", "b", ""); 30 | diff_str("@@@abcdefxzxzxzxzxz9090909090909090990", "#abcdef###xzxzxzxzxz9090909090909090990", "####", "@@@"); 31 | diff_str("#12345###xzxzxzxzxz9090909090909090990", "@@@12345xzxzxzxzxz9090909090909090990", "@@@", "####"); 32 | diff_str("abcd", "e", "e", "abcd"); 33 | diff_str("abced", "e", "", "abcd"); 34 | diff_str("abc", "abc", "", ""); 35 | diff_str("abcd", "obce", "oe", "ad"); 36 | diff_str("abc", "ab", "", "c"); 37 | diff_str("cab", "ab", "", "c"); 38 | diff_str("abc", "bc", "", "a"); 39 | diff_str("12345abcdefg", "6789abc", "6789", "12345defg"); 40 | diff_str("12345abc", "6789abcdefg", "6789defg", "12345"); 41 | diff_str("abcde", "zbodf", "zof", "ace"); 42 | diff_str("bcd", "bod", "o", "c"); 43 | diff_str("aa", "aaaa", "aa", ""); 44 | diff_str("aaaa", "aa", "", "aa"); 45 | diff_str("TGGT", "GG", "", "TT"); 46 | diff_str( 47 | "GTCGTTCGGAATGCCGTTGCTCTGTAAA", "ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", 48 | "ACCGGAGCG", "TTTTTTTA"); 49 | diff_str( 50 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ABCDEFGHIJKL12345678901234567890MNOPQRSTUVWXYZ", 51 | "12345678901234567890", ""); 52 | 53 | }); 54 | 55 | }); 56 | -------------------------------------------------------------------------------- /test/arrayDiff/index.test.js: -------------------------------------------------------------------------------- 1 | import * as diff from "../../src/strategies/deepDiff/arrayDiff/index"; 2 | import * as assert from "assert"; 3 | 4 | /** 5 | * Test for index interface 6 | */ 7 | describe("Index", () => { 8 | 9 | it("same function in index", () => { 10 | assert.deepStrictEqual(diff.same([1, 2, 3], [2, 3, 4]), [2, 3]); 11 | }); 12 | 13 | it ("diff data and function in index", () => { 14 | const result = { 15 | added: [1, 2], 16 | removed: [3, 4], 17 | }; 18 | 19 | assert.deepStrictEqual(diff.diff([3, 4, 5, 6], [1, 2, 5, 6]), result); 20 | }); 21 | 22 | it("getPatch function in index", () => { 23 | assert.deepStrictEqual(diff.getPatch([1, 2, 3], [2, 3, 4]), [ 24 | { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 25 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 26 | ]); 27 | }); 28 | 29 | it("applyPatch function in index", () => { 30 | assert.deepStrictEqual(diff.applyPatch([1, 2, 3], [ 31 | { type: "remove", oldPos: 0, newPos: 0, items: [1] }, 32 | { type: "add", oldPos: 3, newPos: 2, items: [4] }, 33 | ]) , [2, 3, 4]); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /test/arrayDiff/patch.test.js: -------------------------------------------------------------------------------- 1 | import * as es from "../../src/strategies/deepDiff/arrayDiff/diff/patch"; 2 | import * as assert from "assert"; 3 | 4 | /** 5 | * Test for same function 6 | */ 7 | describe("Get Patch", () => { 8 | 9 | it("Array not modified by function", () => { 10 | const a = [1, 2, 3], b = [2, 3, 4]; 11 | 12 | es.getPatch(a, b); 13 | assert.deepStrictEqual(a, [1, 2, 3], "input array changed!"); 14 | assert.deepStrictEqual(b, [2, 3, 4], "input array changed!"); 15 | }); 16 | 17 | it("Functional test", () => { 18 | function add(oldPos, newPos, str) { 19 | return { 20 | type: "add", 21 | oldPos, 22 | newPos, 23 | items: str.split(""), 24 | }; 25 | } 26 | function remove(oldPos, newPos, str) { 27 | return { 28 | type: "remove", 29 | oldPos, 30 | newPos, 31 | items: str.split(""), 32 | }; 33 | } 34 | function es_str(a, b, script, msg) { 35 | assert.deepStrictEqual( 36 | es.getPatch(a.split(""), b.split("")), 37 | script, 38 | msg 39 | ); 40 | } 41 | 42 | es_str("", "", [], "empty"); 43 | es_str("a", "", [remove(0, 0, "a")], "remove a"); 44 | es_str("", "b", [add(0, 0, "b")], "add b"); 45 | es_str("abcd", "e", [remove(0, 0, "abcd"), add(4, 0, "e")], "for abcd-e"); 46 | es_str("abc", "abc", [], "same abc"); 47 | es_str("abcd", "obce", [remove(0, 0, "a"), add(1, 0, "o"), remove(3, 3, "d"), add(4, 3, "e")], "abcd->obce"); 48 | es_str("abc", "ab", [remove(2, 2, "c")], "abc->ac"); 49 | es_str("cab", "ab", [remove(0, 0, "c")], "cab->ab"); 50 | es_str("abcde", "zbodf", [remove(0, 0, "a"), add(1, 0, "z"), 51 | remove(2, 2, "c"), add(3, 2, "o"), 52 | remove(4, 4, "e"), add(5, 4, "f"), 53 | ], "abcde->cbodf"); 54 | es_str("bcd", "bod", [remove(1, 1, "c"), add(2, 1, "o")], "bcd->bod"); 55 | es_str("a", "aa", [add(1, 1, "a")], "a -> aa"); 56 | es_str("aa", "aaaa", [add(2, 2, "aa")], "aa -> aaaa"); 57 | es_str("aaaa", "aa", [remove(2, 2, "aa")], "aaaa -> aa"); 58 | es_str("TGGT", "GG", [remove(0, 0, "T"), remove(3, 2, "T")], "TGGT -> GG"); 59 | // debugger; 60 | es_str( 61 | "G", "AGG", [ 62 | add(0, 0, "AG"), 63 | ]); 64 | 65 | es_str( 66 | "GTCGTTCGGAATGCCGTTGCTCTGTAAA", "ACCGGTCGAGTGCGCGGAAGCCGGCCGAA", [ 67 | add(0, 0, "ACCG"), 68 | add(3, 7, "GA"), 69 | remove(5, 13, "T"), 70 | add(6, 11, "GCG"), 71 | remove(11, 19, "T"), 72 | remove(16, 23, "TT"), 73 | remove(20, 25, "T"), 74 | remove(22, 26, "T"), 75 | remove(24, 27, "TA"), 76 | ], "GTCGTTCGGAATGCCGTTGCTCTGTAAA"); 77 | 78 | es_str( 79 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ABCDEFGHIJKL12345678901234567890MNOPQRSTUVWXYZ", [ 80 | add(12, 12, "12345678901234567890") ], "remove 12345678901234567890"); 81 | }); 82 | 83 | }); 84 | -------------------------------------------------------------------------------- /test/arrayDiff/same.test.js: -------------------------------------------------------------------------------- 1 | import same from "../../src/strategies/deepDiff/arrayDiff/diff/same"; 2 | import * as assert from "assert"; 3 | 4 | /** 5 | * Test for same function 6 | */ 7 | describe("Same", () => { 8 | it("Array not modified by function", () => { 9 | const a = [1, 2, 3], 10 | b = [2, 3, 4]; 11 | 12 | same(a, b); 13 | assert.deepStrictEqual(a, [1, 2, 3], "input array changed!"); 14 | assert.deepStrictEqual(b, [2, 3, 4], "input array changed!"); 15 | }); 16 | 17 | it("Different Type Check", () => { 18 | assert.deepStrictEqual(same([1, 2, 3], [2, 3, 4]), [2, 3]); 19 | assert.deepStrictEqual(same(["1", "2", "3"], ["2", "3", "4"]), ["2", "3"]); 20 | assert.deepStrictEqual(same([true, false], [false, false]), [false]); 21 | }); 22 | 23 | it.skip("Random Check", function () { 24 | this.timeout(100 * 1000); 25 | 26 | function lcs(a, b) { 27 | const s = Array(a.length + 1); 28 | 29 | for (let i = 0; i <= a.length; ++i) { 30 | s[i] = Array(b.length + 1); 31 | s[i][0] = { len: 0 }; 32 | } 33 | for (let i = 0; i <= b.length; ++i) { 34 | s[0][i] = { len: 0 }; 35 | } 36 | for (let i = 1; i <= a.length; ++i) { 37 | for (let j = 1; j <= b.length; ++j) { 38 | if (a[i - 1] === b[j - 1]) { 39 | const v = s[i - 1][j - 1].len + 1; 40 | 41 | s[i][j] = { len: v, direct: [-1, -1] }; 42 | } else { 43 | const v1 = s[i - 1][j].len; 44 | const v2 = s[i][j - 1].len; 45 | 46 | if (v1 > v2) { 47 | s[i][j] = { len: v1, direct: [-1, 0] }; 48 | } else { 49 | s[i][j] = { len: v2, direct: [0, -1] }; 50 | } 51 | } 52 | } 53 | } 54 | let n = a.length, 55 | m = b.length; 56 | const ret = []; 57 | 58 | while (s[n][m].len !== 0) { 59 | const node = s[n][m]; 60 | 61 | if (node.direct[0] === node.direct[1]) { 62 | ret.push(a[n - 1]); 63 | } 64 | n += node.direct[0]; 65 | m += node.direct[1]; 66 | } 67 | return ret.reverse(); 68 | } 69 | 70 | function getRandom() { 71 | const length = Math.floor(Math.random() * 20 + 2); 72 | 73 | return Array(length) 74 | .fill(0) 75 | .map(() => Math.floor(Math.random() * 10)); 76 | } 77 | 78 | function isSubSeq(main, sub) { 79 | let i = 0; 80 | 81 | main.forEach((n) => (i += n === sub[i] ? 1 : 0)); 82 | return i === sub.length; 83 | } 84 | 85 | for (let i = 0; i < 5000; ++i) { 86 | const arr1 = getRandom(), 87 | arr2 = getRandom(); 88 | const lcsResult = lcs(arr1, arr2), 89 | sameResult = same(arr1, arr2); 90 | 91 | assert.strictEqual( 92 | lcsResult.length, 93 | sameResult.length, 94 | `[${arr1}] <=> [${arr2}], correct: [${lcsResult}], incorrect: [${sameResult}]` 95 | ); 96 | assert.strictEqual( 97 | isSubSeq(arr1, sameResult) && isSubSeq(arr2, sameResult), 98 | true 99 | ); 100 | } 101 | }); 102 | 103 | it("Functional Check", () => { 104 | function same_str(a, b) { 105 | return same(a.split(""), b.split("")).join(""); 106 | } 107 | 108 | assert.deepStrictEqual(same_str("846709", "2798"), "79"); 109 | assert.deepStrictEqual(same_str("5561279", "597142"), "512"); 110 | 111 | assert.deepStrictEqual(same_str("", ""), ""); 112 | assert.deepStrictEqual(same_str("a", ""), ""); 113 | assert.deepStrictEqual(same_str("", "b"), ""); 114 | assert.deepStrictEqual(same_str("abcd", "e"), ""); 115 | assert.deepStrictEqual(same_str("abc", "abc"), "abc"); 116 | assert.deepStrictEqual(same_str("abcd", "obce"), "bc"); 117 | assert.deepStrictEqual(same_str("abc", "ab"), "ab"); 118 | assert.deepStrictEqual(same_str("cab", "ab"), "ab"); 119 | assert.deepStrictEqual(same_str("abc", "bc"), "bc"); 120 | assert.deepStrictEqual(same_str("abcde", "zbodf"), "bd"); 121 | assert.deepStrictEqual(same_str("bcd", "bod"), "bd"); 122 | assert.deepStrictEqual(same_str("aa", "aaaa"), "aa"); 123 | assert.deepStrictEqual(same_str("aaaa", "aa"), "aa"); 124 | assert.deepStrictEqual(same_str("TGGT", "GG"), "GG"); 125 | assert.deepStrictEqual( 126 | same_str("GTCGTTCGGAATGCCGTTGCTCTGTAAA", "ACCGGTCGAGTGCGCGGAAGCCGGCCGAA"), 127 | "GTCGTCGGAAGCCGGCCGAA" 128 | ); 129 | assert.deepStrictEqual( 130 | same_str( 131 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 132 | "ABCDEFGHIJKL12345678901234567890MNOPQRSTUVWXYZ" 133 | ), 134 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 135 | ); 136 | }); 137 | 138 | it("Customize compare function", () => { 139 | function compare(a, b) { 140 | return a.name === b.name && a.age === b.age; 141 | } 142 | const a = [ 143 | { name: "Mike", age: 10 }, 144 | { name: "Apple", age: 13 }, 145 | { name: "Jack", age: 15 }, 146 | ], 147 | b = [ 148 | { name: "Apple", age: 13 }, 149 | { name: "Mimi", age: 0 }, 150 | { name: "Jack", age: 15 }, 151 | ], 152 | result = [ 153 | { name: "Apple", age: 13 }, 154 | { name: "Jack", age: 15 }, 155 | ]; 156 | 157 | assert.deepStrictEqual(same(a, b, compare), result); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/deepDiff.test.js: -------------------------------------------------------------------------------- 1 | import deepDiff from '../src/strategies/deepDiff/diff'; 2 | import patchDeepDiff from '../src/strategies/deepDiff/patch'; 3 | import makeDiff from '../src/strategies/deepDiff/makeDiff'; 4 | import { 5 | DIFF_STATUS_ARRAY_UPDATED, 6 | DIFF_STATUS_KEYS_UPDATED, 7 | DIFF_STATUS_REMOVED, 8 | DIFF_STATUS_UPDATED 9 | } from '../src/strategies/constants'; 10 | import sinon from 'sinon'; 11 | 12 | describe('deepDiff strategy', () => { 13 | describe("#diff()", () => { 14 | it('should return an object containing updated fields', () => { 15 | const old = { a: 1 }; 16 | const latest = { a: 2, b: 3 }; 17 | const diff = deepDiff(old, latest); 18 | 19 | diff.length.should.eql(2); 20 | diff.should.eql([ 21 | { 22 | key: 'a', 23 | value: 2, 24 | change: DIFF_STATUS_UPDATED, 25 | }, 26 | { 27 | key: 'b', 28 | value: 3, 29 | change: DIFF_STATUS_UPDATED, 30 | } 31 | ]); 32 | }); 33 | 34 | it('should return an object containing removed fields', () => { 35 | const old = { b: 1 }; 36 | const latest = {}; 37 | const diff = deepDiff(old, latest); 38 | 39 | diff.length.should.eql(1); 40 | diff.should.eql([ 41 | { 42 | key: 'b', 43 | change: DIFF_STATUS_REMOVED, 44 | } 45 | ]); 46 | }); 47 | 48 | it('should not mark falsy values as removed', () => { 49 | const old = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; 50 | const latest = { a: 0, b: null, c: undefined, d: false, e: NaN, f: '', g: "" }; 51 | const diff = deepDiff(old, latest); 52 | 53 | diff.length.should.eql(7); 54 | diff.should.eql([ 55 | { 56 | key: 'a', 57 | value: 0, 58 | change: DIFF_STATUS_UPDATED, 59 | }, 60 | { 61 | key: 'b', 62 | value: null, 63 | change: DIFF_STATUS_UPDATED, 64 | }, 65 | { 66 | key: 'c', 67 | value: undefined, 68 | change: DIFF_STATUS_UPDATED, 69 | }, 70 | { 71 | key: 'd', 72 | value: false, 73 | change: DIFF_STATUS_UPDATED, 74 | }, 75 | { 76 | key: 'e', 77 | value: NaN, 78 | change: DIFF_STATUS_UPDATED, 79 | }, 80 | { 81 | key: 'f', 82 | value: '', 83 | change: DIFF_STATUS_UPDATED, 84 | }, 85 | { 86 | key: 'g', 87 | value: "", 88 | change: DIFF_STATUS_UPDATED, 89 | } 90 | ]); 91 | }); 92 | 93 | describe('when references to keys are equal', () => { 94 | 95 | let old, latest; 96 | 97 | beforeEach(() => { 98 | old = { a: { b: 1 } }; 99 | latest = { ...old }; 100 | }); 101 | 102 | it('should not generate a diff', () => { 103 | const diff = deepDiff(old, latest); 104 | 105 | diff.length.should.eql(0); 106 | }); 107 | 108 | it('should not compare nested values', () => { 109 | let accessed = false; 110 | 111 | sinon.stub(old.a, 'b').get(() => { 112 | accessed = true; 113 | return 1; 114 | }); 115 | deepDiff(old, latest); 116 | accessed.should.eql(false); 117 | latest.a.b; 118 | accessed.should.eql(true); 119 | }); 120 | }); 121 | 122 | describe('when references to keys are different', () => { 123 | 124 | let old, latest; 125 | 126 | beforeEach(() => { 127 | old = { a: { b: 1 } }; 128 | latest = { a: { b: 1 } }; 129 | }); 130 | 131 | it('should generate a diff', () => { 132 | const diff = deepDiff(old, latest); 133 | 134 | diff.should.eql([ 135 | { 136 | key: 'a', 137 | change: DIFF_STATUS_KEYS_UPDATED, 138 | value: [] 139 | } 140 | ]); 141 | }); 142 | 143 | it('should compare nested values', () => { 144 | let accessed = false; 145 | 146 | sinon.stub(old.a, 'b').get(() => { 147 | accessed = true; 148 | return 1; 149 | }); 150 | deepDiff(old, latest); 151 | accessed.should.eql(true); 152 | }); 153 | }); 154 | 155 | describe('when values are different', () => { 156 | 157 | let old, latest; 158 | 159 | beforeEach(() => { 160 | old = { a: { b: 1, c: 2 } }; 161 | latest = { a: { ...old.a, b: 3, d: 4 } }; 162 | }); 163 | 164 | it('should generate a diff', () => { 165 | const diff = deepDiff(old, latest); 166 | 167 | diff.should.eql([ 168 | { 169 | key: 'a', 170 | change: DIFF_STATUS_KEYS_UPDATED, 171 | value: [ 172 | { 173 | key: 'b', 174 | change: DIFF_STATUS_UPDATED, 175 | value: 3 176 | }, 177 | { 178 | key: 'd', 179 | change: DIFF_STATUS_UPDATED, 180 | value: 4 181 | } 182 | ] 183 | } 184 | ]); 185 | }); 186 | }); 187 | 188 | describe('when a null value is being replaced with an object', () => { 189 | it('should generate a diff', () => { 190 | const old = { a: null }; 191 | const latest = { a: { b: 1 } }; 192 | 193 | const diff = deepDiff(old, latest); 194 | 195 | diff.length.should.eql(1); 196 | diff.should.eql([ 197 | { 198 | key: 'a', 199 | value: { b: 1 }, 200 | change: DIFF_STATUS_UPDATED, 201 | } 202 | ]); 203 | }); 204 | }); 205 | 206 | describe("shouldContinue param", () => { 207 | 208 | let old, latest, shouldContinue, diff; 209 | 210 | beforeEach(() => { 211 | old = { a: { b: 1, c: 2, i: { j: 6 } }, e: { f: 5, g: { h: { k: 2 } } } }; 212 | latest = { ...old, e: { ...old.e, g: { h: { k: 3 } } } }; 213 | shouldContinue = sinon.spy(() => true); 214 | diff = deepDiff(old, latest, shouldContinue); 215 | }); 216 | 217 | it("should be called on each object-like value that's different", () => { 218 | // Expect calls for e, e.g, and e.g.h, but *not* a or a.i 219 | shouldContinue.callCount.should.eql(3); 220 | }); 221 | 222 | it("should be called with the right context", () => { 223 | shouldContinue.calledWith(old.e, latest.e, ['e']).should.eql(true); 224 | shouldContinue.calledWith(old.e.g, latest.e.g, ['e', 'g']).should.eql(true); 225 | shouldContinue.calledWith(old.e.g.h, latest.e.g.h, ['e', 'g', 'h']).should.eql(true); 226 | }); 227 | 228 | describe("with default logic", () => { 229 | it("should not affect the diff", () => { 230 | diff.should.eql([ 231 | { 232 | key: "e", 233 | change: "updated_keys", 234 | value: [ 235 | { 236 | key: "g", 237 | change: "updated_keys", 238 | value: [ 239 | { 240 | key: "h", 241 | change: "updated_keys", 242 | value: [ 243 | { 244 | key: "k", 245 | change: "updated", 246 | value: 3 247 | } 248 | ] 249 | } 250 | ] 251 | } 252 | ] 253 | } 254 | ]); 255 | }); 256 | }); 257 | 258 | describe("with custom logic", () => { 259 | it("should honor the custom logic", () => { 260 | // Stop at the second level 261 | shouldContinue = sinon.spy((oldObj, newObj, context) => context.length <= 1); 262 | diff = deepDiff(old, latest, shouldContinue); 263 | shouldContinue.callCount.should.eql(2); 264 | shouldContinue.calledWith(old.e, latest.e, ['e']).should.eql(true); 265 | shouldContinue.calledWith(old.e.g, latest.e.g, ['e', 'g']).should.eql(true); 266 | shouldContinue.calledWith(old.e.g.h, latest.e.g.h, ['e', 'g', 'h']).should.eql(false); 267 | diff.should.eql([ 268 | { 269 | key: "e", 270 | change: "updated_keys", 271 | value: [ 272 | { 273 | key: "g", 274 | change: "updated", 275 | // Diff stopped here 276 | value: { h: { k: 3 } } 277 | } 278 | ] 279 | } 280 | ]); 281 | }); 282 | }); 283 | }); 284 | 285 | describe('handles array values', () => { 286 | it('should generate an array patch for an appended item', () => { 287 | const old = { 288 | a: [1] 289 | }; 290 | const latest = { 291 | a: [1, 2] 292 | }; 293 | 294 | const diff = deepDiff(old, latest); 295 | 296 | // console.log('***** arrays', diff); 297 | diff.length.should.eql(1); 298 | diff.should.eql([ 299 | { 300 | key: 'a', 301 | change: DIFF_STATUS_ARRAY_UPDATED, 302 | value: [ 303 | { 304 | type: 'add', 305 | oldPos: 1, 306 | newPos: 1, 307 | items: [2] 308 | } 309 | ] 310 | } 311 | ]); 312 | }); 313 | 314 | it('should generate an array patch for an inserted item', () => { 315 | const old = { 316 | a: [1, 3] 317 | }; 318 | const latest = { 319 | a: [1, 2, 3] 320 | }; 321 | 322 | const diff = deepDiff(old, latest); 323 | 324 | // console.log('***** arrays', diff); 325 | diff.length.should.eql(1); 326 | diff.should.eql([ 327 | { 328 | key: 'a', 329 | change: DIFF_STATUS_ARRAY_UPDATED, 330 | value: [ 331 | { 332 | type: 'add', 333 | oldPos: 1, 334 | newPos: 1, 335 | items: [2] 336 | } 337 | ] 338 | } 339 | ]); 340 | }); 341 | 342 | it('should generate an array patch for an inserted object', () => { 343 | const aObject = { a: 'a' }; 344 | const cObject = { c: 'c' }; 345 | const old = { 346 | a: [aObject, cObject] 347 | }; 348 | const latest = { 349 | a: [aObject, { b: 'b' }, cObject] 350 | }; 351 | 352 | const diff = deepDiff(old, latest); 353 | 354 | // console.log('***** arrays', diff); 355 | diff.length.should.eql(1); 356 | diff.should.eql([ 357 | { 358 | key: 'a', 359 | change: DIFF_STATUS_ARRAY_UPDATED, 360 | value: [ 361 | { 362 | type: 'add', 363 | oldPos: 1, 364 | newPos: 1, 365 | items: [{ b: 'b' }] 366 | } 367 | ] 368 | } 369 | ]); 370 | }); 371 | }); 372 | }); 373 | 374 | describe("#patch()", () => { 375 | describe("when there are no differences", () => { 376 | it("should return the same object", () => { 377 | const oldObj = { a: 1 }; 378 | const newObj = patchDeepDiff(oldObj, []); 379 | 380 | newObj.should.equal(oldObj); 381 | }); 382 | }); 383 | describe("when keys are updated", () => { 384 | let oldObj, newObj, diff; 385 | 386 | beforeEach(() => { 387 | oldObj = { a: {}, b: {} }; 388 | diff = [{ key: 'a', change: DIFF_STATUS_KEYS_UPDATED, value: [] }]; 389 | newObj = patchDeepDiff(oldObj, diff); 390 | }); 391 | 392 | it("should copy the keys", () => { 393 | newObj.should.not.equal(oldObj); 394 | }); 395 | 396 | it("should not copy unchanged values", () => { 397 | newObj.a.should.equal(oldObj.a); 398 | newObj.b.should.equal(oldObj.b); 399 | }); 400 | 401 | it("should not modify the original object", () => { 402 | oldObj = { a: {}, b: 1 }; 403 | const ref_oldObj = oldObj; 404 | const ref_oldObj_a = oldObj.a; 405 | const ref_oldObj_b = oldObj.b; 406 | 407 | patchDeepDiff(oldObj, diff); 408 | oldObj.should.equal(ref_oldObj); 409 | oldObj.a.should.equal(ref_oldObj_a); 410 | oldObj.b.should.equal(ref_oldObj_b); 411 | 412 | }); 413 | }); 414 | describe("when values are updated", () => { 415 | let oldObj, newObj, diff; 416 | 417 | beforeEach(() => { 418 | oldObj = { a: { b: 1 }, c: {} }; 419 | diff = [{ key: 'a', change: DIFF_STATUS_KEYS_UPDATED, value: [ 420 | { key: 'b', change: DIFF_STATUS_UPDATED, value: 2 }, { key: 'd', change: DIFF_STATUS_UPDATED, value: 3 } 421 | ] }]; 422 | newObj = patchDeepDiff(oldObj, diff); 423 | }); 424 | it("should copy the keys", () => { 425 | newObj.should.not.equal(oldObj); 426 | newObj.a.should.not.equal(oldObj.a); 427 | }); 428 | it("should not copy unchanged values", () => { 429 | newObj.c.should.equal(oldObj.c); 430 | }); 431 | it("should modify the updated values", () => { 432 | newObj.a.b.should.eql(2); 433 | newObj.a.d.should.eql(3); 434 | }); 435 | }); 436 | describe("when values are removed", () => { 437 | let oldObj, newObj, diff; 438 | 439 | beforeEach(() => { 440 | oldObj = { a: { b: 1 }, c: {} }; 441 | diff = [{ key: 'c', change: DIFF_STATUS_REMOVED }]; 442 | newObj = patchDeepDiff(oldObj, diff); 443 | }); 444 | it("should copy the keys", () => { 445 | newObj.should.not.equal(oldObj); 446 | }); 447 | it("should delete the removed values", () => { 448 | newObj.should.not.have.property('c'); 449 | }); 450 | it("should not delete the other values", () => { 451 | newObj.a.should.equal(oldObj.a); 452 | }); 453 | it("should not modify the original object", () => { 454 | oldObj.should.have.property('c'); 455 | }); 456 | }); 457 | 458 | describe("when arrays are updated", () => { 459 | const oldObj = { 460 | a: [ 1 ] 461 | }; 462 | 463 | it("should append the value", () => { 464 | const diff = [ 465 | { 466 | key: 'a', 467 | change: DIFF_STATUS_ARRAY_UPDATED, 468 | value: [ 469 | { 470 | type: 'add', 471 | oldPos: 1, 472 | newPos: 1, 473 | items: [2] 474 | } 475 | ] 476 | } 477 | ]; 478 | 479 | const newObj = patchDeepDiff(oldObj, diff); 480 | 481 | newObj.should.eql({a:[1, 2]}); 482 | }); 483 | }); 484 | }); 485 | 486 | describe("round trips", () => { 487 | it("a simple array item append", () => { 488 | const oldObj = { a: [1] }; 489 | const newObj = { a: [1, 2] }; 490 | 491 | const result = patchDeepDiff(oldObj, deepDiff(oldObj, newObj)); 492 | 493 | result.should.eql(newObj); 494 | }); 495 | }); 496 | 497 | describe("#makeDiff", () => { 498 | it("should return a diff strategy function that uses the provided shouldContinue param", () => { 499 | const shouldContinue = sinon.spy(() => true); 500 | const diffStrategy = makeDiff(shouldContinue); 501 | 502 | shouldContinue.callCount.should.eql(0); 503 | diffStrategy({ a: { b: 1 }}, { a: { b: 2 }}); 504 | shouldContinue.callCount.should.be.greaterThan(0); 505 | }); 506 | }); 507 | }); 508 | -------------------------------------------------------------------------------- /test/listener.test.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | import { createDeferredListener } from "../src/listener"; 3 | import should from "should"; 4 | 5 | const filterAny = () => { 6 | return true; 7 | }; 8 | 9 | describe("createDeferredListener", () => { 10 | it("queues calls to the listener", async () => { 11 | const { setListener, listener } = createDeferredListener(filterAny); 12 | const spy = sinon.spy(); 13 | 14 | // Trigger a couple of events 15 | listener("message", "sender", "sendResponse"); 16 | listener("message2", "sender2", "sendResponse2"); 17 | 18 | // Listener should receive previous messages 19 | setListener(spy); 20 | 21 | // Trigger more events 22 | listener("message3", "sender3", "sendResponse3"); 23 | listener("message4", "sender4", "sendResponse4"); 24 | 25 | // Wait for promise queue to clear 26 | await Promise.resolve(); 27 | 28 | spy.callCount.should.equal(4); 29 | spy.getCall(0).args.should.eql(["message", "sender", "sendResponse"]); 30 | spy.getCall(1).args.should.eql(["message2", "sender2", "sendResponse2"]); 31 | spy.getCall(2).args.should.eql(["message3", "sender3", "sendResponse3"]); 32 | spy.getCall(3).args.should.eql(["message4", "sender4", "sendResponse4"]); 33 | }); 34 | 35 | it("ignores messages that don't pass the filter", async () => { 36 | const filter = (message) => { 37 | return message === "message"; 38 | }; 39 | 40 | const { setListener, listener } = createDeferredListener(filter); 41 | const spy = sinon.spy(); 42 | 43 | const result1 = listener("message", "sender", "sendResponse"); 44 | const result2 = listener("message2", "sender2", "sendResponse2"); 45 | 46 | result1.should.eql(true); 47 | console.log(result2); 48 | should(result2).eql(undefined); 49 | 50 | setListener(spy); 51 | 52 | // Wait for promise queue to clear 53 | await Promise.resolve(); 54 | 55 | spy.callCount.should.equal(1); 56 | spy.getCall(0).args.should.eql(["message", "sender", "sendResponse"]); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/serialization.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | import sinon from 'sinon'; 3 | import cloneDeep from 'lodash.clonedeep'; 4 | 5 | import { withSerializer, withDeserializer } from '../src/serialization'; 6 | 7 | describe("serialization functions", function () { 8 | describe("#withSerializer", function () { 9 | const jsonSerialize = (payload) => JSON.stringify(payload); 10 | 11 | let payload, message, serializedMessage, sender; 12 | 13 | beforeEach(function () { 14 | payload = { 15 | message: "Hello World", 16 | numbers: [1, 2, 3] 17 | }; 18 | 19 | message = { 20 | type: 'TEST', 21 | payload 22 | }; 23 | 24 | serializedMessage = { 25 | type: 'TEST', 26 | payload: jsonSerialize(payload) 27 | }; 28 | 29 | sender = sinon.spy(); 30 | }); 31 | 32 | it("should serialize the message payload before sending", function () { 33 | const serializedSender = sinon.spy(withSerializer(jsonSerialize)(sender)); 34 | 35 | serializedSender(message); 36 | 37 | // Assert that sender and serialized sender were called exactly once 38 | sender.calledOnce.should.eql(true); 39 | serializedSender.calledOnce.should.eql(true); 40 | // Assert that the sender was called with the serialized payload 41 | sender.firstCall.args[0].should.eql(serializedMessage); 42 | }); 43 | 44 | it("should enforce the number of arguments", function () { 45 | const serializedSender = withSerializer(jsonSerialize)(sender, 1); 46 | 47 | // Assert that the serialized sender threw due to insufficient arguments 48 | should.throws(() => serializedSender(message)); 49 | // Assert that the actual sender was never called 50 | sender.called.should.eql(false); 51 | }); 52 | 53 | it("should extract the correct argument index", function () { 54 | const serializedSender = sinon.spy(withSerializer(jsonSerialize)(sender, 1)); 55 | 56 | serializedSender(null, message); 57 | 58 | // Assert that sender and serialized sender were called exactly once 59 | sender.calledOnce.should.eql(true); 60 | serializedSender.calledOnce.should.eql(true); 61 | // Assert that the message was extracted from the correct argument index 62 | sender.firstCall.args[1].should.eql(serializedMessage); 63 | }); 64 | 65 | it("should have the same result when the same message is sent twice", function () { 66 | const serializedSender = sinon.spy(withSerializer(jsonSerialize)(sender)); 67 | 68 | serializedSender(message); 69 | const firstResult = cloneDeep(sender.firstCall.args[0]); 70 | 71 | serializedSender(message); 72 | const secondResult = cloneDeep(sender.secondCall.args[0]); 73 | 74 | // Assert that sender and serialized sender were called exactly twice 75 | sender.calledTwice.should.eql(true); 76 | serializedSender.calledTwice.should.eql(true); 77 | // Assert that the sender was called with the same message both times 78 | firstResult.should.eql(secondResult); 79 | }); 80 | 81 | it("should not modify the original message", function () { 82 | const serializedSender = sinon.spy(withSerializer(jsonSerialize)(sender)); 83 | 84 | serializedSender(message); 85 | 86 | // Assert deep equality between message payload and the payload object 87 | message.payload.should.eql(payload); 88 | // Assert that the original message and the sent message were different objects 89 | sender.firstCall.args[0].should.not.be.exactly(message); 90 | }); 91 | 92 | }); 93 | 94 | describe("#withDeserializer", function () { 95 | const jsonDeserialize = (payload) => JSON.parse(payload); 96 | 97 | // Mock a simple listener scenario 98 | let listeners; 99 | const addListener = listener => { 100 | listeners.push(listener); 101 | }; 102 | const onMessage = (message) => { 103 | listeners.forEach(listener => { 104 | listener(message); 105 | }); 106 | }; 107 | 108 | let payload, message, deserializedMessage, listener, serializedAddListener; 109 | 110 | beforeEach(function () { 111 | payload = JSON.stringify({ 112 | message: "Hello World", 113 | numbers: [1, 2, 3] 114 | }); 115 | 116 | message = { 117 | type: 'TEST', 118 | payload 119 | }; 120 | 121 | deserializedMessage = { 122 | type: 'TEST', 123 | payload: jsonDeserialize(payload) 124 | }; 125 | 126 | listeners = []; 127 | listener = sinon.spy(); 128 | serializedAddListener = sinon.spy(withDeserializer(jsonDeserialize)(addListener)); 129 | }); 130 | 131 | it("should deserialize the message payload before the callback", function () { 132 | serializedAddListener(listener); 133 | onMessage(message); 134 | 135 | // Assert that the listener was called once 136 | listener.calledOnce.should.eql(true); 137 | // Assert that it was called with the deserialized payload 138 | listener.firstCall.args[0].should.eql(deserializedMessage); 139 | }); 140 | 141 | it("should only add the listener once", function () { 142 | serializedAddListener(listener); 143 | onMessage(message); 144 | onMessage(message); 145 | 146 | // Assert that the listener was called exactly twice 147 | listener.calledTwice.should.eql(true); 148 | // Assert that addListener is called once 149 | serializedAddListener.calledOnce.should.eql(true); 150 | }); 151 | 152 | it("should have the same result when the same message is received twice", function () { 153 | serializedAddListener(listener); 154 | 155 | onMessage(message); 156 | const firstResult = cloneDeep(listener.firstCall.args[0]); 157 | 158 | onMessage(message); 159 | const secondResult = cloneDeep(listener.secondCall.args[0]); 160 | 161 | // Assert that the listener was called with the same message both times 162 | firstResult.should.eql(secondResult); 163 | }); 164 | 165 | it("should not modify the original incoming message", function () { 166 | serializedAddListener(listener); 167 | onMessage(message); 168 | 169 | // Assert deep equality between message payload and the payload object 170 | message.payload.should.eql(payload); 171 | // Assert that the original message and the received message are different objects 172 | listener.firstCall.args[0].should.not.be.exactly(message); 173 | }); 174 | 175 | it("should not deserialize messages it isn't supposed to", function () { 176 | const shouldDeserialize = (message) => message.type === 'DESERIALIZE_ME'; 177 | 178 | serializedAddListener(listener, shouldDeserialize); 179 | onMessage(message); 180 | 181 | // Assert that the message has not been deserialized 182 | listener.firstCall.args[0].should.eql(message); 183 | }); 184 | 185 | it("should deserialize messages it is supposed to", function () { 186 | const shouldDeserialize = (message) => message.type === 'TEST'; 187 | 188 | serializedAddListener(listener, shouldDeserialize); 189 | onMessage(message); 190 | 191 | // Assert that the message has been deserialized 192 | listener.firstCall.args[0].should.eql(deserializedMessage); 193 | }); 194 | 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /test/shallowDiff.test.js: -------------------------------------------------------------------------------- 1 | import shallowDiff from '../src/strategies/shallowDiff/diff'; 2 | import patchShallowDiff from "../src/strategies/shallowDiff/patch"; 3 | import { 4 | DIFF_STATUS_UPDATED, 5 | DIFF_STATUS_REMOVED, 6 | } from '../src/strategies/constants'; 7 | 8 | describe('shallowDiff strategy', () => { 9 | describe('#diff()', () => { 10 | it('should return an object containing updated fields', () => { 11 | const old = { a: 1 }; 12 | const latest = { a: 2, b: 3 }; 13 | const diff = shallowDiff(old, latest); 14 | 15 | diff.length.should.eql(2); 16 | diff.should.eql([ 17 | { 18 | key: 'a', 19 | value: 2, 20 | change: DIFF_STATUS_UPDATED, 21 | }, 22 | { 23 | key: 'b', 24 | value: 3, 25 | change: DIFF_STATUS_UPDATED, 26 | } 27 | ]); 28 | }); 29 | 30 | it('should return an object containing removed fields', () => { 31 | const old = { b: 1 }; 32 | const latest = {}; 33 | const diff = shallowDiff(old, latest); 34 | 35 | diff.length.should.eql(1); 36 | diff.should.eql([ 37 | { 38 | key: 'b', 39 | change: DIFF_STATUS_REMOVED, 40 | } 41 | ]); 42 | }); 43 | 44 | it('should not mark falsy values as removed', () => { 45 | const old = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; 46 | const latest = {a: 0, b: null, c: undefined, d: false, e: NaN, f: '', g: ""}; 47 | const diff = shallowDiff(old, latest); 48 | 49 | diff.length.should.eql(7); 50 | diff.should.eql([ 51 | { 52 | key: 'a', 53 | value: 0, 54 | change: DIFF_STATUS_UPDATED, 55 | }, 56 | { 57 | key: 'b', 58 | value: null, 59 | change: DIFF_STATUS_UPDATED, 60 | }, 61 | { 62 | key: 'c', 63 | value: undefined, 64 | change: DIFF_STATUS_UPDATED, 65 | }, 66 | { 67 | key: 'd', 68 | value: false, 69 | change: DIFF_STATUS_UPDATED, 70 | }, 71 | { 72 | key: 'e', 73 | value: NaN, 74 | change: DIFF_STATUS_UPDATED, 75 | }, 76 | { 77 | key: 'f', 78 | value: '', 79 | change: DIFF_STATUS_UPDATED, 80 | }, 81 | { 82 | key: 'g', 83 | value: "", 84 | change: DIFF_STATUS_UPDATED, 85 | } 86 | ]); 87 | }); 88 | }); 89 | describe('#patch()', function () { 90 | let oldObj, newObj; 91 | 92 | beforeEach(() => { 93 | oldObj = { b: 1, c: {} }; 94 | newObj = patchShallowDiff(oldObj, [ 95 | { key: 'a', value: 123, change: DIFF_STATUS_UPDATED }, 96 | { key: 'b', change: DIFF_STATUS_REMOVED }, 97 | ]); 98 | }); 99 | it('should update correctly', function () { 100 | newObj.should.not.equal(oldObj); 101 | newObj.c.should.equal(oldObj.c); 102 | newObj.should.eql({ a: 123, c: {} }); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | 2 | import should from 'should'; 3 | 4 | import {getBrowserAPI} from "../src/util"; 5 | 6 | describe('#getBrowserAPI()', function () { 7 | it('should return the self chrome API if present', function () { 8 | self.chrome = { 9 | isChrome: true 10 | }; 11 | self.browser = undefined; 12 | 13 | const browserAPI = getBrowserAPI(); 14 | 15 | should(browserAPI).equals(self.chrome); 16 | }); 17 | 18 | it('should return the self browser API if chrome is not present', function () { 19 | self.chrome = undefined; 20 | self.browser = { 21 | isBrowser: true 22 | }; 23 | 24 | const browserAPI = getBrowserAPI(); 25 | 26 | should(browserAPI).equals(self.browser); 27 | }); 28 | 29 | it('should throw an error if neither the chrome or browser API is present', function () { 30 | self.chrome = undefined; 31 | self.browser = undefined; 32 | 33 | should.throws(() => getBrowserAPI()); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/wrapStore.test.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | 3 | import sinon from 'sinon'; 4 | import should from 'should'; 5 | 6 | import { createWrapStore } from '../src'; 7 | import shallowDiff from '../src/strategies/shallowDiff/diff'; 8 | import { DISPATCH_TYPE, STATE_TYPE, PATCH_STATE_TYPE } from '../src/constants'; 9 | 10 | describe('wrapStore', function () { 11 | const channelName = 'test'; 12 | 13 | beforeEach(function () { 14 | global.self = {}; 15 | const tabs = [1]; 16 | 17 | // Mock chrome.runtime API 18 | self.chrome = { 19 | runtime: { 20 | onMessage: { 21 | addListener: () => { }, 22 | }, 23 | onConnectExternal: { 24 | addListener: () => { }, 25 | }, 26 | sendMessage: () => { } 27 | }, 28 | tabs: { 29 | query: (tabObject, cb) => { 30 | cb(tabs); 31 | }, 32 | sendMessage: () => { } 33 | } 34 | }; 35 | }); 36 | 37 | function setupListeners() { 38 | const tabs = [1]; 39 | const listeners = { 40 | onMessage: [], 41 | onConnectExternal: [], 42 | }; 43 | 44 | self.chrome = { 45 | runtime: { 46 | onMessage: { 47 | addListener: fn => listeners.onMessage.push(fn), 48 | }, 49 | onConnectExternal: { 50 | addListener: fn => listeners.onConnectExternal.push(fn), 51 | }, 52 | sendMessage: () => { } 53 | }, 54 | tabs: { 55 | query: (tabObject, cb) => { 56 | cb(tabs); 57 | }, 58 | sendMessage: () => { } 59 | } 60 | }; 61 | 62 | return listeners; 63 | } 64 | 65 | describe("on receiving messages", function () { 66 | let listeners, store, payload, message, sender, callback; 67 | 68 | beforeEach(function () { 69 | listeners = setupListeners(); 70 | store = { 71 | dispatch: sinon.spy(), 72 | subscribe: () => { 73 | return () => ({}); 74 | }, 75 | getState: () => ({}) 76 | }; 77 | 78 | payload = { 79 | a: 'a', 80 | }; 81 | message = { 82 | type: DISPATCH_TYPE, 83 | channelName, 84 | payload 85 | }; 86 | sender = {}; 87 | callback = () => { }; // noop. Maybe should validate it is invoked? 88 | }); 89 | 90 | it('should dispatch actions received on onMessage to store', async function () { 91 | const wrapStore = createWrapStore({ channelName }); 92 | 93 | wrapStore(store); 94 | listeners.onMessage.forEach(l => l(message, sender, callback)); 95 | 96 | await Promise.resolve(); 97 | 98 | store.dispatch.calledOnce.should.eql(true); 99 | store.dispatch 100 | .alwaysCalledWith( 101 | Object.assign({}, payload, { 102 | _sender: sender, 103 | }), 104 | ) 105 | .should.eql(true); 106 | }); 107 | 108 | it('should not dispatch actions received on onMessage for other ports', function () { 109 | const wrapStore = createWrapStore({ channelName }); 110 | 111 | wrapStore(store); 112 | message.channelName = channelName + '2'; 113 | listeners.onMessage.forEach(l => l(message, sender, callback)); 114 | 115 | store.dispatch.notCalled.should.eql(true); 116 | }); 117 | 118 | it('should deserialize incoming messages correctly', async function () { 119 | const deserializer = sinon.spy(JSON.parse); 120 | const wrapStore = createWrapStore({ channelName }); 121 | 122 | wrapStore(store, { deserializer }); 123 | message.payload = JSON.stringify(payload); 124 | listeners.onMessage.forEach(l => l(message, sender, callback)); 125 | 126 | await Promise.resolve(); 127 | 128 | deserializer.calledOnce.should.eql(true); 129 | store.dispatch 130 | .alwaysCalledWith( 131 | Object.assign({}, payload, { 132 | _sender: sender, 133 | }), 134 | ) 135 | .should.eql(true); 136 | }); 137 | 138 | it('should not deserialize incoming messages for other ports', function () { 139 | const deserializer = sinon.spy(JSON.parse); 140 | const wrapStore = createWrapStore({ channelName }); 141 | 142 | wrapStore(store, { deserializer }); 143 | message.channelName = channelName + '2'; 144 | message.payload = JSON.stringify(payload); 145 | listeners.onMessage.forEach(l => l(message, sender, callback)); 146 | 147 | deserializer.called.should.eql(false); 148 | }); 149 | }); 150 | 151 | it('should serialize initial state and subsequent patches correctly', function () { 152 | const sendMessage = (self.chrome.tabs.sendMessage = sinon.spy()); 153 | 154 | // Mock store subscription 155 | const subscribers = []; 156 | const store = { 157 | subscribe: subscriber => { 158 | subscribers.push(subscriber); 159 | return () => ({}); 160 | }, 161 | getState: () => ({}) 162 | }; 163 | 164 | // Stub state access (the first access will be on 165 | // initialization, and the second will be on update) 166 | const firstState = { a: 1, b: 2 }; 167 | const secondState = { a: 1, b: 3, c: 5 }; 168 | 169 | sinon.stub(store, 'getState') 170 | .onFirstCall().returns(firstState) 171 | .onSecondCall().returns(secondState) 172 | .onThirdCall().returns(secondState); 173 | 174 | const serializer = (payload) => JSON.stringify(payload); 175 | const wrapStore = createWrapStore({ channelName }); 176 | 177 | wrapStore(store, { serializer }); 178 | 179 | // Simulate a state update by calling subscribers 180 | subscribers.forEach(subscriber => subscriber()); 181 | 182 | const expectedSetupMessage = { 183 | type: STATE_TYPE, 184 | channelName, 185 | payload: serializer(firstState) 186 | }; 187 | const expectedPatchMessage = { 188 | type: PATCH_STATE_TYPE, 189 | channelName, 190 | payload: serializer(shallowDiff(firstState, secondState)) 191 | }; 192 | 193 | sendMessage.calledTwice.should.eql(true); 194 | sendMessage.firstCall.args[1].should.eql(expectedSetupMessage); 195 | sendMessage.secondCall.args[1].should.eql(expectedPatchMessage); 196 | }); 197 | 198 | it('should use the provided diff strategy', function () { 199 | const sendMessage = (self.chrome.tabs.sendMessage = sinon.spy()); 200 | 201 | // Mock store subscription 202 | const subscribers = []; 203 | const store = { 204 | subscribe: subscriber => { 205 | subscribers.push(subscriber); 206 | return () => ({}); 207 | }, 208 | getState: () => ({}) 209 | }; 210 | 211 | // Stub state access (the first access will be on 212 | // initialization, and the second will be on update) 213 | const firstState = { a: 1, b: 2 }; 214 | const secondState = { a: 1, b: 3, c: 5 }; 215 | 216 | sinon.stub(store, 'getState') 217 | .onFirstCall().returns(firstState) 218 | .onSecondCall().returns(secondState) 219 | .onThirdCall().returns(secondState); 220 | 221 | // Create a fake diff strategy 222 | const diffStrategy = (oldObj, newObj) => ([{ 223 | type: 'FAKE_DIFF', 224 | oldObj, newObj 225 | }]); 226 | const wrapStore = createWrapStore({ channelName }); 227 | 228 | wrapStore(store, { diffStrategy }); 229 | 230 | // Simulate a state update by calling subscribers 231 | subscribers.forEach(subscriber => subscriber()); 232 | 233 | const expectedPatchMessage = { 234 | type: PATCH_STATE_TYPE, 235 | channelName, 236 | payload: diffStrategy(firstState, secondState) 237 | }; 238 | 239 | sendMessage.calledTwice.should.eql(true); 240 | sendMessage.secondCall.args[1].should.eql(expectedPatchMessage); 241 | }); 242 | 243 | describe("when validating options", function () { 244 | const store = { 245 | dispatch: sinon.spy(), 246 | subscribe: () => { 247 | return () => ({}); 248 | }, 249 | getState: () => ({}) 250 | }; 251 | 252 | it('should use defaults if no options present', function () { 253 | should.doesNotThrow(() => { 254 | const wrapStore = createWrapStore(); 255 | 256 | wrapStore(store); 257 | }); 258 | }); 259 | 260 | it('should throw an error if serializer is not a function', function () { 261 | should.throws(() => { 262 | const wrapStore = createWrapStore({ channelName }); 263 | 264 | wrapStore(store, { serializer: "abc" }); 265 | }, Error); 266 | }); 267 | 268 | it('should throw an error if deserializer is not a function', function () { 269 | should.throws(() => { 270 | const wrapStore = createWrapStore({ channelName }); 271 | 272 | wrapStore(store, { deserializer: "abc" }); 273 | }, Error); 274 | }); 275 | 276 | it('should throw an error if diffStrategy is not a function', function () { 277 | should.throws(() => { 278 | const wrapStore = createWrapStore({ channelName }); 279 | 280 | wrapStore(store, { diffStrategy: "abc" }); 281 | }, Error); 282 | }); 283 | }); 284 | 285 | it( 286 | 'should send a safety message to all tabs once initialized', 287 | function () { 288 | const tabs = [123, 456, 789, 1011, 1213]; 289 | const tabResponders = []; 290 | const store = { 291 | dispatch: sinon.spy(), 292 | subscribe: () => { 293 | return () => ({}); 294 | }, 295 | getState: () => ({}) 296 | }; 297 | 298 | self.chrome = { 299 | runtime: { 300 | onMessage: { 301 | addListener: () => { }, 302 | }, 303 | onConnectExternal: { 304 | addListener: () => { }, 305 | }, 306 | sendMessage: () => { } 307 | }, 308 | tabs: { 309 | query: (tabObject, cb) => { 310 | cb(tabs); 311 | }, 312 | sendMessage: (tabId) => { 313 | tabResponders.push(tabId); 314 | } 315 | } 316 | }; 317 | const wrapStore = createWrapStore({ channelName }); 318 | 319 | wrapStore(store); 320 | 321 | tabResponders.length.should.equal(5); 322 | }, 323 | ); 324 | }); 325 | --------------------------------------------------------------------------------