├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── pull_request_template.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CREDITS ├── MIT-LICENSE.txt ├── README.md ├── background_scripts ├── bg_utils.js ├── commands.js ├── completion.js ├── completion_engines.js ├── completion_search.js ├── exclusions.js ├── main.js └── marks.js ├── content_scripts ├── file_urls.css ├── hud.js ├── link_hints.js ├── marks.js ├── mode.js ├── mode_find.js ├── mode_insert.js ├── mode_key_handler.js ├── mode_normal.js ├── mode_visual.js ├── scroller.js ├── ui_component.js ├── vimium.css ├── vimium_frontend.js └── vomnibar.js ├── icons ├── 300x300_browser_action_disabled.png ├── 300x300_browser_action_enabled.png ├── 300x300_browser_action_partial.png ├── browser_action_disabled.png ├── browser_action_enabled.png ├── browser_action_partial.png ├── check.png ├── icon128.png ├── icon16.png ├── icon48.png ├── icon48disabled.png ├── icon48partial.png └── vimium.png ├── lib ├── clipboard.js ├── dom_utils.js ├── find_mode_history.js ├── handler_stack.js ├── keyboard_utils.js ├── rect.js ├── settings.js └── utils.js ├── make.js ├── manifest.json ├── pages ├── blank.html ├── completion_engines.css ├── completion_engines.html ├── completion_engines.js ├── exclusions.html ├── help_dialog.html ├── help_dialog.js ├── hud.html ├── hud.js ├── logging.html ├── logging.js ├── options.css ├── options.html ├── options.js ├── popup.html ├── ui_component_server.js ├── vomnibar.css ├── vomnibar.html └── vomnibar.js ├── test_harnesses ├── form.html ├── has_popup_and_link_hud.html ├── iframe.html ├── page_with_links.html ├── visibility_test.html └── vomnibar.html └── tests ├── dom_tests ├── chrome.js ├── dom_test_setup.js ├── dom_tests.html ├── dom_tests.js ├── dom_utils_test.js ├── test_runner.js └── vomnibar_test.js ├── unit_tests ├── commands_test.js ├── completion_test.js ├── exclusion_test.js ├── handler_stack_test.js ├── rect_test.js ├── settings_test.js ├── test_chrome_stubs.js ├── test_helper.js └── utils_test.js └── vendor └── shoulda.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Include a clear bug description. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to URL '...' 16 | 2. Click on '....' 17 | 18 | Include a screenshot if applicable. 19 | 20 | **Browser and Vimium version** 21 | 22 | If you're using Chrome, include the Chrome and OS version found at chrome://version. Also include the Vimium version found at chrome://extensions. 23 | 24 | If you're using Firefox, report the Firefox and OS version found at about:support. Also include the Vimium version found at about:addons. 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Provide a rationale for this PR, and a reference to the corresponding issue, if there is one. 3 | 4 | Please review the "Which pull requests get merged?" section in `CONTRIBUTING.md`. 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.un~ 2 | *.swo 3 | *.swp 4 | *.crx 5 | *.sublime* 6 | options/*.js 7 | node_modules/* 8 | dist 9 | jscoverage.json 10 | tags 11 | .cake_task_cache 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Vimium 2 | 3 | ## Reporting a bug 4 | 5 | File the issue [here](https://github.com/philc/vimium/issues). 6 | 7 | ## Contributing code 8 | 9 | You'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's design 10 | principles and the goals of the maintainers. 11 | 12 | ### Vimium design principles 13 | 14 | When people first start using Vimium, it provides an incredibly powerful workflow improvement and it makes 15 | them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people, not just users of 16 | Vim. 17 | 18 | In addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which prevent a 19 | new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1 in 5 Chrome Store 20 | reviews say this -- and most people have strong web browsing habits forged from years of browsing. Given that, 21 | it's a great experience when Vimium feels like a natural addition to Chrome which augments, but doesn't break, 22 | the user's current browsing habits. 23 | 24 | **Principles:** 25 | 26 | 1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all you need 27 | to know to start using Vimium and feel awesome. 28 | 2. **Reliable**. The core feature set works on most sites on the web. 29 | 3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's useful. Just 30 | watch the video or hit `?`. You can transition into using Vimium piecemeal; you don't need to jump in 31 | whole-hog from the start. 32 | 4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves. 33 | 5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as we add to 34 | Vimium, so it requires our active effort to maintain this simplicity. 35 | 6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into. This 36 | provides us an active dev community. 37 | 38 | ### Which pull requests get merged? 39 | 40 | **Goals of the maintainers** 41 | 42 | The maintainers of Vimium have limited bandwidth, which influences which PRs we can review and merge. 43 | 44 | Our goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use cases. This 45 | is in contrast to adding and maintaining an increasing number of complex or niche features. We recommend those 46 | live in forked repos rather than the mainline Vimium repo. 47 | 48 | PRs we'll likely merge: 49 | 50 | * Reflect all of the Vimium design principles. 51 | * Are useful for lots of Vimium users. 52 | * Have simple implementations (straightforward code, few lines of code). 53 | 54 | PRs we likely won't: 55 | 56 | * Violate one or more of our design principles. 57 | * Are niche. 58 | * Have complex implementations -- more code than they're worth. 59 | 60 | Tips for preparing a PR: 61 | 62 | * If you want to check with us first before implementing something big, open an issue proposing the idea. 63 | You'll get feedback from the maintainers as to whether it's something we'll likely merge. 64 | * Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review. 65 | 66 | ### Installing From Source 67 | 68 | Vimium is written in Javascript. To install Vimium from source: 69 | 70 | **On Chrome/Chromium:** 71 | 72 | 1. Navigate to `chrome://extensions` 73 | 1. Toggle into Developer Mode 74 | 1. Click on "Load Unpacked Extension..." 75 | 1. Select the Vimium directory you've cloned from Github. 76 | 77 | **On Firefox:** 78 | 79 | For 'local storage' to work while using the temporary addon, you need to add an 'application' section to the 80 | manifest with an arbitrary ID that is unique for you, for example: 81 | 82 | "applications": { 83 | "gecko": { 84 | "id": "vimium@example.net" 85 | } 86 | }, 87 | 88 | After that: 89 | 90 | 1. Open Firefox 91 | 1. Enter "about:debugging" in the URL bar 92 | 1. Click "Load Temporary Add-on" 93 | 1. Open the Vimium directory you've cloned from Github, and select any file inside. 94 | 95 | ### Running the tests 96 | 97 | Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and 98 | [Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests: 99 | 100 | 1. Install [Deno](https://deno.land/) if you don't have it already. 101 | 1. `PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@9.0.2/install.ts` to 102 | install [Puppeteer](https://github.com/lucacasonato/deno-puppeteer) 103 | 1. `./make.js test` to build the code and run the tests. 104 | 105 | ### Coding Style 106 | 107 | * We follow the recommendations from the [Airbnb Javascript style guide](https://github.com/airbnb/javascript). 108 | * When writing comments, uppercase the first letter of your sentence, and put a period at the end. 109 | * We follow two major differences from this style guide: 110 | * Wrap lines at 110 characters instead of 100, for historical reasons. 111 | * We use double-quoted strings by default, for historical reasons. 112 | * We allow short, simple if statements to be used without braces, like so: 113 | 114 | if (string.length == 0) 115 | return; 116 | 117 | ... 118 | * We're currently using Javascript language features from ES2018 or earlier. If we desire to use something 119 | introduced in a later version of Javascript, we need to remember to update the minimum Chrome and Firefox 120 | versions required. 121 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Authors & Maintainers: 2 | Ilya Sukhar (github: ilya) 3 | Phil Crosby (github: philc) 4 | 5 | Contributors: 6 | acrollet 7 | Adam Lindberg (github: eproxus) 8 | akhilman 9 | Ângelo Otávio Nuffer Nunes (github: angelonuffer) 10 | Bernardo B. Marques (github: bernardofire) 11 | Bill Casarin (github: jb55) 12 | Bill Mill (github: llimllib) 13 | Branden Rolston (github: branden) 14 | Caleb Spare (github: cespare) 15 | Carl Helmertz (github: chelmertz) 16 | Christian Stefanescu (github: stchris) 17 | ConradIrwin 18 | Daniel MacDougall (github: dmacdougall) 19 | drizzd 20 | gpurkins 21 | hogelog 22 | int3 23 | Johannes Emerich (github: knuton) 24 | Julian Naydichev (github: naydichev) 25 | Justin Blake (github: blaix) 26 | Knorkebrot 27 | lack 28 | markstos 29 | Matthew Cline 30 | Matt Garriott (github: mgarriott) 31 | Matthew Ryan (github: mrmr1993) 32 | Michael Hauser-Raspe (github: mijoharas) 33 | Murph (github: pandeiro) 34 | Niklas Baumstark (github: niklasb) 35 | rodimius 36 | Stephen Blott (github: smblott-github) 37 | Svein-Erik Larsen (github: feinom) 38 | Tim Morgan (github: seven1m) 39 | tsigo 40 | R.T. Lechow (github: rtlechow) 41 | Wang Ning (github:daning) 42 | Werner Laurensse (github: ab3) 43 | Timo Sand (github: deiga) 44 | Shiyong Chen (github: UncleBill) 45 | Utkarsh Upadhyay (github: PrestanceDesign) 47 | Dahan Gong (github: gdh1995) 48 | Scott Pinkelman (github: sco-tt) 49 | Darryl Pogue (github: dpogue) 50 | tobimensch 51 | Ramiro Araujo (github: ramiroaraujo) 52 | Daniel Skogly (github: poacher2k) 53 | Matt Wanchap (github: mwanchap) 54 | Leo Solidum (github: leosolid) 55 | 56 | Feel free to add real names in addition to GitHub usernames. 57 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Phil Crosby, Ilya Sukhar. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Vimium - The Hacker's Browser 2 | ============================= 3 | 4 | Vimium is a browser extension that provides keyboard-based navigation and control of the web in the spirit of 5 | the Vim editor. 6 | 7 | __Installation instructions:__ 8 | 9 | Install via the 10 | [Chrome web store](https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb) or 11 | the [Firefox Addons site](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/). 12 | 13 | To install from source, see [here](CONTRIBUTING.md#installing-from-source). 14 | 15 | Vimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium 16 | on the extension pages of Chrome (`chrome://extensions`) or Firefox (`about:addons`). 17 | 18 | Keyboard Bindings 19 | ----------------- 20 | 21 | Modifier keys are specified as ``, ``, and `` for ctrl+x, meta+x, and alt+x 22 | respectively. For shift+x and ctrl-shift-x, just type `X` and ``. See the next section for how to 23 | customize these bindings. 24 | 25 | Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`. 26 | 27 | Navigating the current page: 28 | 29 | ? show the help dialog for a list of all available keys 30 | h scroll left 31 | j scroll down 32 | k scroll up 33 | l scroll right 34 | gg scroll to top of the page 35 | G scroll to bottom of the page 36 | d scroll down half a page 37 | u scroll up half a page 38 | f open a link in the current tab 39 | F open a link in a new tab 40 | r reload 41 | gs view source 42 | i enter insert mode -- all commands will be ignored until you hit Esc to exit 43 | yy copy the current url to the clipboard 44 | yf copy a link url to the clipboard 45 | gf cycle forward to the next frame 46 | gF focus the main/top frame 47 | 48 | Navigating to new pages: 49 | 50 | o Open URL, bookmark, or history entry 51 | O Open URL, bookmark, history entry in a new tab 52 | b Open bookmark 53 | B Open bookmark in a new tab 54 | 55 | Using find: 56 | 57 | / enter find mode 58 | -- type your search query and hit enter to search, or Esc to cancel 59 | n cycle forward to the next find match 60 | N cycle backward to the previous find match 61 | 62 | For advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the wiki. 63 | 64 | Navigating your history: 65 | 66 | H go back in history 67 | L go forward in history 68 | 69 | Manipulating tabs: 70 | 71 | J, gT go one tab left 72 | K, gt go one tab right 73 | g0 go to the first tab. Use ng0 to go to n-th tab 74 | g$ go to the last tab 75 | ^ visit the previously-visited tab 76 | t create tab 77 | yt duplicate current tab 78 | x close current tab 79 | X restore closed tab (i.e. unwind the 'x' command) 80 | T search through your open tabs 81 | W move current tab to new window 82 | pin/unpin current tab 83 | 84 | Using marks: 85 | 86 | ma, mA set local mark "a" (global mark "A") 87 | `a, `A jump to local mark "a" (global mark "A") 88 | `` jump back to the position before the previous jump 89 | -- that is, before the previous gg, G, n, N, / or `a 90 | 91 | Additional advanced browsing commands: 92 | 93 | ]], [[ Follow the link labeled 'next' or '>' ('previous' or '<') 94 | - helpful for browsing paginated sites 95 | open multiple links in a new tab 96 | gi focus the first (or n-th) text input box on the page. Use to cycle through options. 97 | gu go up one level in the URL hierarchy 98 | gU go up to root of the URL hierarchy 99 | ge edit the current URL 100 | gE edit the current URL and open in a new tab 101 | zH scroll all the way left 102 | zL scroll all the way right 103 | v enter visual mode; use p/P to paste-and-go, use y to yank 104 | V enter visual line mode 105 | 106 | Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid succession. `` 107 | (or ``) will clear any partial commands in the queue and will also exit insert and find modes. 108 | 109 | There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) for a full 110 | list. 111 | 112 | Custom Key Mappings 113 | ------------------- 114 | 115 | You may remap or unmap any of the default key bindings in the "Custom key mappings" on the options page. 116 | 117 | Enter one of the following key mapping commands per line: 118 | 119 | - `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior (if any). 120 | - `unmap key`: Unmaps a key and restores Chrome's default behavior (if any). 121 | - `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults and start 122 | from scratch with your own setup. 123 | 124 | Examples: 125 | 126 | - `map scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of bringing up 127 | a bookmark dialog is suppressed. 128 | - `map r reload` maps the r key to reloading the page. 129 | - `unmap ` removes any mapping for ctrl+d and restores Chrome's default behavior. 130 | - `unmap r` removes any mapping for the r key. 131 | 132 | Available Vimium commands can be found via the "Show available commands" link 133 | near the key mapping box on the options page. The command name appears to the 134 | right of the description in parenthesis. 135 | 136 | You can add comments to key mappings by starting a line with `"` or `#`. 137 | 138 | The following special keys are available for mapping: 139 | 140 | - ``, ``, ``, `` for ctrl, alt, shift, and meta (command on Mac) respectively with any key. Replace `*` 141 | with the key of choice. 142 | - ``, ``, ``, `` for the arrow keys. 143 | - `` through `` for the function keys. 144 | - `` for the space key. 145 | - ``, ``, ``, ``, ``, `` and `` for the corresponding non-printable keys (version 1.62 onwards). 146 | 147 | Shifts are automatically detected so, for example, `` corresponds to ctrl+shift+7 on an English keyboard. 148 | 149 | More documentation 150 | ------------------ 151 | Many of the more advanced or involved features are documented on 152 | [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki). Also 153 | see the [FAQ](https://github.com/philc/vimium/wiki/FAQ). 154 | 155 | Contributing 156 | ------------ 157 | See [CONTRIBUTING.md](CONTRIBUTING.md) for details. 158 | 159 | Release Notes 160 | ------------- 161 | 162 | See [CHANGELOG](CHANGELOG.md) for the major changes in each release. 163 | 164 | License 165 | ------- 166 | Copyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details. 167 | -------------------------------------------------------------------------------- /background_scripts/bg_utils.js: -------------------------------------------------------------------------------- 1 | const TIME_DELTA = 500; // Milliseconds. 2 | 3 | // TabRecency associates a logical timestamp with each tab id. These are used to provide an initial 4 | // recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs). 5 | class TabRecency { 6 | 7 | constructor() { 8 | this.timestamp = 1; 9 | this.current = -1; 10 | this.cache = {}; 11 | this.lastVisited = null; 12 | this.lastVisitedTime = null; 13 | 14 | chrome.tabs.onActivated.addListener(activeInfo => this.register(activeInfo.tabId)); 15 | chrome.tabs.onRemoved.addListener(tabId => this.deregister(tabId)); 16 | 17 | chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => { 18 | this.deregister(removedTabId); 19 | this.register(addedTabId); 20 | }); 21 | 22 | if (chrome.windows != null) { 23 | chrome.windows.onFocusChanged.addListener(wnd => { 24 | if (wnd !== chrome.windows.WINDOW_ID_NONE) { 25 | chrome.tabs.query({windowId: wnd, active: true}, tabs => { 26 | if (tabs[0]) 27 | this.register(tabs[0].id); 28 | }); 29 | } 30 | }); 31 | } 32 | } 33 | 34 | register(tabId) { 35 | const currentTime = new Date(); 36 | // Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a 37 | // very-short time (e.g. those passed through with `5J`) aren't registered as visited at all. 38 | if ((this.lastVisitedTime != null) && (TIME_DELTA <= (currentTime - this.lastVisitedTime))) { 39 | this.cache[this.lastVisited] = ++this.timestamp; 40 | } 41 | 42 | this.current = (this.lastVisited = tabId); 43 | this.lastVisitedTime = currentTime; 44 | } 45 | 46 | deregister(tabId) { 47 | if (tabId === this.lastVisited) { 48 | // Ensure we don't register this tab, since it's going away. 49 | this.lastVisited = (this.lastVisitedTime = null); 50 | } 51 | delete this.cache[tabId]; 52 | } 53 | 54 | // Recently-visited tabs get a higher score (except the current tab, which gets a low score). 55 | recencyScore(tabId) { 56 | if (!this.cache[tabId]) 57 | this.cache[tabId] = 1; 58 | if (tabId === this.current) 59 | return 0.0; 60 | else 61 | return this.cache[tabId] / this.timestamp; 62 | } 63 | 64 | // Returns a list of tab Ids sorted by recency, most recent tab first. 65 | getTabsByRecency() { 66 | const tabIds = Object.keys(this.cache || {}); 67 | tabIds.sort((a,b) => this.cache[b] - this.cache[a]); 68 | return tabIds.map(tId => parseInt(tId)); 69 | } 70 | } 71 | 72 | var BgUtils = { 73 | tabRecency: new TabRecency(), 74 | 75 | // Log messages to the extension's logging page, but only if that page is open. 76 | log: (function() { 77 | const loggingPageUrl = chrome.runtime.getURL("pages/logging.html"); 78 | if (loggingPageUrl != null) { console.log(`Vimium logging URL:\n ${loggingPageUrl}`); } // Do not output URL for tests. 79 | // For development, it's sometimes useful to automatically launch the logging page on reload. 80 | if (localStorage.autoLaunchLoggingPage) { chrome.windows.create({url: loggingPageUrl, focused: false}); } 81 | return function(message, sender = null) { 82 | for (let viewWindow of chrome.extension.getViews({type: "tab"})) { 83 | if (viewWindow.location.pathname === "/pages/logging.html") { 84 | // Don't log messages from the logging page itself. We do this check late because most of the time 85 | // it's not needed. 86 | if ((sender != null ? sender.url : undefined) !== loggingPageUrl) { 87 | const date = new Date; 88 | let [hours, minutes, seconds, milliseconds] = 89 | [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()]; 90 | if (minutes < 10) { minutes = "0" + minutes; } 91 | if (seconds < 10) { seconds = "0" + seconds; } 92 | if (milliseconds < 10) { milliseconds = "00" + milliseconds; } 93 | if (milliseconds < 100) { milliseconds = "0" + milliseconds; } 94 | const dateString = `${hours}:${minutes}:${seconds}.${milliseconds}`; 95 | const logElement = viewWindow.document.getElementById("log-text"); 96 | logElement.value += `${dateString}: ${message}\n`; 97 | logElement.scrollTop = 2000000000; 98 | } 99 | } 100 | } 101 | }; 102 | })(), 103 | 104 | // Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the last 105 | // character on the preceding line is "\". 106 | parseLines(text) { 107 | return text.replace(/\\\n/g, "") 108 | .split("\n") 109 | .map(line => line.trim()) 110 | .filter(line => (line.length > 0) && !(Array.from('#"').includes(line[0]))); 111 | }, 112 | 113 | escapedEntities: { 114 | '"': ""s;", 115 | '&': "&", 116 | "'": "'", 117 | "<": "<", 118 | ">": ">" 119 | }, 120 | 121 | escapeAttribute(string) { 122 | return string.replace(/["&'<>]/g, char => BgUtils.escapedEntities[char]); 123 | } 124 | }; 125 | 126 | // Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the 127 | // search-engine configuration is unchanged. 128 | const SearchEngines = { 129 | previousSearchEngines: null, 130 | searchEngines: null, 131 | 132 | refresh(searchEngines) { 133 | if ((this.previousSearchEngines == null) || (searchEngines !== this.previousSearchEngines)) { 134 | this.previousSearchEngines = searchEngines; 135 | this.searchEngines = new AsyncDataFetcher(function(callback) { 136 | const engines = {}; 137 | for (let line of BgUtils.parseLines(searchEngines)) { 138 | const tokens = line.split(/\s+/); 139 | if (2 <= tokens.length) { 140 | const keyword = tokens[0].split(":")[0]; 141 | const searchUrl = tokens[1]; 142 | const description = tokens.slice(2).join(" ") || `search (${keyword})`; 143 | if (Utils.hasFullUrlPrefix(searchUrl) || Utils.hasJavascriptPrefix(searchUrl)) 144 | engines[keyword] = {keyword, searchUrl, description}; 145 | } 146 | } 147 | 148 | callback(engines); 149 | }); 150 | } 151 | }, 152 | 153 | // Use the parsed search-engine configuration, possibly asynchronously. 154 | use(callback) { this.searchEngines.use(callback); }, 155 | 156 | // Both set (refresh) the search-engine configuration and use it at the same time. 157 | refreshAndUse(searchEngines, callback) { 158 | this.refresh(searchEngines); 159 | this.use(callback); 160 | } 161 | }; 162 | 163 | BgUtils.TIME_DELTA = TIME_DELTA; // Referenced by our tests. 164 | 165 | window.SearchEngines = SearchEngines; 166 | window.BgUtils = BgUtils; 167 | -------------------------------------------------------------------------------- /background_scripts/completion_engines.js: -------------------------------------------------------------------------------- 1 | // A completion engine provides search suggestions for a custom search engine. A custom search engine is 2 | // identified by a "searchUrl". An "engineUrl" is used for fetching suggestions, whereas a "searchUrl" is used 3 | // for the actual search itself. 4 | // 5 | // Each completion engine defines: 6 | // 7 | // 1. An "engineUrl". This is the URL to use for search completions and is passed as the option "engineUrl" 8 | // to the "BaseEngine" constructor. 9 | // 10 | // 2. One or more regular expressions which define the custom search engine URLs for which the completion 11 | // engine will be used. This is passed as the "regexps" option to the "BaseEngine" constructor. 12 | // 13 | // 3. A "parse" function. This takes a successful XMLHttpRequest object (the request has completed 14 | // successfully), and returns a list of suggestions (a list of strings). This method is always executed 15 | // within the context of a try/catch block, so errors do not propagate. 16 | // 17 | // 4. Each completion engine *must* include an example custom search engine. The example must include an 18 | // example "keyword" and an example "searchUrl", and may include an example "description" and an 19 | // "explanation". 20 | // 21 | // Each new completion engine must be added to the list "CompletionEngines" at the bottom of this file. 22 | // 23 | // The lookup logic which uses these completion engines is in "./completion_search.js". 24 | // 25 | 26 | // A base class for common regexp-based matching engines. "options" must define: 27 | // options.engineUrl: the URL to use for the completion engine. This must be a string. 28 | // options.regexps: one or regular expressions. This may either a single string or a list of strings. 29 | // options.example: an example object containing at least "keyword" and "searchUrl", and optional "description". 30 | class BaseEngine { 31 | constructor(options) { 32 | Object.assign(this, options); 33 | this.regexps = this.regexps.map(regexp => new RegExp(regexp)); 34 | } 35 | 36 | match(searchUrl) { return Utils.matchesAnyRegexp(this.regexps, searchUrl); } 37 | getUrl(queryTerms) { return Utils.createSearchUrl(queryTerms, this.engineUrl); } 38 | } 39 | 40 | // Several Google completion engines package responses as XML. This parses such XML. 41 | class GoogleXMLBaseEngine extends BaseEngine { 42 | parse(xhr) { 43 | return Array.from(xhr.responseXML.getElementsByTagName("suggestion")) 44 | .map(suggestion => suggestion.getAttribute("data")) 45 | .filter(suggestion => suggestion); 46 | } 47 | } 48 | 49 | class Google extends GoogleXMLBaseEngine { 50 | constructor() { 51 | super({ 52 | engineUrl: "https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s", 53 | regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"], 54 | example: { 55 | searchUrl: "https://www.google.com/search?q=%s", 56 | keyword: "g" 57 | } 58 | }); 59 | } 60 | } 61 | 62 | 63 | class GoogleMaps extends GoogleXMLBaseEngine { 64 | constructor() { 65 | const q = GoogleMaps.prefix.split(" ").join("+"); 66 | super({ 67 | engineUrl: `https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=${q}%s`, 68 | regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"], 69 | example: { 70 | searchUrl: "https://www.google.com/maps?q=%s", 71 | keyword: "m", 72 | explanation: 73 | `\ 74 | This uses regular Google completion, but prepends the text "map of" to the query. It works 75 | well for places, countries, states, geographical regions and the like, but will not perform address 76 | search.\ 77 | ` 78 | } 79 | }); 80 | } 81 | 82 | parse(xhr) { 83 | return Array.from(super.parse(xhr)) 84 | .filter(suggestion => suggestion.startsWith(GoogleMaps.prefix)) 85 | .map(suggestion => suggestion.slice(GoogleMaps.prefix.length)); 86 | } 87 | } 88 | 89 | GoogleMaps.prefix = "map of"; 90 | 91 | class Youtube extends GoogleXMLBaseEngine { 92 | constructor() { 93 | super({ 94 | engineUrl: "https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s", 95 | regexps: ["^https?://[a-z]+\\.youtube\\.com/results"], 96 | example: { 97 | searchUrl: "https://www.youtube.com/results?search_query=%s", 98 | keyword: "y" 99 | } 100 | }); 101 | } 102 | } 103 | 104 | class Wikipedia extends BaseEngine { 105 | constructor() { 106 | super({ 107 | engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s", 108 | regexps: ["^https?://[a-z]+\\.wikipedia\\.org/"], 109 | example: { 110 | searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s", 111 | keyword: "w" 112 | } 113 | }); 114 | } 115 | 116 | parse(xhr) { return JSON.parse(xhr.responseText)[1]; } 117 | } 118 | 119 | class Bing extends BaseEngine { 120 | constructor() { 121 | super({ 122 | engineUrl: "https://api.bing.com/osjson.aspx?query=%s", 123 | regexps: ["^https?://www\\.bing\\.com/search"], 124 | example: { 125 | searchUrl: "https://www.bing.com/search?q=%s", 126 | keyword: "b" 127 | } 128 | }); 129 | } 130 | 131 | parse(xhr) { return JSON.parse(xhr.responseText)[1]; } 132 | } 133 | 134 | class Amazon extends BaseEngine { 135 | constructor() { 136 | super({ 137 | engineUrl: "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s", 138 | regexps: ["^https?://(www|smile)\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"], 139 | example: { 140 | searchUrl: "https://www.amazon.com/s/?field-keywords=%s", 141 | keyword: "a" 142 | } 143 | }); 144 | } 145 | 146 | parse(xhr) { return JSON.parse(xhr.responseText)[1]; } 147 | } 148 | 149 | class AmazonJapan extends BaseEngine { 150 | constructor() { 151 | super({ 152 | engineUrl: "https://completion.amazon.co.jp/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=6&q=%s", 153 | regexps: ["^https?://www\\.amazon\\.co\\.jp/(s/|gp/search)"], 154 | example: { 155 | searchUrl: "https://www.amazon.co.jp/s/?field-keywords=%s", 156 | keyword: "aj" 157 | } 158 | }); 159 | } 160 | 161 | parse(xhr) { return JSON.parse(xhr.responseText)[1]; } 162 | } 163 | 164 | class DuckDuckGo extends BaseEngine { 165 | constructor() { 166 | super({ 167 | engineUrl: "https://duckduckgo.com/ac/?q=%s", 168 | regexps: ["^https?://([a-z]+\\.)?duckduckgo\\.com/"], 169 | example: { 170 | searchUrl: "https://duckduckgo.com/?q=%s", 171 | keyword: "d" 172 | } 173 | }); 174 | } 175 | 176 | parse(xhr) { 177 | return Array.from(JSON.parse(xhr.responseText)).map((suggestion) => suggestion.phrase); 178 | } 179 | } 180 | 181 | class Webster extends BaseEngine { 182 | constructor() { 183 | super({ 184 | engineUrl: "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s", 185 | regexps: ["^https?://www.merriam-webster.com/dictionary/"], 186 | example: { 187 | searchUrl: "https://www.merriam-webster.com/dictionary/%s", 188 | keyword: "dw", 189 | description: "Dictionary" 190 | } 191 | }); 192 | } 193 | 194 | parse(xhr) { 195 | return Array.from(JSON.parse(xhr.responseText).docs).map((suggestion) => suggestion.word); 196 | } 197 | } 198 | 199 | class Qwant extends BaseEngine { 200 | constructor() { 201 | super({ 202 | engineUrl: "https://api.qwant.com/api/suggest?q=%s", 203 | regexps: ["^https?://www\\.qwant\\.com/"], 204 | example: { 205 | searchUrl: "https://www.qwant.com/?q=%s", 206 | keyword: "qw" 207 | } 208 | }); 209 | } 210 | 211 | parse(xhr) { 212 | return Array.from(JSON.parse(xhr.responseText).data.items).map((suggestion) => suggestion.value); 213 | } 214 | } 215 | 216 | class UpToDate extends BaseEngine { 217 | constructor() { 218 | super({ 219 | engineUrl: "https://www.uptodate.com/services/app/contents/search/autocomplete/json?term=%s&limit=10", 220 | regexps: ["^https?://www\\.uptodate\\.com/"], 221 | example: { 222 | searchUrl: "https://www.uptodate.com/contents/search?search=%s&searchType=PLAIN_TEXT&source=USER_INPUT&searchControl=TOP_PULLDOWN&autoComplete=false", 223 | keyword: "upto" 224 | } 225 | }); 226 | } 227 | 228 | parse(xhr) { return JSON.parse(xhr.responseText).data.searchTerms; } 229 | } 230 | 231 | // A dummy search engine which is guaranteed to match any search URL, but never produces completions. This 232 | // allows the rest of the logic to be written knowing that there will always be a completion engine match. 233 | class DummyCompletionEngine extends BaseEngine { 234 | constructor() { 235 | super({ 236 | regexps: ["."], 237 | dummy: true 238 | }); 239 | } 240 | } 241 | 242 | // Note: Order matters here. 243 | const CompletionEngines = [ 244 | Youtube, 245 | GoogleMaps, 246 | Google, 247 | DuckDuckGo, 248 | Wikipedia, 249 | Bing, 250 | Amazon, 251 | AmazonJapan, 252 | Webster, 253 | Qwant, 254 | UpToDate, 255 | DummyCompletionEngine 256 | ]; 257 | 258 | window.CompletionEngines = CompletionEngines; 259 | -------------------------------------------------------------------------------- /background_scripts/exclusions.js: -------------------------------------------------------------------------------- 1 | const ExclusionRegexpCache = { 2 | cache: {}, 3 | clear(cache) { 4 | this.cache = cache || {}; 5 | }, 6 | get(pattern) { 7 | if (pattern in this.cache) { 8 | return this.cache[pattern]; 9 | } else { 10 | let result; 11 | // We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium. 12 | try { 13 | result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); 14 | } catch (error) { 15 | BgUtils.log(`bad regexp in exclusion rule: ${pattern}`); 16 | result = /^$/; // Match the empty string. 17 | } 18 | this.cache[pattern] = result; 19 | return result; 20 | } 21 | } 22 | }; 23 | 24 | // The Exclusions class manages the exclusion rule setting. An exclusion is an object with two attributes: 25 | // pattern and passKeys. The exclusion rules are an array of such objects. 26 | var Exclusions = { 27 | // Make RegexpCache, which is required on the page popup, accessible via the Exclusions object. 28 | RegexpCache: ExclusionRegexpCache, 29 | 30 | rules: Settings.get("exclusionRules"), 31 | 32 | // Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; hence, this 33 | // is the default. However, when called from the page popup, we are testing what effect candidate new rules 34 | // would have on the current tab. In this case, the candidate rules are provided by the caller. 35 | getRule(url, rules) { 36 | if (rules == null) 37 | rules = this.rules; 38 | const matchingRules = rules.filter(r => r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0)); 39 | // An absolute exclusion rule (one with no passKeys) takes priority. 40 | for (let rule of matchingRules) 41 | if (!rule.passKeys) 42 | return rule; 43 | // Strip whitespace from all matching passKeys strings, and join them together. 44 | const passKeys = matchingRules.map(r => r.passKeys.split(/\s+/).join("")).join(""); 45 | // passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join "" 46 | if (matchingRules.length > 0) 47 | return {passKeys: Utils.distinctCharacters(passKeys)}; 48 | else 49 | return null; 50 | }, 51 | 52 | isEnabledForUrl(url) { 53 | const rule = Exclusions.getRule(url); 54 | return { 55 | isEnabledForUrl: !rule || (rule.passKeys.length > 0), 56 | passKeys: rule ? rule.passKeys : "" 57 | }; 58 | }, 59 | 60 | setRules(rules) { 61 | // Callers map a rule to null to have it deleted, and rules without a pattern are useless. 62 | this.rules = rules.filter(rule => rule && rule.pattern); 63 | Settings.set("exclusionRules", this.rules); 64 | }, 65 | 66 | // TODO(philc): Why does this take a `rules` argument if it's unused? Remove. 67 | postUpdateHook(rules) { 68 | // NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions popup is 69 | // closed. Do NOT store it/use it asynchronously. 70 | this.rules = Settings.get("exclusionRules"); 71 | ExclusionRegexpCache.clear(); 72 | } 73 | }; 74 | 75 | // Register postUpdateHook for exclusionRules setting. 76 | Settings.postUpdateHooks["exclusionRules"] = Exclusions.postUpdateHook.bind(Exclusions); 77 | 78 | window.Exclusions = Exclusions; 79 | -------------------------------------------------------------------------------- /background_scripts/marks.js: -------------------------------------------------------------------------------- 1 | const Marks = { 2 | // This returns the key which is used for storing mark locations in chrome.storage.sync. 3 | getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; }, 4 | 5 | // Get the part of a URL we use for matching here (that is, everything up to the first anchor). 6 | getBaseUrl(url) { return url.split("#")[0]; }, 7 | 8 | // Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the mark is 9 | // used, whether this is the original Vimium session or a subsequent session. This affects whether or not 10 | // tabId can be considered valid. 11 | create(req, sender) { 12 | chrome.storage.local.get("vimiumSecret", items => { 13 | const markInfo = { 14 | vimiumSecret: items.vimiumSecret, 15 | markName: req.markName, 16 | url: this.getBaseUrl(sender.tab.url), 17 | tabId: sender.tab.id, 18 | scrollX: req.scrollX, 19 | scrollY: req.scrollY 20 | }; 21 | 22 | if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) { 23 | return this.saveMark(markInfo); 24 | } else { 25 | // The front-end frame hasn't provided the scroll position (because it's not the top frame within its 26 | // tab). We need to ask the top frame what its scroll position is. 27 | return chrome.tabs.sendMessage(sender.tab.id, {name: "getScrollPosition"}, response => { 28 | return this.saveMark(Object.assign(markInfo, 29 | {scrollX: response.scrollX, scrollY: response.scrollY})); 30 | }); 31 | } 32 | }); 33 | }, 34 | 35 | saveMark(markInfo) { 36 | const item = {}; 37 | item[this.getLocationKey(markInfo.markName)] = markInfo; 38 | return Settings.storage.set(item); 39 | }, 40 | 41 | // Goto a global mark. We try to find the original tab. If we can't find that, then we try to find another 42 | // tab with the original URL, and use that. And if we can't find such an existing tab, then we create a new 43 | // one. Whichever of those we do, we then set the scroll position to the original scroll position. 44 | goto(req, sender) { 45 | chrome.storage.local.get("vimiumSecret", items => { 46 | const { 47 | vimiumSecret 48 | } = items; 49 | const key = this.getLocationKey(req.markName); 50 | return Settings.storage.get(key, items => { 51 | const markInfo = items[key]; 52 | if (markInfo.vimiumSecret !== vimiumSecret) { 53 | // This is a different Vimium instantiation, so markInfo.tabId is definitely out of date. 54 | return this.focusOrLaunch(markInfo, req); 55 | } else { 56 | // Check whether markInfo.tabId still exists. According to 57 | // https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome session. So, if 58 | // we find a match, we can use it. 59 | return chrome.tabs.get(markInfo.tabId, tab => { 60 | if (!chrome.runtime.lastError && tab && tab.url && (markInfo.url === this.getBaseUrl(tab.url))) { 61 | // The original tab still exists. 62 | return this.gotoPositionInTab(markInfo); 63 | } else { 64 | // The original tab no longer exists. 65 | return this.focusOrLaunch(markInfo, req); 66 | } 67 | }); 68 | } 69 | }); 70 | }); 71 | }, 72 | 73 | // Focus an existing tab and scroll to the given position within it. 74 | gotoPositionInTab({ tabId, scrollX, scrollY }) { 75 | chrome.tabs.update(tabId, { active: true }, (tab) => { 76 | chrome.windows.update(tab.windowId, { focused: true }); 77 | chrome.tabs.sendMessage(tabId, {name: "setScrollPosition", scrollX, scrollY}); 78 | }); 79 | }, 80 | 81 | // The tab we're trying to find no longer exists. We either find another tab with a matching URL and use it, 82 | // or we create a new tab. 83 | focusOrLaunch(markInfo, req) { 84 | // If we're not going to be scrolling to a particular position in the tab, then we choose all tabs with a 85 | // matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense to scroll 86 | // unless there's an exact URL match). 87 | const query = markInfo.scrollX === markInfo.scrollY && markInfo.scrollY === 0 ? `${markInfo.url}*` : markInfo.url; 88 | return chrome.tabs.query({ url: query }, tabs => { 89 | if (tabs.length > 0) { 90 | // We have at least one matching tab. Pick one and go to it. 91 | return this.pickTab(tabs, tab => { 92 | return this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id})); 93 | }); 94 | } else { 95 | // There is no existing matching tab, we'll have to create one. 96 | return TabOperations.openUrlInNewTab(Object.assign(req, {url: this.getBaseUrl(markInfo.url)}), tab => { 97 | // Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when the tab 98 | // is loaded, its DOM is ready and it registers with the background page. 99 | return tabLoadedHandlers[tab.id] = 100 | () => this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id})); 101 | }); 102 | } 103 | }); 104 | }, 105 | 106 | // Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with shorter 107 | // (matching) URLs. 108 | pickTab(tabs, callback) { 109 | const tabPicker = function({ id }) { 110 | // Prefer tabs in the current window, if there are any. 111 | let tab; 112 | const tabsInWindow = tabs.filter(tab => tab.windowId === id); 113 | if (tabsInWindow.length > 0) { tabs = tabsInWindow; } 114 | // If more than one tab remains and the current tab is still a candidate, then don't pick the current 115 | // tab (because jumping to it does nothing). 116 | if (tabs.length > 1) 117 | tabs = tabs.filter(t => !t.active) 118 | 119 | // Prefer shorter URLs. 120 | tabs.sort((a, b) => a.url.length - b.url.length); 121 | return callback(tabs[0]); 122 | }; 123 | if (chrome.windows != null) 124 | return chrome.windows.getCurrent(tabPicker); 125 | else 126 | return tabPicker({id: undefined}); 127 | } 128 | }; 129 | 130 | window.Marks = Marks; 131 | -------------------------------------------------------------------------------- /content_scripts/file_urls.css: -------------------------------------------------------------------------------- 1 | /* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This automatically 2 | * sets -webkit-user-select: none, which disables selecting the file names and so prevents Vimium's search 3 | * from working as expected. Here, we reset the value back to default. */ 4 | .icon.file { 5 | -webkit-user-select: auto !important; 6 | } 7 | -------------------------------------------------------------------------------- /content_scripts/hud.js: -------------------------------------------------------------------------------- 1 | // 2 | // A heads-up-display (HUD) for showing Vimium page operations. 3 | // Note: you cannot interact with the HUD until document.body is available. 4 | // 5 | const HUD = { 6 | tween: null, 7 | hudUI: null, 8 | findMode: null, 9 | abandon() { 10 | if (this.hudUI) 11 | this.hudUI.hide(false); 12 | }, 13 | 14 | pasteListener: null, // Set by @pasteFromClipboard to handle the value returned by pasteResponse 15 | 16 | // This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html" 17 | // test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that 18 | // it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. 19 | 20 | init(focusable) { 21 | if (focusable == null) 22 | focusable = true; 23 | if (this.hudUI == null) { 24 | this.hudUI = new UIComponent("pages/hud.html", "vimiumHUDFrame", ({data}) => { 25 | if (this[data.name]) 26 | return this[data.name](data); 27 | }); 28 | } 29 | // this[data.name]? data 30 | if (this.tween == null) 31 | this.tween = new Tween("iframe.vimiumHUDFrame.vimiumUIComponentVisible", this.hudUI.shadowDOM); 32 | if (focusable) { 33 | this.hudUI.toggleIframeElementClasses("vimiumNonClickable", "vimiumClickable"); 34 | // Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible. See more in #3277. 35 | // Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste. 36 | this.hudUI.toggleIframeElementClasses("vimiumUIComponentHidden", "vimiumUIComponentVisible"); 37 | // Force the re-computation of styles, so Chrome sends a visibility change message to the child frame. 38 | // See https://github.com/philc/vimium/pull/3277#issuecomment-487363284 39 | 40 | // Allow to access to the clipboard through iframes. 41 | this.hudUI.iframeElement.allow = "clipboard-read; clipboard-write"; 42 | getComputedStyle(this.hudUI.iframeElement).display; 43 | } else { 44 | this.hudUI.toggleIframeElementClasses("vimiumClickable", "vimiumNonClickable"); 45 | } 46 | }, 47 | 48 | 49 | showForDuration(text, duration) { 50 | this.show(text); 51 | this._showForDurationTimerId = setTimeout((() => this.hide()), duration); 52 | }, 53 | 54 | show(text) { 55 | DomUtils.documentComplete(() => { 56 | // @hudUI.activate will take charge of making it visible 57 | this.init(false); 58 | clearTimeout(this._showForDurationTimerId); 59 | this.hudUI.activate({name: "show", text}); 60 | this.tween.fade(1.0, 150); 61 | }); 62 | }, 63 | 64 | showFindMode(findMode = null) { 65 | this.findMode = findMode; 66 | DomUtils.documentComplete(() => { 67 | this.init(); 68 | this.hudUI.activate({name: "showFindMode"}); 69 | this.tween.fade(1.0, 150); 70 | }); 71 | }, 72 | 73 | search(data) { 74 | // NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use postFindFocus 75 | // to put it back, so the user can continue typing. 76 | this.findMode.findInPlace(data.query, {"postFindFocus": this.hudUI.iframeElement.contentWindow}); 77 | 78 | // Show the number of matches in the HUD UI. 79 | const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0; 80 | const showMatchText = FindMode.query.rawQuery.length > 0; 81 | this.hudUI.postMessage({name: "updateMatchesCount", matchCount, showMatchText}); 82 | }, 83 | 84 | // Hide the HUD. 85 | // If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden immediately). 86 | // If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't update the 87 | // mode indicator, is when hide() is called for the mode indicator itself. 88 | hide(immediate, updateIndicator) { 89 | if (immediate == null) 90 | immediate = false; 91 | if (updateIndicator == null) 92 | updateIndicator = true; 93 | if ((this.hudUI != null) && (this.tween != null)) { 94 | clearTimeout(this._showForDurationTimerId); 95 | this.tween.stop(); 96 | if (immediate) { 97 | if (updateIndicator) 98 | Mode.setIndicator(); 99 | else 100 | this.hudUI.hide(); 101 | } else { 102 | this.tween.fade(0, 150, () => this.hide(true, updateIndicator)); 103 | } 104 | } 105 | }, 106 | 107 | // These parameters describe the reason find mode is exiting, and come from the HUD UI component. 108 | hideFindMode({exitEventIsEnter, exitEventIsEscape}) { 109 | let postExit; 110 | this.findMode.checkReturnToViewPort(); 111 | 112 | // An element won't receive a focus event if the search landed on it while we were in the HUD iframe. To 113 | // end up with the correct modes active, we create a focus/blur event manually after refocusing this 114 | // window. 115 | window.focus(); 116 | 117 | const focusNode = DomUtils.getSelectionFocusElement(); 118 | if (document.activeElement != null) 119 | document.activeElement.blur(); 120 | 121 | if (focusNode && focusNode.focus) 122 | focusNode.focus(); 123 | 124 | if (exitEventIsEnter) { 125 | FindMode.handleEnter(); 126 | if (FindMode.query.hasResults) 127 | postExit = () => newPostFindMode(); 128 | } else if (exitEventIsEscape) { 129 | // We don't want FindMode to handle the click events that FindMode.handleEscape can generate, so we 130 | // wait until the mode is closed before running it. 131 | postExit = FindMode.handleEscape; 132 | } 133 | 134 | this.findMode.exit(); 135 | if (postExit) 136 | postExit(); 137 | }, 138 | 139 | // These commands manage copying and pasting from the clipboard in the HUD frame. 140 | // NOTE(mrmr1993): We need this to copy and paste on Firefox: 141 | // * an element can't be focused in the background page, so copying/pasting doesn't work 142 | // * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur events. 143 | // * the HUD shouldn't be active for this frame while any of the copy/paste commands are running. 144 | copyToClipboard(text) { 145 | DomUtils.documentComplete(() => { 146 | this.init(); 147 | this.hudUI.postMessage({name: "copyToClipboard", data: text}); 148 | }); 149 | }, 150 | 151 | pasteFromClipboard(pasteListener) { 152 | this.pasteListener = pasteListener; 153 | DomUtils.documentComplete(() => { 154 | this.init(); 155 | this.tween.fade(0, 0); 156 | this.hudUI.postMessage({name: "pasteFromClipboard"}); 157 | }); 158 | }, 159 | 160 | pasteResponse({data}) { 161 | // Hide the HUD frame again. 162 | this.hudUI.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden"); 163 | this.unfocusIfFocused(); 164 | this.pasteListener(data); 165 | }, 166 | 167 | unfocusIfFocused() { 168 | // On Firefox, if an 25 | 26 | 27 | -------------------------------------------------------------------------------- /test_harnesses/page_with_links.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | Page with many links 6 | 24 | 25 | 26 | This will be a link spanning two
lines
27 | 28 |

29 |

30 | 31 | This link has a lot of vertical padding 32 | 33 |

34 |

35 |

36 |

37 | 38 | This link has a lot of vertical padding on the top 39 | 40 |

41 |

42 |
div with an onclick attribute
43 | 44 | 45 |

46 |

47 | An anchor with just a name 48 | 49 | 50 | -------------------------------------------------------------------------------- /test_harnesses/visibility_test.html: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | 8 | Visibility test 9 | 29 | 30 | 133 | 134 | 135 | 136 |
137 | 138 | 139 | 140 | 141 |
Node/Test
142 | 143 |
144 | test 145 | 146 | 147 | 148 |
149 | test 150 |
151 | 152 | 153 | 154 |
155 | test 156 |
157 | 158 | test 159 | 160 |
161 | test 162 |
163 |
164 | test 165 |
166 | 167 | 168 | test 169 | 170 | 171 |
172 | 173 | -------------------------------------------------------------------------------- /test_harnesses/vomnibar.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 41 | 42 | 52 | 53 | 54 | 55 | 56 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et 57 | dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex 58 | ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat 59 | nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit 60 | anim id est laborum. 61 | 62 |

63 | 64 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et 65 | dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex 66 | ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat 67 | nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit 68 | anim id est laborum. 69 | 70 | 71 | -------------------------------------------------------------------------------- /tests/dom_tests/chrome.js: -------------------------------------------------------------------------------- 1 | // 2 | // Mock the Chrome extension API. 3 | // 4 | 5 | window.chromeMessages = []; 6 | 7 | document.hasFocus = () => true; 8 | 9 | window.forTrusted = handler => handler; 10 | 11 | const fakeManifest = { 12 | version: "1.51" 13 | }; 14 | 15 | window.chrome = { 16 | runtime: { 17 | connect() { 18 | return { 19 | onMessage: { 20 | addListener() {} 21 | }, 22 | onDisconnect: { 23 | addListener() {} 24 | }, 25 | postMessage() {} 26 | }; 27 | }, 28 | onMessage: { 29 | addListener() {} 30 | }, 31 | sendMessage(message) { return chromeMessages.unshift(message); }, 32 | getManifest() { return fakeManifest; }, 33 | getURL(url) { return `../../${url}`; } 34 | }, 35 | storage: { 36 | local: { 37 | get() {}, 38 | set() {} 39 | }, 40 | sync: { 41 | get(_, callback) { return callback ? callback({}) : null; }, 42 | set() {} 43 | }, 44 | onChanged: { 45 | addListener() {} 46 | } 47 | }, 48 | extension: { 49 | inIncognitoContext: false, 50 | getURL(url) { return chrome.runtime.getURL(url); } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_test_setup.js: -------------------------------------------------------------------------------- 1 | window.vimiumDomTestsAreRunning = true 2 | 3 | // Attach shoulda's functions -- like setup, context, should -- to the global namespace. 4 | Object.assign(window, shoulda); 5 | 6 | // Install frontend event handlers. 7 | Frame.registerFrameId({chromeFrameId: 0}); 8 | 9 | // Shoulda.js doesn't support async code, so we try not to use any. 10 | // TODO(philc): This is outdated; we can consider using async tests now. 11 | Utils.nextTick = (func) => func() 12 | 13 | document.addEventListener("DOMContentLoaded", () => HUD.init()); 14 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_tests.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 29 | 30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 |

Vimium Tests

70 | 71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_utils_test.js: -------------------------------------------------------------------------------- 1 | context("DOM content loaded", () => { 2 | 3 | // The DOM content has already loaded, this should be called immediately. 4 | should("call callback immediately.", () => { 5 | let called = false; 6 | DomUtils.documentReady(() => called = true); 7 | assert.isTrue(called); 8 | }), 9 | 10 | // See ./dom_tests.html; the callback there was installed before the document was ready. 11 | should("already have called callback embedded in test page.", 12 | () => assert.isTrue((window.documentReadyListenerCalled != null) && window.documentReadyListenerCalled)) 13 | }); 14 | 15 | context("Check visibility", () => { 16 | 17 | should("detect visible elements as visible", () => { 18 | document.getElementById("test-div").innerHTML = `\ 19 |
test
\ 20 | `; 21 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 22 | }); 23 | 24 | should("detect display:none links as hidden", () => { 25 | document.getElementById("test-div").innerHTML = `\ 26 | \ 27 | `; 28 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 29 | }); 30 | 31 | should("detect visibility:hidden links as hidden", () => { 32 | document.getElementById("test-div").innerHTML = `\ 33 | \ 34 | `; 35 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 36 | }); 37 | 38 | should("detect elements nested in display:none elements as hidden", () => { 39 | document.getElementById("test-div").innerHTML = `\ 40 |
41 | test 42 |
\ 43 | `; 44 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 45 | }); 46 | 47 | should("detect links nested in visibility:hidden elements as hidden", () => { 48 | document.getElementById("test-div").innerHTML = `\ 49 |
50 | test 51 |
\ 52 | `; 53 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 54 | }); 55 | 56 | should("detect links outside viewport as hidden", () => { 57 | document.getElementById("test-div").innerHTML = `\ 58 | test 59 | test\ 60 | `; 61 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 62 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('bar')), true))); 63 | }); 64 | 65 | should("detect links only partially outside viewport as visible", () => { 66 | document.getElementById("test-div").innerHTML = `\ 67 | test 68 | test\ 69 | `; 70 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 71 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('bar')), true)) !== null); 72 | }); 73 | 74 | should("detect links that contain only floated / absolutely-positioned divs as visible", () => { 75 | document.getElementById("test-div").innerHTML = `\ 76 | 77 |
test
78 |
\ 79 | `; 80 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 81 | 82 | document.getElementById("test-div").innerHTML = `\ 83 | 84 |
test
85 |
\ 86 | `; 87 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 88 | }); 89 | 90 | should("detect links that contain only invisible floated divs as invisible", () => { 91 | document.getElementById("test-div").innerHTML = `\ 92 | 93 |
test
94 |
\ 95 | `; 96 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true))); 97 | }); 98 | 99 | should("detect font-size: 0; and display: inline; links when their children are display: inline", () => { 100 | // This test represents the minimal test case covering issue #1554. 101 | document.getElementById("test-div").innerHTML = `\ 102 | 103 |
test
104 |
\ 105 | `; 106 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 107 | }); 108 | 109 | should("detect links inside opacity:0 elements as visible", () => { 110 | // XXX This is an expected failure. See issue #16. 111 | document.getElementById("test-div").innerHTML = `\ 112 |
113 | test 114 |
\ 115 | `; 116 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null); 117 | }) 118 | }); 119 | 120 | // NOTE(philc): This test doesn't pass on puppeteer. It's unclear from the XXX comment if it's supposed to. 121 | // should("Detect links within SVGs as visible"), () => { 122 | // # XXX this is an expected failure 123 | // document.getElementById("test-div").innerHTML = """ 124 | // 125 | // 126 | // test 127 | // 128 | // 129 | // """ 130 | // assert.equal(null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)); 131 | // } 132 | -------------------------------------------------------------------------------- /tests/dom_tests/test_runner.js: -------------------------------------------------------------------------------- 1 | Tests.outputMethod = function(...args) { 2 | let newOutput = args.join("\n"); 3 | // Escape html 4 | newOutput = newOutput.replace(/&/g, "&").replace(//g, ">"); 5 | // Highlight the source of the error 6 | newOutput = newOutput.replace(/\/([^:/]+):([0-9]+):([0-9]+)/, "/$1:$2:$3"); 7 | document.getElementById("output-div").innerHTML += "
" + newOutput + "
"; 8 | console.log.apply(console, args); 9 | }; 10 | 11 | // Puppeteer will call the tests manually 12 | if (!navigator.userAgent.includes("HeadlessChrome")) { 13 | console.log("we're not in headless chrome"); 14 | // ensure the extension has time to load before commencing the tests 15 | document.addEventListener("DOMContentLoaded", () => setTimeout(Tests.run, 200)); 16 | } 17 | -------------------------------------------------------------------------------- /tests/dom_tests/vomnibar_test.js: -------------------------------------------------------------------------------- 1 | let vomnibarFrame = null; 2 | Vomnibar.init(); 3 | 4 | context("Keep selection within bounds", () => { 5 | 6 | setup(() => { 7 | this.completions = []; 8 | 9 | vomnibarFrame = Vomnibar.vomnibarUI.iframeElement.contentWindow; 10 | 11 | // The Vomnibar frame is dynamically injected, so inject our stubs here. 12 | vomnibarFrame.chrome = chrome; 13 | 14 | const oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind(vomnibarFrame.Vomnibar); 15 | stub(vomnibarFrame.Vomnibar, 'getCompleter', name => { 16 | const completer = oldGetCompleter(name); 17 | stub(completer, 'filter', ({ callback }) => callback({results: this.completions})); 18 | return completer; 19 | }); 20 | 21 | // Shoulda.js doesn't support async tests, so we have to hack around. 22 | stub(Vomnibar.vomnibarUI, "hide", () => {}); 23 | stub(Vomnibar.vomnibarUI, "postMessage", data => vomnibarFrame.UIComponentServer.handleMessage({data})); 24 | stub(vomnibarFrame.UIComponentServer, "postMessage", data => UIComponent.handleMessage({data}));}), 25 | 26 | tearDown(() => Vomnibar.vomnibarUI.hide()), 27 | 28 | should("set selection to position -1 for omni completion by default", () => { 29 | Vomnibar.activate(0, {options: {}}); 30 | const ui = vomnibarFrame.Vomnibar.vomnibarUI; 31 | 32 | this.completions = []; 33 | ui.update(true); 34 | assert.equal(-1, ui.selection); 35 | 36 | this.completions = [{html:'foo',type:'tab',url:'http://example.com'}]; 37 | ui.update(true); 38 | assert.equal(-1, ui.selection); 39 | 40 | this.completions = []; 41 | ui.update(true); 42 | assert.equal(-1, ui.selection); 43 | }); 44 | 45 | should("set selection to position 0 for bookmark completion if possible", () => { 46 | Vomnibar.activateBookmarks(); 47 | const ui = vomnibarFrame.Vomnibar.vomnibarUI; 48 | 49 | this.completions = []; 50 | ui.update(true); 51 | assert.equal(-1, ui.selection); 52 | 53 | this.completions = [{html:'foo',type:'bookmark',url:'http://example.com'}]; 54 | ui.update(true); 55 | assert.equal(0, ui.selection); 56 | 57 | this.completions = []; 58 | ui.update(true); 59 | assert.equal(-1, ui.selection); 60 | }); 61 | 62 | should("keep selection within bounds", () => { 63 | Vomnibar.activate(0, {options: {}}); 64 | const ui = vomnibarFrame.Vomnibar.vomnibarUI; 65 | 66 | this.completions = []; 67 | ui.update(true); 68 | 69 | const eventMock = { 70 | preventDefault() {}, 71 | stopImmediatePropagation() {} 72 | }; 73 | 74 | this.completions = [{html:'foo',type:'tab',url:'http://example.com'}]; 75 | ui.update(true); 76 | stub(ui, "actionFromKeyEvent", () => "down"); 77 | ui.onKeyEvent(eventMock); 78 | assert.equal(0, ui.selection); 79 | 80 | this.completions = []; 81 | ui.update(true); 82 | assert.equal(-1, ui.selection); 83 | }) 84 | }); 85 | -------------------------------------------------------------------------------- /tests/unit_tests/commands_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../background_scripts/bg_utils.js"; 3 | import "../../lib/settings.js"; 4 | import "../../lib/keyboard_utils.js"; 5 | import "../../background_scripts/commands.js"; 6 | import "../../content_scripts/mode.js"; 7 | import "../../content_scripts/mode_key_handler.js"; 8 | // Include mode_normal to check that all commands have been implemented. 9 | import "../../content_scripts/mode_normal.js"; 10 | import "../../content_scripts/link_hints.js"; 11 | import "../../content_scripts/marks.js"; 12 | import "../../content_scripts/vomnibar.js"; 13 | 14 | context("Key mappings", () => { 15 | const testKeySequence = (key, expectedKeyText, expectedKeyLength) => { 16 | const keySequence = Commands.parseKeySequence(key); 17 | assert.equal(expectedKeyText, keySequence.join("/")); 18 | assert.equal(expectedKeyLength, keySequence.length); 19 | }; 20 | 21 | should("lowercase keys correctly", () => { 22 | testKeySequence("a", "a", 1); 23 | testKeySequence("A", "A", 1); 24 | testKeySequence("ab", "a/b", 2); 25 | }); 26 | 27 | should("recognise non-alphabetic keys", () => { 28 | testKeySequence("#", "#", 1); 29 | testKeySequence(".", ".", 1); 30 | testKeySequence("##", "#/#", 2); 31 | testKeySequence("..", "./.", 2); 32 | }); 33 | 34 | should("parse keys with modifiers", () => { 35 | testKeySequence("", "", 1); 36 | testKeySequence("", "", 1); 37 | testKeySequence("", "", 1); 38 | testKeySequence("", "/", 2); 39 | testKeySequence("", "", 1); 40 | testKeySequence("z", "z/", 2); 41 | }); 42 | 43 | should("normalize with modifiers", () => { 44 | // Modifiers should be in alphabetical order. 45 | testKeySequence("", "", 1); 46 | }); 47 | 48 | should("parse and normalize named keys", () => { 49 | testKeySequence("", "", 1); 50 | testKeySequence("", "", 1); 51 | testKeySequence("", "", 1); 52 | testKeySequence("", "", 1); 53 | testKeySequence("", "", 1); 54 | }); 55 | 56 | should("handle angle brackets which are part of not modifiers", () => { 57 | testKeySequence("<", "<", 1); 58 | testKeySequence(">", ">", 1); 59 | 60 | testKeySequence("<<", ">", ">/>", 2); 62 | 63 | testKeySequence("<>", "", 2); 64 | testKeySequence("<>", "", 2); 65 | 66 | testKeySequence("<", "", 2); 67 | testKeySequence(">", ">", 1); 68 | 69 | testKeySequence("", "", 3); 70 | }); 71 | 72 | should("negative tests", () => { 73 | // These should not be parsed as modifiers. 74 | testKeySequence("", "", 5); 75 | testKeySequence("", "", 6); 76 | }); 77 | }); 78 | 79 | 80 | context("Validate commands and options", () => { 81 | // TODO(smblott) For this and each following test, is there a way to structure the tests such that the name 82 | // of the offending command appears in the output, if the test fails? 83 | should("have either noRepeat or repeatLimit, but not both", () => { 84 | for (let command of Object.keys(Commands.availableCommands)) { 85 | const options = Commands.availableCommands[command]; 86 | assert.isTrue(!(options.noRepeat && options.repeatLimit)); 87 | } 88 | }); 89 | 90 | should("describe each command", () => { 91 | for (let command of Object.keys(Commands.availableCommands)) { 92 | const options = Commands.availableCommands[command]; 93 | assert.equal("string", typeof options.description); 94 | } 95 | }); 96 | 97 | should("define each command in each command group", () => { 98 | for (let group of Object.keys(Commands.commandGroups)) { 99 | const commands = Commands.commandGroups[group]; 100 | for (let command of commands) { 101 | assert.equal("string", typeof command); 102 | assert.isTrue(Commands.availableCommands[command]); 103 | } 104 | } 105 | }); 106 | 107 | should("have valid commands for each advanced command", () => { 108 | for (let command of Commands.advancedCommands) { 109 | assert.equal("string", typeof command); 110 | assert.isTrue(Commands.availableCommands[command]); 111 | } 112 | }); 113 | 114 | should("have valid commands for each default key mapping", () => { 115 | const count = Object.keys(Commands.keyToCommandRegistry).length; 116 | assert.isTrue((0 < count)); 117 | for (let key of Object.keys(Commands.keyToCommandRegistry)) { 118 | const command = Commands.keyToCommandRegistry[key]; 119 | assert.equal("object", typeof command); 120 | assert.isTrue(Commands.availableCommands[command.command]); 121 | } 122 | }) 123 | }); 124 | 125 | context("Validate advanced commands", () => { 126 | should("include each advanced command in a command group", () => { 127 | let allCommands = Object.keys(Commands.commandGroups).map((k) => Commands.commandGroups[k]).flat(1); 128 | for (let command of Commands.advancedCommands) 129 | assert.isTrue(allCommands.includes(command)); 130 | }) 131 | }); 132 | 133 | context("Parse commands", () => { 134 | should("omit whitespace", () => { 135 | assert.equal(0, BgUtils.parseLines(" \n \n ").length); 136 | }); 137 | 138 | should("omit comments", () => { 139 | assert.equal(0, BgUtils.parseLines(" # comment \n \" comment \n ").length); 140 | }); 141 | 142 | should("join lines", () => { 143 | assert.equal(1, BgUtils.parseLines("a\\\nb").length); 144 | assert.equal("ab", BgUtils.parseLines("a\\\nb")[0]); 145 | }); 146 | 147 | should("trim lines", () => { 148 | assert.equal(2, BgUtils.parseLines(" a \n b").length); 149 | assert.equal("a", BgUtils.parseLines(" a \n b")[0]); 150 | assert.equal("b", BgUtils.parseLines(" a \n b")[1]); 151 | }) 152 | }); 153 | -------------------------------------------------------------------------------- /tests/unit_tests/exclusion_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | 3 | Utils.getCurrentVersion = () => "1.44"; 4 | 5 | import "../../lib/settings.js"; 6 | import "../../lib/clipboard.js"; 7 | import "../../background_scripts/bg_utils.js"; 8 | import "../../background_scripts/exclusions.js"; 9 | import "../../background_scripts/commands.js"; 10 | 11 | const isEnabledForUrl = (request) => Exclusions.isEnabledForUrl(request.url); 12 | 13 | // These tests cover only the most basic aspects of excluded URLs and passKeys. 14 | context("Excluded URLs and pass keys", () => { 15 | 16 | setup(() => { 17 | Settings.init(); 18 | Settings.set("exclusionRules", 19 | [ 20 | { pattern: "http*://mail.google.com/*", passKeys: "" }, 21 | { pattern: "http*://www.facebook.com/*", passKeys: "abab" }, 22 | { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" }, 23 | { pattern: "http*://www.bbc.com/*", passKeys: "" }, 24 | { pattern: "http*://www.bbc.com/*", passKeys: "ab" }, 25 | { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" }, 26 | { pattern: "http*://www.duplicate.com/*", passKeys: "ace" }, 27 | { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" } 28 | ]); 29 | Exclusions.postUpdateHook(); 30 | }); 31 | 32 | should("be disabled for excluded sites", () => { 33 | const rule = isEnabledForUrl({ url: 'http://mail.google.com/calendar/page' }); 34 | assert.isFalse(rule.isEnabledForUrl); 35 | assert.isFalse(rule.passKeys); 36 | }); 37 | 38 | should("be disabled for excluded sites, one exclusion", () => { 39 | const rule = isEnabledForUrl({ url: 'http://www.bbc.com/calendar/page' }); 40 | assert.isFalse(rule.isEnabledForUrl); 41 | assert.isFalse(rule.passKeys); 42 | }); 43 | 44 | should("be enabled, but with pass keys", () => { 45 | const rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }); 46 | assert.isTrue(rule.isEnabledForUrl); 47 | assert.equal(rule.passKeys, 'abcd'); 48 | }); 49 | 50 | should("be enabled", () => { 51 | const rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' }); 52 | assert.isTrue(rule.isEnabledForUrl); 53 | assert.isFalse(rule.passKeys); 54 | }); 55 | 56 | should("handle spaces and duplicates in passkeys", () => { 57 | const rule = isEnabledForUrl({ url: 'http://www.example.com/pages' }); 58 | assert.isTrue(rule.isEnabledForUrl); 59 | assert.equal("abc", rule.passKeys); 60 | }); 61 | 62 | should("handle multiple passkeys rules", () => { 63 | const rule = isEnabledForUrl({ url: 'http://www.duplicate.com/pages' }); 64 | assert.isTrue(rule.isEnabledForUrl); 65 | assert.equal("abcdef", rule.passKeys); 66 | }); 67 | 68 | should("be enabled for malformed regular expressions", () => { 69 | Exclusions.postUpdateHook([ { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "" } ]); 70 | const rule = isEnabledForUrl({ url: 'http://www.bad-regexp.com/pages' }); 71 | assert.isTrue(rule.isEnabledForUrl); 72 | }) 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit_tests/handler_stack_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/handler_stack.js"; 3 | 4 | context("handlerStack", () => { 5 | let handlerStack, handler1Called, handler2Called; 6 | 7 | setup(() => { 8 | stub(window, "DomUtils", {}); 9 | stub(DomUtils, "consumeKeyup", () => {}); 10 | stub(DomUtils, "suppressEvent", () => {}); 11 | stub(DomUtils, "suppressPropagation", () => {}); 12 | handlerStack = new HandlerStack; 13 | handler1Called = false; 14 | handler2Called = false; 15 | }); 16 | 17 | should("bubble events", () => { 18 | handlerStack.push({ keydown: () => { return handler1Called = true; } }); 19 | handlerStack.push({ keydown: () => { return handler2Called = true; } }); 20 | handlerStack.bubbleEvent('keydown', {}); 21 | assert.isTrue(handler2Called); 22 | assert.isTrue(handler1Called); 23 | }); 24 | 25 | should("terminate bubbling on falsy return value", () => { 26 | handlerStack.push({ keydown: () => { return handler1Called = true; } }); 27 | handlerStack.push({ 28 | keydown: () => { 29 | handler2Called = true; 30 | return false; 31 | } 32 | }); 33 | handlerStack.bubbleEvent('keydown', {}); 34 | assert.isTrue(handler2Called); 35 | assert.isFalse(handler1Called); 36 | }); 37 | 38 | should("terminate bubbling on passEventToPage, and be true", () => { 39 | handlerStack.push({ keydown: () => { return handler1Called = true; } }); 40 | handlerStack.push({ 41 | keydown: () => { 42 | handler2Called = true; 43 | return handlerStack.passEventToPage; 44 | } 45 | }); 46 | assert.isTrue(handlerStack.bubbleEvent('keydown', {})); 47 | assert.isTrue(handler2Called); 48 | assert.isFalse(handler1Called); 49 | }); 50 | 51 | should("terminate bubbling on passEventToPage, and be false", () => { 52 | handlerStack.push({ keydown: () => { return handler1Called = true; } }); 53 | handlerStack.push({ 54 | keydown: () => { 55 | handler2Called = true; 56 | return handlerStack.suppressPropagation; 57 | } 58 | }); 59 | assert.isFalse(handlerStack.bubbleEvent('keydown', {})); 60 | assert.isTrue(handler2Called); 61 | assert.isFalse(handler1Called); 62 | }); 63 | 64 | should("restart bubbling on restartBubbling", () => { 65 | handler1Called = 0; 66 | handler2Called = 0; 67 | var id = handlerStack.push({ 68 | keydown: () => { 69 | handler1Called++; 70 | handlerStack.remove(id); 71 | return handlerStack.restartBubbling; 72 | } 73 | }); 74 | handlerStack.push({ 75 | keydown: () => { 76 | handler2Called++; 77 | return true; 78 | } 79 | }); 80 | assert.isTrue(handlerStack.bubbleEvent('keydown', {})); 81 | assert.isTrue(handler1Called === 1); 82 | assert.isTrue(handler2Called === 2); 83 | }); 84 | 85 | should("remove handlers correctly", () => { 86 | handlerStack.push({ keydown: () => { handler1Called = true; } }); 87 | const handlerId = handlerStack.push({ keydown: () => { handler2Called = true; } }); 88 | handlerStack.remove(handlerId); 89 | handlerStack.bubbleEvent('keydown', {}); 90 | assert.isFalse(handler2Called); 91 | assert.isTrue(handler1Called); 92 | }); 93 | 94 | should("remove handlers correctly", () => { 95 | const handlerId = handlerStack.push({ keydown: () => { handler1Called = true; } }); 96 | handlerStack.push({ keydown: () => { handler2Called = true; } }); 97 | handlerStack.remove(handlerId); 98 | handlerStack.bubbleEvent('keydown', {}); 99 | assert.isTrue(handler2Called); 100 | assert.isFalse(handler1Called); 101 | }); 102 | 103 | should("handle self-removing handlers correctly", () => { 104 | handlerStack.push({ keydown: () => { handler1Called = true; } }); 105 | handlerStack.push({ 106 | keydown() { 107 | handler2Called = true; 108 | this.remove(); 109 | return true; 110 | } 111 | }); 112 | handlerStack.bubbleEvent('keydown', {}); 113 | assert.isTrue(handler2Called); 114 | assert.isTrue(handler1Called); 115 | assert.equal(handlerStack.stack.length, 1); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/unit_tests/settings_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | import "../../pages/options.js"; 4 | 5 | context("settings", () => { 6 | setup(() => { 7 | stub(Utils, "isBackgroundPage", returns(true)); 8 | stub(Utils, "isExtensionPage", returns(true)); 9 | 10 | localStorage.clear(); 11 | Settings.init(); 12 | // Avoid running update hooks which include calls to outside of settings. 13 | Settings.postUpdateHooks = {}; 14 | }); 15 | 16 | should("save settings in localStorage as JSONified strings", () => { 17 | Settings.set('dummy', ""); 18 | assert.equal('""', localStorage.dummy); 19 | }); 20 | 21 | should("obtain defaults if no key is stored", () => { 22 | assert.isFalse(Settings.has('scrollStepSize')); 23 | assert.equal(60, Settings.get('scrollStepSize')); 24 | }); 25 | 26 | should("store values", () => { 27 | Settings.set('scrollStepSize', 20); 28 | assert.equal(20, Settings.get('scrollStepSize')); 29 | }); 30 | 31 | should("revert to defaults if no key is stored", () => { 32 | Settings.set('scrollStepSize', 20); 33 | Settings.clear('scrollStepSize'); 34 | assert.equal(60, Settings.get('scrollStepSize')); 35 | }); 36 | 37 | tearDown(() => { 38 | localStorage.clear(); 39 | }); 40 | }); 41 | 42 | context("synced settings", () => { 43 | setup(() => { 44 | localStorage.clear(); 45 | Settings.init(); 46 | // Avoid running update hooks which include calls to outside of settings. 47 | Settings.postUpdateHooks = {}; 48 | }); 49 | 50 | should("propagate non-default value via synced storage listener", () => { 51 | Settings.set('scrollStepSize', 20); 52 | assert.equal(20, Settings.get('scrollStepSize')); 53 | Settings.propagateChangesFromChromeStorage({ scrollStepSize: { newValue: "40" } }); 54 | assert.equal(40, Settings.get('scrollStepSize')); 55 | }); 56 | 57 | should("propagate default value via synced storage listener", () => { 58 | Settings.set('scrollStepSize', 20); 59 | assert.equal(20, Settings.get('scrollStepSize')); 60 | Settings.propagateChangesFromChromeStorage({ scrollStepSize: { newValue: "60" } }); 61 | assert.equal(60, Settings.get('scrollStepSize')); 62 | }); 63 | 64 | should("propagate non-default values from synced storage", () => { 65 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(20) }); 66 | assert.equal(20, Settings.get('scrollStepSize')); 67 | }); 68 | 69 | should("propagate default values from synced storage", () => { 70 | Settings.set('scrollStepSize', 20); 71 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(60) }); 72 | assert.equal(60, Settings.get('scrollStepSize')); 73 | }); 74 | 75 | should("clear a setting from synced storage", () => { 76 | Settings.set('scrollStepSize', 20); 77 | chrome.storage.sync.remove('scrollStepSize'); 78 | assert.equal(60, Settings.get('scrollStepSize')); 79 | }); 80 | 81 | should("trigger a postUpdateHook", () => { 82 | const message = "Hello World"; 83 | let receivedMessage = ""; 84 | Settings.postUpdateHooks['scrollStepSize'] = value => receivedMessage = value; 85 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(message) }); 86 | assert.equal(message, receivedMessage); 87 | }); 88 | 89 | should("sync a key which is not a known setting (without crashing)", () => { 90 | chrome.storage.sync.set({ notASetting: JSON.stringify("notAUsefullValue") }); 91 | }); 92 | 93 | tearDown(() => { 94 | localStorage.clear(); 95 | }); 96 | }); 97 | 98 | context("default values", () => { 99 | should("have a default value for every option", () => { 100 | for (let key of Object.keys(Options)) { 101 | assert.isTrue(key in Settings.defaults); 102 | } 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/unit_tests/test_chrome_stubs.js: -------------------------------------------------------------------------------- 1 | // 2 | // This file contains stubs for a number of browser and chrome APIs which are missing in Deno. 3 | // The chrome.storage.sync stub does roughly what chrome.storage.sync should do, but does so synchronously. 4 | // 5 | 6 | let XMLHttpRequest; 7 | 8 | window.document = { 9 | createElement() { return {}; }, 10 | addEventListener() {} 11 | }; 12 | 13 | window.XMLHttpRequest = 14 | (XMLHttpRequest = class XMLHttpRequest { 15 | open() {} 16 | onload() {} 17 | send() {} 18 | }); 19 | 20 | window.chrome = { 21 | areRunningVimiumTests: true, 22 | 23 | runtime: { 24 | getURL() {}, 25 | getManifest() { 26 | return {version: "1.2.3"}; 27 | }, 28 | onConnect: { 29 | addListener() { return true; } 30 | }, 31 | onMessage: { 32 | addListener() { return true; } 33 | }, 34 | onInstalled: { 35 | addListener() {} 36 | } 37 | }, 38 | 39 | extension: { 40 | getURL(path) { return path; }, 41 | getBackgroundPage() { return {}; }, 42 | getViews() { return []; } 43 | }, 44 | 45 | tabs: { 46 | onUpdated: { 47 | addListener() { return true; } 48 | }, 49 | onAttached: { 50 | addListener() { return true; } 51 | }, 52 | onMoved: { 53 | addListener() { return true; } 54 | }, 55 | onRemoved: { 56 | addListener() { return true; } 57 | }, 58 | onActivated: { 59 | addListener() { return true; } 60 | }, 61 | onReplaced: { 62 | addListener() { return true; } 63 | }, 64 | query() { return true; } 65 | }, 66 | 67 | webNavigation: { 68 | onHistoryStateUpdated: { 69 | addListener() {} 70 | }, 71 | onReferenceFragmentUpdated: { 72 | addListener() {} 73 | }, 74 | onCommitted: { 75 | addListener() {} 76 | } 77 | }, 78 | 79 | windows: { 80 | onRemoved: { 81 | addListener() { return true; } 82 | }, 83 | getAll() { return true; }, 84 | onFocusChanged: { 85 | addListener() { return true; } 86 | } 87 | }, 88 | 89 | browserAction: { 90 | setBadgeBackgroundColor() {} 91 | }, 92 | 93 | storage: { 94 | // chrome.storage.local 95 | local: { 96 | get(_, callback) { if (callback) callback({}); }, 97 | set(_, callback) { if (callback) callback({}); }, 98 | remove(_, callback) { if (callback) callback({}); } 99 | }, 100 | 101 | // chrome.storage.onChanged 102 | onChanged: { 103 | addListener(func) { 104 | this.func = func; 105 | }, 106 | 107 | // Fake a callback from chrome.storage.sync. 108 | call(key, value) { 109 | chrome.runtime.lastError = undefined; 110 | const key_value = {}; 111 | key_value[key] = { newValue: value }; 112 | if (this.func) { return this.func(key_value,'sync'); } 113 | }, 114 | 115 | callEmpty(key) { 116 | chrome.runtime.lastError = undefined; 117 | if (this.func) { 118 | const items = {}; 119 | items[key] = {}; 120 | this.func(items,'sync'); 121 | } 122 | } 123 | }, 124 | 125 | session: { 126 | MAX_SESSION_RESULTS: 25 127 | }, 128 | 129 | // chrome.storage.sync 130 | sync: { 131 | store: {}, 132 | 133 | set(items, callback) { 134 | let key, value; 135 | chrome.runtime.lastError = undefined; 136 | for (key of Object.keys(items)) { 137 | value = items[key]; 138 | this.store[key] = value; 139 | } 140 | if (callback) { callback(); } 141 | // Now, generate (supposedly asynchronous) notifications for listeners. 142 | for (key of Object.keys(items)) { 143 | value = items[key]; 144 | window.chrome.storage.onChanged.call(key,value); 145 | } 146 | }, 147 | 148 | get(keys, callback) { 149 | let key; 150 | chrome.runtime.lastError = undefined; 151 | if (keys === null) { 152 | keys = []; 153 | for (key of Object.keys(this.store)) { 154 | const value = this.store[key]; 155 | keys.push(key); 156 | } 157 | } 158 | const items = {}; 159 | for (key of keys) { 160 | items[key] = this.store[key]; 161 | } 162 | // Now, generate (supposedly asynchronous) callback 163 | if (callback) { return callback(items); } 164 | }, 165 | 166 | remove(key, callback) { 167 | chrome.runtime.lastError = undefined; 168 | if (key in this.store) { 169 | delete this.store[key]; 170 | } 171 | if (callback) { callback(); } 172 | // Now, generate (supposedly asynchronous) notification for listeners. 173 | window.chrome.storage.onChanged.callEmpty(key); 174 | } 175 | } 176 | } 177 | }; 178 | -------------------------------------------------------------------------------- /tests/unit_tests/test_helper.js: -------------------------------------------------------------------------------- 1 | import * as shoulda from "../vendor/shoulda.js"; 2 | import "../../lib/utils.js"; 3 | import "./test_chrome_stubs.js"; 4 | 5 | const shouldaSubset = { 6 | assert: shoulda.assert, 7 | context: shoulda.context, 8 | ensureCalled: shoulda.ensureCalled, 9 | setup: shoulda.setup, 10 | should: shoulda.should, 11 | shoulda: shoulda, 12 | stub: shoulda.stub, 13 | returns: shoulda.returns, 14 | tearDown: shoulda.tearDown, 15 | }; 16 | 17 | // Attach shoulda's functions, like setup, context, should, to the global namespace. 18 | Object.assign(window, shouldaSubset); 19 | -------------------------------------------------------------------------------- /tests/unit_tests/utils_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | 4 | context("isUrl", () => { 5 | should("accept valid URLs", () => { 6 | assert.isTrue(Utils.isUrl("www.google.com")); 7 | assert.isTrue(Utils.isUrl("www.bbc.co.uk")); 8 | assert.isTrue(Utils.isUrl("yahoo.com")); 9 | assert.isTrue(Utils.isUrl("nunames.nu")); 10 | assert.isTrue(Utils.isUrl("user:pass@ftp.xyz.com/test")); 11 | 12 | assert.isTrue(Utils.isUrl("localhost/index.html")); 13 | assert.isTrue(Utils.isUrl("127.0.0.1:8192/test.php")); 14 | 15 | // IPv6 16 | assert.isTrue(Utils.isUrl("[::]:9000")); 17 | 18 | // Long TLDs 19 | assert.isTrue(Utils.isUrl("illinois.state.museum")); 20 | assert.isTrue(Utils.isUrl("eqt5g4fuenphqinx.onion")); 21 | 22 | // Internal URLs. 23 | assert.isTrue(Utils.isUrl("moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html")); 24 | }); 25 | 26 | should("reject invalid URLs", () => { 27 | assert.isFalse(Utils.isUrl("a.x")); 28 | assert.isFalse(Utils.isUrl("www-domain-tld")); 29 | }); 30 | }); 31 | 32 | context("convertToUrl", () => { 33 | should("detect and clean up valid URLs", () => { 34 | assert.equal("http://www.google.com/", Utils.convertToUrl("http://www.google.com/")); 35 | assert.equal("http://www.google.com/", Utils.convertToUrl(" http://www.google.com/ ")); 36 | assert.equal("http://www.google.com", Utils.convertToUrl("www.google.com")); 37 | assert.equal("http://google.com", Utils.convertToUrl("google.com")); 38 | assert.equal("http://localhost", Utils.convertToUrl("localhost")); 39 | assert.equal("http://xyz.museum", Utils.convertToUrl("xyz.museum")); 40 | assert.equal("chrome://extensions", Utils.convertToUrl("chrome://extensions")); 41 | assert.equal("http://user:pass@ftp.xyz.com/test", Utils.convertToUrl("user:pass@ftp.xyz.com/test")); 42 | assert.equal("http://127.0.0.1", Utils.convertToUrl("127.0.0.1")); 43 | assert.equal("http://127.0.0.1:8080", Utils.convertToUrl("127.0.0.1:8080")); 44 | assert.equal("http://[::]:8080", Utils.convertToUrl("[::]:8080")); 45 | assert.equal("view-source: 0.0.0.0", Utils.convertToUrl("view-source: 0.0.0.0")); 46 | assert.equal("javascript:alert('25 % 20 * 25 ');", Utils.convertToUrl("javascript:alert('25 % 20 * 25%20');")); 47 | }); 48 | 49 | should("convert non-URL terms into search queries", () => { 50 | assert.equal("https://www.google.com/search?q=google", Utils.convertToUrl("google")); 51 | assert.equal("https://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com")); 52 | assert.equal("https://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter")); 53 | }) 54 | }); 55 | 56 | context("createSearchUrl", () => { 57 | should("replace %S without encoding", () => { 58 | assert.equal("https://www.github.com/philc/vimium/pulls", Utils.createSearchUrl("vimium/pulls", "https://www.github.com/philc/%S")) 59 | }) 60 | }); 61 | 62 | context("extractQuery", () => { 63 | should("extract queries from search URLs", () => { 64 | assert.equal("bbc sport 1", Utils.extractQuery("https://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+1")); 65 | assert.equal("bbc sport 2", Utils.extractQuery("http://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+2")); 66 | assert.equal("bbc sport 3", Utils.extractQuery("https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+3")); 67 | assert.equal("bbc sport 4", Utils.extractQuery("https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+4&blah")); 68 | }) 69 | }); 70 | 71 | context("hasChromePrefix", () => { 72 | should("detect chrome prefixes of URLs", () => { 73 | assert.isTrue(Utils.hasChromePrefix("about:foobar")); 74 | assert.isTrue(Utils.hasChromePrefix("view-source:foobar")); 75 | assert.isTrue(Utils.hasChromePrefix("chrome-extension:foobar")); 76 | assert.isTrue(Utils.hasChromePrefix("data:foobar")); 77 | assert.isTrue(Utils.hasChromePrefix("data:")); 78 | assert.isFalse(Utils.hasChromePrefix("")); 79 | assert.isFalse(Utils.hasChromePrefix("about")); 80 | assert.isFalse(Utils.hasChromePrefix("view-source")); 81 | assert.isFalse(Utils.hasChromePrefix("chrome-extension")); 82 | assert.isFalse(Utils.hasChromePrefix("data")); 83 | assert.isFalse(Utils.hasChromePrefix("data :foobar")); 84 | }) 85 | }); 86 | 87 | context("hasJavascriptPrefix", () => { 88 | should("detect javascript: URLs", () => { 89 | assert.isTrue(Utils.hasJavascriptPrefix("javascript:foobar")); 90 | assert.isFalse(Utils.hasJavascriptPrefix("http:foobar")); 91 | }) 92 | }); 93 | 94 | context("decodeURIByParts", () => { 95 | should("decode javascript: URLs", () => { 96 | assert.equal("foobar", Utils.decodeURIByParts("foobar")); 97 | assert.equal(" ", Utils.decodeURIByParts("%20")); 98 | assert.equal("25 % 20 25 ", Utils.decodeURIByParts("25 % 20 25%20")); 99 | }) 100 | }); 101 | 102 | context("isUrl", () => { 103 | should("identify URLs as URLs", () => assert.isTrue(Utils.isUrl("http://www.example.com/blah"))); 104 | 105 | should("identify non-URLs and non-URLs", () => assert.isFalse(Utils.isUrl("http://www.example.com/ blah"))); 106 | }); 107 | 108 | context("Function currying", () => { 109 | should("Curry correctly", () => { 110 | const foo = (a, b) => `${a},${b}`; 111 | assert.equal("1,2", foo.curry()(1,2)); 112 | assert.equal("1,2", foo.curry(1)(2)); 113 | assert.equal("1,2", foo.curry(1,2)()); 114 | }); 115 | }); 116 | 117 | context("compare versions", () => { 118 | should("compare correctly", () => { 119 | assert.equal(0, Utils.compareVersions("1.40.1", "1.40.1")); 120 | assert.equal(0, Utils.compareVersions("1.40", "1.40.0")); 121 | assert.equal(0, Utils.compareVersions("1.40.0", "1.40")); 122 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.40.2")); 123 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.41")); 124 | assert.equal(-1, Utils.compareVersions("1.40", "1.40.1")); 125 | assert.equal(1, Utils.compareVersions("1.41", "1.40")); 126 | assert.equal(1, Utils.compareVersions("1.41.0", "1.40")); 127 | assert.equal(1, Utils.compareVersions("1.41.1", "1.41")); 128 | }); 129 | }); 130 | 131 | context("makeIdempotent", () => { 132 | 133 | let func; 134 | let count = 0; 135 | 136 | setup(() => { 137 | count = 0; 138 | func = Utils.makeIdempotent((n) => { 139 | if (n == null) 140 | n = 1; 141 | count += n; 142 | }); 143 | }); 144 | 145 | should("call a function once", () => { 146 | func(); 147 | assert.equal(1, count); 148 | }); 149 | 150 | should("call a function once with an argument", () => { 151 | func(2); 152 | assert.equal(2, count); 153 | }); 154 | 155 | should("not call a function a second time", () => { 156 | func(); 157 | assert.equal(1, count); 158 | }); 159 | 160 | should("not call a function a second time", () => { 161 | func(); 162 | assert.equal(1, count); 163 | func(); 164 | assert.equal(1, count); 165 | }); 166 | }); 167 | 168 | context("distinctCharacters", () => { 169 | should("eliminate duplicate characters", () => assert.equal("abc", Utils.distinctCharacters("bbabaabbacabbbab"))) 170 | }); 171 | 172 | context("escapeRegexSpecialCharacters", () => { 173 | should("escape regexp special characters", () => { 174 | const str = "-[]/{}()*+?.^$|"; 175 | const regexp = new RegExp(Utils.escapeRegexSpecialCharacters(str)); 176 | assert.isTrue(regexp.test(str)); 177 | }); 178 | }); 179 | 180 | context("extractQuery", () => { 181 | should("extract the query terms from a URL", () => { 182 | const url = "https://www.google.ie/search?q=star+wars&foo&bar"; 183 | const searchUrl = "https://www.google.ie/search?q=%s"; 184 | assert.equal("star wars", Utils.extractQuery(searchUrl, url)); 185 | }); 186 | 187 | should("require trailing URL components", () => { 188 | const url = "https://www.google.ie/search?q=star+wars&foo&bar"; 189 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; 190 | assert.equal(null, Utils.extractQuery(searchUrl, url)); 191 | }); 192 | 193 | should("accept trailing URL components", () => { 194 | const url = "https://www.google.ie/search?q=star+wars&foo&bar&foobar=x"; 195 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; 196 | assert.equal("star wars", Utils.extractQuery(searchUrl, url)); 197 | }); 198 | }); 199 | --------------------------------------------------------------------------------