Repository: philc/vimium Branch: master Commit: e5163eaa32d4 Files: 118 Total size: 749.5 KB Directory structure: gitextract_bk_2yr_t/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── pull_request_template.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CREDITS ├── MIT-LICENSE.txt ├── README.md ├── background_scripts/ │ ├── all_commands.js │ ├── bg_utils.js │ ├── commands.js │ ├── completion/ │ │ ├── completers.js │ │ ├── ranking.js │ │ ├── search_engines.js │ │ └── search_wrapper.js │ ├── exclusions.js │ ├── main.js │ ├── marks.js │ ├── reload.js │ ├── tab_operations.js │ ├── tab_recency.js │ └── user_search_engines.js ├── build_scripts/ │ └── write_command_listing_page.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 ├── deno.json ├── lib/ │ ├── chrome_api_stubs.js │ ├── dom_utils.js │ ├── find_mode_history.js │ ├── handler_stack.js │ ├── keyboard_utils.js │ ├── rect.js │ ├── settings.js │ ├── types.js │ ├── url_utils.js │ └── utils.js ├── make.js ├── manifest.json ├── pages/ │ ├── action.css │ ├── action.html │ ├── action.js │ ├── all_content_scripts.js │ ├── command_listing.css │ ├── command_listing.html │ ├── command_listing.js │ ├── doc.css │ ├── doc_search_completion.html │ ├── doc_search_completion.js │ ├── exclusion_rules_editor.js │ ├── help_dialog_page.css │ ├── help_dialog_page.html │ ├── help_dialog_page.js │ ├── hud_page.css │ ├── hud_page.html │ ├── hud_page.js │ ├── key_mappings.css │ ├── options.css │ ├── options.html │ ├── options.js │ ├── reload.html │ ├── ui_component_messenger.js │ ├── vomnibar_page.css │ ├── vomnibar_page.html │ └── vomnibar_page.js ├── resources/ │ └── tlds.txt ├── test_harnesses/ │ ├── cross_origin_iframe.html │ ├── event_capture.html │ ├── form.html │ ├── has_popup_and_link_hud.html │ ├── iframe.html │ ├── page_with_links.html │ ├── visibility_test.html │ ├── vomnibar_harness.html │ └── vomnibar_harness.js └── tests/ ├── dom_tests/ │ ├── dom_test_setup.js │ ├── dom_tests.html │ ├── dom_tests.js │ └── dom_utils_test.js ├── unit_tests/ │ ├── bg_utils_test.js │ ├── command_listing_test.js │ ├── commands_test.js │ ├── completion/ │ │ ├── completers_test.js │ │ ├── ranking_test.js │ │ └── search_engines_test.js │ ├── doc_search_completion_test.js │ ├── exclusion_test.js │ ├── handler_stack_test.js │ ├── help_dialog_test.js │ ├── hud_page_test.js │ ├── link_hints_test.js │ ├── main_test.js │ ├── marks_test.js │ ├── options_page_test.js │ ├── rect_test.js │ ├── settings_test.js │ ├── tab_operations_test.js │ ├── tab_recency_test.js │ ├── test_chrome_stubs.js │ ├── test_helper.js │ ├── ui_component_test.js │ ├── url_utils_test.js │ ├── user_search_engines_test.js │ ├── utils_test.js │ └── vomnibar_page_test.js └── vendor/ └── shoulda.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: File a bug title: "" labels: "" assignees: "" --- **Describe the bug** Include a clear bug description. **To Reproduce** Steps to reproduce the behavior: 1. Go to URL '...' 2. Click on '....' Include a screenshot if applicable. **Browser and Vimium version** If you're using Chrome, include the Chrome and OS version found at chrome://version. Also include the Vimium version found at chrome://extensions. If you're using Firefox, report the Firefox and OS version found at about:support. Also include the Vimium version found at about:addons. ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Provide a rationale for this PR, and a reference to the corresponding issue, if there is one. Please review the "Which pull requests get merged?" section in `CONTRIBUTING.md`. ================================================ FILE: .gitignore ================================================ dist ================================================ FILE: CHANGELOG.md ================================================ 2.4.1, 2.4.2 (2026-03-07) - Fix issue where existing users were mistakenly opted-in to [Vimium's new tab page](https://github.com/philc/vimium/pull/4795) for the `createTab` command. (https://github.com/philc/vimium/issues/4859) - Fix exclusion rules is empty in downloaded backups. (https://github.com/philc/vimium/issues/4839) 2.4.0 (2026-01-27) - Support a Vimium new tab experience: the browser can be configured to open a blank Vimium page as the new tab page. In Vimium's settings, the Vomnibar can configured to open on new tabs. See [instructions](https://github.com/philc/vimium?tab=readme-ov-file#how-to-allow-vimium-to-work-on-new-tab-pages). (https://github.com/philc/vimium/pull/4795) - Make Google search result links work on sub-tabs like "Web". (https://github.com/philc/vimium/issues/4750) 2.3.1 (2025-11-12) - Fix Vimium to work with Chrome 144. (https://github.com/philc/vimium/issues/4785) 2.3.0 (2025-06-30) - Add a command listing page, which documents all commands and their options. Access it [on the web](https://vimium.github.io/commands/), or from the Vimium Options page > Show available commands. - Some internal CSS classes were changed for Vimium's UI. This may affect those who have customized Vimium's CSS via the options page. (https://github.com/philc/vimium/issues/4668) - Breaking change: when creating a mapping for `setZoom`, a `level` argument is now required. E.g.: `map z2 setZoom level=2.0`. - Make `Vomnibar.activateBookmark` accept a `query` option. (https://github.com/philc/vimium/pull/4591) - Fix `openCopiedUrlInCurrentTab` doesn't launch search queries. (https://github.com/philc/vimium/issues/4657) - Make `openCopiedUrlInCurrentTab` accept a `position` option. - Update `goPrevious` and `goNext` commands to handle google.com's new layout. (https://github.com/philc/vimium/issues/4650) - Add a "hide update notifications" option for silencing "Vimium has been updated" notifications. (https://github.com/philc/vimium/issues/4346) - Use dark mode styles in the HUD when the browser is in dark mode. - Bug fixes. 2.2.1 (2025-03-20) - Fix findSelected and findSelectedBackwards commands (https://github.com/philc/vimium/issues/4655) - Fix openCopiedUrlInCurrentTab (https://github.com/philc/vimium/issues/4654) 2.2.0 (2025-03-08) - Use the browser's default search engine. [(#2598)](https://github.com/philc/vimium/issues/2598) - Add "reload hard" command (R). ([#4445](https://github.com/philc/vimium/pull/4445)). - Add zoomIn (zi), zoomOut (zo), zoomReset (z0), and setZoom commands. ([#4488](https://github.com/philc/vimium/pull/4488)) - Add findSelected and findSelectedBackwards commands. ([#4502](https://github.com/philc/vimium/pull/4502)) - Options page: improve UI, add error validation. - Make tab commands handle Firefox hidden tabs. - Bug fixes. 2.1.2 (2024-04-03) - Better fix for Vomnibar doesn't always list tabs by recency. ([#4368](https://github.com/philc/vimium/issues/4368)) - Add a workaround to make link hints work on Github Enterprise. ([#4446](https://github.com/philc/vimium/issues/4446)) - Fix position=end is ignored in createTab command ([#4450](https://github.com/philc/vimium/issues/4450)) 2.1.1 (2024-03-29) - Fix exclusion rule popup not working. ([#4447](https://github.com/philc/vimium/issues/4447)) 2.1.0 (2024-03-27) - Fix Vomnibar doesn't always list tabs by recency. ([#4368](https://github.com/philc/vimium/issues/4368)) - Better domain detection in the Vomnibar ([#3268](https://github.com/philc/vimium/issues/3268)) - Exclude keys based on the top frame URL, not a subframe's URL. This fixes many cases where the excluded keys feature didn't seem to work. ([#4402](https://github.com/philc/vimium/issues/4402)) - After selecting a link, if ESC is pressed, mouse out of the link. With this, Wikipedia's and Github's link preview popups can be dismissed after following a link. ([#3073](https://github.com/philc/vimium/issues/3073)) - Fix link hints do not appear for links inside of github's popups. This fix is available on Chrome 114+, and soon Firefox. ([#4408](https://github.com/philc/vimium/issues/4408)) 2.0.5, 2.0.6 (2023-11-06) - Fix bug where "esc" wouldn't unfocus a textarea like it should. ([#4336](https://github.com/philc/vimium/issues/4336)) - Fix passNextKey command. 2.0.4 (2023-10-19) - Bug fixes: ([#4340](https://github.com/philc/vimium/issues/4340)), ([#4341](https://github.com/philc/vimium/issues/4341)), ([#4342](https://github.com/philc/vimium/issues/4342)). 2.0.2, 2.0.3 (2023-10-11) - Fix Vomnibar tab search doesn't get pre-populated with recently visited tabs. ([#4326](https://github.com/philc/vimium/issues/4326)) - Fix bookmarklets not working when opened from the Vomnibar. This is a partial fix; a full fix is waiting on a new extensions API. See [#4329](https://github.com/philc/vimium/issues/4329) for discussion. 2.0.1 (2023-10-04) - Fix exception when migrating some pre-v2.0 settings. ([#4323](https://github.com/philc/vimium/issues/4323)) 2.0.0 (2023-09-28) - Support manifest v3, as now required by Chrome. This involved a partial rewrite and many changes. Please report any new issues [here](https://github.com/philc/vimium/issues). - The storage format for Vimium's options has changed in v2.x. That means an options backup from Vimium v2.x cannot be loaded on Vimium v1.x installations. - Revamp the action bar UI, which configures which keys Vimium ignores on a particular site. - Improve Vimium's options UI. - Show link hints for image maps. ([#3493](https://github.com/philc/vimium/issues/3493)) - Remove the use of window.unload handlers, in preparation for Chrome's bfcache. ([#4265](https://github.com/philc/vimium/issues/4265)) - Allow find mode to work when using only private windows. ([#3614](https://github.com/philc/vimium/issues/3614)) - Add a count option to closeTabsOnLeft and closeTabsOnRight commands, to allow binding a key to "close just 1 tab on the left/right" rather than closing all tabs, as is the default. E.g. `map cl closeTabsOnLeft count=1`. ([#4296](https://github.com/philc/vimium/pull/4296)) - Add search completions for Brave Search. ([#3851](https://github.com/philc/vimium/pull/3851)) - Make regular expressions in find mode work again; other find mode improvements. ([#4261](https://github.com/philc/vimium/issues/4261)) - Bug fixes. ([#3944](https://github.com/philc/vimium/pull/3944), [#3752](https://github.com/philc/vimium/pull/3752), [#3675](https://github.com/philc/vimium/pull/3675)) 1.67.7 (2023-07-12) - Fix an issue where focusing the google search box puts the cursor at the start, rather than end, of the search box. ([#4247](https://github.com/philc/vimium/issues/4247)) 1.67.6 (2022-12-19) - Fix a spurious issue preventing approval on the Mozilla addons site ([#4195](https://github.com/philc/vimium/issues/4195)) 1.67.5 (2022-12-17) - For Firefox only, add back the clipboard read and write permissions. This fixes the Vimium commands which use the clipboard in Firefox ([#4186](https://github.com/philc/vimium/pull/4186)) 1.67.4 (2022-12-01) - Remove clipboard read/write permissions. We no longer need them since 1.67.2 (see #4120). - Fix Vimium's dark mode styling, take 2 (see [#4156](https://github.com/philc/vimium/issues/4156), [#4159](https://github.com/philc/vimium/pull/4159)) 1.67.3 (2022-10-29) - Fix copy-to-clipboard issue ([#4147](https://github.com/philc/vimium/issues/4147)) in visual mode. - Fix Vimium's dark mode styling in latest Firefox. ([#4148](https://github.com/philc/vimium/issues/4148)) 1.67.2 (2022-10-17) - In Firefox, remove use of deprecated InstallTrigger, which was issuing a console warning ([#4033](https://github.com/philc/vimium/issues/4033)) - Fix the Vimium toolbar icon to accurately reflect whether keys are excluded ([#4118](https://github.com/philc/vimium/pull/4118)) - Fix usage of deprecated clipboard APIs, which affected commands using copy and paste ([#4120](https://github.com/philc/vimium/issues/4120)) - Fix bug preventing going into caret mode ([#3877](https://github.com/philc/vimium/pull/3877)) 1.67.1 (2022-01-19) - In Firefox 96+, make link hints open one tab, not two ([#3985](https://github.com/philc/vimium/pull/3985)) 1.67 (2021-07-09) - Dark mode: Vimium's UI (URL bar, help dialog, option page, etc.) are dark if the browser is configured for dark mode. Vimium's dark mode is also compatible when using the popular [DarkReader extension](https://github.com/darkreader/darkreader). - Convert the code base from Coffeescript to Javascript, to simplify the dev experience and allow more developers to work on Vimium. - Make search mode work in newer versions of Firefox (#3801) - Make buttons on the Vimium options page work again in newer versions of Firefox (#3624) - Allow Vimium to work in LibreWolf (a Firefox fork) - Fixes to visual mode (#3568, #3779) 1.66 (2020-03-02) - Show tabs in the Vomnibar bar search results ('o') ([#2656](https://github.com/philc/vimium/pull/2656)) - Add commands to hover or focus a link ([#3097](https://github.com/philc/vimium/pull/3097)) (see [wiki)](https://github.com/philc/vimium/wiki/Tips-and-Tricks#hovering-over-links-using-linkhints) - Allow shift as a modifier for keybindings (e.g. ``) ([#2388](https://github.com/philc/vimium/pull/2388)) - Fix some issues with link hints [(#3499](https://github.com/philc/vimium/pull/3499), [#3505](https://github.com/philc/vimium/pull/3505), [#3509](https://github.com/philc/vimium/pull/3509)) - Other fixes. 1.65.2 (2020-02-10) - No code changes; trying to debug a permissions issue as shown in the chrome store ([#3489](https://github.com/philc/vimium/issues/3489)). 1.65.1 (2020-02-09) - Fix an issue with the HUD preventing some link hints from being shown ([#3486](https://github.com/philc/vimium/issues/3486)). 1.65 (2020-02-08) - Many fixes for Firefox ([#3483](https://github.com/philc/vimium/pull/3483), [#2893](https://github.com/philc/vimium/issues/2893), [#3106](https://github.com/philc/vimium/issues/3106), [#3409](https://github.com/philc/vimium/pull/3409), [#3288](https://github.com/philc/vimium/pull/3288)) - Fix javascript bookmarks, broken by Chrome 71+ [(#3473)](https://github.com/philc/vimium/pull/3437) - Improved link hints: show hints on sites with shadow DOM [(#3406)](https://github.com/philc/vimium/pull/3406), don't show hints for obstructed/invisible links ([#2251](https://github.com/philc/vimium/pull/2251)) - Fix scrolling on Reddit.com ([#3327](https://github.com/philc/vimium/pull/3327)) - Show favicons when using the tab switcher ([#2878](https://github.com/philc/vimium/pull/2878)) - The createTab command can now take arguments (start, end, before, after) ([#2895](https://github.com/philc/vimium/pull/2895)) - When using the Vomnibar, you can manually edit the suggested URL by typing ctrl-enter [(#2464)](https://github.com/philc/vimium/pull/2914) - Other fixes 1.64.6 (2019-05-12) - Fix the find mode, and copying the page's URL to the clipboard, which were broken by Chrome 74+. ([#3260](https://github.com/philc/vimium/issues/3260)) 1.64.5 (2019-02-16) - Fix error in Chrome Store distribution. 1.64.4 (2019-02-16) - Fix [Vomnibar focus issue](https://github.com/philc/vimium/issues/3242). 1.64.3 (2018-12-26) - When yanking email addresses with `yf`, Vimium now strips the leading `mailto:`. - For custom search engines, if you use `%S` (instead of `%s`), then your search terms are not URI encoded. - Bug fixes (including horizontal scrolling broken). 1.64.2 (2018-12-16) - Better scrolling on new Reddit ~~and GMail~~. 1.64 (2018-08-30) - Custom search engines can now be `javascript:` URLs (eg., search the current [site](https://github.com/philc/vimium/issues/2956#issuecomment-366509915)). - You can now using local marks to mark a hash/anchor. This is particularly useful for marking labels on GMail. - For filtered hints, you can now start typing the link text before the hints have been generated. - On Twitter, expanded tweets are now scrollable. - Fix bug whereby `` wasn't recognised in the Vomnibar in some circumstances. - Various minor bug fixes. 1.63 (2018-02-16) - The `reload` command now accepts a count prefix; so `999r` reloads all tabs (in the current window). - Better detection of click listeners for link hints. - Display version number in page popup. - The Vomnibar is now loaded on demand (not preloaded). This should fix some issues with the dev console. - The `\I` control (case sensitivity) for find mode has been removed. Find mode uses smartcase. - Various bug fixes. - 1.63.1 (Firefox only): - Fix [#2958](https://github.com/philc/vimium/issues/2958#issuecomment-366488659), link hints broken for `target="_blank"` links. - 1.63.2 (Firefox only): - Fix [#2962](https://github.com/philc/vimium/issues/2962), find mode broken on Firefox Quantum. - 1.63.3: - Fix [#2997](https://github.com/philc/vimium/issues/2997), Vimium's DOM injection breaks Google Pay site. 1.62 (2017-12-09) - Backup and restore Vimium options (see the very bottom of the options page, below _Advanced Options_). - It is now possible to map ``, ``, ``, ``, `` and ``. - New command options for `createTab` to create new normal and incognito windows ([examples](https://github.com/philc/vimium/wiki/Tips-and-Tricks#creating-tabs-with-urls-and-windows)). - Firefox only: - Fix copy and paste commands. - When upgrading, you will be asked to re-validate permissions. The only new permission is "copy and paste to/from clipboard" (the `clipboardWrite` permission). This is necessary to support copy/paste on Firefox. - Various bug fixes. - 1.62.1: Swap global and local marks (1.62.1). In a browser, some people find global marks more useful than local marks. Example: ``` map X Marks.activateCreateMode swap map Y Marks.activateGotoMode swap ``` - Other minor versions: - 1.62.2: Fixes [#2868](https://github.com/philc/vimium/issues/2868) (`createTab` with multiple URLs). - 1.62.4: Fixes bug affecting the enabled state, and really fix `createTab`. 1.61 (2017-10-27) - For _filtered hints_, you can now use alphabetical hint characters instead of digits; use `` for hint characters. - With `map R reload hard`, the reload command now asks Chrome to bypass its cache. - You can now map `` to a command (in which case it will not be treated as `Escape`). - Various bug fixes, particularly for Firefox. - Minor versions: - 1.61.1: Fix `map R reload hard`. 1.60 (2017-09-14) - Features: - There's a new (advanced) option to ignore the keyboard layout; this can be helpful for users of non-Latin keyboards. - Firefox support. This is a work in progress; please report any issues [here](https://github.com/philc/vimium/issues?q=is%3Aopen+sort%3Aupdated-desc); see the [add on](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/). - Bug fixes: - Fixed issue affecting hint placement when the display is zoomed. - Fixed search completion for Firefox (released as 1.59.1, Firefox only). - Minor versions: - 1.60.1: fix [#2642](https://github.com/philc/vimium/issues/2642). - 1.60.2: revert previous fix for HiDPI screens. This was breaking link-hint positioning for some users. - 1.60.3: [fix](https://github.com/philc/vimium/pull/2649) link-hint positioning. - 1.60.4: [fix](https://github.com/philc/vimium/pull/2602) hints opening in new tab (Firefox only). 1.59 (2017-04-07) - Features: - Some commands now work on PDF tabs (`J`, `K`, `o`, `b`, etc.). Scrolling and other content-related commands still do not work. 1.58 (2017-03-08) - Features: - The `createTab` command can now open specific URLs (e.g, `map X createTab http://www.bbc.com/news`). - With pass keys defined for a site (such as GMail), you can now use Vimium's bindings again with, for example, `map \ passNextKey normal`; this reactivates normal mode temporarily, but _without any pass keys_. - You can now map multi-modifier keys, for example: ``. - Vimium can now do simple key mapping in some modes; see [here](https://github.com/philc/vimium/wiki/Tips-and-Tricks#key-mapping). This can be helpful with some non-English keyboards (and can also be used to remap `Escape`). - For _Custom key mappings_ on the options page, lines which end with `\` are now continued on the following line. - Process: - In order to provide faster bug fixes, we may in future push new releases without the noisy notification. - Post-release minor fixes: - 1.58.1 (2017-03-09) fix bug in `LinkHints.activateModeWithQueue` (#2445). - 1.58.2 (2017-03-19) fix key handling bug (#2453). 1.57 (2016-10-01) - New commands: - `toggleMuteTab` - mute or unmute the current tab (default binding ``), see also [advanced usage](https://github.com/philc/vimium/wiki/Tips-and-Tricks#muting-tabs). - Other new features: - You can now map `` to a Vimium command (e.g. `map goBack`). - For link hints, when one hint marker is covered by another, `` now rotates the stacking order. If you use filtered hints, you'll need to use a modifier (e.g. ``). - Changes: - Global marks now search for an existing matching tab by prefix (rather than exact match). This allows global marks to be used as quick bookmarks on sites (like Facebook, Gmail, etc) where the URL changes as you navigate around. - Bug fixes: - `/i` can no longer hang Vimium while the page is loading. - `` is no longer handled (incorrectly) as `Escape`. This also affects ``. - If `goX` is mapped, then `go` no longer launches the vomnibar. This only affects three-key (or longer) bindings. 1.56 (2016-06-11) - Vimium now works around a Chromium bug affecting users with non-standard keyboard layouts (see #2147). - Fixed a bug preventing visual line mode (`V`) from working. 1.55 (2016-05-26) - New commands: - `visitPreviousTab` - visit the previous tab (by recency) with `^`, or the tab before that with `2^`. - `passNextKey` - pass the next key to the page. For example, using `map passNextKey`, you can close Facebook's messenger popups with ``. - Link hints: - Now work across all frames in the tab. - Now select frames and scrollable elements. - Now accept a count prefix; `3F` opens three new background tabs, `999F` opens many tabs. - For filtered link hints, a new option on the settings page requires you to press `Enter` to activate a link; this prevents unintentionally triggering Vimium commands with trailing keystrokes. - Miscellaneous: - `gg` now accepts a `count` prefix. - `W` now accepts a count prefix; `3W` moves three tabs to a new window. - With smooth scrolling, `2j`-and-hold now gives a faster scroll than `j`-and-hold. - You can now bind keys to a command with a defined count prefix; for example, `map d scrollDown count=4`. - You can now bind three-key (or longer) sequences; for example, `map abc enterInsertMode`. - `c-y` and `c-e` now scroll in visual mode. - The Vimium help dialog has been re-styled. - Bug fixes: - `` is no longer treated as escape. - Fix icon display and memory leak due to a regression in recent Chrome versions (49+). - For web-devs only: - When disabled on a tab, Vimium no longer pollutes the dev console with network requests. 1.54 (2016-01-30) - Fix occasional endless scrolling (#1911). 1.53 (2015-09-25) - Vimium now works on the new-tab page for Chrome 47. - `g0` and `g$` now accept count prefixes; so `2g0` selects the second tab, and so on. - Bug fixes: - Fix `moveTabLeft` and `moveTabRight` for pinned tabs (#1814 and #1815). 1.52 (2015-09-09) - Search completion for selected custom search engines (details on the [wiki](https://github.com/philc/vimium/wiki/Search-Completion)). - Use `Tab` on an empty Vomnibar to repeat or edit recent queries (details on the [wiki](https://github.com/philc/vimium/wiki/Tips-and-Tricks#repeat-recent-vomnibar-queries)). - Marks: - Use \`\` to jump back to the previous position after jump-like movements:
(`gg`, `G`, `n`, `N`, `/` and local mark movements). - Global marks are now persistent and synced. - For numeric link hints, you can now use `Tab` and `Enter` to select hints, and hints are ordered by the best match. - The Find Mode text entry box now supports editing, pasting, and better handles non-latin characters. - Vimium now works on XML pages. - Bug fixes. 1.51 (2015-05-02) - Bug [fixes](https://github.com/philc/vimium/pulls?utf8=%E2%9C%93&q=is%3Apr+sort%3Aupdated-desc+is%3Aclosed+merged%3A%3E%3D2015-04-26+merged%3A%3C2015-05-02+state%3Amerged). 1.50 (2015-04-26) - Visual mode (in beta): use `v` and then vim-like keystrokes to select text on the page. Use `y` to yank or `p` and `P` to search with your default search engine.. Please provide feedback on Github. - Added the option to prevent pages from stealing focus from Vimium when loaded. - Many bugfixes for custom search engines, and search engines can now have a description. - Better support for frames: key exclusion rules are much improved and work within frames; the Vomnibar is always activated in the main frame; and a new command (`gF`) focuses the main frame. - Find mode now has history. Use the up arrow to select previous searches. - Ctrl and Shift when using link hints changes the tab in which links are opened in (reinstated feature). - Focus input (`gi`) remembers previously-visited inputs. - Bug fixes. 1.49 (2014-12-16) - An option to toggle smooth scrolling. - Make Vimium work on older versions of Chrome. 1.46, 1.47, 1.48 (2014-12-15) - Site-specific excluded keys: you can disable some Vimium key bindings on sites like gmail.com, so you can use the key bindings provided by the site itself. - Smooth scrolling. - The Vomnibar now orders tabs by recency. Use this to quickly switch between your most recently-used tabs. - New commands: "close tabs to the left", "close tabs to the right", "close all other tabs". - Usability improvements. - Bug fixes. 1.45 (2014-07-20) - Vimium's settings are now synced across computers. - New commands: "open link in new tab and focus", "move tab left", "move tab right", "pin/unpin tab". - Vomnibar can now use [search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to Chrome's Omnibar. - Due to significant ranking improvements, Vomnibar's search results are now even more helpful. - When reopening a closed tab, its history is now preserved. - Bug fixes. 1.44 (2013-11-06) - Add support for recent versions of Chromium. - Bug fixes. 1.43 (2013-05-18) - Relevancy improvements to the Vomnibar's domain & history search. - Added `gU`, which goes to the root of the current URL. - Added `yt`, which duplicates the current tab. - Added `W`, which moves the current tab to a new window. - Added marks for saving and jumping to sections of a page. `mX` to set a mark and `` `X `` to return to it. - Added "LinkHints.activateModeToOpenIncognito", currently an advanced, unbound command. - Disallowed repeat tab closings, since this causes trouble for many people. - Update our Chrome APIs so Vimium works on Chrome 28+. - Bug fixes. 1.42 (2012-11-03) - Bug fixes. 1.41 (2012-10-27) - Bug fixes. 1.40 (2012-10-27) - Bug fixes. - Added options for search engines and regex find. - Pressing unmapped keys in hints mode now deactivates the mode. 1.39 (2012-09-09) - Bug fixes. 1.38 (2012-09-08) - `O` now opens Vomnibar results in a new tab. `B` does the same for bookmarks only. - Add a browser icon to quickly add sites to Vimium's exclude list. - Restyle options page. - `gi` now launches a new mode that allows the user to tab through the input elements on the page. - Bug fixes. 1.37 (2012-07-07) - Select the first result by default in Vomnibar tab and bookmark modes. 1.36 (2012-07-07) - `b` brings up a bookmark-only Vomnibar. - Better support for some bookmarklets. 1.35 (2012-07-05) - Bug fixes. 1.34 (2012-07-03) - A bug fix for bookmarklets in Vomnibar. 1.33 (2012-07-02) - A Vomnibar, which allows you to open sites from history, bookmarks, and tabs using Vimium's UI. Type `o` to try it. 1.32 (2012-03-05) - More tweaks to the next / previous link-detection algorithm. - Minor bug fixes. 1.31 (2012-02-28) - Improve style of link hints, and use fewer characters for hints. - Add an option to hide the heads up display (HUD). Notably, the HUD obscures Facebook Chat's textbox. - Detection and following of next / previous links has been improved. - Addition of `g0` and `g$` commands, for switching tabs. - Addition of `p`/`P` commands for URL pasting. - A new find mode which optionally supports case sensitivity and regular expressions. - Bug fixes. 1.30 (2011-12-04) - Support for image maps in link hints. - Counts now work with forward & backward navigation. - `Tab` & `shift-tab` to navigate bookmarks dialog. - An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's Advanced Preferences. - Bug fixes. 1.29 (2012-07-30) - `yf` to copy a link hint url to the clipboard. - Scatter link hints to prevent clustering on dense sites. - Don't show insert mode notification unless you specifically hit `i`. - Remove zooming functionality now that Chrome does it all natively. 1.28 (2011-06-29) - Support for opening bookmarks (`b` and `B`). - Support for contenteditable text boxes. - Speed improvements and bug fixes. 1.27 (2011-03-24) - Improvements and bug fixes. 1.26 (2011-02-17) - ``, `` and related are no longer bound by default. You can rebind them on the options page. - Faster link hinting. 1.22, 1.23, 1.24, 1.25 (2011-02-10) - Some sites are now excluded by default. - View source (`gs`) now opens in a new tab. - Support for browsing paginated sites using `]]` and `[[` to go forward and backward respectively. - Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by default, so that the core command set is more focused and approachable. - Improvements to link hinting. - Bug fixes. 1.21 (2010-10-24) - Critical bug fix for an excluded URLs regression due to frame support. 1.20 (2010-10-24) - In link hints mode, holding down the shift key will now toggle between opening in the current tab and opening in a new tab. - Two new commands (`zH` and `zL`) to scroll to the left and right edges of the page. - A new command (`gi`) to focus the first (or n-th) visible text input. - A new command (``) to open up multiple links at a time in new tabs. - Frame support. - More robust support for non-US keyboard layouts. - Numerous bug fixes. 1.19 (2010-06-29) - A critical bug fix for development channel Chromium. - Vimium icons for the Chrome extensions panel and other places. 1.18 (2010-06-22) - Vimium now runs on pages with file:/// and ftp:/// - The Options page is now linked from the Help dialog. - Arrow keys and function keys can now be mapped using <left>, <right>, <up>, <down>, <f1>, <f2>, etc. in the mappings interface. - There is a new command `goUp` (mapped to `gu` by default) that will go up one level in the URL hierarchy. For example: from https://vimium.github.io/foo/bar to https://vimium.github.io/foo. At the moment, `goUp` does not support command repetition. - Bug fixes and optimizations. 1.17 (2010-04-18) - `u` now restores tabs that were closed by the mouse or with native shortcuts. Tabs are also restored in their prior position. - New `unmapAll` command in the key mappings interface to remove all default mappings. - Link hints are now faster and more reliable. - Bug fixes. 1.16 (2010-03-09) - Add support for configurable key mappings under Advanced Options. - A help dialog which shows all currently bound keyboard shortcuts. Type `?` to see it. - Bug fixes related to key stroke handling. 1.15 (2010-01-31) - Make the CSS used by the link hints configurable. It's under Advanced Options. - Add a notification linking to the changelog when Vimium is updated in the background. - Link-hinting performance improvements and bug fixes. - `Ctrl+D` and `Ctrl+U` now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior. 1.14 (2010-01-21) - Fixed a bug introduced in 1.13 that prevented excluded URLs from being saved. 1.13 (2010-01-21) - `` and `` are now mapped to scroll a full page up or down respectively. - Bug fixes related to entering insert mode when the page first loads, and when focusing Flash embeds. - Added command listing to the Options page for easy reference. - `J` & `K` have reversed for tab switching: `J` goes left and `K` goes right. - `` is now equivalent to `Esc`, to match the behavior of VIM. - `` and `` are now mapped to scroll down and up respectively. - The characters used for link hints are now configurable under Advanced Options. 1.11, 1.12 (2010-01-08) - Commands `gt` & `gT` to move to the next & previous tab. - Command `yy` to yank (copy) the current tab's url to the clipboard. - Better Linux support. - Fix for `Shift+F` link hints. - `Esc` now clears the keyQueue. So, for example, hitting `g`, `Esc`, `g` will no longer scroll the page. 1.1 (2010-01-03) - A nicer looking settings page. - An exclusion list that allows you to define URL patterns for which Vimium will be disabled (e.g. http\*://mail.google.com/\*). - Vimium-interpreted keystrokes are no longer sent to the page. - Better Windows support. - Various miscellaneous bug fixes and UI improvements. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Vimium ## Reporting a bug File the issue [here](https://github.com/philc/vimium/issues). ## Contributing code You'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's design principles and the goals of the maintainers. ### Vimium design principles When people first start using Vimium, it provides an incredibly powerful workflow improvement and it makes them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people, not just users of Vim. In addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1 in 5 Chrome Store reviews say this -- and most people have strong web browsing habits forged from years of browsing. Given that, it's a great experience when Vimium feels like a natural addition to Chrome which augments, but doesn't break, the user's current browsing habits. **Principles:** 1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all you need to know to start using Vimium and feel awesome. 2. **Reliable**. The core feature set works on most sites on the web. 3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's useful. Just watch the video or hit `?`. You can transition into using Vimium piecemeal; you don't need to jump in whole-hog from the start. 4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves. 5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as we add to Vimium, so it requires our active effort to maintain this simplicity. 6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into. This allows more people to fix bugs and implement features. ### Which pull requests get merged? **Goals of the maintainers** The maintainers of Vimium have limited bandwidth, which influences which PRs we can review and merge. Our goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use cases. This is in contrast to adding and maintaining an increasing number of complex or niche features. We recommend those live in forked repos rather than the mainline Vimium repo. PRs we'll likely merge: - Reflect all of the Vimium design principles. - Are useful for lots of Vimium users. - Have simple implementations (straightforward code, few lines of code). PRs we likely won't: - Violate one or more of our design principles. - Are niche. - Have complex implementations -- more code than they're worth. Tips for preparing a PR: - If you want to check with us first before implementing something big, open an issue proposing the idea. You'll get feedback from the maintainers as to whether it's something we'll likely merge. - Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review. Here's the rationale behind this policy: - Vimium is a volunteer effort. To make it possible to keep the project up-to-date as the web and browsers evolve, the codebase has to remain small and maintainable. - If the maintainers don't use a feature, and most other users don't, then the feature will likely get neglected. - Every feature, particularly neglected ones, increase the complexity of the codebase and makes it more difficult and less pleasant to work on. - Adding a new feature is only part of the work. Once it's added, a feature must be maintained forever. - Vimium is a project which suffers from the [stadium model of open source](https://github.com/philc/book-notes/blob/master/engineering/working%20in%20public%20-%20nadia%20eghbal.md#the-structure-of-an-open-source-project-chap-2): there are many users but unfortunately few maintainers. As a result, there is bandwidth to maintain only a limited number of features in the main repo. ### Installing From Source Vimium is written in Javascript. To install Vimium from source: **On Chrome/Chromium:** 1. Navigate to `chrome://extensions` 1. Toggle into Developer Mode 1. Click on "Load Unpacked Extension..." 1. Select the Vimium directory you've cloned from Github. **On Firefox:** Firefox needs a modified version of the manifest.json that's used for Chrome. To generate this, run `./make.js write-firefox-manifest` After that: 1. Open Firefox 1. Enter "about:debugging" in the URL bar 1. Click "This Firefox" on the left side 1. Click "Load Temporary Add-on" 1. Open the Vimium directory you've cloned from Github, and select any file inside. ### Running the tests Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and [Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests: 1. Install [Deno](https://deno.land/) if you don't have it already. 2. `deno run -A npm:puppeteer browsers install chrome` to install puppeteer 3. `./make.js test` to build the code and run the tests. ### Coding Style - Run `deno fmt` at the root of the Vimium project to format your code. - We generally follow the recommendations from the [Airbnb Javascript style guide](https://github.com/airbnb/javascript). - We wrap lines at 100 characters. - When writing comments, uppercase the first letter of your sentence, and put a period at the end. - We're currently using JavaScript language features from ES2018 or earlier. If we desire to use something introduced in a later version of JavaScript, we need to remember to update the minimum Chrome and Firefox versions required. ================================================ FILE: CREDITS ================================================ Authors & Maintainers: Ilya Sukhar (github: ilya) Phil Crosby (github: philc) Contributors: acrollet Adam Lindberg (github: eproxus) akhilman Ângelo Otávio Nuffer Nunes (github: angelonuffer) Bernardo B. Marques (github: bernardofire) Bill Casarin (github: jb55) Bill Mill (github: llimllib) Branden Rolston (github: branden) Caleb Spare (github: cespare) Carl Helmertz (github: chelmertz) Christian Stefanescu (github: stchris) ConradIrwin Daniel MacDougall (github: dmacdougall) drizzd gpurkins hogelog int3 Johannes Emerich (github: knuton) Julian Naydichev (github: naydichev) Justin Blake (github: blaix) Knorkebrot lack markstos Matthew Cline Matt Garriott (github: mgarriott) Matthew Ryan (github: mrmr1993) Michael Hauser-Raspe (github: mijoharas) Murph (github: pandeiro) Niklas Baumstark (github: niklasb) rodimius Stephen Blott (github: smblott-github) Svein-Erik Larsen (github: feinom) Tim Morgan (github: seven1m) tsigo R.T. Lechow (github: rtlechow) Wang Ning (github:daning) Werner Laurensse (github: ab3) Timo Sand (github: deiga) Shiyong Chen (github: UncleBill) Utkarsh Upadhyay (github: PrestanceDesign) Dahan Gong (github: gdh1995) Scott Pinkelman (github: sco-tt) Darryl Pogue (github: dpogue) tobimensch Ramiro Araujo (github: ramiroaraujo) Daniel Skogly (github: poacher2k) Matt Wanchap (github: mwanchap) Leo Solidum (github: leosolid) Feel free to add real names in addition to GitHub usernames. ================================================ FILE: MIT-LICENSE.txt ================================================ Copyright (c) 2010 Phil Crosby, Ilya Sukhar. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Vimium - The Hacker's Browser Vimium is a browser extension that provides keyboard-based navigation and control of the web in the spirit of the Vim editor. [Watch the demo video](https://www.youtube.com/watch?v=t67Sn0RGK54). **Installation instructions:** - Chrome: [Chrome web store](https://chromewebstore.google.com/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb) - Edge: [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/vimium/djmieaghokpkpjfbpelnlkfgfjapaopa) - Firefox: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/) To install from source, see [here](CONTRIBUTING.md#installing-from-source). Vimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium on the extension pages of Chrome and Edge (`chrome://extensions`), or Firefox (`about:addons`). ## Keyboard Bindings Modifier keys are specified as ``, ``, and `` for ctrl+x, meta+x, and alt+x respectively. For shift+x and ctrl-shift-x, just type `X` and ``. See the next section for how to customize these bindings. Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`. Navigating the current page: ? show the help dialog for a list of all available keys h scroll left j scroll down k scroll up l scroll right gg scroll to top of the page G scroll to bottom of the page d scroll down half a page u scroll up half a page f open a link in the current tab F open a link in a new tab r reload gs view source i enter insert mode -- all commands will be ignored until you hit Esc to exit yy copy the current url to the clipboard yf copy a link url to the clipboard gf cycle forward to the next frame gF focus the main/top frame Navigating to new pages: o Open URL, bookmark, or history entry O Open URL, bookmark, history entry in a new tab b Open bookmark B Open bookmark in a new tab Using find: / enter find mode -- type your search query and hit enter to search, or Esc to cancel n cycle forward to the next find match N cycle backward to the previous find match For advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the wiki. Navigating your history: H go back in history L go forward in history Manipulating tabs: J, gT go one tab left K, gt go one tab right g0 go to the first tab. Use ng0 to go to n-th tab g$ go to the last tab ^ visit the previously-visited tab t create tab yt duplicate current tab x close current tab X restore closed tab (i.e. unwind the 'x' command) T search through your open tabs W move current tab to new window pin/unpin current tab Using marks: ma, mA set local mark "a" (global mark "A") `a, `A jump to local mark "a" (global mark "A") `` jump back to the position before the previous jump -- that is, before the previous gg, G, n, N, / or `a Additional advanced browsing commands: ]], [[ Follow the link labeled 'next' or '>' ('previous' or '<') - helpful for browsing paginated sites open multiple links in a new tab gi focus the first (or n-th) text input box on the page. Use to cycle through options. gu go up one level in the URL hierarchy gU go up to root of the URL hierarchy ge edit the current URL gE edit the current URL and open in a new tab zH scroll all the way left zL scroll all the way right v enter visual mode; use p/P to paste-and-go, use y to yank V enter visual line mode R Hard reload the page (skip the cache) Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid succession. `` (or ``) will clear any partial commands in the queue and will also exit insert and find modes. There are additional commands which aren't included in this README; refer to the help dialog (type `?`) for a full list. ## Custom Key Mappings You may remap or unmap any of the default key bindings in the "Custom key mappings" on the options page. Enter one of the following key mapping statements per line: - `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior for that key, if any. - `unmap key`: Unmaps a key and restores Chrome's default behavior (if any). - `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults and start from scratch with your own setup. Examples: - `map scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of showing a bookmark dialog is suppressed. - `map r reload hard` maps the r key to reloading the page, and also includes the "hard" option to hard-reload the page. - `unmap ` removes any mapping for ctrl+d and restores Chrome's default behavior. - `unmap r` removes any mapping for the r key. See the [docs](https://vimium.github.io/commands/) for every Vimium command and its options. You can add comments to key mappings by starting a line with `"` or `#`. The following special keys are available for mapping: - ``, ``, ``, `` for ctrl, alt, shift, and meta (command on Mac) respectively with any key. Replace `*` with the key of choice. - ``, ``, ``, `` for the arrow keys. - `` through `` for the function keys. - `` for the space key. - ``, ``, ``, ``, ``, `` and `` for the corresponding non-printable keys. Shifts are automatically detected so, for example, `` corresponds to ctrl+shift+7 on an English keyboard. ## How to allow Vimium to work on new tab pages - Vimium will work on new tab pages which are opened with Vimium's `createTab` command (mapped to `t` by default). - To have Vimium work on all new tab pages opened by the browser (e.g. via `cmd-t` or `ctrl-t` shortcuts), a companion [Vimium New Tab Page extension](https://github.com/philc/vimium-new-tab/) is required. - Once that is installed, all new tabs will open a blank Vimium new tab page. ## More documentation - [FAQ](https://github.com/philc/vimium/wiki/FAQ) - [Command listing](https://vimium.github.io/commands/) - [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki): documentation for the more advanced features. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for details. ## Release Notes See [CHANGELOG](CHANGELOG.md) for the major changes in each release. ## License Copyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details. ================================================ FILE: background_scripts/all_commands.js ================================================ // This is the order they will be shown in the help dialog. // // Properties: // - advanced: advanced commands are not shown in the help dialog by default. // - background: whether this command has to be run by the background page. // - desc: shown in the help dialog and command listing page. // - details: extra help information that will only be shown on the command listing page. // - group: commands are displayed in groups in the help dialog and command listing. // - noRepeat: whether this command can be used with a count key prefix. // - repeatLimit: the number of allowed repetitions of this command before the user is prompted for // confirmation. // - topFrame: whether this command must be run only in the top frame of a page. // const allCommands = [ // // Navigation // { name: "scrollDown", desc: "Scroll down", group: "navigation", }, { name: "scrollUp", desc: "Scroll up", group: "navigation", }, { name: "scrollToTop", desc: "Scroll to the top of the page", group: "navigation", }, { name: "scrollToBottom", desc: "Scroll to the bottom of the page", group: "navigation", }, { name: "scrollPageDown", desc: "Scroll a half page down", group: "navigation", }, { name: "scrollPageUp", desc: "Scroll a half page up", group: "navigation", }, { name: "scrollFullPageDown", desc: "Scroll a full page down", group: "navigation", }, { name: "scrollFullPageUp", desc: "Scroll a full page up", group: "navigation", }, { name: "scrollLeft", desc: "Scroll left", group: "navigation", }, { name: "scrollRight", desc: "Scroll right", group: "navigation", advanced: true, }, { name: "scrollToLeft", desc: "Scroll all the way to the left", group: "navigation", advanced: true, }, { name: "scrollToRight", desc: "Scroll all the way to the right", group: "navigation", }, { name: "reload", desc: "Reload the page", group: "navigation", background: true, options: { hard: "Perform a hard reload, forcing the browser to bypass its cache.", }, }, { name: "copyCurrentUrl", desc: "Copy the current URL to the clipboard", group: "navigation", noRepeat: true, }, { name: "openCopiedUrlInCurrentTab", desc: "Open the clipboard's URL in the current tab", group: "navigation", noRepeat: true, }, { name: "openCopiedUrlInNewTab", desc: "Open the clipboard's URL in a new tab", group: "navigation", noRepeat: true, options: { position: "Where to place the tab in the tab bar. " + "One of `start`, `before`, `after`, `end`. `after` is the default.", }, }, { name: "goUp", desc: "Go up the URL hierarchy", group: "navigation", advanced: true, }, { name: "goToRoot", desc: "Go to the root of current URL hierarchy", group: "navigation", advanced: true, }, { name: "enterInsertMode", desc: "Enter insert mode", group: "navigation", noRepeat: true, }, { name: "enterVisualMode", desc: "Enter visual mode", group: "navigation", noRepeat: true, }, { name: "enterVisualLineMode", desc: "Enter visual line mode", group: "navigation", advanced: true, noRepeat: true, }, { name: "passNextKey", desc: "Pass the next key to the page", options: { normal: "Optional. Enter Vimium's normal mode, and ignore any defined pass keys.", }, group: "navigation", advanced: true, }, { name: "focusInput", desc: "Focus the first text input on the page", group: "navigation", }, { name: "LinkHints.activateMode", desc: "Open a link in the current tab", options: { action: "one of `hover`, `focus`, `copy-text`. When a link is selected, " + "instead of clicking on the link, perform the specified action.", }, group: "navigation", advanced: true, }, { name: "LinkHints.activateModeToOpenInNewTab", desc: "Open a link in a new tab", group: "navigation", }, { name: "LinkHints.activateModeToOpenInNewForegroundTab", desc: "Open a link in a new tab & switch to it", group: "navigation", }, { name: "LinkHints.activateModeWithQueue", desc: "Open multiple links in a new tab", group: "navigation", advanced: true, noRepeat: true, }, { name: "LinkHints.activateModeToDownloadLink", desc: "Download link url", group: "navigation", advanced: true, }, { name: "LinkHints.activateModeToOpenIncognito", desc: "Open a link in incognito window", group: "navigation", advanced: true, }, { name: "LinkHints.activateModeToCopyLinkUrl", desc: "Copy a link URL to the clipboard", group: "navigation", advanced: true, }, { name: "goPrevious", desc: "Follow the link labeled previous or <", group: "navigation", advanced: true, noRepeat: true, }, { name: "goNext", desc: "Follow the link labeled next or >", group: "navigation", advanced: true, noRepeat: true, }, { name: "nextFrame", desc: "Select the next frame on the page", group: "navigation", background: true, }, { name: "mainFrame", desc: "Select the page's main/top frame", group: "navigation", topFrame: true, noRepeat: true, }, { name: "Marks.activateCreateMode", desc: "Create a new mark", details: "Do this by typing the key bound to this command, and then a letter. " + "This will set a mark bound to that letter. Lowercase letters are local marks and uppercase " + "letters are global marks.", options: { swap: "Swap global and local marks. This option exists because in a browser, global marks " + "are generally more useful than local marks, and so it may be desirable to make lowercase " + "letters represent global marks rather than local marks.", }, group: "navigation", advanced: true, noRepeat: true, }, { name: "Marks.activateGotoMode", desc: "Jump to a mark", options: { swap: "Swap global and local marks. This option exists because in a browser, global marks " + "are generally more useful than local marks, and so it may be desirable to make lowercase " + "letters represent global marks rather than local marks.", }, group: "navigation", advanced: true, noRepeat: true, }, // // Vomnibar // { name: "Vomnibar.activate", desc: "Open URL, bookmark or history entry", options: { query: "The text to prefill the Vomnibar with.", keyword: 'The keyword of a search engine defined in the "Custom search engines" ' + "section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.", }, group: "vomnibar", topFrame: true, }, { name: "Vomnibar.activateInNewTab", desc: "Open URL, bookmark or history entry in a new tab", group: "vomnibar", options: { query: "The text to prefill the Vomnibar with.", keyword: 'The keyword of a search engine defined in the "Custom search engines" ' + "section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.", }, topFrame: true, }, { name: "Vomnibar.activateBookmarks", desc: "Open a bookmark", group: "vomnibar", options: { query: "The text to prefill the Vomnibar with.", }, topFrame: true, }, { name: "Vomnibar.activateBookmarksInNewTab", desc: "Open a bookmark in a new tab", group: "vomnibar", options: { query: "The text to prefill the Vomnibar with.", }, topFrame: true, }, { name: "Vomnibar.activateTabSelection", desc: "Search through your open tabs", group: "vomnibar", topFrame: true, }, { name: "Vomnibar.activateEditUrl", desc: "Edit the current URL", group: "vomnibar", topFrame: true, }, { name: "Vomnibar.activateEditUrlInNewTab", desc: "Edit the current URL and open in a new tab", group: "vomnibar", topFrame: true, }, // // Find // { name: "enterFindMode", desc: "Enter find mode.", group: "find", noRepeat: true, }, { name: "performFind", desc: "Cycle forward to the next find match", group: "find", }, { name: "performBackwardsFind", desc: "Cycle backward to the previous find match", group: "find", }, { name: "findSelected", desc: "Find the selected text", group: "find", advanced: true, }, { name: "findSelectedBackwards", desc: "Find the selected text, searching backwards", group: "find", advanced: true, }, // // History // { name: "goBack", desc: "Go back in history", group: "history", }, { name: "goForward", desc: "Go forward in history", group: "history", }, // // Tabs // { name: "createTab", desc: "Create new tab", options: { "(any url)": "Open this URL, rather than the browser's new tab page. " + "E.g.: `map X createTab https://example.com`", window: "Create the tab in a new window", incognito: "Create the tab in an incognito window", position: "Where to place the tab in the tab bar. " + "One of `start`, `before`, `after`, `end`. `after` is the default.", }, group: "tabs", background: true, repeatLimit: 20, }, { name: "previousTab", desc: "Go one tab left", group: "tabs", background: true, }, { name: "nextTab", desc: "Go one tab right", group: "tabs", background: true, }, { name: "visitPreviousTab", desc: "Go to previously-visited tab", group: "tabs", background: true, }, { name: "firstTab", desc: "Go to the first tab", group: "tabs", background: true, }, { name: "lastTab", desc: "Go to the last tab", group: "tabs", background: true, }, { name: "duplicateTab", desc: "Duplicate current tab", group: "tabs", background: true, repeatLimit: 20, }, { name: "togglePinTab", desc: "Pin or unpin current tab", group: "tabs", background: true, }, { name: "toggleMuteTab", desc: "Mute or unmute current tab", options: { all: "Mute all tabs.", other: "Mute every tab except the current one.", }, group: "tabs", background: true, noRepeat: true, }, { name: "removeTab", desc: "Close current tab", group: "tabs", background: true, // Don't close (in one command invocation) more than the number of tabs that can be re-opened by // the browser. repeatLimit: chrome.sessions?.MAX_SESSION_RESULTS || 25, }, { name: "restoreTab", desc: "Restore closed tab", group: "tabs", background: true, repeatLimit: 20, }, { name: "moveTabToNewWindow", desc: "Move tab to new window", group: "tabs", advanced: true, background: true, }, { name: "closeTabsOnLeft", desc: "Close tabs on the left", group: "tabs", advanced: true, background: true, }, { name: "closeTabsOnRight", desc: "Close tabs on the right", group: "tabs", advanced: true, background: true, }, { name: "closeOtherTabs", desc: "Close all other tabs", group: "tabs", advanced: true, background: true, noRepeat: true, }, { name: "moveTabLeft", desc: "Move tab to the left", group: "tabs", advanced: true, background: true, }, { name: "moveTabRight", desc: "Move tab to the right", group: "tabs", advanced: true, background: true, }, { name: "setZoom", desc: "Set zoom", group: "tabs", advanced: true, background: true, options: { level: "The zoom level. This can be a range of [0.25, 5.0]. 1.0 is the default.", }, }, { name: "zoomIn", desc: "Zoom in", group: "tabs", advanced: true, background: true, }, { name: "zoomOut", desc: "Zoom out", group: "tabs", advanced: true, background: true, }, { name: "zoomReset", desc: "Reset zoom", group: "tabs", advanced: true, background: true, }, // // Misc // { name: "toggleViewSource", desc: "View page source", group: "misc", advanced: true, noRepeat: true, }, { name: "showHelp", desc: "Show help", group: "misc", noRepeat: true, topFrame: true, }, ]; export { allCommands }; ================================================ FILE: background_scripts/bg_utils.js ================================================ import { TabRecency } from "./tab_recency.js"; // We're using browser.runtime to determine the browser name and version for Firefox. That API is // only available on the background page. We're not using window.navigator because it's unreliable. // Sometimes browser vendors will provide fake values, like when `privacy.resistFingerprinting` is // enabled on `about:config` of Firefox. export function isFirefox() { // We want this browser check to also cover Firefox variants, like LibreWolf. See #3773. // We could also just check browserInfo.name against Firefox and Librewolf. return globalThis.browser?.runtime.getURL("").startsWith("moz") ?? false; } export async function getFirefoxVersion() { return isFirefox() ? (await browser.runtime.getBrowserInfo()).version : null; } // TODO(philc): tabRecency imports bg_utils. We should resovle the cycle for the sake of clarity. export const tabRecency = new TabRecency(); tabRecency.init(); ================================================ FILE: background_scripts/commands.js ================================================ import { allCommands } from "./all_commands.js"; // A specification for a command that's currently bound to a key sequence, as defined by the default // key bindings, or as it appears in the user's keymapping settings. export class RegistryEntry { // Array of keys. keySequence; // Name of the command. command; // Whether this command can be used with a count key prefix. noRepeat; // The number of allowed repetitions of this command before the user is prompted for confirmation. repeatLimit; // Whether this command has to be run by the background page. background; // Whether this command must be run only in the top frame of a page. topFrame; // The map of options for this command. This is a parsed, sanitized version of the user's options // for this command. options; constructor(o) { Object.seal(this); if (o) Object.assign(this, o); } } // This is intentionally a superset of valid modifiers (a, c, m, s). const modifier = "(?:[a-zA-Z]-)"; const namedKey = "(?:[a-z][a-z0-9]+)"; // E.g. "left" or "f12" (always two characters or more). const modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. "c-*" or "c-left". const specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, "i"); // Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the // last character on the preceding line is "\". function parseLines(text) { return text.replace(/\\\n/g, "") .split("\n") .map((line) => line.trim()) .filter((line) => (line.length > 0) && !(Array.from('#"').includes(line[0]))); } // Returns the index of the nth occurrence of the regexp in the string. -1 if not found. function nthRegexIndex(str, regex, n) { if (!regex.global) { regex = new RegExp(regex.source, regex.flags + "g"); } let match; let count = 0; while ((match = regex.exec(str)) !== null) { count++; if (count === n) { return match.index; } // Prevent infinite loop for zero-length matches. if (match.index === regex.lastIndex) { regex.lastIndex++; } } return -1; } const KeyMappingsParser = { // Parses the text supplied by the user in their "keyMappings" setting. // - shouldLogWarnings: if true, logs to the console when part of the user's config is invalid. // Returns { keyToRegistryEntry, keyToMappedKey, validationErrors }. parse(configText, shouldLogWarnings) { let keyToRegistryEntry = {}; let mapKeyRegistry = {}; let errors = []; const configLines = parseLines(configText); const commandsByName = Utils.keyBy(allCommands, "name"); const validModifiers = ["a", "c", "m", "s"]; const validateParsedKey = function (key) { if (!key?.match(modifiedKey)) return; // Check that the modifier is valid and not capitalized. const mod = key.split("-")[0].slice(1); if (!validModifiers.includes(mod)) { return `${key} has an invalid modifier; valid modifiers are ${validModifiers}`; } }; const validateUrl = function (str) { try { new URL(str); return true; } catch { return false; } }; for (const line of configLines) { const tokens = line.split(/\s+/); const action = tokens[0].toLowerCase(); switch (action) { case "map": { if (tokens.length < 3) { errors.push(`"map requires at least 2 arguments on line ${line}`); continue; } const [_, key, command] = tokens; let optionString; const optionsStart = nthRegexIndex(line, /\s+/, 3); if (optionsStart == -1) { optionString = ""; } else { optionString = line.slice(optionsStart).trim(); } const commandInfo = commandsByName[command]; if (!commandInfo) { errors.push(`"${command}" is not a valid command in the line: ${line}`); continue; } const keySequence = this.parseKeySequence(key); const keyErrors = keySequence.map((k) => validateParsedKey(k)).filter((e) => e); if (keyErrors.length > 0) { errors = errors.concat(keyErrors); continue; } const options = this.parseCommandOptions(optionString); const allowedOptions = Object.keys(commandInfo.options || {}); if (!commandInfo.noRepeat) { allowedOptions.push("count"); } let hasUnknownOption = false; for (const option of Object.keys(options)) { if (allowedOptions.includes(option)) continue; if (allowedOptions.includes("(any url)")) { // Since this command allows for any URL as an argument, we perform some basic // validation to ensure the provided option string is indeed a URL. if (validateUrl(option)) continue; hasUnknownOption = true; errors.push( `Command ${command} does not support option ${option}. ` + `Is this meant to be a valid URL?`, ); break; } else { hasUnknownOption = true; errors.push(`Command ${command} does not support option ${option}`); break; } } if (hasUnknownOption) break; keyToRegistryEntry[key] = new RegistryEntry({ keySequence, command, noRepeat: commandInfo.noRepeat, repeatLimit: commandInfo.repeatLimit, background: commandInfo.background, topFrame: commandInfo.topFrame, options, }); break; } case "unmap": { if (tokens.length != 2) { errors.push(`Incorrect usage for unmap in the line: ${line}`); continue; } const key = tokens[1]; delete keyToRegistryEntry[key]; delete mapKeyRegistry[key]; break; } case "unmapall": { keyToRegistryEntry = {}; mapKeyRegistry = {}; break; } case "mapkey": { if (tokens.length != 3) { errors.push(`Incorrect usage for mapKey in the line: ${line}`); continue; } const fromChar = this.parseKeySequence(tokens[1]); const toChar = this.parseKeySequence(tokens[2]); // NOTE(philc): I'm not sure why we enforce that the fromChar and toChar have to be // length one. It's been that way since this feature was introduced in 6596e30. const isValid = fromChar.length == toChar.length && toChar.length === 1; if (isValid) { mapKeyRegistry[fromChar[0]] = toChar[0]; } else { errors.push( `mapkey only supports mapping keys which are single characters. Line: ${line}`, ); } break; } default: errors.push(`"${action}" is not a valid config command in line: ${line}`); } } return { keyToRegistryEntry, keyToMappedKey: mapKeyRegistry, validationErrors: errors, }; }, // Lower-case the appropriate portions of named keys. // // A key name is one of three forms exemplified by or (prefixed normal key, // named key, or prefixed named key). Internally, for simplicity, we would like prefixes and key // names to be lowercase, though humans may prefer other forms or . // On the other hand, and are different named keys - for one of them you have to press // "shift" as well. // We sort modifiers here to match the order used in keyboard_utils.js. // The return value is a sequence of keys: e.g. "b" -> ["", "", "b"]. parseKeySequence(key) { if (key.length === 0) { return []; // Parse "bcd" as "" and "bcd". } else if (0 === key.search(specialKeyRegexp)) { const array = RegExp.$1.split("-"); const adjustedLength = Math.max(array.length, 1); let modifiers = array.slice(0, adjustedLength - 1); let keyChar = array[adjustedLength - 1]; if (keyChar.length !== 1) { keyChar = keyChar.toLowerCase(); } modifiers = modifiers.map((m) => m.toLowerCase()); modifiers.sort(); return [ "<" + modifiers.concat([keyChar]).join("-") + ">", ...this.parseKeySequence(RegExp.$2), ]; } else { return [key[0], ...this.parseKeySequence(key.slice(1))]; } }, // Command options follow command mappings, and are of one of these forms: // key=value - a value // key="value" - a value surrounded by quotes // key - a flag parseCommandOptions(optionString) { const options = {}; while (optionString != "") { // Note that option names are allowed to be letters only; no numbers. let match, matchedString, key, value; // Case: option value surrounded by quotes (key= "a b"). Spaces are allowed in the value. if (match = optionString.match(/^([a-zA-Z]+)="([^"]+)"(\s+|$)/)) { matchedString = match[0]; key = match[1]; value = match[2]; } // Case: option value not surrounded by quotes (key=value). Spaces aren't allowed. else if (match = optionString.match(/^([a-zA-Z]+)=(\S+)(\s+|$)/)) { matchedString = match[0]; key = match[1]; value = match[2]; } // Case: single option (flag), or "any URL". This correctly parses URLs because URLs cannot // contain unescaped equals or space characters. The key will be the option's name (or the // URL), and the value will be true. else if (match = optionString.match(/^([^\s"]+)(\s+|$)/)) { matchedString = match[0]; key = match[1]; value = true; } // NOTE(philc): If this string doesn't match any of our option regexps, we could throw an // error here or use an assert. I think this might only happen in the case where there's a // single equals sign. For now, just add the whole string as a flag option. If the command in // question doesn't accept this option, then an error will get surfaced to the user. if (match == null) { console.log(`Warning: '${optionString}' isn't a valid option string.`); options[optionString] = true; break; } options[key] = value; optionString = optionString.slice(matchedString.length); } // We parse any `count` option immediately (to avoid having to parse it repeatedly later). if ("count" in options) { options.count = parseInt(options.count); if (isNaN(options.count)) { delete options.count; } } return options; }, }; const Commands = { // A map of keyString => RegistryEntry keyToRegistryEntry: null, // A map of typed key => key it's mapped to (via the `mapkey` config statement). mapKeyRegistry: null, async init() { await Settings.onLoaded(); Settings.addEventListener("change", async () => { await this.loadKeyMappings(Settings.get("keyMappings")); }); await this.loadKeyMappings(Settings.get("keyMappings")); }, // Parses the user's keyMapping config text and persists the parsed key mappings into the // extension's storage, for use by the other parts of this extension. async loadKeyMappings(userKeyMappingsConfigText) { let key, command; this.keyToRegistryEntry = {}; this.mapKeyRegistry = {}; const defaultKeyConfig = Object.keys(defaultKeyMappings).map((key) => `map ${key} ${defaultKeyMappings[key]}` ).join("\n"); const parsed = KeyMappingsParser.parse( defaultKeyConfig + "\n" + userKeyMappingsConfigText, true, ); this.mapKeyRegistry = parsed.keyToMappedKey; this.keyToRegistryEntry = parsed.keyToRegistryEntry; await chrome.storage.session.set({ mapKeyRegistry: this.mapKeyRegistry }); await this.installKeyStateMapping(); this.prepareHelpPageData(); // Push the key mappings from any passNextKey commands into storage so that they're's available // to the front end so they can be detected during insert mode. We exclude single-key mappings // for this command (i.e. printable keys) because we're considering that a configuration error: // when users press printable keys in insert mode, they expect that character to be input, not // to be droppped into a special Vimium mode. const passNextKeys = Object.entries(this.keyToRegistryEntry) .filter(([key, v]) => v.command == "passNextKey" && key.length > 1) .map(([key, v]) => key); await chrome.storage.session.set({ passNextKeyKeys: passNextKeys }); }, // This generates and installs a nested key-to-command mapping structure. There is an example in // mode_key_handler.js. async installKeyStateMapping() { const keyStateMapping = {}; for (const keys of Object.keys(this.keyToRegistryEntry || {})) { const registryEntry = this.keyToRegistryEntry[keys]; let currentMapping = keyStateMapping; for (let index = 0; index < registryEntry.keySequence.length; index++) { const key = registryEntry.keySequence[index]; if (currentMapping[key] != null ? currentMapping[key].command : undefined) { // Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is // the legacy behaviour. break; } else if (index < (registryEntry.keySequence.length - 1)) { currentMapping = currentMapping[key] != null ? currentMapping[key] : (currentMapping[key] = {}); } else { currentMapping[key] = Object.assign({}, registryEntry); // We don't need these properties in the content scripts. for (const prop of ["keySequence"]) { delete currentMapping[key][prop]; } } } } await chrome.storage.session.set({ normalModeKeyStateMapping: keyStateMapping, // Inform `KeyboardUtils.isEscape()` whether `` should be interpreted as `Escape` (which it // is by default). useVimLikeEscape: !("" in keyStateMapping), }); }, // Build the "commandToOptionsToKeys" data structure and place it in chrome's session storage. // This is used by the help page and commands listing. prepareHelpPageData() { /* Map of commands to option sets to keys to trigger that command option set. Commands with no options will have the empty string options set. Example: { "zoomReset": { "": ["z0", "zz"] // No options, with two key maps, ie: `map zz zoomReset` }, "setZoom": { "1.1": ["z1"], // `map z1 setZoom 1.1` "1.2": ["z2"], // `map z2 setZoom 1.2` } } */ const commandToOptionsToKeys = {}; const formatOptionString = (options) => { return Object.entries(options) .map(([k, v]) => { // When the value of an option is true, then it was parsed as a flag. if (v === true) { return k; } else { return `${k}=${v}`; } }) .join(" "); }; for (const key of Object.keys(this.keyToRegistryEntry || {})) { const registryEntry = this.keyToRegistryEntry[key]; const optionString = formatOptionString(registryEntry.options || {}); commandToOptionsToKeys[registryEntry.command] ||= {}; commandToOptionsToKeys[registryEntry.command][optionString] ||= []; commandToOptionsToKeys[registryEntry.command][optionString].push(key); } chrome.storage.session.set({ commandToOptionsToKeys }); }, }; const defaultKeyMappings = { // Navigating the current page "j": "scrollDown", "k": "scrollUp", "h": "scrollLeft", "l": "scrollRight", "gg": "scrollToTop", "G": "scrollToBottom", "zH": "scrollToLeft", "zL": "scrollToRight", "": "scrollDown", "": "scrollUp", "d": "scrollPageDown", "u": "scrollPageUp", "r": "reload", "R": "reload hard", "yy": "copyCurrentUrl", "p": "openCopiedUrlInCurrentTab", "P": "openCopiedUrlInNewTab", "gi": "focusInput", "[[": "goPrevious", "]]": "goNext", "gf": "nextFrame", "gF": "mainFrame", "gu": "goUp", "gU": "goToRoot", "i": "enterInsertMode", "v": "enterVisualMode", "V": "enterVisualLineMode", // Link hints "f": "LinkHints.activateMode", "F": "LinkHints.activateModeToOpenInNewTab", "": "LinkHints.activateModeWithQueue", "yf": "LinkHints.activateModeToCopyLinkUrl", // Using find "/": "enterFindMode", "n": "performFind", "N": "performBackwardsFind", "*": "findSelected", "#": "findSelectedBackwards", // Vomnibar "o": "Vomnibar.activate", "O": "Vomnibar.activateInNewTab", "T": "Vomnibar.activateTabSelection", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", "ge": "Vomnibar.activateEditUrl", "gE": "Vomnibar.activateEditUrlInNewTab", // Navigating history "H": "goBack", "L": "goForward", // Manipulating tabs "K": "nextTab", "J": "previousTab", "gt": "nextTab", "gT": "previousTab", "^": "visitPreviousTab", "<<": "moveTabLeft", ">>": "moveTabRight", "g0": "firstTab", "g$": "lastTab", "W": "moveTabToNewWindow", "t": "createTab", "yt": "duplicateTab", "x": "removeTab", "X": "restoreTab", "": "togglePinTab", "": "toggleMuteTab", "zi": "zoomIn", "zo": "zoomOut", "z0": "zoomReset", // Marks "m": "Marks.activateCreateMode", "`": "Marks.activateGotoMode", // Misc "?": "showHelp", "gs": "toggleViewSource", }; export { Commands, // Exported for unit tests. defaultKeyMappings, KeyMappingsParser, parseLines, }; ================================================ FILE: background_scripts/completion/completers.js ================================================ // This file contains the definition of the completers used for the Vomnibar's suggestion UI. A // completer will take a query (whatever the user typed into the Vomnibar) and return a list of // Suggestions, e.g. bookmarks, domains, URLs from history. // // The Vomnibar frontend script makes a "filterCompleter" request to the background page, which in // turn calls filter() on each these completers. // // A completer is a class which has three functions: // - filter(query): "query" will be whatever the user typed into the Vomnibar. // - refresh(): (optional) refreshes the completer's data source (e.g. refetches the list of // bookmarks). // - cancel(): (optional) cancels any pending, cancelable action. import * as bgUtils from "./../bg_utils.js"; import * as completionSearch from "./search_wrapper.js"; import * as userSearchEngines from "../user_search_engines.js"; import * as ranking from "./ranking.js"; import { RegexpCache } from "./ranking.js"; // Set this to true to render relevancy when debugging the ranking scores. const showRelevancy = false; // TODO(philc): Consider moving out the "computeRelevancy" function. export class Suggestion { queryTerms; description; url; // A shortened URL (URI-decoded, protocol removed) suitable for dispaly purposes. shortUrl; title = ""; // A computed relevancy value. relevancy; relevancyFunction; relevancyData; // When true, then this suggestion is automatically pre-selected in the vomnibar. This only affects // the suggestion in slot 0 in the vomnibar. autoSelect = false; // When true, we highlight matched terms in the title and URL. Otherwise we don't. highlightTerms = true; // The text to insert into the vomnibar input when this suggestion is selected. insertText; // This controls whether this suggestion is a candidate for deduplication after simplifying // its URL. deDuplicate = true; // The tab represented by this suggestion. Populated by TabCompleter. tabId; // Whether this is a suggestion provided by a user's custom search engine. isCustomSearch; // Whether this is meant to be the first suggestion from the user's custom search engine which // represents their query as typed, verbatim. isPrimarySuggestion = false; // The generated HTML string for showing this suggestion in the Vomnibar. html; searchUrl; constructor(options) { Object.seal(this); Object.assign(this, options); } // Returns the relevancy score. computeRelevancy() { // We assume that, once the relevancy has been set, it won't change. Completers must set // either @relevancy or @relevancyFunction. if (this.relevancy == null) { this.relevancy = this.relevancyFunction(this); } return this.relevancy; } generateHtml() { if (this.html) return this.html; const relevancyHtml = showRelevancy ? `${this.computeRelevancy()}` : ""; const insertTextClass = this.insertText ? "" : "no-insert-text"; const insertTextIndicator = "↪"; // A right hooked arrow. if (this.insertText && this.isCustomSearch) { this.title = this.insertText; } let faviconHtml = ""; if (this.description === "tab" && !bgUtils.isFirefox()) { const faviconUrl = new URL(chrome.runtime.getURL("/_favicon/")); faviconUrl.searchParams.set("pageUrl", this.url); faviconUrl.searchParams.set("size", "16"); faviconHtml = ``; } if (this.isCustomSearch) { this.html = `\
${insertTextIndicator}${this.description} ${this.highlightQueryTerms(Utils.escapeHtml(this.title))} ${relevancyHtml}
\ `; } else { this.html = `\
${insertTextIndicator}${this.description} ${this.highlightQueryTerms(Utils.escapeHtml(this.title))}
${insertTextIndicator}${faviconHtml}${ this.highlightQueryTerms(Utils.escapeHtml(this.shortenUrl())) } ${relevancyHtml}
\ `; } return this.html; } // Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668). getUrlRoot(url) { const a = document.createElement("a"); a.href = url; return a.protocol + "//" + a.hostname; } getHostname(url) { const a = document.createElement("a"); a.href = url; return a.hostname; } stripTrailingSlash(url) { if (url[url.length - 1] === "/") { url = url.substring(url, url.length - 1); } return url; } // Push the ranges within `string` which match `term` onto `ranges`. pushMatchingRanges(string, term, ranges) { let textPosition = 0; // Split `string` into a (flat) list of pairs: // - for i=0,2,4,6,... // - splits[i] is unmatched text // - splits[i+1] is the following matched text (matching `term`) // (except for the final element, for which there is no following matched text). // Example: // - string = "Abacab" // - term = "a" // - splits = [ "", "A", "b", "a", "c", "a", b" ] // UM M UM M UM M UM (M=Matched, UM=Unmatched) const splits = string.split(RegexpCache.get(term, "(", ")")); for (let index = 0, end = splits.length - 2; index <= end; index += 2) { const unmatchedText = splits[index]; const matchedText = splits[index + 1]; // Add the indices spanning `matchedText` to `ranges`. textPosition += unmatchedText.length; ranges.push([textPosition, textPosition + matchedText.length]); textPosition += matchedText.length; } } // Wraps each occurence of the query terms in the given string in a . highlightQueryTerms(string) { if (!this.highlightTerms) return string; let ranges = []; const escapedTerms = this.queryTerms.map((term) => Utils.escapeHtml(term)); for (const term of escapedTerms) { this.pushMatchingRanges(string, term, ranges); } if (ranges.length === 0) { return string; } ranges = this.mergeRanges(ranges.sort((a, b) => a[0] - b[0])); // Replace portions of the string from right to left. ranges = ranges.sort((a, b) => b[0] - a[0]); for (const [start, end] of ranges) { string = string.substring(0, start) + `${string.substring(start, end)}` + string.substring(end); } return string; } // Merges the given list of ranges such that any overlapping regions are combined. E.g. // mergeRanges([0, 4], [3, 6]) => [0, 6]. A range is [startIndex, endIndex]. mergeRanges(ranges) { let previous = ranges.shift(); const mergedRanges = [previous]; for (const range of ranges) { if (previous[1] >= range[0]) { previous[1] = Math.max(range[1], previous[1]); } else { mergedRanges.push(range); previous = range; } } return mergedRanges; } // Simplify a suggestion's URL (by removing those parts which aren't useful for display or // comparison). shortenUrl() { if (this.shortUrl != null) { return this.shortUrl; } // We get easier-to-read shortened URLs if we URI-decode them. let url = (Utils.decodeURIByParts(this.url) || this.url).toLowerCase(); for (const [filter, replacements] of Suggestion.stripPatterns) { if (new RegExp(filter).test(url)) { for (const replace of replacements) { url = url.replace(replace, ""); } } } this.shortUrl = url; return this.shortUrl; } // Boost a relevancy score by a factor (in the range (0,1.0)), while keeping the score in the // range [0,1]. This makes greater adjustments to scores near the middle of the range (so, very // poor relevancy scores remain very poor). static boostRelevancyScore(factor, score) { return score + (score < 0.5 ? score * factor : (1.0 - score) * factor); } } // Patterns to strip from URLs; of the form [ [ filter, replacements ], [ filter, replacements ], ... ] // - filter is a regexp string; a URL must match this regexp first. // - replacements (itself a list) is a list of regexp objects, each of which is removed from URLs // matching the filter. // // Note. This includes site-specific patterns for very-popular sites with URLs which don't work well // in the vomnibar. // Suggestion.stripPatterns = [ // Google search specific replacements; this replaces query parameters which are known to not be // helpful. There's some additional information here: // http://www.teknoids.net/content/google-search-parameters-2012 [ "^https?://www\\.google\\.(com|ca|com\\.au|co\\.uk|ie)/.*[&?]q=", "ei gws_rd url ved usg sa usg sig2 bih biw cd aqs ie sourceid es_sm" .split(/\s+/).map((param) => new RegExp(`\&${param}=[^&]+`)), ], // On Google maps, we get a new history entry for every pan and zoom event. ["^https?://www\\.google\\.(com|ca|com\\.au|co\\.uk|ie)/maps/place/.*/@", [new RegExp("/@.*")]], // General replacements; replaces leading and trailing fluff. [".", ["^https?://", "\\W+$"].map((re) => new RegExp(re))], ]; const folderSeparator = "/"; // If these names occur as top-level bookmark names, then they are not included in the names of // bookmark folders. const ignoredTopLevelBookmarks = { "Other Bookmarks": true, "Mobile Bookmarks": true, "Bookmarks Bar": true, }; // this.bookmarks are loaded asynchronously when refresh() is called. export class BookmarkCompleter { async filter({ queryTerms }) { if (!this.bookmarks) await this.refresh(); // If the folder separator character is the first character in any query term, then use the // bookmark's full path as its title. Otherwise, just use the its regular title. let results; const usePathAndTitle = queryTerms.reduce( (prev, term) => prev || term.startsWith(folderSeparator), false, ); if (queryTerms.length > 0) { results = this.bookmarks.filter((bookmark) => { const suggestionTitle = usePathAndTitle ? bookmark.pathAndTitle : bookmark.title; if (bookmark.hasJavascriptProtocol == null) { bookmark.hasJavascriptProtocol = UrlUtils.hasJavascriptProtocol(bookmark.url); } if (bookmark.hasJavascriptProtocol && bookmark.shortUrl == null) { bookmark.shortUrl = "javascript:..."; } const suggestionUrl = bookmark.shortUrl != null ? bookmark.shortUrl : bookmark.url; return ranking.matches(queryTerms, suggestionUrl, suggestionTitle); }); } else { results = []; } const suggestions = results.map((bookmark) => { return new Suggestion({ queryTerms, description: "bookmark", url: bookmark.url, title: usePathAndTitle ? bookmark.pathAndTitle : bookmark.title, relevancyFunction: this.computeRelevancy, shortUrl: bookmark.shortUrl, deDuplicate: bookmark.shortUrl == null, }); }); return suggestions; } async refresh() { // In case refresh() is called multiple times before chrome.bookmarks.getTree() completes, only // call chrome.bookmarks.getTree() once. if (this.bookmarksTreePromise) { await this.bookmarksTreePromise; return; } this.bookmarksTreePromise = chrome.bookmarks.getTree(); const bookmarksTree = await this.bookmarksTreePromise; this.bookmarks = this.traverseBookmarks(bookmarksTree) .filter((b) => b.url != null); this.bookmarksTreePromise = null; } // Traverses the bookmark hierarchy, and returns a flattened list of all bookmarks. traverseBookmarks(bookmarks) { const results = []; for (const folder of bookmarks) { this.traverseBookmarksRecursive(folder, results); } return results; } // Recursive helper for `traverseBookmarks`. traverseBookmarksRecursive(bookmark, results, parent) { if (parent == null) { parent = { pathAndTitle: "" }; } if ( bookmark.title && !((parent.pathAndTitle === "") && ignoredTopLevelBookmarks[bookmark.title]) ) { bookmark.pathAndTitle = parent.pathAndTitle + folderSeparator + bookmark.title; } else { bookmark.pathAndTitle = parent.pathAndTitle; } results.push(bookmark); if (bookmark.children) { for (const child of bookmark.children) { this.traverseBookmarksRecursive(child, results, bookmark); } } } computeRelevancy(suggestion) { return ranking.wordRelevancy( suggestion.queryTerms, suggestion.shortUrl || suggestion.url, suggestion.title, ); } } export class HistoryCompleter { // - seenTabToOpenCompletionList: true if the user has typed only , and nothing else. // We interpret this to mean that they want to see all of their history in the Vomnibar, sorted // by recency. async filter({ queryTerms, seenTabToOpenCompletionList }) { await HistoryCache.onLoaded(); let results; if (queryTerms.length > 0) { results = HistoryCache.history .filter((entry) => ranking.matches(queryTerms, entry.url, entry.title)); } else if (seenTabToOpenCompletionList) { // The user has typed to open the entire history (sorted by recency). results = HistoryCache.history; } else { results = []; } const suggestions = results.map((entry) => { return new Suggestion({ queryTerms, description: "history", url: entry.url, title: entry.title, relevancyFunction: this.computeRelevancy, relevancyData: entry, }); }); return suggestions; } computeRelevancy(suggestion) { const historyEntry = suggestion.relevancyData; const recencyScore = ranking.recencyScore(historyEntry.lastVisitTime); // If there are no query terms, then relevancy is based on recency alone. if (suggestion.queryTerms.length === 0) return recencyScore; const wordRelevancy = ranking.wordRelevancy( suggestion.queryTerms, suggestion.url, suggestion.title, ); // Average out the word score and the recency. Recency has the ability to pull the score up, but // not down. return (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2; } } // The domain completer is designed to match a single-word query which looks like it is a domain. // This supports the user experience where they quickly type a partial domain, hit tab -> enter, and // expect to arrive there. export class DomainCompleter { // A map of domain -> { entry: , referenceCount: } // - `entry` is the most recently accessed page in the History within this domain. // - `referenceCount` is a count of the number of History entries within this domain. // If `referenceCount` goes to zero, the domain entry can and should be deleted. domains; async filter({ queryTerms, query }) { const isMultiWordQuery = /\S\s/.test(query); if ((queryTerms.length === 0) || isMultiWordQuery) return []; if (!this.domains) await this.populateDomains(); const firstTerm = queryTerms[0]; const domains = Object.keys(this.domains || []).filter((d) => d.includes(firstTerm)); const domainsAndScores = this.sortDomainsByRelevancy(queryTerms, domains); const result = new Suggestion({ queryTerms, description: "domain", // This should be the URL or the domain, or an empty string, but not null. url: domainsAndScores[0]?.[0] || "", relevancy: 2.0, }); return result.url.length > 0 ? [result] : []; } // Returns a list of domains of the form: [ [domain, relevancy], ... ] sortDomainsByRelevancy(queryTerms, domainCandidates) { const results = []; for (const domain of domainCandidates) { const recencyScore = ranking.recencyScore(this.domains[domain].entry.lastVisitTime || 0); const wordRelevancy = ranking.wordRelevancy(queryTerms, domain, null); const score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2; results.push([domain, score]); } results.sort((a, b) => b[1] - a[1]); return results; } async populateDomains() { await HistoryCache.onLoaded(); this.domains = {}; for (const entry of HistoryCache.history) { this.onVisited(entry); } chrome.history.onVisited.addListener(this.onVisited.bind(this)); chrome.history.onVisitRemoved.addListener(this.onVisitRemoved.bind(this)); } onVisited(newPage) { const domain = this.parseDomainAndScheme(newPage.url); if (domain) { const slot = this.domains[domain] || (this.domains[domain] = { entry: newPage, referenceCount: 0 }); // We want each entry in our domains map to point to the most recent History entry for that // domain. if (slot.entry.lastVisitTime < newPage.lastVisitTime) { slot.entry = newPage; } slot.referenceCount += 1; } } onVisitRemoved(toRemove) { if (toRemove.allHistory) { this.domains = {}; } else { for (const url of toRemove.urls) { const domain = this.parseDomainAndScheme(url); const entry = this.domains[domain]; if (entry == null) continue; entry.referenceCount--; if (entry.referenceCount <= 0) { delete this.domains[domain]; } } } } // Return something like "http://www.example.com" or false. parseDomainAndScheme(url) { if (UrlUtils.urlHasProtocol(url) && !UrlUtils.hasChromeProtocol(url)) { return url.split("/", 3).join("/"); } } } // Searches through all open tabs, matching on title and URL. // If the query is empty, then return a list of open tabs, sorted by recency. export class TabCompleter { async filter({ queryTerms }) { await bgUtils.tabRecency.init(); // We search all tabs, not just those in the current window. const tabs = await chrome.tabs.query({}); const results = tabs.filter((tab) => ranking.matches(queryTerms, tab.url, tab.title)); const suggestions = results .map((tab) => { const suggestion = new Suggestion({ queryTerms, description: "tab", url: tab.url, title: tab.title, tabId: tab.id, deDuplicate: false, }); suggestion.relevancy = this.computeRelevancy(suggestion); return suggestion; }) .sort((a, b) => b.relevancy - a.relevancy); // Boost relevancy with a multiplier so a relevant tab doesn't get crowded out by results from // competing completers. To prevent tabs from crowding out everything else in turn, penalize // them for being further down the results list by scaling on a hyperbola starting at 1 and // approaching 0 asymptotically for higher indexes. The multiplier and the curve fall-off were // subjectively chosen on the grounds that they seem to work pretty well. suggestions.forEach(function (suggestion, i) { suggestion.relevancy *= 8; suggestion.relevancy /= (i / 4) + 1; }); return suggestions; } computeRelevancy(suggestion) { if (suggestion.queryTerms.length > 0) { return ranking.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title); } else { return bgUtils.tabRecency.recencyScore(suggestion.tabId); } } } export class SearchEngineCompleter { cancel() { completionSearch.cancel(); } // Returns the UserSearchEngine for the given query. Returns null if the query does not begin with // a keyword from one of the user's search engines. getUserSearchEngineForQuery(query) { const parts = query.trimStart().split(/\s+/); // For a keyword "w", we match "w search terms" and "w ", but not "w" on its own. const keyword = parts[0]; if (parts.length <= 1) return null; // Don't match queries for built-in properties like "constructor". See #4396. if (Object.hasOwn(userSearchEngines.keywordToEngine, keyword)) { return userSearchEngines.keywordToEngine[keyword]; } return null; } refresh() { userSearchEngines.set(Settings.get("searchEngines")); } async filter(request) { const { queryTerms } = request; const keyword = queryTerms[0]; const queryTermsWithoutKeyword = queryTerms.slice(1); const userSearchEngine = userSearchEngines.keywordToEngine[keyword]; if (!userSearchEngine) return []; const searchUrl = userSearchEngine.url; const completions = await completionSearch.complete(searchUrl, queryTermsWithoutKeyword); const makeSuggestion = (query) => { const url = UrlUtils.createSearchUrl(query, searchUrl); return new Suggestion({ queryTerms, description: userSearchEngine.description, url, title: query, searchUrl, highlightTerms: false, isCustomSearch: true, relevancy: null, relevancyFunction: this.computeRelevancy, }); }; const suggestions = completions.map((completion) => { const s = makeSuggestion(completion); s.insertText = completion; return s; }); if (suggestions[0]) suggestions[0].relevancy = 1.0; // This is a suggestion which contains the user's query. It's the "search for exactly what I // just typed" option. It should always appear first in the list. const primarySuggestion = makeSuggestion(queryTermsWithoutKeyword.join(" ")); primarySuggestion.relevancy = 2; primarySuggestion.isPrimarySuggestion = true; primarySuggestion.autoSelect = true; suggestions.unshift(primarySuggestion); return suggestions; } computeRelevancy({ queryTerms, title }) { // Tweaks: // - Calibration: we boost relevancy scores to try to achieve an appropriate balance between // relevancy scores here, and those provided by other completers. // - Relevancy depends only on the title (which is the search terms), and not on the URL. return Suggestion.boostRelevancyScore( 0.5, 0.7 * ranking.wordRelevancy(queryTerms, title, title), ); } } SearchEngineCompleter.debug = false; // A completer which calls filter() on many completers, aggregates the results, ranks them, and // returns the top 10. All queries from the vomnibar come through a multi completer. const maxResults = 10; export class MultiCompleter { constructor(completers) { this.completers = completers; } refresh() { for (const c of this.completers) { if (c.refresh) c.refresh(); } } cancel() { for (const c of this.completers) { c.cancel?.(); } } async filter(request) { const searchEngineCompleter = this.completers.find((c) => c instanceof SearchEngineCompleter); const query = request.query; const queryTerms = request.queryTerms; // The only UX where we support showing results when there are no query terms is via // Vomnibar.activateTabSelection, where we show the list of open tabs by recency. const isTabCompleter = this.completers.length == 1 && this.completers[0] instanceof TabCompleter; if (queryTerms.length == 0 && !isTabCompleter) { return []; } const queryMatchesUserSearchEngine = searchEngineCompleter?.getUserSearchEngineForQuery(query); // If the user's query matches one of their custom search engines, then use only that engine to // provide completions for their query. const completers = queryMatchesUserSearchEngine ? [searchEngineCompleter] : this.completers.filter((c) => c != searchEngineCompleter); RegexpCache.clear(); const promises = completers.map((c) => c.filter(request)); let results = (await Promise.all(promises)).flat(1); results = this.postProcessSuggestions(request, queryTerms, results); return results; } // Rank them, simplify the URLs, and de-duplicate suggestions with the same simplified URL. postProcessSuggestions(request, queryTerms, suggestions) { for (const s of suggestions) { s.computeRelevancy(queryTerms); } suggestions.sort((a, b) => b.relevancy - a.relevancy); // Simplify URLs and remove duplicates (duplicate simplified URLs, that is). let count = 0; const seenUrls = {}; const dedupedSuggestions = []; for (const s of suggestions) { const url = s.shortenUrl(); if (s.deDuplicate && seenUrls[url]) continue; if (count++ === maxResults) break; seenUrls[url] = s; dedupedSuggestions.push(s); } // Give each completer the opportunity to tweak the suggestions. for (const completer of this.completers) { if (completer.postProcessSuggestions) { completer.postProcessSuggestions(request, dedupedSuggestions); } } // Generate HTML for the remaining suggestions and return them. for (const s of dedupedSuggestions) { s.generateHtml(request); } return dedupedSuggestions; } } // Provides cached access to Chrome's history. As the user browses to new pages, we add those pages // to this history cache. export const HistoryCache = { size: 20000, // An array of History items returned from Chrome. history: null, reset() { this.history = null; chrome.history.onVisited.removeListener(this._onVisitedListener); chrome.history.onVisitRemoved.removeListener(this._onVisitRemovedListener); }, async onLoaded() { if (this.history) return; await this.fetchHistory(); }, async fetchHistory() { if (this.chromeHistoryPromise) { await this.chromeHistoryPromise; return; } this.chromeHistoryPromise = chrome.history.search({ text: "", maxResults: this.size, startTime: 0, }); const history = await this.chromeHistoryPromise; // On Firefox, some history entries do not have titles. for (const entry of history) { if (entry.title == null) entry.title = ""; } history.sort(this.compareHistoryByUrl); this.history = history; chrome.history.onVisited.addListener(this._onVisitedListener); chrome.history.onVisitRemoved.addListener(this._onVisitRemovedListener); this.chromeHistoryPromise = null; }, compareHistoryByUrl(a, b) { if (a.url === b.url) return 0; if (a.url > b.url) return 1; return -1; }, // When a page we've seen before has been visited again, be sure to replace our History item so it // has the correct "lastVisitTime". That's crucial for ranking Vomnibar suggestions. onVisited(newPage) { // On Firefox, some history entries do not have titles. if (newPage.title == null) newPage.title = ""; const i = HistoryCache.binarySearch(newPage, this.history, this.compareHistoryByUrl); const pageWasFound = this.history[i]?.url == newPage.url; if (pageWasFound) { this.history[i] = newPage; } else { this.history.splice(i, 0, newPage); } }, // When a page is removed from the chrome history, remove it from the vimium history too. onVisitRemoved(toRemove) { if (toRemove.allHistory) { this.history = []; } else { for (const url of toRemove.urls) { const i = HistoryCache.binarySearch({ url }, this.history, this.compareHistoryByUrl); if ((i < this.history.length) && (this.history[i].url === url)) { this.history.splice(i, 1); } } } }, }; HistoryCache._onVisitedListener = HistoryCache.onVisited.bind(HistoryCache); HistoryCache._onVisitRemovedListener = HistoryCache.onVisitRemoved.bind(HistoryCache); // Returns the matching index or the closest matching index if the element is not found. That means // you must check the element at the returned index to know whether the element was actually found. // This method is used for quickly searching through our history cache. HistoryCache.binarySearch = function (targetElement, array, compareFunction) { let element, middle; let high = array.length - 1; let low = 0; while (low <= high) { middle = Math.floor((low + high) / 2); element = array[middle]; const compareResult = compareFunction(element, targetElement); if (compareResult > 0) { high = middle - 1; } else if (compareResult < 0) { low = middle + 1; } else { return middle; } } // We didn't find the element. Return the position where it should be in this array. if (compareFunction(element, targetElement) < 0) { return middle + 1; } else { return middle; } }; ================================================ FILE: background_scripts/completion/ranking.js ================================================ // Utilities which help us compute a relevancy score for a given item. // Whether the given things (usually URLs or titles) match any one of the query terms. // This is used to prune out irrelevant suggestions before we try to rank them, and for // calculating word relevancy. Every term must match at least one thing. export function matches(queryTerms, ...things) { for (const term of queryTerms) { const regexp = RegexpCache.get(term); let matchedTerm = false; for (const thing of things) { if (!matchedTerm) { matchedTerm = thing.match(regexp); } } if (!matchedTerm) return false; } return true; } // Weights used for scoring matches. const matchWeights = { matchAnywhere: 1, matchStartOfWord: 1, matchWholeWord: 1, // The following must be the sum of the three weights above; it is used for normalization. maximumScore: 3, // // Calibration factor for balancing word relevancy and recency. recencyCalibrator: 2.0 / 3.0, }; // The current value of 2.0/3.0 has the effect of: // - favoring the contribution of recency when matches are not on word boundaries ( because 2.0/3.0 > (1)/3 ) // - favoring the contribution of word relevance when matches are on whole words ( because 2.0/3.0 < (1+1+1)/3 ) // Calculate a score for matching term against string. // The score is in the range [0, matchWeights.maximumScore], see above. // Returns: [ score, count ], where count is the number of matched characters in string. function scoreTerm(term, string) { let score = 0; let count = 0; const nonMatching = string.split(RegexpCache.get(term)); if (nonMatching.length > 1) { // Have match. score = matchWeights.matchAnywhere; count = nonMatching.reduce((p, c) => p - c.length, string.length); if (RegexpCache.get(term, "\\b").test(string)) { // Have match at start of word. score += matchWeights.matchStartOfWord; if (RegexpCache.get(term, "\\b", "\\b").test(string)) { // Have match of whole word. score += matchWeights.matchWholeWord; } } } return [score, count < string.length ? count : string.length]; } // Returns a number between [0, 1] indicating how often the query terms appear in the url and title. export function wordRelevancy(queryTerms, url, title) { let titleCount, titleScore; let urlScore = (titleScore = 0.0); let urlCount = (titleCount = 0); // Calculate initial scores. for (const term of queryTerms) { let [s, c] = scoreTerm(term, url); urlScore += s; urlCount += c; if (title) { [s, c] = scoreTerm(term, title); titleScore += s; titleCount += c; } } const maximumPossibleScore = matchWeights.maximumScore * queryTerms.length; // Normalize scores. urlScore /= maximumPossibleScore; urlScore *= normalizeDifference(urlCount, url.length); if (title) { titleScore /= maximumPossibleScore; titleScore *= normalizeDifference(titleCount, title.length); } else { titleScore = urlScore; } // Prefer matches in the title over matches in the URL. // In other words, don't let a poor urlScore pull down the titleScore. // For example, urlScore can be unreasonably poor if the URL is very long. if (urlScore < titleScore) { urlScore = titleScore; } // Return the average. return (urlScore + titleScore) / 2; } // Untested alternative to the above: // - Don't let a poor urlScore pull down a good titleScore, and don't let a poor titleScore pull // down a good urlScore. // // return Math.max(urlScore, titleScore) let oneMonthAgo = 1000 * 60 * 60 * 24 * 30; // Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which // are over a month old are counted as 0. This range is quadratic, so an item from one day ago has // a much stronger score than an item from two days ago. export function recencyScore(lastAccessedTime) { const recency = Date.now() - lastAccessedTime; const recencyDifference = Math.max(0, oneMonthAgo - recency) / oneMonthAgo; // recencyScore is between [0, 1]. It is 1 when recenyDifference is 0. This quadratic equation // will incresingly discount older history entries. let recencyScore = recencyDifference * recencyDifference * recencyDifference; // Calibrate recencyScore vis-a-vis word-relevancy scores. return recencyScore *= matchWeights.recencyCalibrator; } // Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference). function normalizeDifference(a, b) { const max = Math.max(a, b); return (max - Math.abs(a - b)) / max; } // We cache regexps because we use them frequently when comparing a query to history entries and // bookmarks, and we don't want to create fresh objects for every comparison. export const RegexpCache = { init() { this.initialized = true; this.clear(); }, clear() { this.cache = {}; }, // Get rexexp for `string` from cache, creating it if necessary. // Regexp meta-characters in `string` are escaped. // Regexp is wrapped in `prefix`/`suffix`, which may contain meta-characters (these are not // escaped). // With their default values, `prefix` and `suffix` have no effect. // Example: // - string="go", prefix="\b", suffix="" // - this returns regexp matching "google", but not "agog" (the "go" must occur at the start of // a word) // TODO: `prefix` and `suffix` might be useful in richer word-relevancy scoring. get(string, prefix, suffix) { if (prefix == null) prefix = ""; if (suffix == null) suffix = ""; if (!this.initialized) this.init(); let regexpString = Utils.escapeRegexSpecialCharacters(string); // Avoid cost of constructing new strings if prefix/suffix are empty (which is expected to be a // common case). if (prefix) regexpString = prefix + regexpString; if (suffix) regexpString = regexpString + suffix; // Smartcase: Regexp is case insensitive, unless `string` contains a capital letter (testing // `string`, not `regexpString`). return this.cache[regexpString] || (this.cache[regexpString] = new RegExp(regexpString, Utils.hasUpperCase(string) ? "" : "i")); }, }; ================================================ FILE: background_scripts/completion/search_engines.js ================================================ // An engine provides search suggestions for a online search engine. // // An "engineUrl" is used for fetching suggestions, whereas a "searchUrl" is used for the actual // search itself. // // Each engine defines: // // 1. An "engineUrl". This is the URL to use for search completions and is passed as the option // "engineUrl" to the "BaseEngine" constructor. // // 2. One or more regular expressions which define the custom search engine URLs for which the // completion engine will be used. This is passed as the "regexps" option to the "BaseEngine" // constructor. // // 3. A "parse" function. This takes the text body of an HTTP response and returns a list of // suggestions (a list of strings). This method is always executed within the context of a // try/catch block, so errors do not propagate. // // 4. Each completion engine *must* include an example custom search engine. The example must // include an example "keyword" and an example "searchUrl", and may include an example // "description" and an "explanation". This info is shown as documentation to the user. // // Each new completion engine must be added to the list "CompletionEngines" at the bottom of this // file. // // The lookup logic which uses these completion engines is in "./completers.js". // // A base class for common regexp-based matching engines. "options" must define: // options.engineUrl: the URL to use for the completion engine. This must be a string. // options.regexps: one or regular expressions. This may either a single string or a list of // strings. // options.example: an example object containing at least "keyword" and "searchUrl", and optional // "description". // TODO(philc): This base class is doing very little. We should remove it and use composition. class BaseEngine { constructor(options) { Object.assign(this, options); this.regexps = this.regexps.map((regexp) => new RegExp(regexp)); } match(searchUrl) { return Utils.matchesAnyRegexp(this.regexps, searchUrl); } getUrl(queryTerms) { return UrlUtils.createSearchUrl(queryTerms.join(" "), this.engineUrl); } } export class Google extends BaseEngine { constructor() { super({ engineUrl: "http://suggestqueries.google.com/complete/search?client=chrome&q=%s", regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"], example: { searchUrl: "https://www.google.com/search?q=%s", keyword: "g", }, }); } parse(text) { return JSON.parse(text)[1]; } } const googleMapsPrefix = "map of "; export class GoogleMaps extends BaseEngine { constructor() { super({ engineUrl: `http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=${googleMapsPrefix}%s`, regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"], example: { searchUrl: "https://www.google.com/maps?q=%s", keyword: "m", explanation: `\ This uses regular Google completion, but prepends the text "map of " to the query. It works well for places, countries, states, geographical regions and the like, but will not perform address search.\ `, }, }); } parse(text) { return JSON.parse(text)[1] .filter((suggestion) => suggestion.startsWith(googleMapsPrefix)) .map((suggestion) => suggestion.slice(googleMapsPrefix)); } } export class Youtube extends BaseEngine { constructor() { super({ engineUrl: "http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=%s", regexps: ["^https?://[a-z]+\\.youtube\\.com/results"], example: { searchUrl: "https://www.youtube.com/results?search_query=%s", keyword: "y", }, }); } parse(text) { return JSON.parse(text)[1]; } } export class Wikipedia extends BaseEngine { constructor() { super({ engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s", regexps: ["^https?://[a-z]+\\.wikipedia\\.org/"], example: { searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s", keyword: "w", }, }); } parse(text) { return JSON.parse(text)[1]; } } export class Bing extends BaseEngine { constructor() { super({ engineUrl: "https://api.bing.com/osjson.aspx?query=%s", regexps: ["^https?://www\\.bing\\.com/search"], example: { searchUrl: "https://www.bing.com/search?q=%s", keyword: "b", }, }); } parse(text) { return JSON.parse(text)[1]; } } export class Amazon extends BaseEngine { constructor() { super({ engineUrl: "https://completion.amazon.com/api/2017/suggestions?mid=ATVPDKIKX0DER&alias=aps&prefix=%s", regexps: ["^https?://(www|smile)\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"], example: { searchUrl: "https://www.amazon.com/s/?field-keywords=%s", keyword: "a", }, }); } parse(text) { return JSON.parse(text).suggestions.map((suggestion) => suggestion.value); } } export class DuckDuckGo extends BaseEngine { constructor() { super({ engineUrl: "https://duckduckgo.com/ac/?q=%s", regexps: ["^https?://([a-z]+\\.)?duckduckgo\\.com/"], example: { searchUrl: "https://duckduckgo.com/?q=%s", keyword: "d", }, }); } parse(text) { return JSON.parse(text).map((suggestion) => suggestion.phrase); } } export class Webster extends BaseEngine { constructor() { super({ engineUrl: "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s", regexps: ["^https?://www.merriam-webster.com/dictionary/"], example: { searchUrl: "https://www.merriam-webster.com/dictionary/%s", keyword: "dw", description: "Dictionary", }, }); } parse(text) { return JSON.parse(text).docs.map((suggestion) => suggestion.word); } } // Qwant is a privacy-friendly search engine. export class Qwant extends BaseEngine { constructor() { super({ engineUrl: "https://api.qwant.com/api/suggest?q=%s", regexps: ["^https?://www\\.qwant\\.com/"], example: { searchUrl: "https://www.qwant.com/?q=%s", keyword: "qw", }, }); } parse(text) { return JSON.parse(text).data.items.map((suggestion) => suggestion.value); } } // Brave is a privacy-friendly search engine. export class Brave extends BaseEngine { constructor() { super({ engineUrl: "https://search.brave.com/api/suggest?rich=false&q=%s", regexps: ["^https?://search\\.brave\\.com/"], example: { searchUrl: "https://search.brave.com/search?q=%s", keyword: "br", }, }); } parse(text) { return JSON.parse(text)[1]; } } // Kagi is a paid ad-free search engine export class Kagi extends BaseEngine { constructor() { super({ engineUrl: "https://kagi.com/autosuggest?q=%s", regexps: ["^https?://www\\.kagi\\.com/"], example: { searchUrl: "https://www.kagi.com/search?q=%s", keyword: "k", }, }); } parse(text) { return JSON.parse(text).map((suggestion) => suggestion.t); } } // On the user-facing documentation page pages/doc_search_completion.html, the completion search // engines will be shown in this order. export const list = [ Youtube, GoogleMaps, Google, DuckDuckGo, Wikipedia, Bing, Amazon, Webster, Brave, Qwant, Kagi, ]; ================================================ FILE: background_scripts/completion/search_wrapper.js ================================================ import * as searchEngines from "./search_engines.js"; // This is a wrapper class for completion engines. It handles the case where a custom search engine // includes a prefix query term (or terms). For example: // // https://www.google.com/search?q=javascript+%s // // In this case, we get better suggestions if we include the term "javascript" in queries sent to // the completion engine. This wrapper handles adding such prefixes to completion-engine queries and // removing them from the resulting suggestions. class EnginePrefixWrapper { constructor(searchUrl, engine) { this.searchUrl = searchUrl; this.engine = engine; } getUrl(queryTerms) { // This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which // we extract a prefix of the form "abc def ". if (/\=.+\+%s/.test(this.searchUrl)) { let terms = this.searchUrl.replace(/\+%s.*/, ""); terms = terms.replace(/.*=/, ""); terms = terms.replace(/\+/g, " "); queryTerms = [...terms.split(" "), ...queryTerms]; const prefix = `${terms} `; this.transformSuggestionsFn = (suggestions) => { return suggestions .filter((s) => s.startsWith(prefix)) .map((s) => s.slice(prefix.length)); }; } return this.engine.getUrl(queryTerms); } parse(responseText) { const suggestions = this.engine.parse(responseText); return this.transformSuggestionsFn ? this.transformSuggestionsFn(suggestions) : suggestions; } } let debug = false; const inTransit = {}; const completionCache = new SimpleCache(2 * 60 * 60 * 1000, 5000); // Two hours, 5000 entries. const engineCache = new SimpleCache(1000 * 60 * 60 * 1000); // 1000 hours. // The amount of time to wait for new requests before launching the current request (for example, // if the user is still typing). const DELAY = 100; // This gets incremented each time we make a request to the completion engine. This allows us to // dedupe requets which overlap, which is the case when the user is typing fast. let requestId = 0; async function get(url) { const timeoutDuration = 2500; const controller = new AbortController(); let isError = false; let responseText; const timer = Utils.setTimeout(timeoutDuration, () => controller.abort()); try { const response = await fetch(url, { signal: controller.signal }); responseText = await response.text(); } catch { // Fetch throws an error if the network is unreachable, etc. isError = true; } clearTimeout(timer); return isError ? null : responseText; } // Look up the completion engine for this searchUrl. function lookupEngine(searchUrl) { if (engineCache.has(searchUrl)) { return engineCache.get(searchUrl); } else { for (const engineClass of searchEngines.list) { const engine = new engineClass(); if (engine.match(searchUrl)) { return engineCache.set(searchUrl, engine); } } } } // This is the main entry point. // - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search // engine's URL. This is only used as a key for determining the relevant completion engine. // - queryTerms are the query terms. export async function complete(searchUrl, queryTerms) { const query = queryTerms.join(" ").toLowerCase(); // We don't complete queries which are too short: the results are usually useless. if (query.length < 4) return []; // We don't complete regular URLs or Javascript URLs. if (queryTerms.length == 1 && await UrlUtils.isUrl(query)) return []; if (UrlUtils.hasJavascriptProtocol(query)) return []; const engine = lookupEngine(searchUrl); if (!engine) return []; const completionCacheKey = JSON.stringify([searchUrl, queryTerms]); if (completionCache.has(completionCacheKey)) { if (debug) console.log("hit", completionCacheKey); return completionCache.get(completionCacheKey); } const createTimeoutPromise = (ms) => { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); }); }; requestId++; const lastRequestId = requestId; // We delay sending a completion request in case the user is still typing. await createTimeoutPromise(DELAY); // If the user has issued a new query while we were waiting, then this query is old; abort it. if (lastRequestId != requestId) return []; const engineWrapper = new EnginePrefixWrapper(searchUrl, engine); const url = engineWrapper.getUrl(queryTerms); if (debug) console.log("GET", url); const responseText = await get(url); // Parsing the response may fail if we receive an unexpectedly-formatted response. In all cases, // we fall back to the catch clause, below. Therefore, we "fail safe" in the case of incorrect // or out-of-date completion engine implementations. let suggestions = []; let isError = responseText == null; if (!isError) { try { suggestions = engineWrapper.parse(responseText) // Make all suggestions lower case. It looks odd when suggestions from one // completion engine are upper case, and those from another are lower case. .map((s) => s.toLowerCase()) // Filter out the query itself. It's not adding anything. .filter((s) => s !== query); } catch (error) { if (debug) console.log("error:", error); isError = true; } } if (isError) { // We allow failures to be cached too, but remove them after just thirty seconds. Utils.setTimeout( 30 * 1000, () => completionCache.set(completionCacheKey, null), ); } completionCache.set(completionCacheKey, suggestions); return suggestions; } // Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is // called whenever the user is typing. export function cancel() { requestId++; } ================================================ FILE: background_scripts/exclusions.js ================================================ // This module manages manages the exclusion rule setting. An exclusion is an object with two // attributes: pattern and passKeys. The exclusion rules are an array of such objects. const ExclusionRegexpCache = { cache: {}, clear(cache) { this.cache = cache || {}; }, get(pattern) { if (pattern in this.cache) { return this.cache[pattern]; } else { let result; // We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium. try { result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); } catch { if (!globalThis.isUnitTests) { console.log(`bad regexp in exclusion rule: ${pattern}`); } result = /^$/; // Match the empty string. } this.cache[pattern] = result; return result; } }, }; // Make RegexpCache, which is required on the page popup, accessible via the Exclusions object. const RegexpCache = ExclusionRegexpCache; // Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; // hence, this is the default. However, when called from the page popup, we are testing what // effect candidate new rules would have on the current tab. In this case, the candidate rules are // provided by the caller. function getRule(url, rules) { if (rules == null) { rules = Settings.get("exclusionRules"); } const matchingRules = rules.filter((r) => r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0) ); // An absolute exclusion rule (one with no passKeys) takes priority. for (const rule of matchingRules) { if (!rule.passKeys) return rule; } // Strip whitespace from all matching passKeys strings, and join them together. const passKeys = matchingRules.map((r) => r.passKeys.split(/\s+/).join("")).join(""); // TODO(philc): Remove this commented out code. // passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join "" if (matchingRules.length > 0) { return { passKeys: Utils.distinctCharacters(passKeys) }; } else { return null; } } export function isEnabledForUrl(url) { const rule = getRule(url); return { isEnabledForUrl: !rule || (rule.passKeys.length > 0), passKeys: rule ? rule.passKeys : "", }; } function setRules(rules) { // Callers map a rule to null to have it deleted, and rules without a pattern are useless. const newRules = rules.filter((rule) => rule?.pattern); Settings.set("exclusionRules", newRules); } function onSettingsUpdated() { // NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions // popup is closed. Do NOT store it/use it asynchronously. ExclusionRegexpCache.clear(); } Settings.addEventListener("change", () => onSettingsUpdated()); ================================================ FILE: background_scripts/main.js ================================================ import "../lib/utils.js"; import "../lib/settings.js"; import "../lib/url_utils.js"; import "../background_scripts/tab_recency.js"; import * as bgUtils from "../background_scripts/bg_utils.js"; import "../background_scripts/all_commands.js"; import { Commands } from "../background_scripts/commands.js"; import * as exclusions from "../background_scripts/exclusions.js"; import "../background_scripts/completion/search_engines.js"; import "../background_scripts/completion/search_wrapper.js"; import "../background_scripts/completion/completers.js"; import "../background_scripts/tab_operations.js"; import * as marks from "../background_scripts/marks.js"; import { BookmarkCompleter, DomainCompleter, HistoryCompleter, MultiCompleter, SearchEngineCompleter, TabCompleter, } from "./completion/completers.js"; // NOTE(philc): This file has many superfluous return statements in its functions, as a result of // converting from coffeescript to es6. Many can be removed, but I didn't take the time to // diligently track down precisely which return statements could be removed when I was doing the // conversion. import * as TabOperations from "./tab_operations.js"; // Allow Vimium's content scripts to access chrome.storage.session. Otherwise, // chrome.storage.session will be null in content scripts. chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }); // This is exported for use by "marks.js". globalThis.tabLoadedHandlers = {}; // tabId -> function() // A Vimium secret, available only within the current browser session. The secret is a generated // strong random string. const randomArray = globalThis.crypto.getRandomValues(new Uint8Array(32)); // 32-byte random token. const secretToken = randomArray.reduce((a, b) => a.toString(16) + b.toString(16)); chrome.storage.session.set({ vimiumSecret: secretToken }); const completionSources = { bookmarks: new BookmarkCompleter(), history: new HistoryCompleter(), domains: new DomainCompleter(), tabs: new TabCompleter(), searchEngines: new SearchEngineCompleter(), }; const completers = { omni: new MultiCompleter([ completionSources.bookmarks, completionSources.history, completionSources.domains, completionSources.tabs, completionSources.searchEngines, ]), bookmarks: new MultiCompleter([completionSources.bookmarks]), tabs: new MultiCompleter([completionSources.tabs]), }; // A query dictionary for `chrome.tabs.query` that will return only the visible tabs. const visibleTabsQueryArgs = { currentWindow: true }; if (bgUtils.isFirefox()) { // Only Firefox supports hidden tabs. visibleTabsQueryArgs.hidden = false; } function onURLChange(details) { // sendMessage will throw "Error: Could not establish connection. Receiving end does not exist." // if there is no Vimium content script loaded in the given tab. This can occur if the user // navigated to a page where Vimium doesn't have permissions, like chrome:// URLs. This error is // noisy and mysterious (it usually doesn't have a valid line number), so we silence it. const message = { handler: "checkEnabledAfterURLChange", silenceLogging: true, }; chrome.tabs.sendMessage(details.tabId, message, { frameId: details.frameId }) .catch(() => {}); } // Re-check whether Vimium is enabled for a frame when the URL changes without a reload. // There's no reliable way to detect when the URL has changed in the content script, so we // have to use the webNavigation API in our background script. chrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.pushState. chrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed. if (!globalThis.isUnitTests) { // Cache "content_scripts/vimium.css" in chrome.storage.session for UI components. (function () { const url = chrome.runtime.getURL("content_scripts/vimium.css"); fetch(url).then(async (response) => { if (response.ok) { chrome.storage.session.set({ vimiumCSSInChromeStorage: await response.text() }); } }); })(); } function muteTab(tab) { chrome.tabs.update(tab.id, { muted: !tab.mutedInfo.muted }); } function toggleMuteTab(request, sender) { const currentTab = request.tab; const tabId = request.tabId; const registryEntry = request.registryEntry; if ((registryEntry.options.all != null) || (registryEntry.options.other != null)) { // If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs. chrome.tabs.query({ audible: true }, function (tabs) { let tab; if (registryEntry.options.other != null) { tabs = tabs.filter((t) => t.id !== currentTab.id); } const audibleUnmutedTabs = tabs.filter((t) => t.audible && !t.mutedInfo.muted); if (audibleUnmutedTabs.length >= 0) { chrome.tabs.sendMessage(tabId, { frameId: sender.frameId, handler: "showMessage", message: `Muting ${audibleUnmutedTabs.length} tab(s).`, }); for (tab of audibleUnmutedTabs) { muteTab(tab); } } else { chrome.tabs.sendMessage(tabId, { frameId: sender.frameId, handler: "showMessage", message: "Unmuting all muted tabs.", }); for (tab of tabs) { if (tab.mutedInfo.muted) { muteTab(tab); } } } }); } else { if (currentTab.mutedInfo.muted) { chrome.tabs.sendMessage(tabId, { frameId: sender.frameId, handler: "showMessage", message: "Unmuted tab.", }); } else { chrome.tabs.sendMessage(tabId, { frameId: sender.frameId, handler: "showMessage", message: "Muted tab.", }); } muteTab(currentTab); } } // Find a tab's actual index in a given tab array returned by chrome.tabs.query. In Firefox, there // may be hidden tabs, so tab.tabIndex may not be the actual index into the array of visible tabs. function getTabIndex(tab, tabs) { // First check if the tab is where we expect it, to avoid searching the array. if (tabs.length > tab.index && tabs[tab.index].index === tab.index) { return tab.index; } else { return tabs.findIndex((t) => t.index === tab.index); } } // // Selects the tab with the ID specified in request.id // async function selectSpecificTab(request) { const tab = await chrome.tabs.get(request.id); // Focus the tab's window. TODO(philc): Why are we null-checking chrome.windows here? if (chrome.windows != null) { await chrome.windows.update(tab.windowId, { focused: true }); } await chrome.tabs.update(request.id, { active: true }); } function moveTab({ count, tab, registryEntry }) { if (registryEntry.command === "moveTabLeft") { count = -count; } return chrome.tabs.query(visibleTabsQueryArgs, function (tabs) { const pinnedCount = (tabs.filter((tab) => tab.pinned)).length; const minIndex = tab.pinned ? 0 : pinnedCount; const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1; // The tabs array index of the new position. const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + count)); return chrome.tabs.move(tab.id, { index: tabs[moveIndex].index, }); }); } function createRepeatCommand(command) { return async function (request) { let i = request.count - 1; const r = Object.assign({}, request); delete r.count; while (i >= 0) { i--; await command(r); } }; } function nextZoomLevel(currentZoom, steps) { // Chrome's default zoom levels. const chromeLevels = [0.25, 0.33, 0.5, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; // Firefox's default zoom levels. const firefoxLevels = [0.3, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.2, 1.33, 1.5, 1.7, 2, 2.4, 3, 4, 5]; let zoomLevels = chromeLevels; // Chrome by default if (bgUtils.isFirefox()) { zoomLevels = firefoxLevels; } if (steps === 0) { // Nothing return currentZoom; } else if (steps > 0) { // In // Chrome sometimes returns values with floating point errors. // Example: Chrome gives 0.32999999999999996 instead of 0.33. currentZoom += 0.0000001; // This is needed to solve floating point bugs in Chrome. const nextIndex = zoomLevels.findIndex((level) => level > currentZoom); const floorIndex = nextIndex == -1 ? zoomLevels.length : nextIndex - 1; return zoomLevels[Math.min(zoomLevels.length - 1, floorIndex + steps)]; } else if (steps < 0) { // Out currentZoom -= 0.0000001; // This is needed to solve floating point bugs in Chrome. let ceilIndex = zoomLevels.findIndex((level) => level >= currentZoom); ceilIndex = ceilIndex == -1 ? zoomLevels.length : ceilIndex; return zoomLevels[Math.max(0, ceilIndex + steps)]; } } // These are commands which are bound to keystrokes which must be handled by the background page. // They are mapped in commands.js. const BackgroundCommands = { // Create a new tab. Also, with: // map X createTab http://www.bbc.com/news // create a new tab with the given URL. createTab: createRepeatCommand(async function (request) { if (request.urls == null) { if (request.url) { // If the request contains a URL, then use it. request.urls = [request.url]; } else { // Otherwise, if we have a registryEntry containing URLs, then use them. const options = Object.keys(request.registryEntry.options); const promises = options.map((opt) => UrlUtils.isUrl(opt)); const isUrl = await Promise.all(promises); const urlList = options.filter((_, i) => isUrl[i]); if (urlList.length > 0) { request.urls = urlList; } else { // Otherwise, just create a new tab. let url; const destination = Settings.get("newTabDestination"); const customUrl = Settings.get("newTabCustomUrl"); if (destination == Settings.newTabDestinations.vimiumNewTabPage) { url = Settings.vimiumNewTabPageUrl; } else if (destination == Settings.newTabDestinations.customUrl && customUrl.length > 0) { url = customUrl; } else { url = UrlUtils.chromeNewTabUrl; } request.urls = [url]; } } } if (request.registryEntry.options.incognito || request.registryEntry.options.window) { // Firefox does not allow an incognito window to be created with the URL about:newtab. It // throws this error: "Illegal URL: about:newtab". const urls = request.urls.filter((u) => u != UrlUtils.chromeNewTabUrl); const windowConfig = { url: urls, incognito: request.registryEntry.options.incognito || false, }; await chrome.windows.create(windowConfig); } else { const urls = request.urls.slice().reverse(); if (request.position == null) { request.position = request.registryEntry.options.position; } while (urls.length > 0) { const url = urls.pop(); const tab = await TabOperations.openUrlInNewTab(Object.assign(request, { url })); // Ensure subsequent invocations of this command place the next tab directly after this one. Object.assign(request, { tab, position: "after", active: false }); } } }), duplicateTab: createRepeatCommand(async (request) => { const tab = await chrome.tabs.duplicate(request.tabId); // Ensure subsequent invocations of this command place the next tab directly after this one. request.tabId = tab.id; }), moveTabToNewWindow({ count, tab }) { // TODO(philc): Switch to the promise API of chrome.tabs.query. chrome.tabs.query(visibleTabsQueryArgs, function (tabs) { const activeTabIndex = getTabIndex(tab, tabs); const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count)); [tab, ...tabs] = tabs.slice(startTabIndex, startTabIndex + count); chrome.windows.create({ tabId: tab.id, incognito: tab.incognito }, function (window) { chrome.tabs.move(tabs.map((t) => t.id), { windowId: window.id, index: -1 }); }); }); }, nextTab(request) { return selectTab("next", request); }, previousTab(request) { return selectTab("previous", request); }, firstTab(request) { return selectTab("first", request); }, lastTab(request) { return selectTab("last", request); }, async removeTab({ count, tab }) { await forCountTabs(count, tab, (tab) => { // In Firefox, Ctrl-W will not close a pinned tab, but on Chrome, it will. We try to be // consistent with each browser's UX for pinned tabs. if (tab.pinned && bgUtils.isFirefox()) return; chrome.tabs.remove(tab.id); }); }, restoreTab: createRepeatCommand(async (request) => { await chrome.sessions.restore(null); }), async togglePinTab({ count, tab }) { await forCountTabs(count, tab, (tab) => { chrome.tabs.update(tab.id, { pinned: !tab.pinned }); }); }, toggleMuteTab, moveTabLeft: moveTab, moveTabRight: moveTab, async setZoom({ tabId, registryEntry }) { const level = registryEntry.options?.["level"] ?? "1"; const newZoom = parseFloat(level); if (!isNaN(newZoom)) { chrome.tabs.setZoom(tabId, newZoom); } }, async zoomIn({ count, tabId }) { const currentZoom = await chrome.tabs.getZoom(tabId); const newZoom = nextZoomLevel(currentZoom, count); chrome.tabs.setZoom(tabId, newZoom); }, async zoomOut({ count, tabId }) { const currentZoom = await chrome.tabs.getZoom(tabId); const newZoom = nextZoomLevel(currentZoom, -count); chrome.tabs.setZoom(tabId, newZoom); }, async zoomReset({ tabId }) { chrome.tabs.setZoom(tabId, 0); // setZoom of 0 sets to the tab default. }, async nextFrame({ count, tabId }) { // We're assuming that these frames are returned in the order that they appear on the page. This // seems to be the case empirically. If it's ever needed, we could also sort by frameId. let frameIds = await getFrameIdsForTab(tabId); const promises = frameIds.map(async (frameId) => { // It is possible that this sendMessage call fails, if a frame gets unloaded while the request // is in flight. let isError = false; const status = await (chrome.tabs.sendMessage(tabId, { handler: "getFocusStatus" }, { frameId: frameId, }).catch((_) => { isError = true; })); return { frameId, status, isError }; }); const frameResponses = (await Promise.all(promises)).filter((r) => !r.isError); const focusedFrameId = frameResponses.find(({ status }) => status.focused)?.frameId; // It's theoretically possible that focusedFrameId is null if the user switched tabs or away // from the browser while the request is in flight. if (focusedFrameId == null) return; // Prune any frames which gave an error response (i.e. they disappeared). frameIds = frameResponses.filter((r) => r.status.focusable).map((r) => r.frameId); const index = frameIds.indexOf(focusedFrameId); count = count ?? 1; const nextIndex = (index + count) % frameIds.length; if (index == nextIndex) return; await chrome.tabs.sendMessage(tabId, { handler: "focusFrame", highlight: true }, { frameId: frameIds[nextIndex], }); }, async closeTabsOnLeft(request) { await removeTabsRelative("before", request); }, async closeTabsOnRight(request) { await removeTabsRelative("after", request); }, async closeOtherTabs(request) { await removeTabsRelative("both", request); }, async visitPreviousTab({ count, tab }) { await bgUtils.tabRecency.init(); let tabIds = bgUtils.tabRecency.getTabsByRecency(); tabIds = tabIds.filter((tabId) => tabId !== tab.id); if (tabIds.length > 0) { const id = tabIds[(count - 1) % tabIds.length]; selectSpecificTab({ id }); } }, async reload({ count, tab, registryEntry }) { const bypassCache = registryEntry.options.hard != null ? registryEntry.options.hard : false; await forCountTabs(count, tab, (tab) => { chrome.tabs.reload(tab.id, { bypassCache }); }); }, }; async function forCountTabs(count, currentTab, callback) { const tabs = await chrome.tabs.query(visibleTabsQueryArgs); const activeTabIndex = getTabIndex(currentTab, tabs); const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count)); for (const tab of tabs.slice(startTabIndex, startTabIndex + count)) { callback(tab); } } // Remove tabs before, after, or either side of the currently active tab async function removeTabsRelative(direction, { count, tab }) { // count is null if the user didn't type a count prefix before issuing this command and didn't // specify a count=n option in their keymapping settings. Interpret this as closing all tabs on // either side. if (count == null) count = 99999; const activeTab = tab; const tabs = await chrome.tabs.query(visibleTabsQueryArgs); const activeIndex = getTabIndex(activeTab, tabs); const toRemove = tabs.filter((tab, tabIndex) => { if (tab.pinned || tab.id == activeTab.id) { return false; } switch (direction) { case "before": return tabIndex < activeIndex && tabIndex >= activeIndex - count; case "after": return tabIndex > activeIndex && tabIndex <= activeIndex + count; case "both": return true; } }); await chrome.tabs.remove(toRemove.map((t) => t.id)); } // Selects a tab before or after the currently selected tab. // - direction: "next", "previous", "first" or "last". function selectTab(direction, { count, tab }) { chrome.tabs.query(visibleTabsQueryArgs, function (tabs) { if (tabs.length > 1) { const toSelect = (() => { switch (direction) { case "next": return (getTabIndex(tab, tabs) + count) % tabs.length; case "previous": return ((getTabIndex(tab, tabs) - count) + (count * tabs.length)) % tabs.length; case "first": return Math.min(tabs.length - 1, count - 1); case "last": return Math.max(0, tabs.length - count); } })(); chrome.tabs.update(tabs[toSelect].id, { active: true }); } }); } chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId }) => { // Vimium can't run on all tabs (e.g. chrome:// URLs). insertCSS will throw an error on such tabs, // which is expected, and noise. Swallow that error. const swallowError = () => {}; await Settings.onLoaded(); await chrome.scripting.insertCSS({ css: Settings.get("userDefinedLinkHintCss"), target: { tabId: tabId, frameIds: [frameId], }, }).catch(swallowError); }); // Returns all frame IDs for the given tab. Note that in Chrome, this will omit frame IDs for frames // or iFrames which contain chrome-extension:// URLs, even if those pages are listed in Vimium's // web_accessible_resources in manifest.json. async function getFrameIdsForTab(tabId) { // getAllFrames unfortunately excludes frames and iframes from chrome-extension:// URLs. // In Firefox, by contrast, pages with moz-extension:// URLs are included. const frames = await chrome.webNavigation.getAllFrames({ tabId: tabId }); return frames.map((f) => f.frameId); } const HintCoordinator = { // Forward the message in "request" to all frames the in sender's tab. broadcastLinkHintsMessage(request, sender) { chrome.tabs.sendMessage( sender.tab.id, Object.assign(request, { handler: "linkHintsMessage" }), ); }, // This is sent by the content script once the user issues the link hints command. async prepareToActivateLinkHintsMode( tabId, originatingFrameId, { modeIndex, requestedByHelpDialog, isExtensionPage }, ) { const frameIds = await getFrameIdsForTab(tabId); // If link hints was triggered on a Vimium extension page (like the vimium help dialog or // options page), we cannot directly retrieve the frameIds for those pages using the // getFrameIdsForTab. However, as a workaround, if those pages were the pages activating hints, // their frameId is equal to originatingFrameId. if (isExtensionPage && !frameIds.includes(originatingFrameId)) { frameIds.push(originatingFrameId); } const timeout = 3000; let promises = frameIds.map(async (frameId) => { let promise = chrome.tabs.sendMessage( tabId, { handler: "linkHintsMessage", messageType: "getHintDescriptors", modeIndex, requestedByHelpDialog, }, { frameId }, ); promise = Utils.promiseWithTimeout(promise, timeout) .catch((error) => Utils.debugLog("Swallowed getHintDescriptors error:", error)); const descriptors = await promise; return { frameId, descriptors, }; }); const responses = (await Promise.all(promises)) .filter((r) => r.descriptors != null); const frameIdToDescriptors = {}; for (const { frameId, descriptors } of responses) { frameIdToDescriptors[frameId] = descriptors; } promises = responses.map(({ frameId }) => { // Don't send this frame's own link hints back to it -- they're already stored in that frame's // content script. At the time that we wrote this, this resulted in a 150% speedup for link // busy sites like Reddit. const outgoingFrameIdToHintDescriptors = Object.assign({}, frameIdToDescriptors); delete outgoingFrameIdToHintDescriptors[frameId]; return chrome.tabs.sendMessage( tabId, { handler: "linkHintsMessage", messageType: "activateMode", frameId: frameId, originatingFrameId: originatingFrameId, frameIdToHintDescriptors: outgoingFrameIdToHintDescriptors, modeIndex: modeIndex, }, { frameId }, ).catch((error) => { Utils.debugLog( "Swallowed linkHints activateMode error:", error, "tabId", tabId, "frameId", frameId, ); }); }); await Promise.all(promises); }, }; const sendRequestHandlers = { runBackgroundCommand(request, sender) { return BackgroundCommands[request.registryEntry.command](request, sender); }, // getCurrentTabUrl is used by the content scripts to get their full URL, because window.location // cannot help with Chrome-specific URLs like "view-source:http:..". getCurrentTabUrl({ tab }) { return tab.url; }, openUrlInNewTab: createRepeatCommand(async (request, callback) => { await TabOperations.openUrlInNewTab(request, callback); }), async openUrlInNewWindow(request) { await TabOperations.openUrlInNewWindow(request); }, async openUrlInIncognito(request) { await chrome.windows.create({ incognito: true, url: await UrlUtils.convertToUrl(request.url), }); }, openUrlInCurrentTab: TabOperations.openUrlInCurrentTab, openOptionsPageInNewTab(request) { return chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1, }); }, launchSearchQuery({ query, openInNewTab }) { const disposition = openInNewTab ? "NEW_TAB" : "CURRENT_TAB"; chrome.search.query({ disposition, text: query }); }, domReady(_, sender) { const isTopFrame = sender.frameId == 0; if (!isTopFrame) return; const tabId = sender.tab.id; // The only feature that uses tabLoadedHandlers is marks. if (tabLoadedHandlers[tabId]) tabLoadedHandlers[tabId](); delete tabLoadedHandlers[tabId]; }, nextFrame: BackgroundCommands.nextFrame, selectSpecificTab, createMark: marks.create, gotoMark: marks.goto, // Send a message to all frames in the current tab. If request.frameId is provided, then send // messages to only the frame with that ID. sendMessageToFrames(request, sender) { const newRequest = Object.assign({}, request.message); const options = request.frameId != null ? { frameId: request.frameId } : {}; chrome.tabs.sendMessage(sender.tab.id, newRequest, options); }, broadcastLinkHintsMessage(request, sender) { HintCoordinator.broadcastLinkHintsMessage(request, sender); }, prepareToActivateLinkHintsMode(request, sender) { HintCoordinator.prepareToActivateLinkHintsMode(sender.tab.id, sender.frameId, request); }, async initializeFrame(request, sender) { // Check whether the extension is enabled for the top frame's URL, rather than the URL of the // specific frame that sent this request. const enabledState = exclusions.isEnabledForUrl(sender.tab.url); const isTopFrame = sender.frameId == 0; if (isTopFrame) { let whichIcon; if (!enabledState.isEnabledForUrl) { whichIcon = "disabled"; } else if (enabledState.passKeys.length > 0) { whichIcon = "partial"; } else { whichIcon = "enabled"; } let iconSet = { "enabled": { "16": "../icons/action_enabled_16.png", "32": "../icons/action_enabled_32.png", }, "partial": { "16": "../icons/action_partial_16.png", "32": "../icons/action_partial_32.png", }, "disabled": { "16": "../icons/action_disabled_16.png", "32": "../icons/action_disabled_32.png", }, }; if (bgUtils.isFirefox()) { // Only Firefox supports SVG icons. iconSet = { "enabled": "../icons/action_enabled.svg", "partial": "../icons/action_partial.svg", "disabled": "../icons/action_disabled.svg", }; } chrome.action.setIcon({ path: iconSet[whichIcon], tabId: sender.tab.id }); } const response = Object.assign({ isFirefox: bgUtils.isFirefox(), firefoxVersion: await bgUtils.getFirefoxVersion(), frameId: sender.frameId, }, enabledState); return response; }, async getBrowserInfo() { return { isFirefox: bgUtils.isFirefox(), firefoxVersion: await bgUtils.getFirefoxVersion(), }; }, async filterCompletions(request) { const completer = completers[request.completerName]; let response = await completer.filter(request); // NOTE(smblott): response contains `relevancyFunction` (function) properties which cause // postMessage, below, to fail in Firefox. See #2576. We cannot simply delete these methods, // as they're needed elsewhere. Converting the response to JSON and back is a quick and easy // way to sanitize the object. response = JSON.parse(JSON.stringify(response)); return response; }, refreshCompletions(request) { const completer = completers[request.completerName]; completer.refresh(); }, cancelCompletions(request) { const completer = completers[request.completerName]; completer.cancel(); }, }; Utils.addChromeRuntimeOnMessageListener( Object.keys(sendRequestHandlers), async function (request, sender) { Utils.debugLog( "main.js: onMessage:%ourl:%otab:%oframe:%o", request.handler, sender.url.replace(/https?:\/\//, ""), sender.tab?.id, sender.frameId, // request // Often useful for debugging. ); // NOTE(philc): We expect all messages to come from a content script in a tab. I've observed in // Firefox when the extension is first installed, domReady and initializeFrame messages come from // content scripts in about:blank URLs, which have a null sender.tab. I don't know what this // corresponds to. Since we expect a valid sender.tab, ignore those messages. if (sender.tab == null) return; await Settings.onLoaded(); request = Object.assign({ count: 1 }, request, { tab: sender.tab, tabId: sender.tab.id, }); const handler = sendRequestHandlers[request.handler]; const result = handler ? await handler(request, sender) : null; return result; }, ); // Remove chrome.storage.local/findModeRawQueryListIncognito if there are no remaining // incognito-mode windows. Since the common case is that there are none to begin with, we first // check whether the key is set at all. chrome.tabs.onRemoved.addListener(function (tabId) { if (tabLoadedHandlers[tabId]) { delete tabLoadedHandlers[tabId]; } chrome.storage.session.get("findModeRawQueryListIncognito", function (items) { if (items.findModeRawQueryListIncognito) { return chrome.windows != null ? chrome.windows.getAll(null, function (windows) { for (const window of windows) { if (window.incognito) return; } // There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set. return chrome.storage.session.remove("findModeRawQueryListIncognito"); }) : undefined; } }); }); // Convenience function for development use. globalThis.runTests = () => open(chrome.runtime.getURL("tests/dom_tests/dom_tests.html")); // // Begin initialization. // // True if the major version of Vimium has changed. // - previousVersion: this will be null for new installs. function majorVersionHasIncreased(previousVersion) { const currentVersion = Utils.getCurrentVersion(); if (previousVersion == null) return false; const currentMajorVersion = currentVersion.split(".").slice(0, 2).join("."); const previousMajorVersion = previousVersion.split(".").slice(0, 2).join("."); return Utils.compareVersions(currentMajorVersion, previousMajorVersion) == 1; } // Show notification on upgrade. async function showUpgradeMessageIfNecessary(onInstalledDetails) { const currentVersion = Utils.getCurrentVersion(); // We do not show an upgrade message for patch/silent releases. Such releases have the same // major and minor version numbers. if ( !majorVersionHasIncreased(onInstalledDetails.previousVersion) || Settings.get("hideUpdateNotifications") ) { return; } // NOTE(philc): These notifications use the system notification UI. So, if you don't have // notifications enabled from your browser (e.g. in Notification Settings in OSX), then // chrome.notification.create will succeed, but you won't see it. const notificationId = "VimiumUpgradeNotification"; await chrome.notifications.create( notificationId, { type: "basic", iconUrl: chrome.runtime.getURL("icons/icon128.png"), title: "Vimium Upgrade", message: `Vimium has been upgraded to version ${currentVersion}. Click here for more information.`, isClickable: true, }, ); if (!chrome.runtime.lastError) { chrome.notifications.onClicked.addListener(async function (id) { if (id != notificationId) return; const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs[0]; TabOperations.openUrlInNewTab({ tab, tabId: tab.id, url: "https://github.com/philc/vimium/blob/master/CHANGELOG.md", }); }); } } async function injectContentScriptsAndCSSIntoExistingTabs() { const manifest = chrome.runtime.getManifest(); const contentScriptConfig = manifest.content_scripts[0]; const contentScripts = contentScriptConfig.js; const cssFiles = contentScriptConfig.css; // The scripting.executeScript and scripting.insertCSS APIs can fail if we don't have permissions // to run scripts in a given tab. Examples are: chrome:// URLs, file:// pages (if the user hasn't // granted Vimium access to file URLs), and probably incognito tabs (unconfirmed). Calling these // APIs on such tabs results in an error getting logged on the background page. To avoid this // noise, we swallow the failures. We could instead try to determine if the tab is scriptable by // checking its URL scheme before calling these APIs, but that approach has some nuance to it. // This is simpler. const swallowError = (_) => {}; const tabs = await chrome.tabs.query({ status: "complete" }); for (const tab of tabs) { const target = { tabId: tab.id, allFrames: true }; // Inject all of our content javascripts. chrome.scripting.executeScript({ files: contentScripts, target: target, }).catch(swallowError); // Inject our extension's CSS. chrome.scripting.insertCSS({ files: cssFiles, target: target, }).catch(swallowError); // Inject the user's link hint CSS. chrome.scripting.insertCSS({ css: Settings.get("userDefinedLinkHintCss"), target: target, }).catch(swallowError); } } async function initializeExtension() { await Settings.onLoaded(); await Commands.init(); } // The browser may have tabs already open. We inject the content scripts and Vimium's CSS // immediately so that the extension is running on the pages immediately after install, rather than // having to reload those pages. chrome.runtime.onInstalled.addListener(async (details) => { Utils.debugLog("chrome.runtime.onInstalled"); // NOTE(philc): In my testing, when the onInstalled event occurs, the onStartup event does not // also occur, so we need to initialize Vimium here. await initializeExtension(); const shouldInjectContentScripts = // NOTE(philc): 2023-06-16: we do not install the content scripts in all tabs on Firefox. // I believe this is because Firefox does this already. See https://stackoverflow.com/a/37132144 // for commentary. !bgUtils.isFirefox() && (["chrome_update", "shared_module_update"].includes(details.reason)); if (shouldInjectContentScripts) injectContentScriptsAndCSSIntoExistingTabs(); await showUpgradeMessageIfNecessary(details); }); // Note that this event is not fired when an incognito profile is started. chrome.runtime.onStartup.addListener(async () => { Utils.debugLog("chrome.runtime.onStartup"); await initializeExtension(); }); Object.assign(globalThis, { TabOperations, // Exported for tests: HintCoordinator, BackgroundCommands, majorVersionHasIncreased, nextZoomLevel, }); // The chrome.runtime.onStartup and onInstalled events are not fired when disabling and then // re-enabling the extension in developer mode, so we also initialize the extension here. initializeExtension(); ================================================ FILE: background_scripts/marks.js ================================================ import * as TabOperations from "./tab_operations.js"; // This returns the key which is used for storing mark locations in chrome.storage.sync. // Exported for tests. export function getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; } // Get the part of a URL we use for matching here (that is, everything up to the first anchor). function getBaseUrl(url) { return url.split("#")[0]; } // Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the // mark is used, whether this is the original Vimium session or a subsequent session. This affects // whether or not tabId can be considered valid. export async function create(req, sender) { const items = await chrome.storage.session.get("vimiumSecret"); const markInfo = { vimiumSecret: items.vimiumSecret, markName: req.markName, url: getBaseUrl(sender.tab.url), tabId: sender.tab.id, scrollX: req.scrollX, scrollY: req.scrollY, }; if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) { saveMark(markInfo); } else { // The front-end frame hasn't provided the scroll position (because it's not the top frame // within its tab). We need to ask the top frame what its scroll position is. chrome.tabs.sendMessage(sender.tab.id, { handler: "getScrollPosition" }, (response) => { saveMark(Object.assign(markInfo, { scrollX: response.scrollX, scrollY: response.scrollY })); }); } } function saveMark(markInfo) { const item = {}; item[getLocationKey(markInfo.markName)] = markInfo; chrome.storage.local.set(item); } // Goto a global mark. We try to find the original tab. If we can't find that, then we try to find // another tab with the original URL, and use that. And if we can't find such an existing tab, then // we create a new one. Whichever of those we do, we then set the scroll position to the original // scroll position. export async function goto(req) { const vimiumSecret = (await chrome.storage.session.get("vimiumSecret"))["vimiumSecret"]; const key = getLocationKey(req.markName); const items = await chrome.storage.local.get(key); const markInfo = items[key]; if (markInfo.vimiumSecret !== vimiumSecret) { // This is a different Vimium instantiation, so markInfo.tabId is definitely out of date. Utils.debugLog("marks: vimiumSecret is incorrect."); await focusOrLaunch(markInfo, req); } else { // Check whether markInfo.tabId still exists. According to // https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome // session. So, if we find a match, we can use it. let tab; // This will throw an error if the tab doesn't exist. try { tab = await chrome.tabs.get(markInfo.tabId); } catch { // Swallow. } const originalTabStillExists = tab?.url && (markInfo.url === getBaseUrl(tab.url)); if (originalTabStillExists) { await gotoPositionInTab(markInfo); } else { await focusOrLaunch(markInfo, req); } } } // Focus an existing tab and scroll to the given position within it. async function gotoPositionInTab({ tabId, scrollX, scrollY }) { const tab = await chrome.tabs.update(tabId, { active: true }); chrome.windows.update(tab.windowId, { focused: true }); chrome.tabs.sendMessage(tabId, { handler: "setScrollPosition", scrollX, scrollY }); } // The tab we're trying to find no longer exists. We either find another tab with a matching URL and // use it, or we create a new tab. async function focusOrLaunch(markInfo, req) { // If we're not going to be scrolling to a particular position in the tab, then we choose all tabs // with a matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense // to scroll unless there's an exact URL match). const markIsScrolled = markInfo.scrollX > 0 || markInfo.scrollY > 0; const query = markIsScrolled ? markInfo.url : `${markInfo.url}*`; const tabs = await chrome.tabs.query({ url: query }); if (tabs.length > 0) { // There is at least one matching tab. Pick one and go to it. const tab = await pickTab(tabs); gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id })); } else { // There is no existing matching tab. We'll have to create one. TabOperations.openUrlInNewTab( Object.assign(req, { url: getBaseUrl(markInfo.url) }), (tab) => { // Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when // the tab is loaded, its DOM is ready and it registers with the background page. return tabLoadedHandlers[tab.id] = () => gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id })); }, ); } } // Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with // shorter (matching) URLs. async function pickTab(tabs) { // NOTE(philc): We assume getCurrent() can return null, but I didn't confirm this. Also, it should // be impossible for the user to invoke Vimium-related keys if all windows are closed. const window = await chrome.windows.getCurrent(); const windowId = window?.id; // Prefer tabs in the current window, if there are any. const tabsInWindow = tabs.filter((tab) => tab.windowId === windowId); if (tabsInWindow.length > 0) tabs = tabsInWindow; // If more than one tab remains and the current tab is still a candidate, then don't pick the // current tab (because jumping to it does nothing). if (tabs.length > 1) { tabs = tabs.filter((t) => !t.active); } // Prefer shorter URLs. tabs.sort((a, b) => a.url.length - b.url.length); return tabs[0]; } ================================================ FILE: background_scripts/reload.js ================================================ // Used as part of a debugging workflow when developing the extension. const tabs = await chrome.tabs.query({}); // Clear the background page's console log, if its console window is open. console.clear(); await chrome.runtime.reload(); // Chrome does not execute past this point. This is for Firefox-based browsers. Note that Chrome // will not reload every tab that Vimium was open in. That must be done outside of Vimium, e.g. via // an Applescript on Mac. // Firefox will reload every tab as a result of chrome.runtime.reload(). However, the console // on those pages does not get cleared for some reason, so we manually clear it. for (const tab of tabs) { chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { console.clear(); }, }); } // We want to close the reload.html page as part of reloading the extension. In both Chrome and // Firefox, the browser will automatically close every tab that's specific to this extension, // including this page. However, in Firefox, if there's an error in manifest.json and the extension // can't reload, then the extension's pages will not get closed, so close this page manually. // globalThis.close(); ================================================ FILE: background_scripts/tab_operations.js ================================================ // // Functions for opening URLs in tabs. // import * as bgUtils from "../background_scripts/bg_utils.js"; import "../lib/url_utils.js"; // Opens request.url in the current tab. If the URL is keywords, search for them in the default // search engine. If the URL is a javascript: snippet, execute it in the current tab. export async function openUrlInCurrentTab(request) { const urlStr = await UrlUtils.convertToUrl(request.url); if (urlStr == null) { // The requested destination is not a URL, so treat it like a search query. chrome.search.query({ text: request.url }); } else if (UrlUtils.hasJavascriptProtocol(urlStr)) { // Note that when injecting JavaScript, it's subject to the site's CSP. Sites with strict CSPs // (like github.com, developer.mozilla.org) will raise an error when we try to run this code. // See https://github.com/philc/vimium/issues/4331. const scriptingArgs = { target: { tabId: request.tabId }, func: (text) => { const prefix = "javascript:"; text = text.slice(prefix.length).trim(); // TODO(philc): Why do we try to double decode here? Discover and then document it. text = decodeURIComponent(text); try { text = decodeURIComponent(text); } catch { // Swallow } const el = document.createElement("script"); el.textContent = text; document.head.appendChild(el); }, args: [urlStr], }; if (!bgUtils.isFirefox()) { // The MAIN world -- where the webpage runs -- is less privileged than the ISOLATED world. // Specifying a world is required for Chrome, but not Firefox. // As of Firefox 118, specifying "MAIN" as the world is not yet supported. scriptingArgs.world = "MAIN"; } chrome.scripting.executeScript(scriptingArgs); } else { // The requested destination is a regular URL. chrome.tabs.update(request.tabId, { url: urlStr }); } } // Opens request.url in new tab and switches to it. // Returns the created tab. export async function openUrlInNewTab(request) { const urlStr = await UrlUtils.convertToUrl(request.url); const tabConfig = { windowId: request.tab.windowId }; const position = request.position; let tabIndex = null; switch (position) { case "start": tabIndex = 0; break; case "before": tabIndex = request.tab.index; break; // if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end. // but on Firefox and with openerTabId, it opens a new tab next to the opener tab case "end": tabIndex = bgUtils.isFirefox() ? 9999 : null; break; // "after" is the default case when there are no options. default: tabIndex = request.tab.index + 1; } tabConfig.index = tabIndex; tabConfig.active = request.active ?? true; tabConfig.openerTabId = request.tab.id; let newTab; if (urlStr == null) { // The requested destination is not a URL, so treat it like a search query. // // The chrome.search.query API lets us open the search in a new tab, but it doesn't let us // control the precise position of that tab. So, we open a new blank tab using our position // parameter, and then execute the search in that tab. // In Chrome, if we create a blank tab and call chrome.search.query, the omnibar is focused, // which we don't want. To work around that, first create an empty page. This is not needed in // Firefox. And in fact, firefox doesn't support a data:text URL to the chrome.tab.create API. tabConfig.url = bgUtils.isFirefox() ? null : "data:text/html,"; newTab = await chrome.tabs.create(tabConfig); const query = request.url; await chrome.search.query({ text: query, tabId: newTab.id }); } else { // The requested destination is a regular URL. // Firefox does not support "about:newtab" in chrome.tabs.create, so omit it. if (urlStr != UrlUtils.chromeNewTabUrl) { tabConfig.url = urlStr; } newTab = await chrome.tabs.create(tabConfig); } return newTab; } // Open request.url in new window and switch to it. export async function openUrlInNewWindow(request) { const winConfig = { url: await UrlUtils.convertToUrl(request.url), active: true, }; if (request.active != null) { winConfig.active = request.active; } // Firefox does not support "about:newtab" in chrome.tabs.create, so omit it. if (tabConfig["url"] === UrlUtils.chromeNewTabUrl) { delete winConfig["url"]; } await chrome.windows.create(winConfig); } ================================================ FILE: background_scripts/tab_recency.js ================================================ // TabRecency associates an integer with each tab id representing how recently it has been accessed. // The order of tabs as tracked by TabRecency is used to provide a recency-based ordering in the // tabs vomnibar. // // The values are persisted to chrome.storage.session so that they're not lost when the extension's // background page is unloaded. // // Callers must await TabRecency.init before calling recencyScore or getTabsByRecency. // // In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by // recency, but in practice it does not work across several edge cases. See the comments on #4368. class TabRecency { constructor() { this.counter = 1; this.tabIdToCounter = {}; this.loaded = false; this.queuedActions = []; } // Add listeners to chrome.tabs, and load the index from session storage. async init() { if (this.initPromise) { await this.initPromise; return; } let resolveFn; this.initPromise = new Promise((resolve, _reject) => { resolveFn = resolve; }); chrome.tabs.onActivated.addListener((activeInfo) => { this.queueAction("register", activeInfo.tabId); }); chrome.tabs.onRemoved.addListener((tabId) => { this.queueAction("deregister", tabId); }); chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => { this.queueAction("deregister", removedTabId); this.queueAction("register", addedTabId); }); chrome.windows.onFocusChanged.addListener(async (windowId) => { if (windowId == chrome.windows.WINDOW_ID_NONE) return; const tabs = await chrome.tabs.query({ windowId, active: true }); if (tabs[0]) { this.queueAction("register", tabs[0].id); } }); await this.loadFromStorage(); while (this.queuedActions.length > 0) { const [action, tabId] = this.queuedActions.shift(); this.handleAction(action, tabId); } this.loaded = true; resolveFn(); } // Loads the index from session storage. async loadFromStorage() { const tabsPromise = chrome.tabs.query({}); const storagePromise = chrome.storage.session.get("tabRecency"); const [tabs, storage] = await Promise.all([tabsPromise, storagePromise]); if (storage.tabRecency == null) return; let maxCounter = 0; for (const counter of Object.values(storage.tabRecency)) { if (maxCounter < counter) maxCounter = counter; } if (this.counter < maxCounter) { this.counter = maxCounter; } this.tabIdToCounter = Object.assign({}, storage.tabRecency); // Remove any tab IDs which aren't currently loaded. const tabIds = new Set(tabs.map((t) => t.id)); for (const id in this.tabIdToCounter) { if (!tabIds.has(parseInt(id))) { delete this.tabIdToCounter[id]; } } } async saveToStorage() { await chrome.storage.session.set({ tabRecency: this.tabIdToCounter }); } // - action: "register" or "unregister". queueAction(action, tabId) { if (!this.loaded) { this.queuedActions.push([action, tabId]); } else { this.handleAction(action, tabId); } } // - action: "register" or "unregister". handleAction(action, tabId) { if (action == "register") { this.register(tabId); } else if (action == "deregister") { this.deregister(tabId); } else { throw new Error(`Unexpected action type: ${action}`); } } register(tabId) { this.counter++; this.tabIdToCounter[tabId] = this.counter; this.saveToStorage(); } deregister(tabId) { delete this.tabIdToCounter[tabId]; this.saveToStorage(); } // Recently-visited tabs get a higher score (except the current tab, which gets a low score). recencyScore(tabId) { if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded."); const tabCounter = this.tabIdToCounter[tabId]; const isCurrentTab = tabCounter == this.counter; if (isCurrentTab) return 0; return (tabCounter ?? 1) / this.counter; // tabCounter may be null. } // Returns a list of tab Ids sorted by recency, most recent tab first. getTabsByRecency() { if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded."); const ids = Object.keys(this.tabIdToCounter); ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]); return ids.map((id) => parseInt(id)); } } export { TabRecency }; ================================================ FILE: background_scripts/user_search_engines.js ================================================ import "../lib/url_utils.js"; import * as commands from "./commands.js"; // A struct representing a search engine entry in the "searchEngine" setting. export class UserSearchEngine { keyword; url; description; constructor(o) { Object.seal(this); if (o) Object.assign(this, o); } } // Parses a user's search engine configuration from Settings, and stores the parsed results. // TODO(philc): Should this be responsible for updating itself when Settings changes, rather than // the callers doing so? Or, remove this class and re-parse the configuration every keystroke in // Vomnibar, so we don't introduce another layer of caching in the code. export let keywordToEngine = {}; // Returns a result of the shape: { keywordToEngine, validationErrors }. export function parseConfig(configText) { const results = {}; const errors = []; for (const line of commands.parseLines(configText)) { const tokens = line.split(/\s+/); if (tokens.length < 2) { errors.push(`This line has less than two tokens: ${line}`); continue; } if (!tokens[0].includes(":")) { errors.push(`This line doesn't include a ":" character: ${line}`); continue; } const keyword = tokens[0].split(":")[0]; const url = tokens[1]; const description = tokens.length > 2 ? tokens.slice(2).join(" ") : `search (${keyword})`; if (!UrlUtils.urlHasProtocol(url) && !UrlUtils.hasJavascriptProtocol(url)) { errors.push(`This search engine doesn't have a valid URL: ${line}`); continue; } results[keyword] = new UserSearchEngine({ keyword, url, description }); } return { keywordToEngine: results, validationErrors: errors, }; } export function set(searchEnginesConfigText) { keywordToEngine = parseConfig(searchEnginesConfigText).keywordToEngine; } ================================================ FILE: build_scripts/write_command_listing_page.js ================================================ #!/usr/bin/env -S deno run --allow-read --allow-write --allow-env // Write a static version of the command_listing.html page to dist, to be hosted on vimium.github.io // as an online reference. import * as testHelper from "../tests/unit_tests/test_helper.js"; import "../tests/unit_tests/test_chrome_stubs.js"; import * as commandListing from "../pages/command_listing.js"; import * as fs from "@std/fs"; import * as path from "@std/path"; const scriptDir = path.dirname(path.fromFileUrl(import.meta.url)); chrome.storage.session.get = async (key) => { if (key == "commandToOptionsToKeys") { return { commandToOptionsToKeys: {} }; } }; await testHelper.jsdomStub(path.join(scriptDir, "../pages/command_listing.html")); await Settings.onLoaded(); await commandListing.populatePage(); const dist = path.join(scriptDir, "../dist/command_listing_page"); if (await fs.exists(dist)) { await Deno.remove(dist, { recursive: true }); } await Deno.mkdir(dist, { recursive: true }); // Write out all required CSS files to disk. const linkEls = document.head.querySelectorAll("link[rel=stylesheet]"); for (const el of linkEls) { const cssPath = el.getAttribute("href"); const src = path.join(scriptDir, "../pages/" + cssPath); const dest = path.join(dist, path.basename(cssPath)); await Deno.copyFile(src, dest); el.setAttribute("href", path.basename(cssPath)); } // Remove any external javascripts. Since this page's HTML has already been generated, it doesn't // need JS at runtime. for (const el of document.head.querySelectorAll("script")) { el.remove(); } // Indicate that this is the hosted version of the page. This causes a link back to the // Github repo to be shown. document.querySelector("html").classList.add("hosted-version"); // Use the website's favicon. const favicon = document.createElement("link"); favicon.setAttribute("rel", "shortcut icon"); favicon.href = "../vimium_logo.svg"; document.head.appendChild(favicon); // The doctype tag is not included in outerHTML; add it back in. const html = "" + document.documentElement.outerHTML; await Deno.writeTextFile(path.join(dist, "index.html"), html); ================================================ FILE: content_scripts/file_urls.css ================================================ /* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This * automatically sets -webkit-user-select: none, which disables selecting the file names and so * prevents Vimium's search from working as expected. Here, we reset the value back to default. */ .icon.file { -webkit-user-select: auto !important; } ================================================ FILE: content_scripts/hud.js ================================================ // // A heads-up-display (HUD) for showing Vimium page operations. // Note: you cannot interact with the HUD until document.body is available. // const HUD = { tween: null, hudUI: null, findMode: null, abandon() { if (this.hudUI) { this.hudUI.hide(false); } }, // Set by @pasteFromClipboard to handle the value returned by pasteResponse pasteListener: null, // This HUD is styled to precisely mimick the chrome HUD on Mac. Use the // "has_popup_and_link_hud.html" test harness to tweak these styles to match Chrome's. One // limitation of our HUD display is that it doesn't sit on top of horizontal scrollbars like // Chrome's HUD does. handleUIComponentMessage({ data }) { const handlers = { hideFindMode: this.hideFindMode, search: this.search, unfocusIfFocused: this.unfocusIfFocused, pasteResponse: this.pasteResponse, showClipboardUnavailableMessage: this.showClipboardUnavailableMessage, }; const handler = handlers[data.name]; if (handler) { return handler.bind(this)(data); } }, async init(focusable) { await Settings.onLoaded(); if (focusable == null) { focusable = true; } if (this.hudUI == null) { const queryString = globalThis.vimiumDomTestsAreRunning ? "?dom_tests=true" : ""; this.hudUI = new UIComponent(); this.hudUI.load( `pages/hud_page.html${queryString}`, "vimium-hud-frame", this.handleUIComponentMessage.bind(this), ); } // this[data.name]? data if (this.tween == null) { this.tween = new Tween( "iframe.vimium-hud-frame.vimium-ui-component-visible", this.hudUI.shadowDOM, ); } const classList = this.hudUI.iframeElement.classList; if (focusable) { classList.remove("vimium-non-clickable"); classList.add("vimium-clickable"); // Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible. // See more in #3277. // Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste. this.hudUI.setIframeVisible(true); // Force the re-computation of styles, so Chrome sends a visibility change message to the // child frame. See https://github.com/philc/vimium/pull/3277#issuecomment-487363284 getComputedStyle(this.hudUI.iframeElement).display; } else { classList.remove("vimium-non-clickable"); classList.add("vimium-clickable"); } }, // duration - if omitted, the message will show until dismissed. async show(text, duration) { await DomUtils.documentComplete(); clearTimeout(this._showForDurationTimerId); // @hudUI.activate will take charge of making it visible await this.init(false); this.hudUI.show({ name: "show", text }); this.tween.fade(1.0, 150); if (duration != null) { this._showForDurationTimerId = setTimeout(() => this.hide(), duration); } }, async showFindMode(findMode = null) { this.findMode = findMode; await DomUtils.documentComplete(); await this.init(); this.hudUI.show({ name: "showFindMode" }); this.tween.fade(1.0, 150); }, search(data) { // NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use // postFindFocus to put it back, so the user can continue typing. this.findMode.findInPlace(data.query, { "postFindFocus": this.hudUI.iframeElement.contentWindow, }); // Show the number of matches in the HUD UI. const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0; const showMatchText = FindMode.query.rawQuery.length > 0; this.hudUI.postMessage({ name: "updateMatchesCount", matchCount, showMatchText }); }, // Hide the HUD. // If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden // immediately). // If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't // update the mode indicator, is when hide() is called for the mode indicator itself. hide(immediate, updateIndicator) { if (immediate == null) { immediate = false; } if (updateIndicator == null) { updateIndicator = true; } if ((this.hudUI != null) && (this.tween != null)) { clearTimeout(this._showForDurationTimerId); this.tween.stop(); if (immediate) { if (updateIndicator) { Mode.setIndicator(); } else { this.hudUI.hide(); } } else { this.tween.fade(0, 150, () => this.hide(true, updateIndicator)); } } }, // These parameters describe the reason find mode is exiting, and come from the HUD UI component. hideFindMode({ exitEventIsEnter, exitEventIsEscape }) { let postExit; this.findMode.checkReturnToViewPort(); // An element won't receive a focus event if the search landed on it while we were in the HUD // iframe. To end up with the correct modes active, we create a focus/blur event manually after // refocusing this window. globalThis.focus(); const focusNode = DomUtils.getSelectionFocusElement(); if (document.activeElement != null) { document.activeElement.blur(); } if (focusNode && focusNode.focus) { focusNode.focus(); } if (exitEventIsEnter) { FindMode.handleEnter(); if (FindMode.query.hasResults) { postExit = () => newPostFindMode(); } } else if (exitEventIsEscape) { // We don't want FindMode to handle the click events that FindMode.handleEscape can generate, // so we wait until the mode is closed before running it. postExit = FindMode.handleEscape; } this.findMode.exit(); if (postExit) { postExit(); } }, // These commands manage copying and pasting from the clipboard in the HUD frame. // NOTE(mrmr1993): We need this to copy and paste on Firefox: // * an element can't be focused in the background page, so copying/pasting doesn't work // * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur // * events. // * the HUD shouldn't be active for this frame while any of the copy/paste commands are running. async copyToClipboard(text) { await DomUtils.documentComplete(); await this.init(); this.hudUI.postMessage({ name: "copyToClipboard", data: text }); }, async pasteFromClipboard(pasteListener) { this.pasteListener = pasteListener; await DomUtils.documentComplete(); await this.init(); this.tween.fade(0, 0); this.hudUI.postMessage({ name: "pasteFromClipboard" }); }, pasteResponse({ data }) { // Hide the HUD frame again. this.hudUI.setIframeVisible(false); this.unfocusIfFocused(); this.pasteListener(data); }, unfocusIfFocused() { // On Firefox, if an ================================================ FILE: test_harnesses/page_with_links.html ================================================ Page with many links This will be a link spanning two
lines




This link has a lot of vertical padding







This link has a lot of vertical padding on the top

div with an onclick attribute


An anchor with just a name

Next and previous links.

Below is an image map:
Section A Section B ================================================ FILE: test_harnesses/visibility_test.html ================================================ Visibility test
Node/Test
test
test
test
test
test
test
test
================================================ FILE: test_harnesses/vomnibar_harness.html ================================================ Vomnibar harness Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ================================================ FILE: test_harnesses/vomnibar_harness.js ================================================ import "../pages/all_content_scripts.js"; import "../pages/vomnibar_page.js"; function setup() { Vomnibar.activate(0, {}); } document.addEventListener("DOMContentLoaded", setup, false); ================================================ FILE: tests/dom_tests/dom_test_setup.js ================================================ globalThis.vimiumDomTestsAreRunning = true; import * as shoulda from "../vendor/shoulda.js"; // Attach shoulda's functions -- like setup, context, should -- to the global namespace. Object.assign(globalThis, shoulda); globalThis.shoulda = shoulda; document.addEventListener("DOMContentLoaded", async () => { isEnabledForUrl = true; await Settings.onLoaded(); await HUD.init(); }); ================================================ FILE: tests/dom_tests/dom_tests.html ================================================

Vimium Tests

================================================ FILE: tests/dom_tests/dom_tests.js ================================================ let commandCount = null; let commandName = null; // Some tests have side effects on the handler stack and the active mode, so these are reset on // setup. Also, some tests affect the focus (e.g. Vomnibar tests), so we make sure the window has // the focus. const initializeModeState = () => { globalThis.focus(); Mode.reset(); handlerStack.reset(); const normalMode = installModes(); normalMode.setPassKeys("p"); normalMode.setKeyMapping({ m: { options: {}, command: "m" }, // A mapped key. p: { options: {}, command: "p" }, // A pass key. z: { p: { options: {}, command: "zp" } }, // Not a pass key. }); normalMode.setCommandHandler(({ command, count }) => { [commandName, commandCount] = [command.command, count]; }); commandName = null; commandCount = null; return normalMode; }; // // Retrieve the hint markers as an array object. // const getHintMarkerEls = () => Array.from(document.querySelectorAll(".vimiumHintMarker")); const stubSettings = (key, value) => stub(Settings._settings, key, value); HintCoordinator.sendMessage = (name, request) => { if (request == null) { request = {}; } if (HintCoordinator[name]) { HintCoordinator[name](request); } return request; }; const activateLinkHintsMode = () => { HintCoordinator.getHintDescriptors({ modeIndex: 0 }, {}, () => {}); HintCoordinator.activateMode({ frameIdToHintDescriptors: {}, modeIndex: 0, originatingFrameId: frameId, }); return HintCoordinator.linkHintsMode; }; // // Generate tests that are common to both default and filtered // link hinting modes. // const createGeneralHintTests = (isFilteredMode) => { globalThis.vimiumOnClickAttributeName = "does-not-matter"; context("Link hints", () => { setup(() => { initializeModeState(); const testContent = "testtress"; document.getElementById("test-div").innerHTML = testContent; stubSettings("filterLinkHints", isFilteredMode); stubSettings("linkHintCharacters", "ab"); stubSettings("linkHintNumbers", "12"); stub(globalThis, "windowIsFocused", () => true); }); teardown(() => document.getElementById("test-div").innerHTML = ""); should("create hints when activated, discard them when deactivated", () => { const mode = activateLinkHintsMode(); assert.isFalse(mode.containerEl == null); mode.deactivateMode(); assert.isTrue(mode.containerEl == null); }); should("position items correctly", () => { const assertStartPosition = (element1, element2) => { assert.equal(element1.getClientRects()[0].left, element2.getClientRects()[0].left); assert.equal(element1.getClientRects()[0].top, element2.getClientRects()[0].top); }; stub(document.body.style, "position", "static"); let mode = activateLinkHintsMode(); let markerEls = getHintMarkerEls(); assertStartPosition(document.getElementsByTagName("a")[0], markerEls[0]); assertStartPosition(document.getElementsByTagName("a")[1], markerEls[1]); mode.deactivateMode(); stub(document.body.style, "position", "relative"); mode = activateLinkHintsMode(); markerEls = getHintMarkerEls(); assertStartPosition(document.getElementsByTagName("a")[0], markerEls[0]); assertStartPosition(document.getElementsByTagName("a")[1], markerEls[1]); mode.deactivateMode(); }); }); }; createGeneralHintTests(false); createGeneralHintTests(true); context("False positives in link-hint", () => { setup(() => { const testContent = 'false positiveclickable' + 'clickable'; document.getElementById("test-div").innerHTML = testContent; stubSettings("filterLinkHints", true); stubSettings("linkHintNumbers", "12"); stub(globalThis, "windowIsFocused", () => true); }); teardown(() => document.getElementById("test-div").innerHTML = ""); should("handle false positives", () => { const mode = activateLinkHintsMode(); mode.deactivateMode(); assert.equal(["clickable", "clickable"], mode.hintMarkers.map((m) => m.linkText)); }); }); context("jsaction matching", () => { let element; setup(() => { stubSettings("filterLinkHints", true); const testContent = '

clickable

'; document.getElementById("test-div").innerHTML = testContent; element = document.getElementById("test-paragraph"); }); teardown(() => document.getElementById("test-div").innerHTML = ""); should("select jsaction elements", () => { for (const text of ["click:namespace.actionName", "namespace.actionName"]) { element.setAttribute("jsaction", text); const mode = activateLinkHintsMode(); mode.deactivateMode(); assert.equal(1, mode.hintMarkers.length); assert.equal("clickable", mode.hintMarkers[0].linkText); assert.equal(element, mode.hintMarkers[0].localHint.element); } }); should("not select inactive jsaction elements", () => { const attributes = [ "mousedown:namespace.actionName", "click:namespace._", "none", "namespace:_", ]; for (const attribute of attributes) { element.setAttribute("jsaction", attribute); const linkHints = activateLinkHintsMode(); const hintMarkers = getHintMarkerEls().filter((marker) => marker.linkText !== "Frame."); linkHints.deactivateMode(); assert.equal(0, hintMarkers.length); } }); }); context("link hints for image maps", () => { setup(() => { const testContent = '' + '' + '' + '' + ""; document.getElementById("test-div").innerHTML = testContent; }); teardown(() => document.getElementById("test-div").innerHTML = ""); should("generate a hint for each area in the image map", () => { const mode = activateLinkHintsMode(); const markerEls = getHintMarkerEls(); assert.equal(2, markerEls.length); mode.deactivateMode(); }); }); const sendKeyboardEvent = (key, type, extra) => { if (type == null) type = "keydown"; if (extra == null) extra = {}; handlerStack.bubbleEvent( type, Object.assign(extra, { type, key, preventDefault() {}, stopImmediatePropagation() {}, }), ); }; const sendKeyboardEvents = (keys) => { for (const key of keys.split("")) { sendKeyboardEvent(key); } }; // TODO(philc): For some reason, this test corrupts the state linkhints state for other tests, in particular, // the alphabet hints tests. I haven't yet dug into why. // const inputs = []; // context("Test link hints for focusing input elements correctly", () => { // let linkHintsMode; // setup(() => { // let input; // initializeModeState(); // const testDiv = document.getElementById("test-div"); // testDiv.innerHTML = ""; // stubSettings("filterLinkHints", false); // stubSettings("linkHintCharacters", "ab"); // // Every HTML5 input type except for hidden. We should be able to activate all of them with link hints. // // NOTE(philc): I'm not sure why, but "image" doesn't get a link hint in Puppeteer, so I've omitted it. // const inputTypes = ["button", "checkbox", "color", "date", "datetime", "datetime-local", "email", "file", // "month", "number", "password", "radio", "range", "reset", "search", "submit", "tel", "text", // "time", "url", "week"]; // for (let type of inputTypes) { // input = document.createElement("input"); // input.type = type; // testDiv.appendChild(input); // inputs.push(input); // } // // Manually add also a select element to test focus. // input = document.createElement("select"); // testDiv.appendChild(input); // inputs.push(input); // }); // teardown(() => { // document.getElementById("test-div").innerHTML = ""; // // linkHintsMode.deactivateMode(); // TODO(philc): I don't think this should be necessary. // }); // should("Focus each input when its hint text is typed", () => { // for (var input of inputs) { // input.scrollIntoView(); // Ensure the element is visible so we create a link hint for it. // const activeListener = ensureCalled(function(event) { // if (event.type === "focus") { return input.blur(); } // }); // input.addEventListener("focus", activeListener, false); // input.addEventListener("click", activeListener, false); // linkHintsMode = activateLinkHintsMode(); // const [hint] = getHintMarkerEls(). // filter(hint => input === HintCoordinator.getLocalHint(hint.hintDescriptor).element); // for (let char of hint.hintString) // sendKeyboardEvent(char); // linkHintsMode.deactivateMode(); // input.removeEventListener("focus", activeListener, false); // input.removeEventListener("click", activeListener, false); // } // }); // }); context("Test link hints for changing mode", () => { let linkHints; setup(() => { initializeModeState(); const testDiv = document.getElementById("test-div"); testDiv.innerHTML = "link"; linkHints = activateLinkHintsMode(); }); teardown(() => { document.getElementById("test-div").innerHTML = ""; linkHints.deactivateMode(); }); should("change mode on shift", () => { assert.equal("curr-tab", linkHints.mode.name); sendKeyboardEvent("Shift", "keydown"); assert.equal("bg-tab", linkHints.mode.name); sendKeyboardEvent("Shift", "keyup"); assert.equal("curr-tab", linkHints.mode.name); }); should("change mode on ctrl", () => { assert.equal("curr-tab", linkHints.mode.name); sendKeyboardEvent("Control", "keydown"); assert.equal("fg-tab", linkHints.mode.name); sendKeyboardEvent("Control", "keyup"); assert.equal("curr-tab", linkHints.mode.name); }); }); const createLinks = function (n) { for (let i = 0, end = n; i < end; i++) { const link = document.createElement("a"); link.textContent = "test"; document.getElementById("test-div").appendChild(link); } }; context("Alphabet link hints", () => { let mode; setup(() => { initializeModeState(); stubSettings("filterLinkHints", false); stubSettings("linkHintCharacters", "ab"); stub(globalThis, "windowIsFocused", () => true); document.getElementById("test-div").innerHTML = ""; // Three hints will trigger double hint chars. createLinks(3); mode = activateLinkHintsMode(); }); teardown(() => { mode.deactivateMode(); document.getElementById("test-div").innerHTML = ""; }); should("label the hints correctly", () => { assert.equal( ["aa", "b", "ab"], mode.hintMarkers.map((m) => m.hintString), ); }); should("narrow the hints", () => { sendKeyboardEvent("a"); assert.equal( ["", "none", ""], mode.hintMarkers.map((m) => m.element.style.display), ); }); should("generate the correct number of alphabet hints", () => { const alphabetHints = new AlphabetHints(); for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { const hintStrings = alphabetHints.hintStrings(n); assert.equal(n, hintStrings.length); } }); should("generate non-overlapping alphabet hints", () => { const alphabetHints = new AlphabetHints(); for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { const hintStrings = alphabetHints.hintStrings(n); for (const h1 of hintStrings) { for (const h2 of hintStrings) { if (h1 !== h2) { assert.isFalse(0 === h1.indexOf(h2)); } } } } }); }); context("Filtered link hints", () => { // In all of these tests, the order of the elements returned by getHintMarkerEls() may be // different from the order they are listed in the test HTML content. This is because // LinkHints.activateMode() sorts the elements. let mode; setup(() => { stubSettings("filterLinkHints", true); stubSettings("linkHintNumbers", "0123456789"); stub(globalThis, "windowIsFocused", () => true); }); context("Text hints", () => { setup(() => { initializeModeState(); const testContent = "testtresstraittrackalt text"; document.getElementById("test-div").innerHTML = testContent; mode = activateLinkHintsMode(); }); teardown(() => { document.getElementById("test-div").innerHTML = ""; mode.deactivateMode(); }); should("label the hints", () => { const hintMarkers = getHintMarkerEls(); const expectedMarkers = [1, 2, 3, 4].map((m) => m.toString()); const actualMarkers = [0, 1, 2, 3].map((i) => hintMarkers[i].textContent.toLowerCase()); assert.equal(expectedMarkers.length, actualMarkers.length); for (const marker of expectedMarkers) { assert.isTrue(actualMarkers.includes(marker)); } }); should("narrow the hints", () => { sendKeyboardEvent("t"); sendKeyboardEvent("r"); assert.equal( ["none", "", "", ""], mode.hintMarkers.map((m) => m.element.style.display), ); assert.equal("3", mode.hintMarkers[1].hintString); sendKeyboardEvent("a"); assert.equal("1", mode.hintMarkers[3].hintString); }); // This test is the same as above, but with an extra non-matching character. The effect should // be the same. should("narrow the hints and ignore typing mistakes", () => { sendKeyboardEvent("t"); sendKeyboardEvent("r"); sendKeyboardEvent("x"); assert.equal( ["none", "", "", ""], mode.hintMarkers.map((m) => m.element.style.display), ); assert.equal("3", mode.hintMarkers[1].hintString); sendKeyboardEvent("a"); assert.equal("1", mode.hintMarkers[3].hintString); }); }); context("Image hints", () => { setup(() => { initializeModeState(); const testContent = "alt text" + "alt text" + "" + ""; document.getElementById("test-div").innerHTML = testContent; mode = activateLinkHintsMode(); }); teardown(() => { document.getElementById("test-div").innerHTML = ""; mode.deactivateMode(); }); should("label the images", () => { let hintMarkers = getHintMarkerEls().map((m) => m.textContent.toLowerCase()); // We don't know the actual hint numbers which will be assigned, so we replace them with "N". hintMarkers = hintMarkers.map((str) => str.replace(/^[1-4]/, "N")); assert.equal(4, hintMarkers.length); assert.isTrue(hintMarkers.includes("N: alt text")); assert.isTrue(hintMarkers.includes("N: some title")); assert.isTrue(hintMarkers.includes("N: alt text")); assert.isTrue(hintMarkers.includes("N")); }); }); context("Input hints", () => { setup(() => { initializeModeState(); const testContent = ` \ \ \ `; document.getElementById("test-div").innerHTML = testContent; mode = activateLinkHintsMode(); }); teardown(() => { document.getElementById("test-div").innerHTML = ""; mode.deactivateMode(); }); should("label the input elements", () => { let hintMarkers = getHintMarkerEls(); hintMarkers = getHintMarkerEls().map((m) => m.textContent.toLowerCase()); // We don't know the actual hint numbers which will be assigned, so we replace them with "N". hintMarkers = hintMarkers.map((str) => str.replace(/^[0-9]+/, "N")); assert.equal(5, hintMarkers.length); assert.isTrue(hintMarkers.includes("N")); assert.isTrue(hintMarkers.includes("N")); assert.isTrue(hintMarkers.includes("N: a label")); assert.isTrue(hintMarkers.includes("N: a label")); assert.isTrue(hintMarkers.includes("N")); }); }); context("Text hint scoring", () => { let getActiveHintMarker; setup(() => { initializeModeState(); const testContent = [ { id: 0, text: "the xboy stood on the xburning deck" }, // Noise. { id: 1, text: "the boy stood on the xburning deck" }, // Whole word (boy). { id: 2, text: "on the xboy stood the xburning deck" }, // Start of text (on). { id: 3, text: "the xboy stood on the xburning deck" }, // Noise. { id: 4, text: "the xboy stood on the xburning deck" }, // Noise. { id: 5, text: "the xboy stood on the xburning" }, // Shortest text.. { id: 6, text: "the xboy stood on the burning xdeck" }, // Start of word (bu) { id: 7, text: "test abc one - longer" }, // For tab test - 2. { id: 8, text: "test abc one" }, // For tab test - 1. { id: 9, text: "test abc one - longer still" }, // For tab test - 3. ].map(({ id, text }) => `${text}`).join(" "); document.getElementById("test-div").innerHTML = testContent; mode = activateLinkHintsMode(); getActiveHintMarker = () => { return HintCoordinator.getLocalHint( mode.markerMatcher.activeHintMarker.hintDescriptor, ).element.id; }; }); teardown(() => { document.getElementById("test-div").innerHTML = ""; mode.deactivateMode(); }); should("score start-of-word matches highly", () => { sendKeyboardEvents("bu"); assert.equal("6", getActiveHintMarker()); }); should("score start-of-text matches highly (br)", () => { sendKeyboardEvents("on"); assert.equal("2", getActiveHintMarker()); }); should("score whole-word matches highly", () => { sendKeyboardEvents("boy"); assert.equal("1", getActiveHintMarker()); }); should("score shorter texts more highly", () => { sendKeyboardEvents("stood"); assert.equal("5", getActiveHintMarker()); }); should("use tab to select the active hint", () => { sendKeyboardEvents("abc"); assert.equal("8", getActiveHintMarker()); sendKeyboardEvent("Tab", "keydown"); assert.equal("7", getActiveHintMarker()); sendKeyboardEvent("Tab", "keydown"); assert.equal("9", getActiveHintMarker()); }); }); }); context("Input focus", () => { setup(() => { initializeModeState(); const testContent = ` \ `; document.getElementById("test-div").innerHTML = testContent; }); teardown(() => document.getElementById("test-div").innerHTML = ""), should("focus the first element", () => { NormalModeCommands.focusInput(1); assert.equal("first", document.activeElement.id); }); should("focus the nth element", () => { NormalModeCommands.focusInput(100); assert.equal("third", document.activeElement.id); }); should("activate insert mode on the first element", () => { NormalModeCommands.focusInput(1); assert.isTrue(InsertMode.permanentInstance.isActive()); }); should("activate insert mode on the first element", () => { NormalModeCommands.focusInput(100); assert.isTrue(InsertMode.permanentInstance.isActive()); }); should("activate the most recently-selected input if the count is 1", () => { NormalModeCommands.focusInput(3); NormalModeCommands.focusInput(1); assert.equal("third", document.activeElement.id); }); should("not trigger insert if there are no inputs", () => { document.getElementById("test-div").innerHTML = ""; NormalModeCommands.focusInput(1); assert.isFalse(InsertMode.permanentInstance.isActive()); }); }); // TODO: these find prev/next link tests could be refactored into unit tests which invoke a function // which has a tighter contract than goNext(), since they test minor aspects of goNext()'s link // matching behavior, and we don't need to construct external state many times over just to test // that. i.e. these tests should look something like: // assert.equal(findLink(html("))[0].href, "first") // These could then move outside of the dom_tests file. context("Find prev / next links", () => { setup(() => { initializeModeState(); globalThis.location.hash = ""; }); should("find exact matches", () => { document.getElementById("test-div").innerHTML = `\ nextcorrupted next page\ `; stubSettings("nextPatterns", "next"); NormalModeCommands.goNext(); assert.equal("#second", globalThis.location.hash); }); should("match against non-word patterns", () => { document.getElementById("test-div").innerHTML = `\ >>\ `; stubSettings("nextPatterns", ">>"); NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); should("favor matches with fewer words", () => { document.getElementById("test-div").innerHTML = `\ lorem ipsum next next!\ `; stubSettings("nextPatterns", "next"); NormalModeCommands.goNext(); assert.equal("#second", globalThis.location.hash); }); should("find link relation in header", () => { document.getElementById("test-div").innerHTML = `\ \ `; NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); should("favor link relation to text matching", () => { document.getElementById("test-div").innerHTML = `\ next\ `; NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); should("match mixed case link relation", () => { document.getElementById("test-div").innerHTML = `\ \ `; NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); should("match against the title attribute", () => { document.getElementById("test-div").innerHTML = `\ unhelpful text\ `; NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); should("match against the aria-label attribute", () => { document.getElementById("test-div").innerHTML = `\ unhelpful text\ `; NormalModeCommands.goNext(); assert.equal("#first", globalThis.location.hash); }); }); context("Key mapping", () => { let normalMode, handlerCalled, handlerCalledCount; setup(() => { normalMode = initializeModeState(); handlerCalled = false; handlerCalledCount = 0; normalMode.setCommandHandler(({ count }) => { handlerCalled = true; handlerCalledCount = count; }); }); should("recognize first mapped key", () => { assert.isTrue(normalMode.isMappedKey("m")); }); should("recognize second mapped key", () => { assert.isFalse(normalMode.isMappedKey("p")); sendKeyboardEvent("z"); assert.isTrue(normalMode.isMappedKey("p")); }); should("recognize pass keys", () => { assert.isTrue(normalMode.isPassKey("p")); }); should("not mis-recognize pass keys", () => { assert.isFalse(normalMode.isMappedKey("p")); sendKeyboardEvent("z"); assert.isTrue(normalMode.isMappedKey("p")); }); should("recognize initial count keys", () => { assert.isTrue(normalMode.isCountKey("1")); assert.isTrue(normalMode.isCountKey("9")); }); should("not recognize '0' as initial count key", () => { assert.isFalse(normalMode.isCountKey("0")); }); should("recognize subsequent count keys", () => { sendKeyboardEvent("1"); assert.isTrue(normalMode.isCountKey("0")); assert.isTrue(normalMode.isCountKey("9")); }); should("set and call command handler", () => { sendKeyboardEvent("m"); assert.isTrue(handlerCalled); }); should("not call command handler for pass keys", () => { sendKeyboardEvent("p"); assert.isFalse(handlerCalled); }); should("accept a count prefix with a single digit", () => { sendKeyboardEvent("2"); sendKeyboardEvent("m"); assert.equal(2, handlerCalledCount); }); should("accept a count prefix with multiple digits", () => { sendKeyboardEvent("2"); sendKeyboardEvent("0"); sendKeyboardEvent("m"); assert.equal(20, handlerCalledCount); }); should("cancel a count prefix", () => { sendKeyboardEvent("2"); sendKeyboardEvent("z"); sendKeyboardEvent("m"); assert.equal(true, handlerCalled); assert.equal(null, handlerCalledCount); }); should("accept a count prefix for multi-key command mappings", () => { sendKeyboardEvent("5"); sendKeyboardEvent("z"); sendKeyboardEvent("p"); assert.equal(5, handlerCalledCount); }); should("cancel a key prefix", () => { sendKeyboardEvent("z"); assert.equal(false, handlerCalled); sendKeyboardEvent("m"); assert.equal(true, handlerCalled); }); should("cancel a count prefix after a prefix key", () => { sendKeyboardEvent("2"); sendKeyboardEvent("z"); sendKeyboardEvent("m"); assert.equal(null, handlerCalledCount); }); should("cancel a prefix key on escape", () => { sendKeyboardEvent("z"); sendKeyboardEvent("Escape", "keydown"); sendKeyboardEvent("p"); assert.equal(0, handlerCalledCount); }); }); context("Normal mode", () => { setup(() => initializeModeState()); should("invoke commands for mapped keys", () => { sendKeyboardEvent("m"); assert.equal("m", commandName); }); should("invoke commands for mapped keys with a mapped prefix", () => { sendKeyboardEvent("z"); sendKeyboardEvent("m"); assert.equal("m", commandName); }); should("invoke commands for mapped keys with an unmapped prefix", () => { sendKeyboardEvent("a"); sendKeyboardEvent("m"); assert.equal("m", commandName); }); should("not invoke commands for pass keys", () => { sendKeyboardEvent("p"); assert.equal(null, commandName); }); should("not invoke commands for pass keys with an unmapped prefix", () => { sendKeyboardEvent("a"); sendKeyboardEvent("p"); assert.equal(null, commandName); }); should("invoke commands for pass keys with a count", () => { sendKeyboardEvent("1"); sendKeyboardEvent("p"); assert.equal("p", commandName); }); should("invoke commands for pass keys with a key queue", () => { sendKeyboardEvent("z"); sendKeyboardEvent("p"); assert.equal("zp", commandName); }); should("accept count prefixes of length 1", () => { sendKeyboardEvent("2"); sendKeyboardEvent("m"); assert.equal(2, commandCount); }); should("accept count prefixes of length 2", () => { sendKeyboardEvents("12"); sendKeyboardEvent("m"); assert.equal(12, commandCount); }); should("get the correct count for mixed inputs (single key)", () => { sendKeyboardEvent("2"); sendKeyboardEvent("z"); sendKeyboardEvent("m"); assert.equal(null, commandCount); }); should("get the correct count for mixed inputs (multi key)", () => { sendKeyboardEvent("2"); sendKeyboardEvent("z"); sendKeyboardEvent("p"); assert.equal(2, commandCount); }); should("get the correct count for mixed inputs (multi key, duplicates)", () => { sendKeyboardEvent("2"); sendKeyboardEvent("z"); sendKeyboardEvent("z"); sendKeyboardEvent("p"); assert.equal(null, commandCount); }); should("get the correct count for mixed inputs (with leading mapped keys)", () => { sendKeyboardEvent("z"); sendKeyboardEvent("2"); sendKeyboardEvent("m"); assert.equal(2, commandCount); }); should("get the correct count for mixed inputs (with leading unmapped keys)", () => { sendKeyboardEvent("a"); sendKeyboardEvent("2"); sendKeyboardEvent("m"); assert.equal(2, commandCount); }); should("not get a count after unmapped keys", () => { sendKeyboardEvent("2"); sendKeyboardEvent("a"); sendKeyboardEvent("m"); assert.equal(null, commandCount); }); should("get the correct count after unmapped keys", () => { sendKeyboardEvent("2"); sendKeyboardEvent("a"); sendKeyboardEvent("3"); sendKeyboardEvent("m"); assert.equal(3, commandCount); }); should("not handle unmapped keys", () => { sendKeyboardEvent("u"); assert.equal(null, commandCount); }); }); context("Insert mode", () => { let insertMode; setup(() => { initializeModeState(); insertMode = new InsertMode({ global: true }); }); should("exit on escape", () => { assert.isTrue(insertMode.modeIsActive); sendKeyboardEvent("Escape", "keydown"); assert.isFalse(insertMode.modeIsActive); }); should("resume normal mode after leaving insert mode", () => { assert.equal(null, commandName); insertMode.exit(); sendKeyboardEvent("m"); assert.equal("m", commandName); }); }); context("Triggering insert mode", () => { setup(() => { initializeModeState(); const testContent = ` \ \ \

\

`; document.getElementById("test-div").innerHTML = testContent; }); teardown(() => { if (document.activeElement != null) { document.activeElement.blur(); } document.getElementById("test-div").innerHTML = ""; }); should("trigger insert mode on focus of text input", () => { assert.isFalse(InsertMode.permanentInstance.isActive()); document.getElementById("first").focus(); assert.isTrue(InsertMode.permanentInstance.isActive()); }); should("trigger insert mode on focus of password input", () => { assert.isFalse(InsertMode.permanentInstance.isActive()); document.getElementById("third").focus(); assert.isTrue(InsertMode.permanentInstance.isActive()); }); should("trigger insert mode on focus of contentEditable elements", () => { assert.isFalse(InsertMode.permanentInstance.isActive()); document.getElementById("fourth").focus(); assert.isTrue(InsertMode.permanentInstance.isActive()); }); should("not trigger insert mode on other elements", () => { assert.isFalse(InsertMode.permanentInstance.isActive()); document.getElementById("fifth").focus(); assert.isFalse(InsertMode.permanentInstance.isActive()); }); }); // NOTE(philc): I'm disabling the caret and visual mode tests because I think they're fallen into // disrepair, or we merged changes to master and neglected to update the tests. We should return to // these and fix+re-enable them. // context("Caret mode", // setup(() => { // document.getElementById("test-div").innerHTML = `\ //

//   It is an ancient Mariner,
//   And he stoppeth one of three.
//   By thy long grey beard and glittering eye,
//   Now wherefore stopp'st thou me?
// 

\ // `; // initializeModeState(); // this.initialVisualMode = new VisualMode; // }); // teardown(() => document.getElementById("test-div").innerHTML = ""), // should("enter caret mode", () => { // assert.isFalse(this.initialVisualMode.modeIsActive); // assert.equal("I", getSelection()); // }); // should("exit caret mode on escape", () => { // sendKeyboardEvent("Escape", "keydown"); // assert.equal("", getSelection()); // }); // should("move caret with l and h", () => { // assert.equal("I", getSelection()); // sendKeyboardEvent("l"); // assert.equal("t", getSelection()); // sendKeyboardEvent("h"); // assert.equal("I", getSelection()); // }); // should("move caret with w and b", () => { // assert.equal("I", getSelection()); // sendKeyboardEvent("w"); // assert.equal("i", getSelection()); // sendKeyboardEvent("b"); // assert.equal("I", getSelection()); // }); // should("move caret with e", () => { // assert.equal("I", getSelection()); // sendKeyboardEvent("e"); // assert.equal(" ", getSelection()); // sendKeyboardEvent("e"); // assert.equal(" ", getSelection()); // }); // should("move caret with j and k", () => { // assert.equal("I", getSelection()); // sendKeyboardEvent("j"); // assert.equal("A", getSelection()); // sendKeyboardEvent("k"); // assert.equal("I", getSelection()); // }); // should("re-use an existing selection", () => { // assert.equal("I", getSelection()); // sendKeyboardEvents("ww"); // assert.equal("a", getSelection()); // sendKeyboardEvent("Escape", "keydown"); // new VisualMode; // assert.equal("a", getSelection()); // }); // should("not move the selection on caret/visual mode toggle", () => { // sendKeyboardEvents("ww"); // assert.equal("a", getSelection()); // for (let key of "vcvcvc".split()) { // sendKeyboardEvent(key); // assert.equal("a", getSelection()); // } // }) // ); // // TODO(philc): Re-enable // context("Visual mode", // setup(() => { // document.getElementById("test-div").innerHTML = `\ //

//   It is an ancient Mariner,
//   And he stoppeth one of three.
//   By thy long grey beard and glittering eye,
//   Now wherefore stopp'st thou me?
// 

\ // `; // initializeModeState(); // this.initialVisualMode = new VisualMode; // sendKeyboardEvent("w"); // sendKeyboardEvent("w"); // // We should now be at the "a" of "an". // sendKeyboardEvent("v"); // }); // teardown(() => document.getElementById("test-div").innerHTML = ""), // should("select word with e", () => { // assert.equal("a", getSelection()); // sendKeyboardEvent("e"); // assert.equal("an", getSelection()); // sendKeyboardEvent("e"); // assert.equal("an ancient", getSelection()); // }); // should("select opposite end of the selection with o", () => { // assert.equal("a", getSelection()); // sendKeyboardEvent("e"); // assert.equal("an", getSelection()); // sendKeyboardEvent("e"); // assert.equal("an ancient", getSelection()); // sendKeyboardEvents("ow"); // assert.equal("ancient", getSelection()); // sendKeyboardEvents("oe"); // assert.equal("ancient Mariner", getSelection()); // }); // should("accept a count", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("2e"); // assert.equal("an ancient", getSelection()); // }); // should("select a word", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("aw"); // assert.equal("an", getSelection()); // }); // should("select a word with a count", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("2aw"); // assert.equal("an ancient", getSelection()); // }); // should("select a word with a count", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("2aw"); // assert.equal("an ancient", getSelection()); // }); // should("select to start of line", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("0"); // assert.equal("It is", getSelection().trim()); // }); // should("select to end of line", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("$"); // assert.equal("an ancient Mariner,", getSelection()); // }); // should("re-enter caret mode", () => { // assert.equal("a", getSelection()); // sendKeyboardEvents("cww"); // assert.equal("M", getSelection()); // }) // ); const createMode = (options) => { const mode = new Mode(); mode.init(options); return mode; }; context("Mode utilities", () => { setup(() => { initializeModeState(); const testContent = ` \ \ `; document.getElementById("test-div").innerHTML = testContent; }); teardown(() => document.getElementById("test-div").innerHTML = ""); should("not have duplicate singletons", () => { let mode; let count = 0; class Test extends Mode { constructor() { count += 1; super(); super.init({ singleton: "test" }); } exit() { count -= 1; return super.exit(); } } assert.isTrue(count === 0); for (let i = 1; i <= 10; i++) { mode = new Test(); assert.isTrue(count === 1); } mode.exit(); assert.isTrue(count === 0); }); should("exit on escape", () => { const test = createMode({ exitOnEscape: true }); assert.isTrue(test.modeIsActive); sendKeyboardEvent("Escape", "keydown"); assert.isFalse(test.modeIsActive); }); should("not exit on escape if not enabled", () => { const test = createMode({ exitOnEscape: false }); assert.isTrue(test.modeIsActive); sendKeyboardEvent("Escape", "keydown"); assert.isTrue(test.modeIsActive); }); should("exit on blur", () => { const element = document.getElementById("first"); element.focus(); const test = createMode({ exitOnBlur: element }); assert.isTrue(test.modeIsActive); element.blur(); assert.isFalse(test.modeIsActive); }); should("not exit on blur if not enabled", () => { const element = document.getElementById("first"); element.focus(); const test = createMode({ exitOnBlur: false }); assert.isTrue(test.modeIsActive); element.blur(); assert.isTrue(test.modeIsActive); }); }); context("PostFindMode", () => { let postFindMode; setup(() => { initializeModeState(); const testContent = ""; document.getElementById("test-div").innerHTML = testContent; document.getElementById("first").focus(); postFindMode = new PostFindMode(); }); teardown(() => document.getElementById("test-div").innerHTML = ""), should("be a singleton", () => { assert.isTrue(postFindMode.modeIsActive); new PostFindMode(); assert.isFalse(postFindMode.modeIsActive); }); should("suppress unmapped printable keys", () => { sendKeyboardEvent("a"); assert.equal(null, commandCount); }); should("be deactivated on click events", () => { handlerStack.bubbleEvent("click", { target: document.activeElement }); assert.isFalse(postFindMode.modeIsActive); }); should("enter insert mode on immediate escape", () => { sendKeyboardEvent("Escape", "keydown"); assert.equal(null, commandCount); assert.isFalse(postFindMode.modeIsActive); }); should("not enter insert mode on subsequent escapes", () => { sendKeyboardEvent("a"); sendKeyboardEvent("Escape", "keydown"); assert.isTrue(postFindMode.modeIsActive); }); }); context("WaitForEnter", () => { let isSuccess, waitForEnter; setup(() => { initializeModeState(); isSuccess = null; waitForEnter = new WaitForEnter((value) => { isSuccess = value; }); }); should("exit with success on Enter", () => { assert.isTrue(waitForEnter.modeIsActive); assert.isFalse(isSuccess != null); sendKeyboardEvent("Enter", "keydown"); assert.isFalse(waitForEnter.modeIsActive); assert.isTrue((isSuccess != null) && (isSuccess === true)); }); should("exit without success on Escape", () => { assert.isTrue(waitForEnter.modeIsActive); assert.isFalse(isSuccess != null); sendKeyboardEvent("Escape", "keydown"); assert.isFalse(waitForEnter.modeIsActive); assert.isTrue((isSuccess != null) && (isSuccess === false)); }); should("not exit on other keyboard events", () => { assert.isTrue(waitForEnter.modeIsActive); assert.isFalse(isSuccess != null); sendKeyboardEvents("abc"); assert.isTrue(waitForEnter.modeIsActive); assert.isFalse(isSuccess != null); }); }); context("GrabBackFocus", () => { setup(() => { const testContent = ""; document.getElementById("test-div").innerHTML = testContent; stubSettings("grabBackFocus", true); }); teardown(() => document.getElementById("test-div").innerHTML = ""), should("blur an already focused input", () => { document.getElementById("input").focus(); assert.isTrue(document.activeElement); assert.isTrue(DomUtils.isEditable(document.activeElement)); initializeModeState(); assert.isTrue(document.activeElement); assert.isFalse(DomUtils.isEditable(document.activeElement)); }); should("blur a newly focused input", () => { initializeModeState(); document.getElementById("input").focus(); assert.isTrue(document.activeElement); assert.isFalse(DomUtils.isEditable(document.activeElement)); }); should("exit on a key event", () => { initializeModeState(); sendKeyboardEvent("a"); document.getElementById("input").focus(); assert.isTrue(document.activeElement); assert.isTrue(DomUtils.isEditable(document.activeElement)); }); should("exit on a mousedown event", () => { initializeModeState(); handlerStack.bubbleEvent("mousedown", { target: document.body }); document.getElementById("input").focus(); assert.isTrue(document.activeElement); assert.isTrue(DomUtils.isEditable(document.activeElement)); }); }); ================================================ FILE: tests/dom_tests/dom_utils_test.js ================================================ context("Check visibility", () => { should("detect visible elements as visible", () => { document.getElementById("test-div").innerHTML = `\
test
`; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); }); should("detect display:none links as hidden", () => { document.getElementById("test-div").innerHTML = `\ `; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); }); should("detect visibility:hidden links as hidden", () => { document.getElementById("test-div").innerHTML = `\ `; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); }); should("detect elements nested in display:none elements as hidden", () => { document.getElementById("test-div").innerHTML = `\ `; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); }); should("detect links nested in visibility:hidden elements as hidden", () => { document.getElementById("test-div").innerHTML = `\ `; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); }); should("detect links outside viewport as hidden", () => { document.getElementById("test-div").innerHTML = `\ test test`; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("bar"), true)); }); should("detect links only partially outside viewport as visible", () => { document.getElementById("test-div").innerHTML = `\ test test`; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("bar"), true)) !== null); }); should("detect links that contain only floated / absolutely-positioned divs as visible", () => { document.getElementById("test-div").innerHTML = `\
test
`; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); document.getElementById("test-div").innerHTML = `\
test
`; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); }); should("detect links that contain only invisible floated divs as invisible", () => { document.getElementById("test-div").innerHTML = `\
test
`; assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); }); should( "detect font-size: 0; and display: inline; links when their children are display: inline", () => { // This test represents the minimal test case covering issue #1554. document.getElementById("test-div").innerHTML = `\
test
`; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); }, ); should("detect links inside opacity:0 elements as visible", () => { // XXX This is an expected failure. See issue #16. document.getElementById("test-div").innerHTML = `\ `; assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); }); }); context("getClientRectsForAreas", () => { let img, area; setup(() => { img = document.createElement("img"); area = document.createElement("area"); }); should("return the associated rect for an image map", () => { area.setAttribute("coords", "1,2,3,4"); const result = DomUtils.getClientRectsForAreas(img, [area]); assert.equal([{ element: area, rect: Rect.create(1, 2, 3, 4) }], result); }); should("skip when a map's coords are malformed", () => { area.setAttribute("coords", "1,2,3"); // This is only 3 coords rather than 4. assert.equal([], DomUtils.getClientRectsForAreas(img, [area])); area.setAttribute("coords", "1,2,3,junk-value"); assert.equal([], DomUtils.getClientRectsForAreas(img, [area])); }); }); // NOTE(philc): This test doesn't pass on puppeteer. It's unclear from the XXX comment if it's // supposed to. // should("Detect links within SVGs as visible"), () => { // # XXX this is an expected failure // document.getElementById("test-div").innerHTML = """ // // // test // // // """ // assert.equal(null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)); // } ================================================ FILE: tests/unit_tests/bg_utils_test.js ================================================ import "./test_helper.js"; import "../../lib/url_utils.js"; import "../../background_scripts/tab_recency.js"; import "../../background_scripts/bg_utils.js"; ================================================ FILE: tests/unit_tests/command_listing_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import "../../lib/utils.js"; import "../../lib/settings.js"; import { allCommands } from "../../background_scripts/all_commands.js"; import * as commandListing from "../../pages/command_listing.js"; context("command listing", () => { setup(async () => { await testHelper.jsdomStub("pages/command_listing.html"); await Settings.onLoaded(); stub(chrome.storage.session, "get", async (key) => { if (key == "commandToOptionsToKeys") { const data = { "reload": { "": ["a"], "hard": ["b"], }, }; return { commandToOptionsToKeys: data }; } }); }); should("have a section in the html for every group", async () => { // This is to prevent editing errors, where a new command group is added, and we forget to add a // corresponding group to the command listing. await commandListing.populatePage(); const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort(); const groupsInPage = Array.from(globalThis.document.querySelectorAll("h2[data-group]")) .map((e) => e.dataset.group) .sort(); assert.equal(groups, groupsInPage); }); should("have one entry per command", async () => { await commandListing.populatePage(); const rows = globalThis.document.querySelectorAll(".command"); assert.equal(allCommands.length, rows.length); }); should("show key mappings for mapped commands", async () => { const getKeys = (commandName) => { const el = globalThis.document.querySelector(`.command#${commandName}`); if (!el) throw new Error(`${commandName} el not found.`); const keys = Array.from(el.querySelectorAll(".key")).map((el) => el.textContent); return keys; }; await commandListing.populatePage(); assert.equal(["a", "b"], getKeys("reload")); // This command isn't bound in our stubbed test environment: assert.equal([], getKeys("scrollDown")); }); }); ================================================ FILE: tests/unit_tests/commands_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import "../../lib/keyboard_utils.js"; import { allCommands } from "../../background_scripts/all_commands.js"; import { Commands, defaultKeyMappings, KeyMappingsParser, parseLines, } from "../../background_scripts/commands.js"; import "../../content_scripts/mode.js"; import "../../content_scripts/mode_key_handler.js"; import "../../content_scripts/marks.js"; import "../../content_scripts/link_hints.js"; import "../../content_scripts/vomnibar.js"; // Include mode_normal to check that all commands have been implemented. import "../../content_scripts/mode_normal.js"; import "../../content_scripts/link_hints.js"; import "../../content_scripts/marks.js"; import "../../content_scripts/vomnibar.js"; await Commands.init(); context("KeyMappingsParser", () => { const getErrors = (config) => KeyMappingsParser.parse(config).validationErrors; should("handle map statements", () => { const { keyToRegistryEntry } = KeyMappingsParser.parse("map a scrollDown"); assert.equal("scrollDown", keyToRegistryEntry["a"]?.command); }); should("ignore mappings for unknown commands", () => { assert.equal({}, KeyMappingsParser.parse("map a unknownCommand").keyToRegistryEntry); }); should("handle mapkey statements", () => { const { keyToMappedKey } = KeyMappingsParser.parse("mapkey a b"); assert.equal({ "a": "b" }, keyToMappedKey); }); should("handle unmap statements", () => { const input = "mapkey a b \n unmap a"; const { keyToMappedKey } = KeyMappingsParser.parse(input); assert.equal({}, keyToMappedKey); }); should("handle unmapall statements", () => { const input = "mapkey a b \n unmapall \n mapkey b c"; const { keyToMappedKey } = KeyMappingsParser.parse(input); assert.equal({ "b": "c" }, keyToMappedKey); }); should("ignore commands with the wrong number of tokens", () => { assert.equal({}, KeyMappingsParser.parse("mapkey a b c").keyToMappedKey); assert.equal({}, KeyMappingsParser.parse("map a").keyToRegistryEntry); assert.equal( { "a": "b" }, KeyMappingsParser.parse("mapkey a b \n unmap a a").keyToMappedKey, ); }); should("parse option values surrounded by quotes", () => { const { keyToRegistryEntry } = KeyMappingsParser.parse('map v Vomnibar.activate query="a b"'); const entry = keyToRegistryEntry["v"]; assert.equal({ query: "a b" }, entry.options); }); should("parse options using all 3 syntaxes", () => { // This test exercises some of the edge cases of the underlying regular expressions. const result = KeyMappingsParser.parseCommandOptions('keyA keyB="a b=c" keyC=" '); assert.equal({ keyA: true, keyB: "a b=c", keyC: '"' }, result); }); should("parse a URL parameter alongside an option value", () => { // URLs alongside the "position" option occurs in the createTab command. const result = KeyMappingsParser.parseCommandOptions('abc.com/?param=val position="end"'); assert.equal({ "abc.com/?param=val": true, position: "end" }, result); }); should("return parsing validation errors", () => { assert.equal(0, getErrors("map a scrollDown").length); // Missing an action (e.g. map). assert.equal(1, getErrors("a scrollDown").length); // Invalid action. assert.equal(1, getErrors("invalidAction a scrollDown").length); // Map requires at least two arguments assert.equal(0, getErrors("map a scrollDown").length); assert.equal(1, getErrors("map a").length); // Unmap allows only 1 argument. assert.equal(0, getErrors("unmap a").length); assert.equal(1, getErrors("unmap a b").length); // Mapkey requires 2 arguments. assert.equal(0, getErrors("mapkey a b").length); assert.equal(1, getErrors("mapkey a").length); // Reject unknown modifiers. assert.equal(0, getErrors("map scrollDown").length); assert.equal(1, getErrors("map scrollDown").length); }); should("reject unknown commands on map statements", () => { // Reject unknown commands. assert.equal(1, getErrors("map a example-command").length); }); should("reject unknown options on map statements", () => { assert.equal(0, getErrors("map j LinkHints.activateMode action=focus").length); assert.equal(1, getErrors("map j LinkHints.activateMode unknownOption=a").length); }); should("reject count option on commands with noRepeat=true", () => { assert.equal(0, getErrors("map j scrollLeft count=1").length); assert.equal(1, getErrors("map j copyCurrentUrl count=1").length); }); should("allow arbitrary URLs as arguments to commands with (any url) as an option", () => { assert.equal(0, getErrors("map j createTab http://example.com").length); assert.equal(1, getErrors("map j createTab invalid-url").length); }); context("parseLines", () => { should("omit whitespace", () => { assert.equal(0, parseLines(" \n \n ").length); }); should("omit comments", () => { assert.equal(0, parseLines(' # comment \n " comment \n ').length); }); should("join lines", () => { assert.equal(1, parseLines("a\\\nb").length); assert.equal("ab", parseLines("a\\\nb")[0]); }); should("trim lines", () => { assert.equal(2, parseLines(" a \n b").length); assert.equal("a", parseLines(" a \n b")[0]); assert.equal("b", parseLines(" a \n b")[1]); }); }); context("parseKeySequence", () => { const testKeySequence = (key, expectedKeyText, expectedKeyLength) => { const keySequence = KeyMappingsParser.parseKeySequence(key); assert.equal(expectedKeyText, keySequence.join("/")); assert.equal(expectedKeyLength, keySequence.length); }; should("lowercase keys correctly", () => { testKeySequence("a", "a", 1); testKeySequence("A", "A", 1); testKeySequence("ab", "a/b", 2); }); should("recognise non-alphabetic keys", () => { testKeySequence("#", "#", 1); testKeySequence(".", ".", 1); testKeySequence("##", "#/#", 2); testKeySequence("..", "./.", 2); }); should("parse keys with modifiers", () => { testKeySequence("", "", 1); testKeySequence("", "", 1); testKeySequence("", "", 1); testKeySequence("", "/", 2); testKeySequence("", "", 1); testKeySequence("z", "z/", 2); }); should("normalize with modifiers", () => { // Modifiers should be in alphabetical order. testKeySequence("", "", 1); }); should("parse and normalize named keys", () => { testKeySequence("", "", 1); testKeySequence("", "", 1); testKeySequence("", "", 1); testKeySequence("", "", 1); testKeySequence("", "", 1); }); should("handle angle brackets which are part of not modifiers", () => { testKeySequence("<", "<", 1); testKeySequence(">", ">", 1); testKeySequence("<<", ">", ">/>", 2); testKeySequence("<>", "", 2); testKeySequence("<>", "", 2); testKeySequence("<", "", 2); testKeySequence(">", ">", 1); testKeySequence("", "", 3); }); should("negative tests", () => { // This should not be parsed as modifiers. testKeySequence("", "", 6); }); }); }); context("Validate commands and options data structures", () => { should("have either noRepeat or repeatLimit, but not both", () => { for (const command of allCommands) { const validProperties = !(command.noRepeat && command.repeatLimit); if (!validProperties) { assert.fail(`${command.name} has incorrect noRepeat and/or repeatLimit config.`); } } }); should("have required properties", () => { for (const command of allCommands) { const hasRequired = command.desc.length > 0 && command.group.length > 0; if (!hasRequired) { assert.fail(`${command.name} is missing required properties.`); } } }); should("have valid commands for each default key mapping", () => { const commandsByName = Utils.keyBy(allCommands, "name"); for (const [key, commandString] of Object.entries(defaultKeyMappings)) { // The comamnd string might be command name + an option string. Ignore the options. const name = commandString.split(" ")[0]; if (commandsByName[name] == null) { assert.fail(`The default mapping for ${key} is bound to non-existant command ${name}.`); } } }); }); ================================================ FILE: tests/unit_tests/completion/completers_test.js ================================================ import "../test_helper.js"; import "../../../background_scripts/tab_recency.js"; import "../../../background_scripts/bg_utils.js"; import "../../../background_scripts/completion/search_engines.js"; import "../../../background_scripts/completion/search_wrapper.js"; import * as userSearchEngines from "../../../background_scripts/user_search_engines.js"; import { BookmarkCompleter, DomainCompleter, HistoryCache, HistoryCompleter, MultiCompleter, SearchEngineCompleter, Suggestion, TabCompleter, } from "../../../background_scripts/completion/completers.js"; import * as ranking from "../../../background_scripts/completion/ranking.js"; import { RegexpCache } from "../../../background_scripts/completion/ranking.js"; import "../../../lib/url_utils.js"; const hours = (n) => 1000 * 60 * 60 * n; // A convenience wrapper around completer.filter() so it can be called synchronously in tests. const filterCompleter = async (completer, queryTerms) => { return await completer.filter({ queryTerms, query: queryTerms.join(" "), }); }; context("bookmark completer", () => { const bookmark3 = { title: "bookmark3", url: "bookmark3.com" }; const bookmark2 = { title: "bookmark2", url: "bookmark2.com" }; const bookmark1 = { title: "bookmark1", url: "bookmark1.com", children: [bookmark2] }; let completer; setup(() => { stub(globalThis.chrome.bookmarks, "getTree", () => [bookmark1]); completer = new BookmarkCompleter(); }); should("flatten a list of bookmarks with inorder traversal", async () => { const result = await completer.traverseBookmarks([bookmark1, bookmark3]); assert.equal([bookmark1, bookmark2, bookmark3], result); }); should("return matching bookmarks when searching", async () => { completer.refresh(); const results = await filterCompleter(completer, ["mark2"]); assert.equal([bookmark2.url], results.map((suggestion) => suggestion.url)); }); should("return *no* matching bookmarks when there is no match", async () => { completer.refresh(); const results = await filterCompleter(completer, ["does-not-match"]); assert.equal([], results.map((suggestion) => suggestion.url)); }); should("construct bookmark paths correctly", async () => { completer.refresh(); await filterCompleter(completer, ["mark2"]); assert.equal("/bookmark1/bookmark2", bookmark2.pathAndTitle); }); should( "return matching bookmark *titles* when searching *without* the folder separator character", async () => { completer.refresh(); const results = await filterCompleter(completer, ["mark2"]); assert.equal(["bookmark2"], results.map((suggestion) => suggestion.title)); }, ); should( "return matching bookmark *paths* when searching with the folder separator character", async () => { completer.refresh(); const results = await filterCompleter(completer, ["/bookmark1", "mark2"]); assert.equal(["/bookmark1/bookmark2"], results.map((suggestion) => suggestion.title)); }, ); }); context("HistoryCache", () => { const compare = (a, b) => a - b; context("binary search", () => { should("find elements to the left of the middle", () => { assert.equal(0, HistoryCache.binarySearch(3, [3, 5, 8], compare)); }); should("find elements to the right of the middle", () => { assert.equal(2, HistoryCache.binarySearch(8, [3, 5, 8], compare)); }); context("unfound elements", () => { should("return 0 if it should be the head of the list", () => { assert.equal(0, HistoryCache.binarySearch(1, [3, 5, 8], compare)); }); should("return length - 1 if it should be at the end of the list", () => { assert.equal(0, HistoryCache.binarySearch(3, [3, 5, 8], compare)); }); should( "return one passed end of array (so: array.length) if greater than last element in array", () => { assert.equal(3, HistoryCache.binarySearch(10, [3, 5, 8], compare)); }, ); should("found return the position if it's between two elements", () => { assert.equal(1, HistoryCache.binarySearch(4, [3, 5, 8], compare)); assert.equal(2, HistoryCache.binarySearch(7, [3, 5, 8], compare)); }); }); }); context("fetchHistory", () => { const history1 = { url: "b.com", lastVisitTime: 5 }; const history2 = { url: "a.com", lastVisitTime: 10 }; let onVisitedListener, onVisitRemovedListener; setup(async () => { const history = [history1, history2]; // const history = [history2, history1]; onVisitedListener = null; onVisitRemovedListener = null; stub(globalThis.chrome, "history", { search: (_options) => history, onVisited: { addListener(listener) { onVisitedListener = listener; }, removeListener() {}, }, onVisitRemoved: { addListener(listener) { onVisitRemovedListener = listener; }, removeListener() {}, }, }); HistoryCache.reset(); await HistoryCache.fetchHistory(); }); should("store visits sorted by url ascending", () => { assert.equal([history2, history1], HistoryCache.history); }); should("add new visits to the history", () => { const newSite = { url: "ab.com" }; onVisitedListener(newSite); assert.equal([history2, newSite, history1], HistoryCache.history); }); should("replace new visits in the history", () => { assert.equal([history2, history1], HistoryCache.history); const newSite = { url: "a.com", lastVisitTime: 15 }; onVisitedListener(newSite); assert.equal([newSite, history1], HistoryCache.history); }); should( "(not) remove page from the history, when page is not in history (it should be a no-op)", () => { assert.equal([history2, history1], HistoryCache.history); const toRemove = { urls: ["x.com"], allHistory: false }; onVisitRemovedListener(toRemove); assert.equal([history2, history1], HistoryCache.history); }, ); should("remove pages from the history", () => { assert.equal([history2, history1], HistoryCache.history); const toRemove = { urls: ["a.com"], allHistory: false }; onVisitRemovedListener(toRemove); assert.equal([history1], HistoryCache.history); }); should("remove all pages from the history", () => { assert.equal([history2, history1], HistoryCache.history); const toRemove = { allHistory: true }; onVisitRemovedListener(toRemove); assert.equal([], HistoryCache.history); }); }); }); context("history completer", () => { const history1 = { title: "history1", url: "history1.com", lastVisitTime: hours(1) }; const history2 = { title: "history2", url: "history2.com", lastVisitTime: hours(5) }; let completer; setup(() => { completer = new HistoryCompleter(); stub(globalThis.chrome, "history", { search: (_options) => [history1, history2], onVisited: { addListener() {}, removeListener() {} }, onVisitRemoved: { addListener() {}, removeListener() {} }, }); HistoryCache.reset(); }); should("return matching history entries when searching", async () => { const results = await filterCompleter(completer, ["story1"]); assert.equal([history1.url], results.map((s) => s.url)); }); should("rank recent results higher than nonrecent results", async () => { stub(Date, "now", returns(hours(24))); const results = await filterCompleter(completer, ["hist"]); results.forEach((result) => result.computeRelevancy()); results.sort((a, b) => b.relevancy - a.relevancy); assert.equal([history2.url, history1.url], results.map((result) => result.url)); }); }); context("domain completer", () => { const history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(1) }; const history2 = { title: "history2", url: "http://history2.com", lastVisitTime: hours(1) }; const undef = { title: "history2", url: "http://undefined.net", lastVisitTime: hours(1) }; let completer = null; setup(() => { stub(globalThis.chrome, "history", { search: (_options) => [history1, history2, undef], onVisited: { addListener() {}, removeListener() {} }, onVisitRemoved: { addListener() {}, removeListener() {} }, }); stub(Date, "now", returns(hours(24))); completer = new DomainCompleter(); HistoryCache.reset(); }); should("return only a single matching domain", async () => { const results = await filterCompleter(completer, ["story"]); assert.equal(["http://history1.com"], results.map((r) => r.url)); }); should("pick domains which are more recent", async () => { // These domains are the same except for their last visited time. let result = await filterCompleter(completer, ["story"]); assert.equal("http://history1.com", result[0].url); history2.lastVisitTime = hours(3); result = await filterCompleter(completer, ["story"]); assert.equal("http://history2.com", result[0].url); }); should( "returns no results when there's more than one query term, because clearly it's not a domain", async () => { assert.equal([], await filterCompleter(completer, ["his", "tory"])); }, ); should("not return any results for empty queries", async () => { assert.equal([], await filterCompleter(completer, [])); }); }); context("domain completer (removing entries)", () => { const history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(2) }; const history2 = { title: "history2", url: "http://history2.com", lastVisitTime: hours(1) }; const history3 = { title: "history2something", url: "http://history2.com/something", lastVisitTime: hours(0), }; let onVisitRemovedListener, completer; setup(async () => { onVisitRemovedListener = null; stub(globalThis.chrome, "history", { search: (_options) => [history1, history2, history3], onVisited: { addListener(_listener) { }, }, onVisitRemoved: { addListener(listener) { onVisitRemovedListener = listener; }, }, }); stub(Date, "now", returns(hours(24))); completer = new DomainCompleter(); // Force installation of listeners. await filterCompleter(completer, ["story"]); }); should("remove 1 entry for domain with reference count of 1", async () => { onVisitRemovedListener({ allHistory: false, urls: [history1.url] }); let result = await filterCompleter(completer, ["story"]); assert.equal("http://history2.com", result[0].url); result = await filterCompleter(completer, ["story1"]); assert.equal(0, result.length); }); should("remove 2 entries for domain with reference count of 2", async () => { onVisitRemovedListener({ allHistory: false, urls: [history2.url] }); let result = await filterCompleter(completer, ["story2"]); assert.equal("http://history2.com", result[0].url); onVisitRemovedListener({ allHistory: false, urls: [history3.url] }); result = await filterCompleter(completer, ["story2"]); assert.equal(0, result.length); result = await filterCompleter(completer, ["story"]); assert.equal("http://history1.com", result[0].url); }); should("remove 3 (all) matching domain entries", async () => { onVisitRemovedListener({ allHistory: false, urls: [history2.url] }); onVisitRemovedListener({ allHistory: false, urls: [history1.url] }); onVisitRemovedListener({ allHistory: false, urls: [history3.url] }); const result = await filterCompleter(completer, ["story"]); assert.equal(0, result.length); }); should("remove 3 (all) matching domain entries, and do it all at once", async () => { onVisitRemovedListener({ allHistory: false, urls: [history2.url, history1.url, history3.url] }); const result = await filterCompleter(completer, ["story"]); assert.equal(0, result.length); }); should("remove *all* domain entries", async () => { onVisitRemovedListener({ allHistory: true }); const result = await filterCompleter(completer, ["story"]); assert.equal(0, result.length); }); }); context("multi completer", () => { const tabs = [{ url: "tab1.com", title: "tab1", id: 1 }]; const tabCompleter = new TabCompleter(); let multiCompleter; setup(() => { stub(chrome.tabs, "query", () => tabs); multiCompleter = new MultiCompleter([tabCompleter, new DomainCompleter()]); }); should("return an empty list when the query is empty", async () => { // Even though a TabCompleter returns results when the query is empty, a MultiCompleter which // wraps a TabCompleter should not. assert.equal(1, (await filterCompleter(tabCompleter, [])).length); assert.equal([], await filterCompleter(multiCompleter, [])); }); }); context("tab completer", () => { const tabs = [ { url: "tab1.com", title: "tab1", id: 1 }, { url: "tab2.com", title: "tab2", id: 2 }, ]; let completer; setup(() => { stub(chrome.tabs, "query", () => tabs); completer = new TabCompleter(); }); should("return tabs by recency when query is empty", async () => { const results = await filterCompleter(completer, []); assert.equal(["tab1.com", "tab2.com"], results.map((tab) => tab.url)); }); should("return matching tabs", async () => { const results = await filterCompleter(completer, ["tab2"]); assert.equal(["tab2.com"], results.map((tab) => tab.url)); assert.equal([2], results.map((tab) => tab.tabId)); }); }); context("SearchEngineCompleter", () => { const googleSearchUrl = "http://www.google.com/search?q="; let completer; const createResponse = (responseText) => { return { text: () => responseText }; }; setup(() => { completer = new SearchEngineCompleter(); const searchEngineConfig = `g: ${googleSearchUrl}%s`; userSearchEngines.set(searchEngineConfig); }); should("complete search results using the given completer", async () => { const googleResults = ["blue", ["blue1", "blue2"]]; stub(globalThis, "fetch", () => createResponse(JSON.stringify(googleResults))); const results = await filterCompleter(completer, ["g", "blue"]); assert.equal( [googleSearchUrl + "blue", googleSearchUrl + "blue1", googleSearchUrl + "blue2"], results.map((suggestion) => suggestion.url), ); }); }); context("suggestions", () => { setup(() => { stub(chrome.runtime, "getURL", returns("https://test/")); }); should("escape html in page titles", () => { const suggestion = new Suggestion({ queryTerms: ["queryterm"], description: "tab", url: "url", title: "title ", relevancyFunction: returns(1), }); assert.isTrue(suggestion.generateHtml({}).indexOf("title <span>") >= 0); }); should("highlight query words", () => { const suggestion = new Suggestion({ queryTerms: ["ninj", "words"], description: "tab", url: "url", title: "ninjawords", relevancyFunction: returns(1), }); const expected = "ninjawords"; assert.isTrue(suggestion.generateHtml({}).indexOf(expected) >= 0); }); should("highlight query words correctly when whey they overlap", () => { const suggestion = new Suggestion({ queryTerms: ["ninj", "jaword"], description: "tab", url: "url", title: "ninjawords", relevancyFunction: returns(1), }); const expected = "ninjawords"; assert.isTrue(suggestion.generateHtml({}).indexOf(expected) >= 0); }); should("shorten urls", () => { const suggestion = new Suggestion({ queryTerms: ["queryterm"], description: "history", url: "http://ninjawords.com", title: "ninjawords", relevancyFunction: returns(1), }); assert.equal(-1, suggestion.generateHtml({}).indexOf("http://ninjawords.com")); }); }); // TODO: (smblott) // Word relevancy should take into account the number of matches (it doesn't currently). should // "score higher for multiple matches (in a URL)", -> // lowScore = ranking.wordRelevancy(["stack"], "http://stackoverflow.com/Xxxxxx", "a-title") // highScore = ranking.wordRelevancy(["stack"], "http://stackoverflow.com/Xstack", "a-title") // assert.isTrue highScore > lowScore // should "score higher for multiple matches (in a title)", -> // lowScore = ranking.wordRelevancy(["bbc"], "http://stackoverflow.com/same", "BBC Radio 4 (XBCr4)") // highScore = ranking.wordRelevancy(["bbc"], "http://stackoverflow.com/same", "BBC Radio 4 (BBCr4)") // assert.isTrue highScore > lowScore context("Suggestion.pushMatchingRanges", () => { should("extract ranges matching term (simple case, two matches)", () => { const ranges = []; const [one, two, three] = ["one", "two", "three"]; const suggestion = new Suggestion([], "", "", "", returns(1)); suggestion.pushMatchingRanges(`${one}${two}${three}${two}${one}`, two, ranges); assert.equal( 2, Utils.zip([ranges, [[3, 6], [11, 14]]]).filter((pair) => (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1]) ).length, ); }); should("extract ranges matching term (two matches, one at start of string)", () => { const ranges = []; const [one, two, three] = ["one", "two", "three"]; const suggestion = new Suggestion([], "", "", "", returns(1)); suggestion.pushMatchingRanges(`${two}${three}${two}${one}`, two, ranges); assert.equal( 2, Utils.zip([ranges, [[0, 3], [8, 11]]]).filter((pair) => (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1]) ).length, ); }); should("extract ranges matching term (two matches, one at end of string)", () => { const ranges = []; const [one, two, three] = ["one", "two", "three"]; const suggestion = new Suggestion([], "", "", "", returns(1)); suggestion.pushMatchingRanges(`${one}${two}${three}${two}`, two, ranges); assert.equal( 2, Utils.zip([ranges, [[3, 6], [11, 14]]]).filter((pair) => (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1]) ).length, ); }); should("extract ranges matching term (no matches)", () => { const ranges = []; const [one, two, three] = ["one", "two", "three"]; const suggestion = new Suggestion([], "", "", "", returns(1)); suggestion.pushMatchingRanges(`${one}${two}${three}${two}${one}`, "does-not-match", ranges); assert.equal(0, ranges.length); }); }); ================================================ FILE: tests/unit_tests/completion/ranking_test.js ================================================ import "../test_helper.js"; import * as ranking from "../../../background_scripts/completion/ranking.js"; import { RegexpCache } from "../../../background_scripts/completion/ranking.js"; import "../../../lib/url_utils.js"; context("wordRelevancy", () => { should("score higher in shorter URLs", () => { const highScore = ranking.wordRelevancy( ["stack"], "http://stackoverflow.com/short", "a-title", ); const lowScore = ranking.wordRelevancy( ["stack"], "http://stackoverflow.com/longer", "a-title", ); assert.isTrue(highScore > lowScore); }); should("score higher in shorter titles", () => { const highScore = ranking.wordRelevancy(["milk"], "a-url", "Milkshakes"); const lowScore = ranking.wordRelevancy(["milk"], "a-url", "Milkshakes rocks"); assert.isTrue(highScore > lowScore); }); should("score higher for matching the start of a word (in a URL)", () => { const lowScore = ranking.wordRelevancy( ["stack"], "http://Xstackoverflow.com/same", "a-title", ); const highScore = ranking.wordRelevancy( ["stack"], "http://stackoverflowX.com/same", "a-title", ); assert.isTrue(highScore > lowScore); }); should("score higher for matching the start of a word (in a title)", () => { const lowScore = ranking.wordRelevancy(["te"], "a-url", "Dist racted"); const highScore = ranking.wordRelevancy(["te"], "a-url", "Distrac ted"); assert.isTrue(highScore > lowScore); }); should("score higher for matching a whole word (in a URL)", () => { const lowScore = ranking.wordRelevancy( ["com"], "http://stackoverflow.comX/same", "a-title", ); const highScore = ranking.wordRelevancy( ["com"], "http://stackoverflowX.com/same", "a-title", ); assert.isTrue(highScore > lowScore); }); should("score higher for matching a whole word (in a title)", () => { const lowScore = ranking.wordRelevancy(["com"], "a-url", "abc comX"); const highScore = ranking.wordRelevancy(["com"], "a-url", "abcX com"); assert.isTrue(highScore > lowScore); }); }); context("matches", () => { should("do a case insensitive match", () => { assert.isTrue(ranking.matches(["ari"], "maRio")); }); should("do a case insensitive match on full term", () => { assert.isTrue(ranking.matches(["mario"], "MARio")); }); should("do a case insensitive match on several terms", () => { assert.isTrue( ranking.matches(["ari"], "DOES_NOT_MATCH", "DOES_NOT_MATCH_EITHER", "MARio"), ); }); should("do a smartcase match (positive)", () => { assert.isTrue(ranking.matches(["Mar"], "Mario")); }); should("do a smartcase match (negative)", () => { assert.isFalse(ranking.matches(["Mar"], "mario")); }); should("do a match with regexp meta-characters (positive)", () => { assert.isTrue(ranking.matches(["ma.io"], "ma.io")); }); should("do a match with regexp meta-characters (negative)", () => { assert.isFalse(ranking.matches(["ma.io"], "mario")); }); should("do a smartcase match on full term", () => { assert.isTrue(ranking.matches(["Mario"], "Mario")); assert.isFalse(ranking.matches(["Mario"], "mario")); }); should("do case insensitive word relevancy (matching)", () => { assert.isTrue(ranking.wordRelevancy(["ari"], "MARIO", "MARio") > 0.0); }); should("do case insensitive word relevancy (not matching)", () => { assert.isTrue(ranking.wordRelevancy(["DOES_NOT_MATCH"], "MARIO", "MARio") === 0.0); }); should("every query term must match at least one thing (matching)", () => { assert.isTrue(ranking.matches(["cat", "dog"], "catapult", "hound dog")); }); should("every query term must match at least one thing (not matching)", () => { assert.isTrue(!ranking.matches(["cat", "dog", "wolf"], "catapult", "hound dog")); }); }); context("RegexpCache", () => { should("RegexpCache is in fact caching (positive case)", () => { assert.isTrue(RegexpCache.get("this") === RegexpCache.get("this")); }); should("RegexpCache is in fact caching (negative case)", () => { assert.isTrue(RegexpCache.get("this") !== RegexpCache.get("that")); }); should("RegexpCache prefix/suffix wrapping is working (positive case)", () => { assert.isTrue(RegexpCache.get("this", "(", ")") === RegexpCache.get("this", "(", ")")); }); should("RegexpCache prefix/suffix wrapping is working (negative case)", () => { assert.isTrue(RegexpCache.get("this", "(", ")") !== RegexpCache.get("this")); }); should("search for a string", () => { assert.isTrue("hound dog".search(RegexpCache.get("dog")) === 6); }); should("search for a string which isn't there", () => { assert.isTrue("hound dog".search(RegexpCache.get("cat")) === -1); }); should("search for a string with a prefix/suffix (positive case)", () => { assert.isTrue("hound dog".search(RegexpCache.get("dog", "\\b", "\\b")) === 6); }); should("search for a string with a prefix/suffix (negative case)", () => { assert.isTrue("hound dog".search(RegexpCache.get("do", "\\b", "\\b")) === -1); }); }); ================================================ FILE: tests/unit_tests/completion/search_engines_test.js ================================================ import "../test_helper.js"; import "../../../background_scripts/bg_utils.js"; import * as Engines from "../../../background_scripts/completion/search_engines.js"; import "../../../background_scripts/completion/completers.js"; context("Amazon completion", () => { should("parses results", () => { const response = JSON.stringify({ "suggestions": [ { "value": "one" }, { "value": "two" }, ], }); const results = new Engines.Amazon().parse(response); assert.equal(["one", "two"], results); }); }); context("Brave completion", () => { should("parses results", () => { const response = JSON.stringify(["the-query", ["one", "two"]]); const results = new Engines.Brave().parse(response); assert.equal(["one", "two"], results); }); }); context("Kagi completion", () => { should("parses results", () => { const response = JSON.stringify([{ t: "one" }, { t: "two" }]); const results = new Engines.Kagi().parse(response); assert.equal(["one", "two"], results); }); }); context("DuckDuckGo completion", () => { should("parses results", () => { const response = JSON.stringify([ { "phrase": "one" }, { "phrase": "two" }, ]); const results = new Engines.DuckDuckGo().parse(response); assert.equal(["one", "two"], results); }); }); context("Qwant completion", () => { should("parses results", () => { const response = JSON.stringify({ "data": { "items": [ { "value": "one" }, { "value": "two" }, ], }, }); const results = new Engines.Qwant().parse(response); assert.equal(["one", "two"], results); }); }); // Engines which have trivial parsers are omitted from these tests. context("Webster completion", () => { should("parses results", () => { const response = JSON.stringify({ "docs": [ { "word": "one" }, { "word": "two" }, ], }); const results = new Engines.Webster().parse(response); assert.equal(["one", "two"], results); }); }); ================================================ FILE: tests/unit_tests/doc_search_completion_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import "../../lib/utils.js"; import "../../lib/settings.js"; import * as completionEngines from "../../background_scripts/completion/search_engines.js"; import * as page from "../../pages/doc_search_completion.js"; context("completion engines page", () => { setup(async () => { await testHelper.jsdomStub("pages/doc_search_completion.html"); }); should("have a section in the html for every engine", () => { // This is to prevent editing errors, where a new command group is added, and we forget to add a // corresponding group to the command listing. page.populatePage(); const engines = completionEngines.list.map((e) => e.name); const enginesInPage = Array.from(globalThis.document.querySelectorAll("h4[data-engine]")) .map((e) => e.dataset.engine); assert.equal(engines, enginesInPage); }); }); ================================================ FILE: tests/unit_tests/exclusion_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import "../../background_scripts/bg_utils.js"; import * as exclusions from "../../background_scripts/exclusions.js"; import "../../background_scripts/commands.js"; const isEnabledForUrl = (request) => exclusions.isEnabledForUrl(request.url); // These tests cover only the most basic aspects of excluded URLs and passKeys. context("Excluded URLs and pass keys", () => { setup(async () => { await Settings.onLoaded(); await Settings.set("exclusionRules", [ { pattern: "http*://mail.google.com/*", passKeys: "" }, { pattern: "http*://www.facebook.com/*", passKeys: "abab" }, { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" }, { pattern: "http*://www.bbc.com/*", passKeys: "" }, { pattern: "http*://www.bbc.com/*", passKeys: "ab" }, { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" }, { pattern: "http*://www.duplicate.com/*", passKeys: "ace" }, { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" }, ]); }); teardown(async () => { await Settings.clear(); }); should("be disabled for excluded sites", () => { const rule = isEnabledForUrl({ url: "http://mail.google.com/calendar/page" }); assert.isFalse(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); }); should("be disabled for excluded sites, one exclusion", () => { const rule = isEnabledForUrl({ url: "http://www.bbc.com/calendar/page" }); assert.isFalse(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); }); should("be enabled, but with pass keys", () => { const rule = isEnabledForUrl({ url: "https://www.facebook.com/something" }); assert.isTrue(rule.isEnabledForUrl); assert.equal(rule.passKeys, "abcd"); }); should("be enabled", () => { const rule = isEnabledForUrl({ url: "http://www.twitter.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); }); should("handle spaces and duplicates in passkeys", () => { const rule = isEnabledForUrl({ url: "http://www.example.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.equal("abc", rule.passKeys); }); should("handle multiple passkeys rules", () => { const rule = isEnabledForUrl({ url: "http://www.duplicate.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.equal("abcdef", rule.passKeys); }); should("be enabled when given malformed regular expressions", async () => { await Settings.set("exclusionRules", [ { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "" }, ]); const rule = isEnabledForUrl({ url: "http://www.bad-regexp.com/pages" }); assert.isTrue(rule.isEnabledForUrl); }); }); ================================================ FILE: tests/unit_tests/handler_stack_test.js ================================================ import "./test_helper.js"; import "../../lib/handler_stack.js"; context("handlerStack", () => { let handlerStack, handler1Called, handler2Called; setup(() => { stub(globalThis, "DomUtils", {}); stub(DomUtils, "consumeKeyup", () => {}); stub(DomUtils, "suppressEvent", () => {}); stub(DomUtils, "suppressPropagation", () => {}); handlerStack = new HandlerStack(); handler1Called = false; handler2Called = false; }); should("bubble events", () => { handlerStack.push({ keydown: () => { return handler1Called = true; }, }); handlerStack.push({ keydown: () => { return handler2Called = true; }, }); handlerStack.bubbleEvent("keydown", {}); assert.isTrue(handler2Called); assert.isTrue(handler1Called); }); should("terminate bubbling on falsy return value", () => { handlerStack.push({ keydown: () => { return handler1Called = true; }, }); handlerStack.push({ keydown: () => { handler2Called = true; return false; }, }); handlerStack.bubbleEvent("keydown", {}); assert.isTrue(handler2Called); assert.isFalse(handler1Called); }); should("terminate bubbling on passEventToPage, and be true", () => { handlerStack.push({ keydown: () => { return handler1Called = true; }, }); handlerStack.push({ keydown: () => { handler2Called = true; return handlerStack.passEventToPage; }, }); assert.isTrue(handlerStack.bubbleEvent("keydown", {})); assert.isTrue(handler2Called); assert.isFalse(handler1Called); }); should("terminate bubbling on passEventToPage, and be false", () => { handlerStack.push({ keydown: () => { return handler1Called = true; }, }); handlerStack.push({ keydown: () => { handler2Called = true; return handlerStack.suppressPropagation; }, }); assert.isFalse(handlerStack.bubbleEvent("keydown", {})); assert.isTrue(handler2Called); assert.isFalse(handler1Called); }); should("restart bubbling on restartBubbling", () => { handler1Called = 0; handler2Called = 0; const id = handlerStack.push({ keydown: () => { handler1Called++; handlerStack.remove(id); return handlerStack.restartBubbling; }, }); handlerStack.push({ keydown: () => { handler2Called++; return true; }, }); assert.isTrue(handlerStack.bubbleEvent("keydown", {})); assert.isTrue(handler1Called === 1); assert.isTrue(handler2Called === 2); }); should("remove handlers correctly", () => { handlerStack.push({ keydown: () => { handler1Called = true; }, }); const handlerId = handlerStack.push({ keydown: () => { handler2Called = true; }, }); handlerStack.remove(handlerId); handlerStack.bubbleEvent("keydown", {}); assert.isFalse(handler2Called); assert.isTrue(handler1Called); }); should("remove handlers correctly", () => { const handlerId = handlerStack.push({ keydown: () => { handler1Called = true; }, }); handlerStack.push({ keydown: () => { handler2Called = true; }, }); handlerStack.remove(handlerId); handlerStack.bubbleEvent("keydown", {}); assert.isTrue(handler2Called); assert.isFalse(handler1Called); }); should("handle self-removing handlers correctly", () => { handlerStack.push({ keydown: () => { handler1Called = true; }, }); handlerStack.push({ keydown() { handler2Called = true; this.remove(); return true; }, }); handlerStack.bubbleEvent("keydown", {}); assert.isTrue(handler2Called); assert.isTrue(handler1Called); assert.equal(handlerStack.stack.length, 1); }); }); ================================================ FILE: tests/unit_tests/help_dialog_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import "../../background_scripts/completion/completers.js"; import { allCommands } from "../../background_scripts/all_commands.js"; import { HelpDialogPage } from "../../pages/help_dialog_page.js"; context("help dialog", () => { setup(async () => { await testHelper.jsdomStub("pages/help_dialog_page.html"); await Settings.onLoaded(); stub(chrome.storage.session, "get", async (key) => { if (key == "commandToOptionsToKeys") { const data = { "reload": { "": ["a"], "hard": ["b"], }, }; return { commandToOptionsToKeys: data }; } }); }); should("getRowsForDialog includes one row per command-options pair", () => { const config = { "reload": { "": ["a"], "hard": ["b", "c"], }, }; const result = HelpDialogPage.getRowsForDialog(config); const rows = result["navigation"] .filter((row) => row[0].name == "reload"); assert.equal(2, rows.length); assert.equal(["reload", "", ["a"]], [rows[0][0].name, rows[0][1], rows[0][2]]); assert.equal(["reload", "hard", ["b", "c"]], [rows[1][0].name, rows[1][1], rows[1][2]]); }); should("have a section in the help dialog for every group", async () => { // This test is to prevent code editing errors, where a command is added but doesn't have a // corresponding group in the help dialog. HelpDialogPage.init(); await HelpDialogPage.show(); const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort(); const groupsInDialog = Array.from( HelpDialogPage.dialogElement.querySelectorAll("div[data-group]"), ) .map((e) => e.dataset.group) .sort(); assert.equal(groups, groupsInDialog); }); }); ================================================ FILE: tests/unit_tests/hud_page_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import * as hudPage from "../../pages/hud_page.js"; import * as UIComponentMessenger from "../../pages/ui_component_messenger.js"; function newKeyEvent(properties) { return Object.assign( { type: "keydown", key: "a", ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, stopImmediatePropagation: function () {}, preventDefault: function () {}, }, properties, ); } context("hud page", () => { let ui; setup(async () => { stub(Utils, "isFirefox", () => false); await testHelper.jsdomStub("pages/hud_page.html"); // Make Utils.setTimeout synchronous so that the tests easier to deal with. stub(Utils, "setTimeout", (timeout, fn) => { fn(); }); }); teardown(() => { UIComponentMessenger.unregister(); }); should("find mode hides when escape is pressed", async () => { let message; const stubPort = { postMessage: (event) => { message = event; }, }; await UIComponentMessenger.registerPortWithOwnerPage({ data: (await chrome.storage.session.get("vimiumSecret")).vimiumSecret, ports: [stubPort], }); hudPage.handlers.showFindMode(); await hudPage.onKeyEvent(newKeyEvent({ key: "Escape" })); assert.equal("hideFindMode", message.name); }); }); ================================================ FILE: tests/unit_tests/link_hints_test.js ================================================ import "./test_helper.js"; import "../../lib/keyboard_utils.js"; import "../../lib/settings.js"; import "../../content_scripts/mode.js"; import "../../content_scripts/link_hints.js"; context("With insufficient link characters", () => { setup(async () => { await Settings.onLoaded(); }); teardown(async () => { await Settings.clear(); }); should("throw error in AlphabetHints", async () => { await Settings.set("linkHintCharacters", "ab"); new AlphabetHints(); await Settings.set("linkHintCharacters", "a"); assert.throwsError(() => new AlphabetHints(), "Error"); }); should("throw error in FilterHints", async () => { await Settings.set("linkHintNumbers", "12"); new FilterHints(); await Settings.set("linkHintNumbers", "1"); assert.throwsError(() => new FilterHints(), "Error"); }); }); ================================================ FILE: tests/unit_tests/main_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import "../../background_scripts/main.js"; import { RegistryEntry } from "../../background_scripts/commands.js"; context("HintCoordinator", () => { should("prepareToActivateLinkHintsMode", async () => { let receivedMessages = []; const frameIdToHintDescriptors = { "0": { frameId: 0, localIndex: 123, linkText: null }, "1": { frameId: 1, localIndex: 456, linkText: null }, }; stub(chrome.webNavigation, "getAllFrames", () => [{ frameId: 0 }, { frameId: 1 }]); stub(chrome.tabs, "sendMessage", async (_tabId, message, options) => { if (message.messageType == "getHintDescriptors") { return frameIdToHintDescriptors[options.frameId]; } else if (message.messageType == "activateMode") { receivedMessages.push(message); } }); await HintCoordinator.prepareToActivateLinkHintsMode(0, 0, { modeIndex: 0, requestedByHelpDialog: false, }); receivedMessages = receivedMessages.map( (m) => Utils.pick(m, ["frameId", "frameIdToHintDescriptors"]), ); // Each frame should receive only the hint descriptors from the other frames. assert.equal([ { frameId: 0, frameIdToHintDescriptors: { "1": frameIdToHintDescriptors[1] } }, { frameId: 1, frameIdToHintDescriptors: { "0": frameIdToHintDescriptors[0] } }, ], receivedMessages); }); }); context("createTab command", () => { let tabCreated; let requestStub; setup(async () => { stub(chrome.tabs, "create", (args) => { tabCreated = args; }); requestStub = { registryEntry: new RegistryEntry({ options: {} }), tab: {}, count: 1, }; await Settings.load(); }); should("open the provided URL", async () => { requestStub.url = "https://example.com"; await BackgroundCommands.createTab(requestStub); assert.equal("https://example.com", tabCreated.url); }); should("open the vimium new tab page", async () => { await Settings.set("newTabDestination", Settings.newTabDestinations.vimiumNewTabPage); await BackgroundCommands.createTab(requestStub); assert.equal(Settings.vimiumNewTabPageUrl, tabCreated.url); }); should("open the browser's new tab page", async () => { await Settings.set("newTabDestination", Settings.newTabDestinations.browserNewTabPage); await BackgroundCommands.createTab(requestStub); // The URL argument to chrome.tabs.create is omitted when we want to use the browser's NTP. assert.isTrue(tabCreated != null); assert.equal(undefined, tabCreated.url); }); should("open custom URL", async () => { await Settings.set("newTabDestination", Settings.newTabDestinations.customUrl); await BackgroundCommands.createTab(requestStub); // If a specific custom URL isn't provided, the browser's new tab page will be used. // The URL argument to chrome.tabs.create is omitted when we want to use the browser's NTP. assert.isTrue(tabCreated != null); assert.equal(undefined, tabCreated.url); await Settings.set("newTabCustomUrl", "http://example.com"); await BackgroundCommands.createTab(requestStub); assert.equal("http://example.com", tabCreated.url); }); teardown(() => { tabCreated = null; Settings.clear(); }); }); context("Next zoom level", () => { // All these tests use the Chrome zoom levels, which are the default. should("Zoom in 0 times", async () => { const zoom = await nextZoomLevel(1.00, 0); assert.equal(1.00, zoom); }); should("Zoom in 1", async () => { const zoom = await nextZoomLevel(1.00, 1); assert.equal(1.10, zoom); }); should("Zoom out 1", async () => { const zoom = await nextZoomLevel(1.00, -1); assert.equal(0.90, zoom); }); should("Zoom in 2", async () => { const zoom = await nextZoomLevel(1.00, 2); assert.equal(1.25, zoom); }); should("Zoom out 2", async () => { const zoom = await nextZoomLevel(1.00, -2); assert.equal(0.80, zoom); }); should("Zoom in from between values", async () => { const zoom = await nextZoomLevel(1.05, 1); assert.equal(1.10, zoom); }); should("Zoom out from between values", async () => { const zoom = await nextZoomLevel(1.05, -1); assert.equal(1.00, zoom); }); should("Zoom in past the maximum", async () => { const zoom = await nextZoomLevel(1.00, 15); assert.equal(5.00, zoom); }); should("Zoom out past the minimum", async () => { const zoom = await nextZoomLevel(1.00, -15); assert.equal(0.25, zoom); }); should("Zoom in from below the minimum", async () => { const lowZoom = 0.01; // Lowest non-broken Chrome zoom level const zoom = await nextZoomLevel(lowZoom, 1); assert.equal(0.25, zoom); }); should("Zoom out from above the maximum", async () => { const highZoom = 9.99; // highest non-broken Chrome zoom level const zoom = await nextZoomLevel(highZoom, -1); assert.equal(5.00, zoom); }); should("Zoom in from above the maximum", async () => { const highZoom = 9.99; // highest non-broken Chrome zoom level const zoom = await nextZoomLevel(highZoom, 1); assert.equal(5.00, zoom); }); should("Zoom out from below the minimum", async () => { const lowZoom = 0.01; // lowest non-broken Chrome zoom level const zoom = await nextZoomLevel(lowZoom, -1); assert.equal(0.25, zoom); }); should("Test Chrome 33% zoom in with float error", async () => { const floatZoom = 0.32999999999999996; // The value chrome actually gives for 33%. const zoom = await nextZoomLevel(floatZoom, 1); assert.equal(0.50, zoom); }); should("Test Chrome 175% zoom in with float error", async () => { const floatZoom = 1.7499999999999998; // The value chrome actually gives for 175%. const zoom = await nextZoomLevel(floatZoom, 1); assert.equal(2.00, zoom); }); }); context("Selecting frames", () => { should("nextFrame", async () => { const focusedFrames = []; stub(chrome.webNavigation, "getAllFrames", () => [{ frameId: 1 }, { frameId: 2 }]); stub(chrome.tabs, "sendMessage", async (_tabId, message, options) => { if (message.handler == "getFocusStatus") { return { focused: options.frameId == 2, focusable: true }; } else if (message.handler == "focusFrame") { focusedFrames.push(options.frameId); } }); await BackgroundCommands.nextFrame(1, 0); assert.equal([1], focusedFrames); }); }); context("majorVersionHasIncreased", () => { should("return whether the major version has changed", () => { assert.equal(false, majorVersionHasIncreased(null)); shoulda.stub(Utils, "getCurrentVersion", () => "2.0.1"); assert.equal(false, majorVersionHasIncreased("2.0.0")); shoulda.stub(Utils, "getCurrentVersion", () => "2.1.0"); assert.equal(true, majorVersionHasIncreased("2.0.0")); }); }); ================================================ FILE: tests/unit_tests/marks_test.js ================================================ import "./test_helper.js"; import * as marks from "../../background_scripts/marks.js"; context("marks", () => { const createMark = async (markProperties, tabProperties) => { const mark = Object.assign({ scrollX: 0, scrollY: 0 }, markProperties); const tab = Object.assign({ url: "http://example.com" }, tabProperties); const sender = { tab: tab }; await marks.create(mark, sender); }; setup(() => { chrome.storage.session.clear(); chrome.storage.session.set({ vimiumSecret: "secret" }); }); teardown(() => { chrome.storage.session.clear(); chrome.storage.local.clear(); }); should("record the vimium secret in the mark's info", async () => { await createMark({ markName: "a" }); const key = marks.getLocationKey("a"); const savedMark = (await chrome.storage.local.get(key))[key]; assert.equal("secret", savedMark.vimiumSecret); }); should("goto a mark when its tab exists", async () => { await createMark({ markName: "A" }, { id: 1 }); const tab = { url: "http://example.com" }; stub(globalThis.chrome.tabs, "get", (id) => id == 1 ? tab : null); const updatedTabs = []; stub(globalThis.chrome.tabs, "update", (id, properties) => updatedTabs[id] = properties); await marks.goto({ markName: "A" }); assert.isTrue(updatedTabs[1] && updatedTabs[1].active); }); should("find a new tab if a mark's tab no longer exists", async () => { await createMark({ markName: "A" }, { id: 1 }); const tab = { url: "http://example.com", id: 2 }; stub(globalThis.chrome.tabs, "get", (_id) => { throw new Error(); }); stub(globalThis.chrome.tabs, "query", (_) => [tab]); const updatedTabs = []; stub(globalThis.chrome.tabs, "update", (id, properties) => updatedTabs[id] = properties); await marks.goto({ markName: "A" }); assert.isTrue(updatedTabs[2] && updatedTabs[2].active); }); }); ================================================ FILE: tests/unit_tests/options_page_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import * as optionsPage from "../../pages/options.js"; context("options page", () => { setup(async () => { await testHelper.jsdomStub("pages/options.html"); await optionsPage.init(); }); teardown(async () => { await Settings.clear(); }); should("populate the form fields with the settings", () => { const settings = Settings.getSettings(); const field = optionsPage.getOptionEl("keyMappings"); assert.isTrue(Settings.defaultOptions.keyMappings.length > 0); assert.equal(Settings.defaultOptions.keyMappings, settings.keyMappings); assert.equal(settings.keyMappings, field.value); }); should("show validation errors for invalid fields on save", async () => { const el = optionsPage.getOptionEl("keyMappings"); assert.isFalse(el.classList.contains("validation-error")); assert.equal(0, document.querySelectorAll(".validation-message").length); el.value = "invalid-mapping-statement"; await optionsPage.saveOptions(); assert.isTrue(el.classList.contains("validation-error")); const messageEls = document.querySelectorAll(".validation-message"); assert.equal(1, messageEls.length); assert.isTrue(messageEls[0].innerHTML.includes(el.value)); }); should("show exclusion rule editor for exclusion rules", async () => { const rule = { passKeys: "", pattern: "example.com", }; await Settings.set("exclusionRules", [rule]); await optionsPage.init(); const el = document.querySelector("#exclusion-rules input[name=pattern]"); assert.equal("example.com", el.value); }); context("backup", () => { should("exclude settings which are default values", () => { const settings = JSON.parse(optionsPage.prepareBackupSettings()); // This should exclude all values which are defaults. assert.equal(["settingsVersion"], Object.keys(settings)); }); should("include settings which have changed from the default", () => { optionsPage.getOptionEl("keyMappings").value = "map a scrollUp"; const settings = JSON.parse(optionsPage.prepareBackupSettings()); assert.equal(["keyMappings", "settingsVersion"], Object.keys(settings)); assert.equal("map a scrollUp", settings.keyMappings); }); should("export settings with sorted keys", () => { optionsPage.getOptionEl("linkHintCharacters").value = "abcd"; optionsPage.getOptionEl("keyMappings").value = "map a scrollUp"; const settings = JSON.parse(optionsPage.prepareBackupSettings()); assert.equal(["keyMappings", "linkHintCharacters", "settingsVersion"], Object.keys(settings)); }); should("include exclusion rules", async () => { const rule = { passKeys: "", pattern: "example.com", }; await Settings.set("exclusionRules", [rule]); await optionsPage.init(); const settings = JSON.parse(optionsPage.prepareBackupSettings()); assert.equal([rule], settings["exclusionRules"]); }); }); }); ================================================ FILE: tests/unit_tests/rect_test.js ================================================ import "./test_helper.js"; import "../../lib/rect.js"; context("Rect", () => { should("set rect properties correctly", () => { const [x1, y1, x2, y2] = [1, 2, 3, 4]; const rect = Rect.create(x1, y1, x2, y2); assert.equal(rect.left, x1); assert.equal(rect.top, y1); assert.equal(rect.right, x2); assert.equal(rect.bottom, y2); assert.equal(rect.width, x2 - x1); assert.equal(rect.height, y2 - y1); }); should("translate rect horizontally", () => { const [x1, y1, x2, y2] = [1, 2, 3, 4]; const x = 5; const rect1 = Rect.create(x1, y1, x2, y2); const rect2 = Rect.translate(rect1, x); assert.equal(rect1.left + x, rect2.left); assert.equal(rect1.right + x, rect2.right); assert.equal(rect1.width, rect2.width); assert.equal(rect1.height, rect2.height); assert.equal(rect1.top, rect2.top); assert.equal(rect1.bottom, rect2.bottom); }); should("translate rect vertically", () => { const [x1, y1, x2, y2] = [1, 2, 3, 4]; const y = 5; const rect1 = Rect.create(x1, y1, x2, y2); const rect2 = Rect.translate(rect1, undefined, y); assert.equal(rect1.top + y, rect2.top); assert.equal(rect1.bottom + y, rect2.bottom); assert.equal(rect1.width, rect2.width); assert.equal(rect1.height, rect2.height); assert.equal(rect1.left, rect2.left); assert.equal(rect1.right, rect2.right); }); }); context("Rect subtraction", () => { context("unchanged by rects outside", () => { should("left, above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-2, -2, -1, -1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("left", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-2, 0, -1, 1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("left, below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-2, 2, -1, 3); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right, above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(2, -2, 3, -1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(2, 0, 3, 1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right, below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(2, 2, 3, 3); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(0, -2, 1, -1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(0, 2, 1, 3); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); }); context("unchanged by rects touching", () => { should("left, above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-1, -1, 0, 0); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("left", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-1, 0, 0, 1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("left, below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(-1, 1, 0, 2); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right, above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(1, -1, 2, 0); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(1, 0, 2, 1); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("right, below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(1, 1, 2, 2); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("above", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(0, -1, 1, 0); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); should("below", () => { const rect1 = Rect.create(0, 0, 1, 1); const rect2 = Rect.create(0, 1, 1, 2); const rects = Rect.subtract(rect1, rect2); assert.equal(rects.length, 1); const rect = rects[0]; assert.isTrue(Rect.equals(rect1, rect)); }); }); should("have nothing when subtracting itself", () => { const rect = Rect.create(0, 0, 1, 1); const rects = Rect.subtract(rect, rect); assert.equal(rects.length, 0); }); should("not overlap subtracted rect", () => { const rect = Rect.create(0, 0, 3, 3); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { for (let width = 1; width <= 3; width++) { for (let height = 1; height <= 3; height++) { const subtractRect = Rect.create(x, y, x + width, y + height); const resultRects = Rect.subtract(rect, subtractRect); for (const resultRect of resultRects) { assert.isFalse(Rect.intersects(subtractRect, resultRect)); } } } } } }); should("be contained in original rect", () => { const rect = Rect.create(0, 0, 3, 3); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { for (let width = 1; width <= 3; width++) { for (let height = 1; height <= 3; height++) { const subtractRect = Rect.create(x, y, x + width, y + height); const resultRects = Rect.subtract(rect, subtractRect); for (const resultRect of resultRects) { assert.isTrue(Rect.intersects(rect, resultRect)); } } } } } }); should("contain the subtracted rect in the original minus the results", () => { const rect = Rect.create(0, 0, 3, 3); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { for (let width = 1; width <= 3; width++) { for (let height = 1; height <= 3; height++) { const subtractRect = Rect.create(x, y, x + width, y + height); const resultRects = Rect.subtract(rect, subtractRect); let resultComplement = [Rect.copy(rect)]; for (const resultRect of resultRects) { resultComplement = Array.prototype.concat.apply( [], resultComplement.map((rect) => Rect.subtract(rect, resultRect)), ); } assert.isTrue((resultComplement.length === 0) || (resultComplement.length === 1)); if (resultComplement.length === 1) { const complementRect = resultComplement[0]; assert.isTrue(Rect.intersects(subtractRect, complementRect)); } } } } } }); }); context("Rect overlaps", () => { should("detect that a rect overlaps itself", () => { const rect = Rect.create(2, 2, 4, 4); assert.isTrue(Rect.intersectsStrict(rect, rect)); }); should("detect that non-overlapping rectangles do not overlap on the left", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(0, 2, 1, 4); assert.isFalse(Rect.intersectsStrict(rect1, rect2)); }); should("detect that non-overlapping rectangles do not overlap on the right", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(5, 2, 6, 4); assert.isFalse(Rect.intersectsStrict(rect1, rect2)); }); should("detect that non-overlapping rectangles do not overlap on the top", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(2, 0, 2, 1); assert.isFalse(Rect.intersectsStrict(rect1, rect2)); }); should("detect that non-overlapping rectangles do not overlap on the bottom", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(2, 5, 2, 6); assert.isFalse(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles on the left", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(0, 2, 2, 4); assert.isTrue(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles on the right", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(4, 2, 5, 4); assert.isTrue(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles on the top", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(2, 4, 4, 5); assert.isTrue(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles on the bottom", () => { const rect1 = Rect.create(2, 2, 4, 4); const rect2 = Rect.create(2, 0, 4, 2); assert.isTrue(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles when second rectangle is contained in first", () => { const rect1 = Rect.create(1, 1, 4, 4); const rect2 = Rect.create(2, 2, 3, 3); assert.isTrue(Rect.intersectsStrict(rect1, rect2)); }); should("detect overlapping rectangles when first rectangle is contained in second", () => { const rect1 = Rect.create(1, 1, 4, 4); const rect2 = Rect.create(2, 2, 3, 3); assert.isTrue(Rect.intersectsStrict(rect2, rect1)); }); }); ================================================ FILE: tests/unit_tests/settings_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; context("settings", () => { context("v2.0 migration", () => { setup(async () => { // Prior to Vimium 2.0.0, the settings values were encoded as JSON strings. await chrome.storage.sync.set({ scrollStepSize: JSON.stringify(123) }); }); teardown(async () => { await Settings.clear(); }); should("Run v2.0.0 migration when loading settings", async () => { let storage = await chrome.storage.sync.get(null); assert.equal("123", storage.scrollStepSize); // The JSON value should've been migrated to an int when loading settings. await Settings.load(); const settings = Settings.getSettings(); assert.equal(123, settings["scrollStepSize"]); // When writing settings, the JSON value should be persisted back to storage. await Settings.set(settings); storage = await chrome.storage.sync.get(null); assert.equal(123, storage.scrollStepSize); }); }); context("v2.4 migration", () => { setup(async () => { await chrome.storage.sync.set({ settingsVersion: "2.3", }); }); teardown(async () => { await Settings.clear(); }); should("Handle null newTabUrl", async () => { // Users who never changed newTabUrl from its old default ("about:newtab") won't have it // stored, because Settings.pruneOutDefaultValues removes keys equal to the default. The // migration should still set browserNewTabPage as the destination. await Settings.load(); const settings = Settings.getSettings(); assert.equal(Settings.newTabDestinations.browserNewTabPage, settings.newTabDestination); }); should("Remove deprecated option", async () => { await chrome.storage.sync.set({ newTabUrl: "pages/blank.html" }); await Settings.load(); const settings = Settings.getSettings(); assert.isFalse(Object.hasOwn(settings, "newTabUrl")); }); should("Handle pages/blank.html new tab URL", async () => { await chrome.storage.sync.set({ newTabUrl: "pages/blank.html" }); await Settings.load(); const settings = Settings.getSettings(); assert.equal(Settings.newTabDestinations.vimiumNewTabPage, settings.newTabDestination); }); should("Handle https://example.com new tab URL", async () => { await chrome.storage.sync.set({ newTabUrl: "https://example.com" }); await Settings.load(); const settings = Settings.getSettings(); assert.equal(Settings.newTabDestinations.customUrl, settings.newTabDestination); assert.equal("https://example.com", settings.newTabCustomUrl); }); }); context("v2.4.1 migration", () => { setup(async () => { await chrome.storage.sync.set({ settingsVersion: "2.4.0" }); }); teardown(async () => { await Settings.clear(); }); should("Sets default/missing newTabDestination to browserNewTabPage", async () => { await Settings.load(); const settings = Settings.getSettings(); assert.equal(Settings.newTabDestinations.browserNewTabPage, settings.newTabDestination); }); should("Preserve customUrl destination", async () => { await chrome.storage.sync.set({ newTabDestination: Settings.newTabDestinations.customUrl }); await Settings.load(); const settings = Settings.getSettings(); assert.equal(Settings.newTabDestinations.customUrl, settings.newTabDestination); }); }); }); ================================================ FILE: tests/unit_tests/tab_operations_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import * as to from "../../background_scripts/tab_operations.js"; context("TabOperations openurlInCurrentTab", () => { should("open a regular URL", async () => { let url = null; stub(chrome.tabs, "update", (id, args) => { url = args.url; }); const expected = "http://example.com"; await to.openUrlInCurrentTab({ url: expected }); assert.equal(expected, url); }); should("open a non-URL in the default search engine", async () => { let searchQuery = null; stub(chrome.search, "query", (queryInfo) => { searchQuery = queryInfo.text; }); const expected = "example query"; await to.openUrlInCurrentTab({ url: expected }); assert.equal(expected, searchQuery); }); should("open a javascript URL", async () => { let details = null; // NOTE(philc): This is a shallow test. stub(chrome.scripting, "executeScript", (_details) => { details = _details; }); const expected = "javascript:console.log('hello')"; await to.openUrlInCurrentTab({ url: expected }); assert.equal(expected, details.args[0]); }); }); context("TabOperations openUrlInNewTab", () => { should("open a regular URL", async () => { let config = null; stub(chrome.tabs, "create", (_config) => { config = _config; const newTab = { url: config.url }; return newTab; }); const expected = "http://example.com"; const tab = await to.openUrlInNewTab({ tab: { index: 1 }, position: "after", url: expected, }); assert.equal(2, config.index); assert.equal(expected, tab.url); }); should("open a non-URL in the default search engine", async () => { let createConfig, queryInfo; stub(chrome.tabs, "create", (config) => { createConfig = config; const newTab = { id: config.index }; return newTab; }); stub(chrome.search, "query", (info) => { queryInfo = info; }); await to.openUrlInNewTab({ tab: { index: 1 }, position: "after", url: "example query", }); assert.equal("data:text/html,", createConfig.url); assert.equal(2, createConfig.index); assert.equal("example query", queryInfo.text); assert.equal(2, queryInfo.tabId); }); }); ================================================ FILE: tests/unit_tests/tab_recency_test.js ================================================ import "./test_helper.js"; import { TabRecency } from "../../background_scripts/tab_recency.js"; context("TabRecency", () => { let tabRecency; setup(() => tabRecency = new TabRecency()); context("order", () => { setup(async () => { stub(chrome.tabs, "query", () => Promise.resolve([])); await tabRecency.init(); tabRecency.queueAction("register", 1); tabRecency.queueAction("register", 2); tabRecency.queueAction("register", 3); tabRecency.queueAction("register", 4); tabRecency.queueAction("deregister", 4); tabRecency.queueAction("register", 2); }); should("have the correct entries in the correct order", () => { const expected = [2, 3, 1]; assert.equal(expected, tabRecency.getTabsByRecency()); }); should("score tabs by recency; current tab should be last", () => { const score = (id) => tabRecency.recencyScore(id); assert.equal(0, score(2)); assert.isTrue(score(2) < score(1)); assert.isTrue(score(1) < score(3)); }); }); should("navigate actions are queued until state from storage is loaded", async () => { let onActivated; stub(chrome.tabs.onActivated, "addListener", (fn) => { onActivated = fn; }); let resolveStorage; const storagePromise = new Promise((resolve, _) => resolveStorage = resolve); stub(chrome.storage.session, "get", () => storagePromise); tabRecency.init(); // Here, chrome.tabs.onActivated listeners have been added by tabrecency, but the // chrome.storage.session data hasn't yet loaded. onActivated({ tabId: 5 }); resolveStorage({}); await tabRecency.init(); assert.equal([5], tabRecency.getTabsByRecency()); }); should("loadFromStorage handles empty values", async () => { stub(chrome.tabs, "query", () => Promise.resolve([{ id: 1 }])); stub(chrome.storage.session, "get", () => Promise.resolve({})); await tabRecency.init(); assert.equal([], tabRecency.getTabsByRecency()); stub(chrome.storage.session, "get", () => Promise.resolve({ tabRecency: {} })); await tabRecency.loadFromStorage(); assert.equal([], tabRecency.getTabsByRecency()); }); should("loadFromStorage works", async () => { const tabs = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; stub(chrome.tabs, "query", () => Promise.resolve(tabs)); const storage = { tabRecency: { 1: 5, 2: 6 } }; stub(chrome.storage.session, "get", () => Promise.resolve(storage)); // Even though the in-storage tab counters are higher than the in-memory tabs, during // loading, the in-memory tab counters are adjusted to be the most recent. await tabRecency.init(); assert.equal([2, 1], tabRecency.getTabsByRecency()); tabRecency.queueAction("register", 3); tabRecency.queueAction("register", 1); assert.equal([1, 3, 2], tabRecency.getTabsByRecency()); }); should("loadFromStorage prunes out tabs which are no longer active", async () => { const tabs = [{ id: 1 }]; stub(chrome.tabs, "query", () => Promise.resolve(tabs)); const storage = { tabRecency: { 1: 5, 2: 6 } }; stub(chrome.storage.session, "get", () => Promise.resolve(storage)); await tabRecency.init(); assert.equal([1], tabRecency.getTabsByRecency()); }); }); ================================================ FILE: tests/unit_tests/test_chrome_stubs.js ================================================ // This file contains stubs for a number of browser and chrome APIs which are missing in Deno. import JSON5 from "npm:json5"; // There are 3 chrome.storage.* objects with identical APIs. // - areaName: one of "local", "sync", "session". const createStorageAPI = (areaName) => { const storage = { store: {}, async set(items) { let key, value; chrome.runtime.lastError = undefined; for (key of Object.keys(items)) { value = items[key]; this.store[key] = value; } for (key of Object.keys(items)) { value = items[key]; globalThis.chrome.storage.onChanged.call(key, value, areaName); } }, async get(keysArg) { chrome.runtime.lastError = undefined; if (keysArg == null) { return globalThis.structuredClone(this.store); } else if (typeof keysArg == "string") { const result = {}; result[keysArg] = globalThis.structuredClone(this.store[keysArg]); return result; } else { const result = {}; for (key of keysArg) { result[key] = globalThis.structuredClone(this.store[key]); } return result; } }, async remove(key) { chrome.runtime.lastError = undefined; if (key in this.store) { delete this.store[key]; } globalThis.chrome.storage.onChanged.callEmpty(key); }, async clear() { // TODO: Consider firing the change listener if Chrome's API implementation does. this.store = {}; }, }; // The "session" storage has one API that the others don't. if (areaName == "session") storage.setAccessLevel = () => {}; return storage; }; globalThis.chrome = { areRunningVimiumTests: true, _manifest: null, _loadManifest: async function () { this._manifest = JSON5.parse(await Deno.readTextFile("./manifest.json")); }, _getManifest: function () { if (!this._manifest) { throw new Error("manifest.json has not yet been read."); } return this._manifest; }, runtime: { getURL() { return ""; }, getManifest() { return chrome._getManifest(); }, onConnect: { addListener() { return true; }, }, onMessage: { addListener() { return true; }, }, onInstalled: { addListener() {}, }, onStartup: { addListener() {}, }, }, extension: { getURL(path) { return path; }, getBackgroundPage() { return {}; }, getViews() { return []; }, }, scripting: { executeScript() {}, }, search: { query() {}, }, tabs: { get(_id) {}, onUpdated: { addListener() { return true; }, }, onAttached: { addListener() { return true; }, }, onMoved: { addListener() { return true; }, }, onRemoved: { addListener() { return true; }, }, onActivated: { addListener() { return true; }, }, onReplaced: { addListener() { return true; }, }, query() { return true; }, sendMessage(_id, _properties) {}, update(_id, _properties) {}, }, webNavigation: { onHistoryStateUpdated: { addListener() {}, }, onReferenceFragmentUpdated: { addListener() {}, }, onCommitted: { addListener() {}, }, }, windows: { onRemoved: { addListener() { return true; }, }, getAll() { return true; }, getCurrent() { return {}; }, onFocusChanged: { addListener() { return true; }, }, update(_id, _properties) {}, }, browserAction: { setBadgeBackgroundColor() {}, }, sessions: { MAX_SESSION_RESULTS: 25, }, storage: { onChanged: { addListener(func) { this.func = func; }, // Fake a callback from chrome.storage.sync. call(key, value, area) { chrome.runtime.lastError = undefined; const key_value = {}; key_value[key] = { newValue: value }; if (this.func) return this.func(key_value, area); }, callEmpty(key) { chrome.runtime.lastError = undefined; if (this.func) { const items = {}; items[key] = {}; this.func(items, "sync"); } }, }, local: createStorageAPI("sync"), sync: createStorageAPI("sync"), session: createStorageAPI("session"), }, bookmarks: { getTree: () => [], }, }; await chrome._loadManifest(); ================================================ FILE: tests/unit_tests/test_helper.js ================================================ import * as shoulda from "../vendor/shoulda.js"; import * as jsdom from "jsdom"; import "./test_chrome_stubs.js"; import "../../lib/utils.js"; const shouldaSubset = { assert: shoulda.assert, context: shoulda.context, ensureCalled: shoulda.ensureCalled, setup: shoulda.setup, should: shoulda.should, shoulda: shoulda, stub: shoulda.stub, returns: shoulda.returns, teardown: shoulda.teardown, }; globalThis.isUnitTests = true; // Attach shoulda's functions, like setup, context, should, to the global namespace. Object.assign(globalThis, shouldaSubset); export async function jsdomStub(htmlFile) { const html = await Deno.readTextFile(htmlFile); const w = new jsdom.JSDOM(html).window; stub(globalThis, "window", w); stub(globalThis, "document", w.document); stub(globalThis, "MouseEvent", w.MouseEvent); stub(globalThis, "MutationObserver", w.MutationObserver); // We might not need to stub HTMLElement once we resolve the TODO on DomUtils.createElement stub(globalThis, "HTMLElement", w.HTMLElement); } ================================================ FILE: tests/unit_tests/ui_component_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../lib/utils.js"; import "../../lib/dom_utils.js"; import "../../content_scripts/ui_component.js"; function stubPostMessage(iframeEl, fn) { if (!iframeEl || !fn) throw new Error("iframeEl and fn are required."); Object.defineProperty(iframeEl, "contentWindow", { value: { postMessage: fn }, writable: false, configurable: true, }); } context("UIComponent", () => { let c; setup(async () => { // Which page we load doesn't matter; we just need any DOM. await testHelper.jsdomStub("pages/help_dialog_page.html"); stub(Utils, "isFirefox", () => false); }); teardown(() => { // MessageChannel ports must be closed, or our test process will never terminate. See // https://github.com/facebook/react/issues/26608 for (const port of c?.messageChannelPorts) { port.close(); } }); should("focus the frame when showing", async () => { c = new UIComponent("testing.html", "example-class"); await c.load("example.html", "example-class"); stubPostMessage(c.iframeElement, function () {}); c.iframeElement.dispatchEvent(new window.Event("load")); assert.equal(document.body, document.activeElement); // The shadow root element containing the iframe should be focused. c.show(); assert.equal(c.iframeElement.getRootNode().host, document.activeElement); }); }); ================================================ FILE: tests/unit_tests/url_utils_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import "../../lib/url_utils.js"; context("isUrl", () => { should("accept valid URLs", async () => { assert.isTrue(await UrlUtils.isUrl("www.google.com")); assert.isTrue(await UrlUtils.isUrl("www.bbc.co.uk")); assert.isTrue(await UrlUtils.isUrl("yahoo.com")); assert.isTrue(await UrlUtils.isUrl("nunames.nu")); assert.isTrue(await UrlUtils.isUrl("user:pass@ftp.xyz.com/test")); assert.isTrue(await UrlUtils.isUrl("localhost/index.html")); assert.isTrue(await UrlUtils.isUrl("127.0.0.1:8192/test.php")); // IPv6 assert.isTrue(await UrlUtils.isUrl("[::]:9000")); // Long TLDs assert.isTrue(await UrlUtils.isUrl("testing.social")); assert.isTrue(await UrlUtils.isUrl("testing.onion")); // // Internal URLs. assert.isTrue( await UrlUtils.isUrl( "moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html", ), ); }); should("reject invalid URLs", async () => { assert.isFalse(await UrlUtils.isUrl("a.x")); assert.isFalse(await UrlUtils.isUrl("www-domain-tld")); assert.isFalse(await UrlUtils.isUrl("http://www.example.com/ has-space")); }); }); context("convertToUrl", async () => { should("detect and clean up valid URLs", async () => { assert.equal("http://www.google.com/", await UrlUtils.convertToUrl("http://www.google.com/")); assert.equal( "http://www.google.com/", await UrlUtils.convertToUrl(" http://www.google.com/ "), ); assert.equal("http://www.google.com", await UrlUtils.convertToUrl("www.google.com")); assert.equal("http://google.com", await UrlUtils.convertToUrl("google.com")); assert.equal("http://localhost", await UrlUtils.convertToUrl("localhost")); assert.equal("http://xyz.museum", await UrlUtils.convertToUrl("xyz.museum")); assert.equal("chrome://extensions", await UrlUtils.convertToUrl("chrome://extensions")); assert.equal( "http://user:pass@ftp.xyz.com/test", await UrlUtils.convertToUrl("user:pass@ftp.xyz.com/test"), ); assert.equal("http://127.0.0.1", await UrlUtils.convertToUrl("127.0.0.1")); assert.equal("http://127.0.0.1:8080", await UrlUtils.convertToUrl("127.0.0.1:8080")); assert.equal("http://[::]:8080", await UrlUtils.convertToUrl("[::]:8080")); assert.equal("view-source: 0.0.0.0", await UrlUtils.convertToUrl("view-source: 0.0.0.0")); assert.equal( "javascript:alert('25 % 20 * 25%20');", await UrlUtils.convertToUrl("javascript:alert('25 % 20 * 25%20');"), ); }); }); context("createSearchUrl", () => { should("replace %S without encoding", () => { assert.equal( "https://www.github.com/philc/vimium/pulls", UrlUtils.createSearchUrl("vimium/pulls", "https://www.github.com/philc/%S"), ); }); }); context("hasChromeProtocol", () => { should("detect chrome prefixes of URLs", () => { assert.isTrue(UrlUtils.hasChromeProtocol("about:foobar")); assert.isTrue(UrlUtils.hasChromeProtocol("view-source:foobar")); assert.isTrue(UrlUtils.hasChromeProtocol("chrome-extension:foobar")); assert.isTrue(UrlUtils.hasChromeProtocol("data:foobar")); assert.isTrue(UrlUtils.hasChromeProtocol("data:")); assert.isFalse(UrlUtils.hasChromeProtocol("")); assert.isFalse(UrlUtils.hasChromeProtocol("about")); assert.isFalse(UrlUtils.hasChromeProtocol("view-source")); assert.isFalse(UrlUtils.hasChromeProtocol("chrome-extension")); assert.isFalse(UrlUtils.hasChromeProtocol("data")); assert.isFalse(UrlUtils.hasChromeProtocol("data :foobar")); }); }); context("hasJavascriptProtocol", () => { should("detect javascript: URLs", () => { assert.isTrue(UrlUtils.hasJavascriptProtocol("javascript:foobar")); assert.isFalse(UrlUtils.hasJavascriptProtocol("http:foobar")); }); }); ================================================ FILE: tests/unit_tests/user_search_engines_test.js ================================================ import "./test_helper.js"; import * as userSearchEngines from "../../background_scripts/user_search_engines.js"; import { UserSearchEngine } from "../../background_scripts/user_search_engines.js"; context("UserSearchEngines", () => { should("parse out search engine text", () => { const config = [ "g: http://google.com/%s Google Search", "random line", "# comment", " w: http://wikipedia.org/%s", ].join("\n"); const results = userSearchEngines.parseConfig(config).keywordToEngine; assert.equal( { g: new UserSearchEngine({ keyword: "g", url: "http://google.com/%s", description: "Google Search", }), w: new UserSearchEngine({ keyword: "w", url: "http://wikipedia.org/%s", description: "search (w)", }), }, results, ); }); should("return validation errors", () => { const getErrors = (config) => userSearchEngines.parseConfig(config).validationErrors; assert.equal(0, getErrors("g: http://google.com").length); // Missing colon. assert.equal(1, getErrors("g http://google.com").length); // Not enough tokens. assert.equal(1, getErrors("g:").length); // Invalid search engine URL. assert.equal(1, getErrors("g: invalid-url").length); }); }); ================================================ FILE: tests/unit_tests/utils_test.js ================================================ import "./test_helper.js"; import "../../lib/settings.js"; import "../../lib/url_utils.js"; context("forTrusted", () => { should("invoke an event handler if the event is trusted", () => { let called = false; const f = forTrusted(() => called = true); const event = { isTrusted: true }; f(event); assert.equal(true, called); }); should("not invoke an event handler if the event is untrusted", () => { let called = false; const f = forTrusted(() => called = true); const event = { isTrusted: false }; f(event); assert.equal(false, called); f(null); assert.equal(false, called); }); }); context("extractQuery", () => { should("extract queries from search URLs", () => { assert.equal( "bbc sport 1", Utils.extractQuery( "https://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+1", ), ); assert.equal( "bbc sport 2", Utils.extractQuery( "http://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+2", ), ); assert.equal( "bbc sport 3", Utils.extractQuery( "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+3", ), ); assert.equal( "bbc sport 4", Utils.extractQuery( "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+4&blah", ), ); }); }); context("decodeURIByParts", () => { should("decode javascript: URLs", () => { assert.equal("foobar", Utils.decodeURIByParts("foobar")); assert.equal(" ", Utils.decodeURIByParts("%20")); assert.equal("25 % 20 25 ", Utils.decodeURIByParts("25 % 20 25%20")); }); }); context("compare versions", () => { should("compare correctly", () => { assert.equal(0, Utils.compareVersions("1.40.1", "1.40.1")); assert.equal(0, Utils.compareVersions("1.40", "1.40.0")); assert.equal(0, Utils.compareVersions("1.40.0", "1.40")); assert.equal(-1, Utils.compareVersions("1.40.1", "1.40.2")); assert.equal(-1, Utils.compareVersions("1.40.1", "1.41")); assert.equal(-1, Utils.compareVersions("1.40", "1.40.1")); assert.equal(1, Utils.compareVersions("1.41", "1.40")); assert.equal(1, Utils.compareVersions("1.41.0", "1.40")); assert.equal(1, Utils.compareVersions("1.41.1", "1.41")); }); }); context("makeIdempotent", () => { let func; let count = 0; setup(() => { count = 0; func = Utils.makeIdempotent((n) => { if (n == null) { n = 1; } count += n; }); }); should("call a function once", () => { func(); assert.equal(1, count); }); should("call a function once with an argument", () => { func(2); assert.equal(2, count); }); should("not call a function a second time", () => { func(); assert.equal(1, count); }); should("not call a function a second time", () => { func(); assert.equal(1, count); func(); assert.equal(1, count); }); }); context("distinctCharacters", () => { should( "eliminate duplicate characters", () => assert.equal("abc", Utils.distinctCharacters("bbabaabbacabbbab")), ); }); context("escapeRegexSpecialCharacters", () => { should("escape regexp special characters", () => { const str = "-[]/{}()*+?.^$|"; const regexp = new RegExp(Utils.escapeRegexSpecialCharacters(str)); assert.isTrue(regexp.test(str)); }); }); context("extractQuery", () => { should("extract the query terms from a URL", () => { const url = "https://www.google.ie/search?q=star+wars&foo&bar"; const searchUrl = "https://www.google.ie/search?q=%s"; assert.equal("star wars", Utils.extractQuery(searchUrl, url)); }); should("require trailing URL components", () => { const url = "https://www.google.ie/search?q=star+wars&foo&bar"; const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; assert.equal(null, Utils.extractQuery(searchUrl, url)); }); should("accept trailing URL components", () => { const url = "https://www.google.ie/search?q=star+wars&foo&bar&foobar=x"; const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; assert.equal("star wars", Utils.extractQuery(searchUrl, url)); }); }); context("pick", () => { should("omit properties", () => { assert.equal({ a: 1, b: 2 }, Utils.pick({ a: 1, b: 2, c: 3 }, ["a", "b", "d"])); }); }); context("keyBy", () => { const array = [ { key: "a" }, { key: "b" }, ]; should("group by string key", () => { assert.equal( { a: array[0], b: array[1] }, Utils.keyBy(array, "key"), ); }); should("group by key function", () => { assert.equal( { a: array[0], b: array[1] }, Utils.keyBy(array, (el) => el.key), ); }); }); context("assertType", () => { should("fail if schema or object is null", () => { assert.throwsError(() => Utils.assertType(null, { a: 1 })); assert.throwsError(() => Utils.assertType({ a: null }, null)); }); should("not allow unknown fields", () => { const schema = { a: null }; Utils.assertType(schema, { a: 1 }); assert.throwsError(() => Utils.assertType(schema, { b: 1 })); }); should("type check fields with types", () => { const schema = { bool: "boolean", num: "number", string: "string", }; Utils.assertType(schema, { bool: true, num: 1, string: "example", }); assert.throwsError(() => Utils.assertType(schema, { bool: 1 })); assert.throwsError(() => Utils.assertType(schema, { num: "example" })); assert.throwsError(() => Utils.assertType(schema, { string: 1 })); }); should("allow null values for typed fields", () => { Utils.assertType({ bool: "boolean" }, { bool: null }); }); }); ================================================ FILE: tests/unit_tests/vomnibar_page_test.js ================================================ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; import { Suggestion } from "../../background_scripts/completion/completers.js"; import * as vomnibarPage from "../../pages/vomnibar_page.js"; function newKeyEvent(properties) { return Object.assign( { type: "keydown", key: "a", ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, stopImmediatePropagation: function () {}, preventDefault: function () {}, }, properties, ); } context("vomnibar page", () => { let ui; setup(async () => { await testHelper.jsdomStub("pages/vomnibar_page.html"); stub(chrome.runtime, "sendMessage", async (message) => { if (message.handler == "filterCompletions") { return []; } }); vomnibarPage.reset(); await vomnibarPage.activate(); ui = vomnibarPage.ui; }); should("hide when escape is pressed", async () => { ui.setQuery("www.example.com"); // Here we assert that the dialog has been reset when esc is pressed, which happens as part of // hiding the dialog. It would be better to check more directly that the dialog was hidden, but // jacking into the channels for this are not worthwhile for this test. await ui.onKeyEvent(newKeyEvent({ key: "Escape" })); assert.equal("", ui.input.value); }); should("edit a completion's URL when ctrl-enter is pressed", async () => { stub(chrome.runtime, "sendMessage", async (message) => { if (message.handler == "filterCompletions") { const s = new Suggestion({ url: "http://hello.com" }); return [s]; } }); await ui.update(); await ui.onKeyEvent(newKeyEvent({ type: "keydown", key: "up" })); // TODO(philc): Why does this need to be lowercase enter? await ui.onKeyEvent(newKeyEvent({ type: "keypress", ctrlKey: true, key: "enter" })); assert.equal("http://hello.com", ui.input.value); }); should("open a URL-like query when enter is pressed", async () => { ui.setQuery("www.example.com"); let handler = null; let url = null; stub(chrome.runtime, "sendMessage", async (message) => { handler = message.handler; url = message.url; }); await ui.onKeyEvent(newKeyEvent({ type: "keypress", key: "Enter" })); ui.onHidden(); assert.equal("openUrlInCurrentTab", handler); assert.equal("www.example.com", url); }); should("search for a non-URL query when enter is pressed", async () => { ui.setQuery("example"); let handler = null; let query = null; stub(chrome.runtime, "sendMessage", async (message) => { handler = message.handler; query = message.query; }); await ui.onKeyEvent(newKeyEvent({ type: "keypress", key: "Enter" })); ui.onHidden(); assert.equal("launchSearchQuery", handler); assert.equal("example", query); }); // This test covers #4396. should("not treat javascript keywords as user-defined search engines", async () => { ui.setQuery("constructor "); // "constructor" is a built-in JS property ui.onInput(); // The query should not be treated as a user search engine. assert.equal("constructor ", ui.input.value); }); }); ================================================ FILE: tests/vendor/shoulda.js ================================================ /* * A unit testing micro framework. Tests are grouped into "contexts", each of which can share common * setup and teardown functions. */ /* * Assertions. */ const assert = { isTrue(value) { if (!value) { this.fail("Expected true, but got " + value); } }, isFalse(value) { if (value) { this.fail("Expected false, but got " + value); } }, // Does a deep-equal check on complex objects. equal(expected, actual) { const areEqual = typeof expected === "object" ? JSON.stringify(expected) === JSON.stringify(actual) : expected === actual; if (!areEqual) { this.fail(`Expected:\n${this._print(expected)}\nGot:\n${this._print(actual)}`); } }, // We cannot name this function simply "throws", because it's a reserved JavaScript keyword. throwsError(expression, errorName) { try { expression(); } catch (error) { if (errorName) { if (error.name == errorName) { return; } else { assert.fail( `Expected error ${errorName} to be thrown but error ${error.name} was thrown instead.`, ); } } else { return; } } if (errorName) { assert.fail(`Expected error ${errorName} but no error was thrown.`); } else { assert.fail("Expected error but none was thrown."); } }, fail(message) { throw new AssertionError(message); }, // Used for printing the arguments passed to assertions. _print(object) { if (object === null) return "null"; else if (object === undefined) return "undefined"; else if (typeof object === "string") return '"' + object + '"'; else { try { // Pretty-print with indentation. return JSON.stringify(object, undefined, 2); } catch (_) { // `object` might not be stringifiable (e.g. DOM nodes), or JSON.stringify may not exist. return object.toString(); } } }, }; /* * ensureCalled ensures the given function is called by the end of the test case. This is useful * when testing APIs that use callbacks. */ function ensureCalled(fn) { const wrappedFunction = function () { const i = Tests.requiredCallbacks.indexOf(wrappedFunction); if (i >= 0) { Tests.requiredCallbacks.splice(i, 1); // Delete. } return fn?.apply(null, arguments); }; Tests.requiredCallbacks.push(wrappedFunction); return wrappedFunction; } class AssertionError extends Error { constructor(message) { super(message); this.name = "AssertionError"; // Omit this constructor from the error's backtrace. Error.captureStackTrace?.(this, AssertionError); } } /* * A Context is a named set of test methods and nested contexts, with optional setup and teardown * methods. */ function Context(name) { this.name = name; this.setupMethod = null; this.teardownMethod = null; this.contexts = []; this.tests = []; } const contextStack = []; /* * See the usage documentation for details on how to use the "context" and "should" functions. */ function context(name, fn) { if (typeof fn != "function") { throw new Error("context() requires a function argument."); } const newContext = new Context(name); if (contextStack.length > 0) { contextStack[contextStack.length - 1].tests.push(newContext); } else { Tests.topLevelContexts.push(newContext); } contextStack.push(newContext); fn(); contextStack.pop(); return newContext; } context.only = (name, fn) => { const c = context(name, fn); c.isFocused = true; Tests.focusIsUsed = true; }; function setup(fn) { contextStack[contextStack.length - 1].setupMethod = fn; } function teardown(fn) { contextStack[contextStack.length - 1].teardownMethod = fn; } function should(name, fn) { const test = { name, fn }; contextStack[contextStack.length - 1].tests.push(test); return test; } should.only = (name, fn) => { const test = should(name, fn); test.isFocused = true; Tests.focusIsUsed = true; }; /* * Tests is used to run tests and keep track of the count of successes and failures. */ const Tests = { topLevelContexts: [], testsRun: 0, testsFailed: 0, // The list of callbacks created by `ensureCalled` which must be called by the end of the test. requiredCallbacks: [], // True if, during the collection phase, should.only or context.only was used. focusIsUsed: false, /* * Run all contexts which have been defined. * - testNameFilter: a String. If provided, only run tests which match testNameFilter will be run. */ async run(testNameFilter) { // Run every top level context (i.e. those not defined within another context). These will in // turn run any nested contexts. The very last context ever added to Tests.testContexts is a top // level context. Note that any contexts which have not already been run by a previous top level // context must themselves be top level contexts. this.testsRun = 0; this.testsFailed = 0; for (const context of this.topLevelContexts) { await this.runContext(context, [], testNameFilter); } this.printTestSummary(); return this.testsFailed == 0; }, /* * This resets (clears) the state of shoulda, including the tests which have been defined. This is * useful when running shoulda tests in a REPL environment, to prevent tests from getting defined * multiple times when a file is re-evaluated. */ reset() { this.topLevelContexts = []; this.focusedTests = []; this.focusIsUsed = false; }, /* * Run a context. This runs the test methods defined in the context first, and then any nested * contexts. */ async runContext(context, parentContexts, testNameFilter) { parentContexts = parentContexts.concat([context]); for (const test of context.tests) { if (test instanceof Context) { await this.runContext(test, parentContexts, testNameFilter); } else { await this.runTest(test, parentContexts, testNameFilter); } } }, /* * Run a test. This will run all setup methods in all contexts, and then all teardown methods. * - testMethod: an object with keys name, fn. * - contexts: an array of Contexts, ordered outer to inner. * - testNameFilter: A String. If provided, only run the test if it matches testNameFilter. */ async runTest(testMethod, contexts, testNameFilter) { const shouldSkip = this.focusIsUsed && !testMethod.isFocused && !contexts.some((c) => c.isFocused); if (shouldSkip) return; const fullTestName = this.fullyQualifiedName(testMethod.name, contexts); if (testNameFilter && !fullTestName.includes(testNameFilter)) { return; } this.testsRun++; let failureMessage = null; // This is the scope which all references to "this" in the setup and test methods resolve to. const testScope = {}; const errors = []; for (const context of contexts.filter((c) => c.setupMethod)) { try { await context.setupMethod.call(testScope, testScope); } catch (error) { errors.push(error); break; } } if (errors.length == 0) { try { await testMethod.fn.call(testScope, testScope); } catch (error) { errors.push(error); } } for (const context of contexts.filter((c) => c.teardownMethod)) { try { await context.teardownMethod.call(testScope, testScope); } catch (error) { errors.push(error); break; } } if (this.requiredCallbacks.length > 0) { errors.push("A callback function should have been called during this test, but wasn't."); } if (errors.length > 0) { Tests.testsFailed++; } // Print the errors in the order they occurred in the setup, test, teardown chain. for (const [i, error] of Object.entries(errors)) { // Note that in JavaScript, any object can be thrown, even a string or null. let message; if (Error.isError(error)) { if (error instanceof AssertionError) { message = error.message; } else { // In Deno and Chrome, error.stack also includes the error's message. message = error.stack; } } else { // Thrown types which are not Errors will not have a backtrace. message = String(error); } // For the first failure only, print the failed test header message. if (i == 0) { Tests.printFailure(fullTestName, message); } else { console.log("---"); // Add a visual separator between backtraces when there are many. console.log(message); } } this.requiredCallbacks = []; clearStubs(); }, // The fully-qualified name of the test or context, e.g. "context1: context2: testName". fullyQualifiedName(testName, contexts) { return contexts.map((c) => c.name).concat(testName).join(": "); }, printTestSummary() { if (this.testsFailed > 0) { console.log(`Fail (${Tests.testsFailed}/${Tests.testsRun})`); } else { console.log(`Pass (${Tests.testsRun}/${Tests.testsRun})`); } }, printFailure(testName, failureMessage) { console.log(`Fail "${testName}"\n${failureMessage}`); }, }; function run(testNameFilter) { return Tests.run(testNameFilter); } function reset() { Tests.reset(); } /* * Stats of the latest test run. */ function getStats() { return { failed: Tests.testsFailed, run: Tests.testsRun, }; } /* * Stubs */ const stubbedObjects = []; function stub(object, propertyName, returnValue) { stubbedObjects.push({ object: object, propertyName: propertyName, original: object[propertyName], }); object[propertyName] = returnValue; } /* * returns creates a function which returns the given value. This is useful for stubbing functions * to return a hardcoded value. */ function returns(value) { return () => value; } function clearStubs() { // Restore stubs in the reverse order they were defined in, in case the same property was stubbed // twice. for (let i = stubbedObjects.length - 1; i >= 0; i--) { const stubProperties = stubbedObjects[i]; stubProperties.object[stubProperties.propertyName] = stubProperties.original; } } export { assert, context, ensureCalled, getStats, reset, returns, run, setup, should, stub, teardown, };