tor-browser

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

ContentTiles.test.jsx (30085B)


      1 import React from "react";
      2 import { shallow, mount } from "enzyme";
      3 import { ContentTiles } from "content-src/components/ContentTiles";
      4 import { ActionChecklist } from "content-src/components/ActionChecklist";
      5 import { MobileDownloads } from "content-src/components/MobileDownloads";
      6 import { EmbeddedBackupRestore } from "content-src/components/EmbeddedBackupRestore";
      7 import { EmbeddedMigrationWizard } from "content-src/components/EmbeddedMigrationWizard";
      8 import { AboutWelcomeUtils } from "content-src/lib/aboutwelcome-utils.mjs";
      9 import { GlobalOverrider } from "asrouter/tests/unit/utils";
     10 
     11 describe("ContentTiles component", () => {
     12  let sandbox;
     13  let wrapper;
     14  let handleAction;
     15  let setActiveMultiSelect;
     16  let setActiveSingleSelectSelection;
     17  let globals;
     18 
     19  const CHECKLIST_TILE = {
     20    type: "action_checklist",
     21    header: {
     22      title: "Checklist Header",
     23      subtitle: "Checklist Subtitle",
     24      style: {
     25        border: "1px solid #ccc",
     26      },
     27    },
     28    data: [
     29      {
     30        id: "action-checklist-test",
     31        targeting: "false",
     32        label: {
     33          raw: "Test label",
     34        },
     35        action: {
     36          data: {
     37            pref: {
     38              name: "messaging-system-action.test1",
     39              value: "false",
     40            },
     41          },
     42          type: "SET_PREF",
     43        },
     44      },
     45    ],
     46  };
     47 
     48  const MOBILE_TILE = {
     49    type: "mobile_downloads",
     50    header: {
     51      title: "Mobile Header",
     52      style: {
     53        backgroundColor: "#e0e0e0",
     54        border: "1px solid #999",
     55      },
     56    },
     57    data: {
     58      email: {
     59        link_text: "Email yourself a link",
     60      },
     61    },
     62  };
     63 
     64  const TITLE_TILE = {
     65    type: "mobile_downloads",
     66    title: "Tile Title",
     67    subtitle: "Tile Subtitle",
     68    data: {
     69      email: {
     70        link_text: "Email yourself a link",
     71      },
     72    },
     73  };
     74 
     75  const TEST_CONTENT = {
     76    tiles: [CHECKLIST_TILE, MOBILE_TILE],
     77  };
     78 
     79  const EMBEDDED_BACKUP_RESTORE_TILE = {
     80    type: "backup_restore",
     81    title: "Tile Title",
     82    subtitle: "Tile Subtitle",
     83  };
     84 
     85  beforeEach(() => {
     86    sandbox = sinon.createSandbox();
     87    handleAction = sandbox.stub();
     88    setActiveMultiSelect = sandbox.stub();
     89    setActiveSingleSelectSelection = sandbox.stub();
     90    globals = new GlobalOverrider();
     91    globals.set({
     92      AWSendToDeviceEmailsSupported: () => Promise.resolve(),
     93    });
     94    globals.set({ AWSendToParent: sandbox.stub() });
     95    globals.set({
     96      AWSendToParent: sandbox.stub(),
     97      AWFindBackupsInWellKnownLocations: sandbox.stub().resolves({
     98        found: false,
     99        multipleBackupsFound: false,
    100        backupFileToRestore: null,
    101      }),
    102    });
    103    wrapper = shallow(
    104      <ContentTiles
    105        content={TEST_CONTENT}
    106        handleAction={handleAction}
    107        activeMultiSelect={null}
    108        setActiveMultiSelect={setActiveMultiSelect}
    109      />
    110    );
    111  });
    112 
    113  afterEach(() => {
    114    sandbox.restore();
    115    globals.restore();
    116  });
    117 
    118  it("should render the component when tiles are provided", () => {
    119    assert.ok(wrapper.exists());
    120  });
    121 
    122  it("should not render the component when no tiles are provided", () => {
    123    wrapper.setProps({ content: {} });
    124    assert.ok(wrapper.isEmptyRender());
    125  });
    126 
    127  it("should render the correct number of tiles", () => {
    128    assert.equal(wrapper.find(".content-tile").length, 2);
    129  });
    130 
    131  it("should toggle a tile and send telemetry when its header is clicked", () => {
    132    let telemetrySpy = sandbox.spy(AboutWelcomeUtils, "sendActionTelemetry");
    133    const [firstTile] = TEST_CONTENT.tiles;
    134    const tileId = `${firstTile.type}${firstTile.id ? "_" : ""}${
    135      firstTile.id ?? ""
    136    }_header`;
    137    const firstTileButton = wrapper.find(".tile-header").at(0);
    138    firstTileButton.simulate("click");
    139    assert.equal(
    140      wrapper.find(".tile-content").at(0).prop("id"),
    141      "tile-content-0"
    142    );
    143    assert.equal(wrapper.find(".tile-content").at(0).exists(), true);
    144    assert.calledOnce(telemetrySpy);
    145    assert.equal(telemetrySpy.firstCall.args[1], tileId);
    146  });
    147 
    148  it("should only expand one tile at a time", () => {
    149    const firstTileButton = wrapper.find(".tile-header").at(0);
    150    firstTileButton.simulate("click");
    151    const secondTileButton = wrapper.find(".tile-header").at(1);
    152    secondTileButton.simulate("click");
    153 
    154    assert.equal(wrapper.find(".tile-content").length, 1);
    155    assert.equal(
    156      wrapper.find(".tile-content").at(0).prop("id"),
    157      "tile-content-1"
    158    );
    159  });
    160 
    161  it("should toggle all tiles and send telemetry when the tiles header is clicked", () => {
    162    const TEST_CONTENT_HEADER = {
    163      tiles: [CHECKLIST_TILE, MOBILE_TILE],
    164      tiles_header: {
    165        title: "Toggle Tiles Header",
    166      },
    167    };
    168 
    169    wrapper = mount(
    170      <ContentTiles
    171        content={TEST_CONTENT_HEADER}
    172        handleAction={handleAction}
    173        activeMultiSelect={null}
    174        setActiveMultiSelect={setActiveMultiSelect}
    175      />
    176    );
    177 
    178    let telemetrySpy = sandbox.spy(AboutWelcomeUtils, "sendActionTelemetry");
    179    const tilesHeaderButton = wrapper.find(".content-tiles-header");
    180 
    181    assert.ok(tilesHeaderButton.exists(), "Tiles header button should exist");
    182    tilesHeaderButton.simulate("click");
    183 
    184    assert.equal(
    185      wrapper.find("#content-tiles-container").exists(),
    186      true,
    187      "Content tiles container should be visible after toggle"
    188    );
    189    assert.calledOnce(telemetrySpy);
    190    assert.equal(
    191      telemetrySpy.firstCall.args[1],
    192      "content_tiles_header",
    193      "Telemetry should be sent for tiles header toggle"
    194    );
    195 
    196    tilesHeaderButton.simulate("click");
    197    assert.equal(
    198      wrapper.find("#content-tiles-container").exists(),
    199      false,
    200      "Content tiles container should not be visible after second toggle"
    201    );
    202  });
    203 
    204  it("should apply configured styles to the header buttons", () => {
    205    const mountedWrapper = mount(
    206      <ContentTiles
    207        content={TEST_CONTENT}
    208        handleAction={() => {}}
    209        activeMultiSelect={null}
    210        setActiveMultiSelect={setActiveMultiSelect}
    211      />
    212    );
    213 
    214    const firstTileHeader = mountedWrapper
    215      .find(".tile-header")
    216      .at(0)
    217      .getDOMNode();
    218    const secondTileHeader = mountedWrapper
    219      .find(".tile-header")
    220      .at(1)
    221      .getDOMNode();
    222 
    223    assert.equal(
    224      firstTileHeader.style.cssText,
    225      "border: 1px solid rgb(204, 204, 204);",
    226      "First tile header styles should match configured values"
    227    );
    228    assert.equal(
    229      secondTileHeader.style.cssText,
    230      "background-color: rgb(224, 224, 224); border: 1px solid rgb(153, 153, 153);",
    231      "Second tile header styles should match configured values"
    232    );
    233 
    234    mountedWrapper.unmount();
    235  });
    236 
    237  it("should render ActionChecklist for 'action_checklist' tile type", () => {
    238    const firstTileButton = wrapper.find(".tile-header").at(0);
    239    assert.ok(firstTileButton.exists(), "Tile header button should exist");
    240    firstTileButton.simulate("click");
    241 
    242    const actionChecklist = wrapper.find(ActionChecklist);
    243    assert.ok(actionChecklist.exists());
    244    assert.deepEqual(actionChecklist.prop("content").tiles[0].data, [
    245      {
    246        id: "action-checklist-test",
    247        targeting: "false",
    248        label: {
    249          raw: "Test label",
    250        },
    251        action: {
    252          data: {
    253            pref: {
    254              name: "messaging-system-action.test1",
    255              value: "false",
    256            },
    257          },
    258          type: "SET_PREF",
    259        },
    260      },
    261    ]);
    262  });
    263 
    264  it("should render MobileDownloads for 'mobile_downloads' tile type", () => {
    265    const secondTileButton = wrapper.find(".tile-header").at(1);
    266    assert.ok(secondTileButton.exists(), "Tile header button should exist");
    267    secondTileButton.simulate("click");
    268 
    269    const mobileDownloads = wrapper.find(MobileDownloads);
    270    assert.ok(mobileDownloads.exists());
    271    assert.deepEqual(mobileDownloads.prop("data"), {
    272      email: {
    273        link_text: "Email yourself a link",
    274      },
    275    });
    276    assert.equal(mobileDownloads.prop("handleAction"), handleAction);
    277  });
    278 
    279  it("should render EmbeddedBackupRestore for 'backup_restore' tile type", () => {
    280    const TEST_CONTENT_WITH_EMBEDDED_BACKUP_RESTORE = {
    281      tiles: [EMBEDDED_BACKUP_RESTORE_TILE],
    282    };
    283 
    284    const backupWrapper = mount(
    285      <ContentTiles
    286        content={TEST_CONTENT_WITH_EMBEDDED_BACKUP_RESTORE}
    287        handleAction={handleAction}
    288        activeMultiSelect={null}
    289        setActiveMultiSelect={setActiveMultiSelect}
    290      />
    291    );
    292 
    293    const embeddedBackupRestore = backupWrapper.find(EmbeddedBackupRestore);
    294    assert.ok(
    295      embeddedBackupRestore.exists(),
    296      "EmbeddedBackupRestore component should be rendered"
    297    );
    298 
    299    backupWrapper.unmount();
    300  });
    301 
    302  it("should handle a single tile object", () => {
    303    wrapper.setProps({
    304      content: {
    305        tiles: {
    306          type: "action_checklist",
    307          data: [
    308            {
    309              id: "action-checklist-single",
    310              targeting: "false",
    311              label: {
    312                raw: "Single tile label",
    313              },
    314              action: {
    315                data: {
    316                  pref: {
    317                    name: "messaging-system-action.single",
    318                    value: "false",
    319                  },
    320                },
    321                type: "SET_PREF",
    322              },
    323            },
    324          ],
    325        },
    326      },
    327    });
    328 
    329    const actionChecklist = wrapper.find(ActionChecklist);
    330    assert.ok(actionChecklist.exists());
    331    assert.deepEqual(actionChecklist.prop("content").tiles.data, [
    332      {
    333        id: "action-checklist-single",
    334        targeting: "false",
    335        label: {
    336          raw: "Single tile label",
    337        },
    338        action: {
    339          data: {
    340            pref: {
    341              name: "messaging-system-action.single",
    342              value: "false",
    343            },
    344          },
    345          type: "SET_PREF",
    346        },
    347      },
    348    ]);
    349  });
    350 
    351  it("should prefill activeMultiSelect for a MultiSelect tile based on default values", () => {
    352    const MULTISELECT_TILE = {
    353      type: "multiselect",
    354      header: {
    355        title: "Multiselect Header",
    356        style: {
    357          border: "1px solid #ddd",
    358        },
    359      },
    360      data: [
    361        { id: "option1", defaultValue: true },
    362        { id: "option2", defaultValue: false },
    363        { id: "option3", defaultValue: true },
    364      ],
    365    };
    366 
    367    const contentWithMultiselect = { tiles: MULTISELECT_TILE };
    368 
    369    wrapper = mount(
    370      <ContentTiles
    371        content={contentWithMultiselect}
    372        activeMultiSelect={null}
    373        setActiveMultiSelect={setActiveMultiSelect}
    374        handleAction={handleAction}
    375      />
    376    );
    377    wrapper.update();
    378 
    379    sinon.assert.calledOnce(setActiveMultiSelect);
    380    sinon.assert.calledWithExactly(
    381      setActiveMultiSelect,
    382      ["option1", "option3"],
    383      "tile-0"
    384    );
    385  });
    386 
    387  it("should not prefill activeMultiSelect if it is already set", () => {
    388    const MULTISELECT_TILE = {
    389      type: "multiselect",
    390      header: {
    391        title: "Multiselect Header",
    392        style: {
    393          border: "1px solid #ddd",
    394        },
    395      },
    396      data: [
    397        { id: "option1", defaultValue: true },
    398        { id: "option2", defaultValue: false },
    399        { id: "option3", defaultValue: true },
    400      ],
    401    };
    402 
    403    const contentWithMultiselect = { tiles: [MULTISELECT_TILE] };
    404 
    405    wrapper = mount(
    406      <ContentTiles
    407        content={contentWithMultiselect}
    408        activeMultiSelect={["option2"]}
    409        setActiveMultiSelect={setActiveMultiSelect}
    410        handleAction={handleAction}
    411      />
    412    );
    413    wrapper.update();
    414 
    415    sinon.assert.notCalled(setActiveMultiSelect);
    416  });
    417 
    418  it("should render title and subtitle if present", () => {
    419    sandbox.stub(window, "AWSendToDeviceEmailsSupported").resolves(true);
    420 
    421    let TEST_TILE_CONTENT = {
    422      tiles: [TITLE_TILE],
    423    };
    424 
    425    const mountedWrapper = mount(
    426      <ContentTiles
    427        content={TEST_TILE_CONTENT}
    428        handleAction={() => {}}
    429        activeMultiSelect={null}
    430        setActiveMultiSelect={setActiveMultiSelect}
    431      />
    432    );
    433 
    434    const tileTitle = mountedWrapper.find(".tile-title");
    435    const tileSubtitle = mountedWrapper.find(".tile-subtitle");
    436 
    437    assert.ok(tileTitle.exists(), "Title should render");
    438    assert.ok(tileSubtitle.exists(), "Subtitle should render");
    439 
    440    assert.equal(
    441      tileTitle.text(),
    442      "Tile Title",
    443      "Tile title should have correct text"
    444    );
    445    assert.equal(
    446      tileSubtitle.text(),
    447      "Tile Subtitle",
    448      "Tile subtitle should have correct text"
    449    );
    450 
    451    mountedWrapper.unmount();
    452  });
    453 
    454  it("should render multiple title and subtitles if multiple tiles contain them", () => {
    455    sandbox.stub(window, "AWSendToDeviceEmailsSupported").resolves(true);
    456    const SECOND_TITLE_TILE = {
    457      type: "mobile_downloads",
    458      title: "Tile Title 2",
    459      subtitle: "Tile Subtitle 2",
    460      data: {
    461        email: {
    462          link_text: "Email yourself a link",
    463        },
    464      },
    465    };
    466 
    467    let MULTIPLE_TILES_CONTENT = {
    468      tiles: [TITLE_TILE, SECOND_TITLE_TILE],
    469    };
    470 
    471    const mountedWrapper = mount(
    472      <ContentTiles
    473        content={MULTIPLE_TILES_CONTENT}
    474        handleAction={() => {}}
    475        activeMultiSelect={null}
    476        setActiveMultiSelect={setActiveMultiSelect}
    477        setActiveSingleSelectSelection={setActiveSingleSelectSelection}
    478      />
    479    );
    480 
    481    const tileTitles = mountedWrapper.find(".tile-title");
    482    const tileSubtitles = mountedWrapper.find(".tile-subtitle");
    483 
    484    assert.equal(tileTitles.length, 2, "Should render two tile titles");
    485    assert.equal(tileSubtitles.length, 2, "Should render two tile subtitles");
    486 
    487    assert.equal(
    488      tileTitles.at(0).text(),
    489      "Tile Title",
    490      "First tile title should have correct text"
    491    );
    492    assert.equal(
    493      tileSubtitles.at(0).text(),
    494      "Tile Subtitle",
    495      "First tile subtitle should have correct text"
    496    );
    497 
    498    assert.equal(
    499      tileTitles.at(1).text(),
    500      "Tile Title 2",
    501      "Second tile title should have correct text"
    502    );
    503    assert.equal(
    504      tileSubtitles.at(1).text(),
    505      "Tile Subtitle 2",
    506      "Second tile subtitle should have correct text"
    507    );
    508 
    509    mountedWrapper.unmount();
    510  });
    511 
    512  it("should pre-populate multiple MultiSelect components in the same ContentTiles independently of one another", () => {
    513    const FIRST_MULTISELECT_TILE = {
    514      type: "multiselect",
    515      header: {
    516        title: "First Multiselect",
    517      },
    518      data: [
    519        { id: "checklist1-option1", defaultValue: true },
    520        { id: "checklist1-option2", defaultValue: false },
    521      ],
    522    };
    523 
    524    const SECOND_MULTISELECT_TILE = {
    525      type: "multiselect",
    526      header: {
    527        title: "Second Multiselect",
    528      },
    529      data: [
    530        { id: "checklist2-option1", defaultValue: false },
    531        { id: "checklist2-option2", defaultValue: true },
    532      ],
    533    };
    534 
    535    const contentWithMultipleMultiselects = {
    536      tiles: [FIRST_MULTISELECT_TILE, SECOND_MULTISELECT_TILE],
    537    };
    538 
    539    wrapper = mount(
    540      <ContentTiles
    541        content={contentWithMultipleMultiselects}
    542        activeMultiSelect={null}
    543        setActiveMultiSelect={setActiveMultiSelect}
    544        handleAction={handleAction}
    545      />
    546    );
    547    wrapper.update();
    548 
    549    sinon.assert.calledWithExactly(
    550      setActiveMultiSelect.getCall(0),
    551      ["checklist1-option1"],
    552      "tile-0"
    553    );
    554    sinon.assert.calledWithExactly(
    555      setActiveMultiSelect.getCall(1),
    556      ["checklist2-option2"],
    557      "tile-1"
    558    );
    559 
    560    wrapper.unmount();
    561  });
    562 
    563  it("should handle interaction between multiselect instances independently of one another", () => {
    564    const FIRST_MULTISELECT_TILE = {
    565      type: "multiselect",
    566      header: {
    567        title: "First Multiselect",
    568      },
    569      data: [
    570        { id: "checklist1-option1", defaultValue: true, label: "Option 1-1" },
    571        { id: "checklist1-option2", defaultValue: false, label: "Option 1-2" },
    572      ],
    573    };
    574 
    575    const SECOND_MULTISELECT_TILE = {
    576      type: "multiselect",
    577      header: {
    578        title: "Second Multiselect",
    579      },
    580      data: [
    581        { id: "checklist2-option1", defaultValue: false, label: "Option 2-1" },
    582        { id: "checklist2-option2", defaultValue: true, label: "Option 2-2" },
    583      ],
    584    };
    585 
    586    const contentWithMultipleMultiselects = {
    587      tiles: [FIRST_MULTISELECT_TILE, SECOND_MULTISELECT_TILE],
    588    };
    589 
    590    const initialActiveMultiSelect = {
    591      "tile-0": ["checklist1-option1"],
    592      "tile-1": ["checklist2-option2"],
    593    };
    594 
    595    const setScreenMultiSelects = sandbox.stub();
    596 
    597    wrapper = mount(
    598      <ContentTiles
    599        content={contentWithMultipleMultiselects}
    600        activeMultiSelect={initialActiveMultiSelect}
    601        setActiveMultiSelect={setActiveMultiSelect}
    602        setScreenMultiSelects={setScreenMultiSelects}
    603        handleAction={handleAction}
    604      />
    605    );
    606 
    607    // First multiselect
    608    const firstTileButton = wrapper.find(".tile-header").at(0);
    609    assert.ok(firstTileButton.exists(), "First tile header should exist");
    610    firstTileButton.simulate("click");
    611 
    612    const firstChecklistInputs = wrapper.find(".multi-select-container input");
    613    assert.equal(
    614      firstChecklistInputs.length,
    615      2,
    616      "First checklist should have 2 inputs"
    617    );
    618 
    619    assert.equal(
    620      firstChecklistInputs.at(0).prop("checked"),
    621      true,
    622      "First option should be checked"
    623    );
    624    assert.equal(
    625      firstChecklistInputs.at(1).prop("checked"),
    626      false,
    627      "Second option should be unchecked"
    628    );
    629 
    630    const secondInput = firstChecklistInputs.at(1);
    631    secondInput.getDOMNode().checked = true;
    632    secondInput.simulate("change");
    633 
    634    sinon.assert.calledOnce(setActiveMultiSelect);
    635    sinon.assert.calledWithExactly(
    636      setActiveMultiSelect,
    637      ["checklist1-option1", "checklist1-option2"],
    638      "tile-0"
    639    );
    640    setActiveMultiSelect.reset();
    641 
    642    // Second multiSelect
    643    const secondTileButton = wrapper.find(".tile-header").at(1);
    644    secondTileButton.simulate("click");
    645 
    646    const secondChecklistInputs = wrapper.find(".multi-select-container input");
    647    assert.equal(
    648      secondChecklistInputs.length,
    649      2,
    650      "Second checklist should have 2 inputs"
    651    );
    652 
    653    assert.equal(
    654      secondChecklistInputs.at(0).prop("checked"),
    655      false,
    656      "First option should be unchecked"
    657    );
    658    assert.equal(
    659      secondChecklistInputs.at(1).prop("checked"),
    660      true,
    661      "Second option should be checked"
    662    );
    663 
    664    // Uncheck the second checkbox in the second multiselect
    665    const lastInput = secondChecklistInputs.at(1);
    666    lastInput.getDOMNode().checked = false;
    667    lastInput.simulate("change");
    668 
    669    sinon.assert.calledOnce(setActiveMultiSelect);
    670    sinon.assert.calledWithExactly(setActiveMultiSelect, [], "tile-1");
    671 
    672    // Ensure the first multiselect's state wasn't altered by changes to the second multiselect
    673    wrapper.setProps({
    674      activeMultiSelect: {
    675        "tile-0": ["checklist1-option1", "checklist1-option2"],
    676        "tile-1": [],
    677      },
    678    });
    679 
    680    firstTileButton.simulate("click");
    681 
    682    // Get the updated checkboxes from the first checklist
    683    const updatedFirstInputs = wrapper.find(".multi-select-container input");
    684 
    685    assert.equal(
    686      updatedFirstInputs.at(0).prop("checked"),
    687      true,
    688      "First option should still be checked"
    689    );
    690    assert.equal(
    691      updatedFirstInputs.at(1).prop("checked"),
    692      true,
    693      "Second option should still be checked"
    694    );
    695 
    696    wrapper.unmount();
    697  });
    698 
    699  it("should select defaults of single select tiles independently of one another", () => {
    700    const SINGLE_SELECT_1 = {
    701      type: "single-select",
    702      selected: "test1",
    703      data: [
    704        {
    705          id: "test1",
    706          label: {
    707            raw: "test1 label",
    708          },
    709        },
    710        {
    711          defaultValue: true,
    712          id: "test2",
    713          label: {
    714            raw: "test2 label",
    715          },
    716        },
    717      ],
    718    };
    719 
    720    const SINGLE_SELECT_2 = {
    721      type: "single-select",
    722      selected: "test4",
    723      data: [
    724        {
    725          id: "test3",
    726          label: {
    727            raw: "test3 label",
    728          },
    729        },
    730        {
    731          defaultValue: true,
    732          id: "test4",
    733          label: {
    734            raw: "test4 label",
    735          },
    736        },
    737      ],
    738    };
    739 
    740    const content = { tiles: [SINGLE_SELECT_1, SINGLE_SELECT_2] };
    741    wrapper = mount(
    742      <ContentTiles
    743        content={content}
    744        setActiveSingleSelectSelection={setActiveSingleSelectSelection}
    745        handleAction={handleAction}
    746      />
    747    );
    748    wrapper.update();
    749 
    750    sinon.assert.calledWithExactly(
    751      setActiveSingleSelectSelection.getCall(0),
    752      "test1",
    753      "single-select-0"
    754    );
    755 
    756    sinon.assert.calledWithExactly(
    757      setActiveSingleSelectSelection.getCall(1),
    758      "test4",
    759      "single-select-1"
    760    );
    761    wrapper.unmount();
    762  });
    763 
    764  it("should handle interactions with multiple single select tiles independently of one another", () => {
    765    const SINGLE_SELECT_1 = {
    766      type: "single-select",
    767      selected: "test1",
    768      data: [
    769        {
    770          id: "test1",
    771          label: {
    772            raw: "test1 label",
    773          },
    774        },
    775        {
    776          defaultValue: true,
    777          id: "test2",
    778          label: {
    779            raw: "test2 label",
    780          },
    781        },
    782      ],
    783    };
    784 
    785    const SINGLE_SELECT_2 = {
    786      type: "single-select",
    787      selected: "test4",
    788      data: [
    789        {
    790          id: "test3",
    791          label: {
    792            raw: "test3 label",
    793          },
    794        },
    795        {
    796          defaultValue: true,
    797          id: "test4",
    798          label: {
    799            raw: "test4 label",
    800          },
    801        },
    802      ],
    803    };
    804 
    805    const content = { tiles: [SINGLE_SELECT_1, SINGLE_SELECT_2] };
    806    wrapper = mount(
    807      <ContentTiles
    808        content={content}
    809        setActiveSingleSelectSelection={setActiveSingleSelectSelection}
    810        handleAction={handleAction}
    811      />
    812    );
    813 
    814    wrapper.update();
    815 
    816    const tile2 = wrapper.find('input[value="test2"]');
    817    tile2.simulate("click");
    818 
    819    sinon.assert.calledWithExactly(
    820      setActiveSingleSelectSelection.getCall(2),
    821      "test2",
    822      "single-select-0"
    823    );
    824 
    825    const tile3 = wrapper.find('input[value="test3"]');
    826    tile3.simulate("click");
    827 
    828    sinon.assert.calledWithExactly(
    829      setActiveSingleSelectSelection.getCall(3),
    830      "test3",
    831      "single-select-1"
    832    );
    833    wrapper.unmount();
    834  });
    835 
    836  it("should apply styles to label element", () => {
    837    const TEST_STYLE = { marginBlock: "5px" };
    838    const tileData = {
    839      type: "single-select",
    840      selected: "vertical",
    841      data: [
    842        {
    843          id: "vertical",
    844          label: { raw: "Vertical", marginBlock: "5px" },
    845        },
    846      ],
    847    };
    848 
    849    wrapper = mount(
    850      <ContentTiles
    851        content={{ tiles: [tileData] }}
    852        setActiveSingleSelectSelection={() => {}}
    853        handleAction={() => {}}
    854      />
    855    );
    856 
    857    const styledDiv = wrapper.find(".text").at(0);
    858 
    859    assert.deepEqual(
    860      styledDiv.prop("style"),
    861      TEST_STYLE,
    862      "Style prop should match TEST_STYLE"
    863    );
    864    wrapper.unmount();
    865  });
    866 
    867  it("should apply valid styles from tile.data.style and include minWidth from icon.width", () => {
    868    const icon = {
    869      width: "101px",
    870    };
    871 
    872    const style = {
    873      paddingBlock: "8px",
    874    };
    875 
    876    const tileData = {
    877      type: "single-select",
    878      selected: "test",
    879      data: [
    880        {
    881          id: "test",
    882          icon,
    883          label: { raw: "Test" },
    884          style,
    885        },
    886      ],
    887    };
    888 
    889    wrapper = mount(
    890      <ContentTiles
    891        content={{ tiles: [tileData] }}
    892        setActiveSingleSelectSelection={() => {}}
    893        handleAction={() => {}}
    894      />
    895    );
    896 
    897    const label = wrapper.find("label.select-item").at(0);
    898    const labelStyle = label.prop("style");
    899 
    900    assert.equal(
    901      labelStyle.paddingBlock,
    902      "8px",
    903      "paddingBlock should be applied"
    904    );
    905    assert.equal(
    906      labelStyle.minWidth,
    907      "101px",
    908      "minWidth should be set from icon.width"
    909    );
    910    wrapper.unmount();
    911  });
    912 
    913  it("restores last tiles focus in Spotlight context and genuine Tab is ignored", async () => {
    914    const TAB_GRACE_WINDOW_MS = 250;
    915 
    916    function nextFrame() {
    917      return new Promise(r => requestAnimationFrame(r));
    918    }
    919    function delay(ms) {
    920      return new Promise(r => setTimeout(r, ms));
    921    }
    922    async function waitFor(condition, timeout = TAB_GRACE_WINDOW_MS) {
    923      const start = performance.now();
    924      while (!condition()) {
    925        await nextFrame();
    926        if (performance.now() - start > timeout) {
    927          throw new Error("timeout waiting for condition");
    928        }
    929      }
    930    }
    931 
    932    // Pretend we're in a Spotlight dialog so the effect runs
    933    const root = document.body.appendChild(document.createElement("div"));
    934    root.id = "multi-stage-message-root";
    935    root.className = "onboardingContainer";
    936    root.dataset.page = "spotlight";
    937 
    938    const mountNode = document.body.appendChild(document.createElement("div"));
    939 
    940    const content = {
    941      tiles: [{ type: "multiselect", header: { title: "Test" }, data: [] }],
    942    };
    943 
    944    const focusWrapper = mount(
    945      <main role="alertdialog">
    946        <ContentTiles
    947          content={content}
    948          handleAction={() => {}}
    949          activeMultiSelect={null}
    950          setActiveMultiSelect={() => {}}
    951        />
    952        <div className="action-buttons">
    953          <button className="primary">Continue</button>
    954        </div>
    955      </main>,
    956      { attachTo: mountNode }
    957    );
    958 
    959    // Let the hook attach listeners
    960    await nextFrame();
    961 
    962    const dialog = focusWrapper.getDOMNode();
    963    const header = dialog.querySelector(".tile-header");
    964    const primary = dialog.querySelector(".action-buttons .primary");
    965 
    966    // Record real DOM focus inside tiles
    967    header.focus();
    968    header.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
    969    await nextFrame();
    970 
    971    // Wait past the tab grace window so this isn’t treated as a real Tab
    972    await delay(TAB_GRACE_WINDOW_MS + 1);
    973 
    974    // Simulate programmatic focus “snap” to an outside control
    975    primary.focus();
    976    primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
    977 
    978    await waitFor(() => document.activeElement === header);
    979    assert.strictEqual(
    980      document.activeElement,
    981      header,
    982      "restored focus to tiles header"
    983    );
    984 
    985    dialog.dispatchEvent(
    986      new KeyboardEvent("keydown", {
    987        key: "Tab",
    988        bubbles: true,
    989        cancelable: true,
    990      })
    991    );
    992    primary.focus();
    993    primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
    994    await nextFrame();
    995    assert.strictEqual(
    996      document.activeElement,
    997      primary,
    998      "did not override genuine Tab focus"
    999    );
   1000 
   1001    // Cleanup
   1002    focusWrapper.unmount();
   1003    mountNode.remove();
   1004    root.remove();
   1005  });
   1006 
   1007  it("passes content.skip_button to EmbeddedBackupRestore as skipButton", () => {
   1008    const content = {
   1009      tiles: [EMBEDDED_BACKUP_RESTORE_TILE],
   1010      skip_button: {
   1011        label: { raw: "Don't restore" },
   1012        action: { navigate: true },
   1013      },
   1014    };
   1015 
   1016    const mountedWrapper = mount(
   1017      <ContentTiles
   1018        content={content}
   1019        handleAction={handleAction}
   1020        activeMultiSelect={null}
   1021        setActiveMultiSelect={setActiveMultiSelect}
   1022      />
   1023    );
   1024 
   1025    const embeddedBackupComponent = mountedWrapper.find(EmbeddedBackupRestore);
   1026    assert.ok(
   1027      embeddedBackupComponent.exists(),
   1028      "EmbeddedBackupRestore rendered"
   1029    );
   1030    assert.deepEqual(
   1031      embeddedBackupComponent.prop("skipButton"),
   1032      content.skip_button,
   1033      "prop is wired"
   1034    );
   1035    mountedWrapper.unmount();
   1036  });
   1037 
   1038  it("renders a single tile without container when there is no header or container style", () => {
   1039    const SINGLE_TILE_NO_CONTAINER = {
   1040      tiles: {
   1041        tile_items: {
   1042          type: "mobile_downloads",
   1043          data: { email: { link_text: "Email!" } },
   1044        },
   1045        container: {},
   1046      },
   1047    };
   1048 
   1049    const mounted = mount(
   1050      <ContentTiles
   1051        content={SINGLE_TILE_NO_CONTAINER}
   1052        handleAction={() => {}}
   1053        activeMultiSelect={null}
   1054        setActiveMultiSelect={setActiveMultiSelect}
   1055      />
   1056    );
   1057 
   1058    assert.isFalse(
   1059      mounted.find("#content-tiles-container").exists(),
   1060      "should not render container wrapper"
   1061    );
   1062    assert.equal(mounted.find(".content-tile").length, 1);
   1063 
   1064    mounted.unmount();
   1065  });
   1066 
   1067  it("passes migration_wizard_options properties to migration-wizard element", () => {
   1068    const MIGRATION_WIZARD_TILE = {
   1069      type: "migration-wizard",
   1070      migration_wizard_options: {
   1071        force_show_import_all: true,
   1072        option_expander_title_string: "Custom title",
   1073        hide_option_expander_subtitle: true,
   1074        hide_select_all: true,
   1075      },
   1076    };
   1077 
   1078    const content = {
   1079      tiles: [MIGRATION_WIZARD_TILE],
   1080    };
   1081 
   1082    const mountedWrapper = mount(
   1083      <ContentTiles
   1084        content={content}
   1085        handleAction={handleAction}
   1086        activeMultiSelect={null}
   1087        setActiveMultiSelect={setActiveMultiSelect}
   1088      />
   1089    );
   1090 
   1091    const embeddedMigrationWizard = mountedWrapper.find(
   1092      EmbeddedMigrationWizard
   1093    );
   1094    assert.ok(
   1095      embeddedMigrationWizard.exists(),
   1096      "EmbeddedMigrationWizard rendered"
   1097    );
   1098 
   1099    const migrationWizardEl = mountedWrapper.find("migration-wizard");
   1100    assert.ok(migrationWizardEl.exists(), "migration-wizard element rendered");
   1101 
   1102    assert.equal(
   1103      migrationWizardEl.prop("force-show-import-all"),
   1104      true,
   1105      "force-show-import-all is set"
   1106    );
   1107    assert.equal(
   1108      migrationWizardEl.prop("option-expander-title-string"),
   1109      "Custom title",
   1110      "option-expander-title-string is set"
   1111    );
   1112    assert.equal(
   1113      migrationWizardEl.prop("hide-option-expander-subtitle"),
   1114      true,
   1115      "hide-option-expander-subtitle is set"
   1116    );
   1117    assert.equal(
   1118      migrationWizardEl.prop("hide-select-all"),
   1119      true,
   1120      "hide-select-all is set"
   1121    );
   1122 
   1123    mountedWrapper.unmount();
   1124  });
   1125 });