├── .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 | 
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 | [](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 |
--------------------------------------------------------------------------------