tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 0b5f51198c472b75663a009774b5356cd9e6ce72
parent 7f7b981ad6048b870aaa0eadcb6e2c264b3c78b1
Author: Irene Ni <ini@mozilla.com>
Date:   Sat, 18 Oct 2025 06:52:11 +0000

Bug 1993363 - Add unit tests for New Tab story cards keyboard navigation. r=home-newtab-reviewers,nbarrett

Differential Revision: https://phabricator.services.mozilla.com/D268149

Diffstat:
Mbrowser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardSections.test.jsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/unit/content-src/lib/utils.test.jsx | 23+++++++++++++++++++++++
3 files changed, 237 insertions(+), 0 deletions(-)

diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx @@ -199,6 +199,127 @@ describe("<CardGrid>", () => { // confrim that the next child is the trending search widget assert.ok(grid.childAt(2).find(".trending-searches-list-view").exists()); }); + + describe("Keyboard navigation", () => { + beforeEach(() => { + const commonProps = { + items: 3, + data: { + recommendations: [{}, {}, {}], + }, + Prefs: INITIAL_STATE.Prefs, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, + }; + + wrapper = mount( + <WrapWithProvider> + <CardGrid {...commonProps} /> + </WrapWithProvider> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => { + const firstCard = wrapper.find(DSCard).at(0); + const secondCard = wrapper.find(DSCard).at(1); + const thirdCard = wrapper.find(DSCard).at(2); + + assert.equal(firstCard.prop("tabIndex"), 0); + assert.equal(secondCard.prop("tabIndex"), -1); + assert.equal(thirdCard.prop("tabIndex"), -1); + }); + + it("should update focused index when onFocus is called", () => { + const secondCard = wrapper.find(DSCard).at(1); + const onFocus = secondCard.prop("onFocus"); + + onFocus(); + wrapper.update(); + + assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0); + assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1); + }); + + describe("handleCardKeyDown", () => { + let sandbox; + let grid; + let mockLink; + let mockTargetCard; + let mockCurrentCard; + let mockEvent; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + grid = wrapper.find(".ds-card-grid"); + + mockLink = { focus: sandbox.spy() }; + mockTargetCard = { + matches: sandbox.stub().returns(true), + querySelector: sandbox.stub().returns(mockLink), + }; + mockCurrentCard = {}; + mockEvent = { + preventDefault: sandbox.spy(), + target: { + closest: sandbox.stub().returns(mockCurrentCard), + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should navigate to next card with ArrowRight", () => { + mockEvent.key = "ArrowRight"; + mockCurrentCard.nextElementSibling = mockTargetCard; + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.calledOnce(mockTargetCard.querySelector); + assert.calledWith(mockTargetCard.querySelector, "a.ds-card-link"); + assert.calledOnce(mockLink.focus); + }); + + it("should navigate to previous card with ArrowLeft", () => { + mockEvent.key = "ArrowLeft"; + mockCurrentCard.previousElementSibling = mockTargetCard; + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.calledOnce(mockTargetCard.querySelector); + assert.calledWith(mockTargetCard.querySelector, "a.ds-card-link"); + assert.calledOnce(mockLink.focus); + }); + + it("should return early if no current card found", () => { + mockEvent.key = "ArrowRight"; + mockEvent.target.closest.returns(null); + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.notCalled(mockTargetCard.querySelector); + }); + + it("should handle case where no matching sibling card is found", () => { + mockEvent.key = "ArrowRight"; + mockCurrentCard.nextElementSibling = { + matches: sandbox.stub().returns(false), + }; + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.notCalled(mockLink.focus); + }); + }); + }); }); // Build IntersectionObserver class with the arg `entries` for the intersect callback. diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardSections.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardSections.test.jsx @@ -448,4 +448,97 @@ describe("<CardSections />", () => { assert.equal(highlight.length, 1); assert.isTrue(wrapper.html().includes("follow-section-button-highlight")); }); + + describe("Keyboard navigation", () => { + beforeEach(() => { + // Mock window.innerWidth to return a value that will make getActiveColumnLayout return "col-1" + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 500, + }); + }); + + it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => { + const firstCard = wrapper.find(DSCard).at(0); + const secondCard = wrapper.find(DSCard).at(1); + const thirdCard = wrapper.find(DSCard).at(2); + + assert.equal(firstCard.prop("tabIndex"), 0); + assert.equal(secondCard.prop("tabIndex"), -1); + assert.equal(thirdCard.prop("tabIndex"), -1); + }); + + it("should update focused index when onFocus is called", () => { + const secondCard = wrapper.find(DSCard).at(1); + const onFocus = secondCard.prop("onFocus"); + + onFocus(); + wrapper.update(); + + assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0); + assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1); + }); + + describe("handleCardKeyDown", () => { + let grid; + let mockLink; + let mockTargetCard; + let mockGridElement; + let mockCurrentCard; + let mockEvent; + + beforeEach(() => { + grid = wrapper.find(".ds-section-grid.ds-card-grid"); + mockLink = { focus: sandbox.spy() }; + mockTargetCard = { + querySelector: sandbox.stub().returns(mockLink), + }; + mockGridElement = { + querySelector: sandbox.stub().returns(mockTargetCard), + }; + mockCurrentCard = { + parentElement: mockGridElement, + }; + mockEvent = { + preventDefault: sandbox.spy(), + target: { + closest: sandbox.stub().returns(mockCurrentCard), + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should navigate to next card with ArrowRight", () => { + mockEvent.key = "ArrowRight"; + mockCurrentCard.classList = ["col-1-position-0"]; + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.calledWith( + mockGridElement.querySelector, + "article.ds-card.col-1-position-1" + ); + assert.calledOnce(mockLink.focus); + }); + + it("should navigate to previous card with ArrowLeft", () => { + mockEvent.key = "ArrowLeft"; + mockCurrentCard.classList = ["col-1-position-1"]; + + grid.prop("onKeyDown")(mockEvent); + + assert.calledOnce(mockEvent.preventDefault); + assert.calledWith( + mockGridElement.querySelector, + "article.ds-card.col-1-position-0" + ); + assert.calledOnce(mockLink.focus); + }); + }); + }); }); diff --git a/browser/extensions/newtab/test/unit/content-src/lib/utils.test.jsx b/browser/extensions/newtab/test/unit/content-src/lib/utils.test.jsx @@ -3,6 +3,7 @@ import { mount } from "enzyme"; import { useIntersectionObserver, getActiveCardSize, + getActiveColumnLayout, useConfetti, selectWeatherPlacement, } from "content-src/lib/utils.jsx"; @@ -165,6 +166,28 @@ describe("getActiveCardSize", () => { }); }); +describe("getActiveColumnLayout", () => { + it("returns 'col-4' for screen width 1920", () => { + const result = getActiveColumnLayout(1920); + assert.equal(result, "col-4"); + }); + + it("returns 'col-3' for screen width 1200", () => { + const result = getActiveColumnLayout(1200); + assert.equal(result, "col-3"); + }); + + it("returns 'col-2' for screen width 800", () => { + const result = getActiveColumnLayout(800); + assert.equal(result, "col-2"); + }); + + it("returns 'col-1' for screen width 500", () => { + const result = getActiveColumnLayout(500); + assert.equal(result, "col-1"); + }); +}); + describe("useConfetti hook", () => { let sandbox; let rafStub;