Migrate user-onboarding-*.spec.ts from Cypress to Playwright (#11927)

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Johannes Marbach <johannesm@element.io>
Co-authored-by: Milton Moura <miltonmoura@gmail.com>
This commit is contained in:
Michael Telatynski 2023-11-27 12:11:00 +00:00 committed by GitHub
parent 87f1ae4665
commit d827723b3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 314 additions and 273 deletions

View file

@ -1,42 +0,0 @@
/*
Copyright 2023 Suguru Hirahara
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { HomeserverInstance } from "../../plugins/utils/homeserver";
describe("LeftPanel", () => {
let homeserver: HomeserverInstance;
beforeEach(() => {
cy.startHomeserver("default").then((data) => {
homeserver = data;
cy.initTestUser(homeserver, "Hanako");
});
});
afterEach(() => {
cy.stopHomeserver(homeserver);
});
it("should render the Rooms list", () => {
// create rooms and check room names are correct
cy.createRoom({ name: "Apple" }).then(() => cy.findByRole("treeitem", { name: "Apple" }));
cy.createRoom({ name: "Pineapple" }).then(() => cy.findByRole("treeitem", { name: "Pineapple" }));
cy.createRoom({ name: "Orange" }).then(() => cy.findByRole("treeitem", { name: "Orange" }));
});
});

View file

@ -1,75 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { SinonStub } from "cypress/types/sinon";
import { HomeserverInstance } from "../../plugins/utils/homeserver";
describe("Consent", () => {
let homeserver: HomeserverInstance;
beforeEach(() => {
cy.startHomeserver("consent").then((data) => {
homeserver = data;
cy.initTestUser(homeserver, "Bob");
});
});
afterEach(() => {
cy.stopHomeserver(homeserver);
});
it("should prompt the user to consent to terms when server deems it necessary", () => {
// Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN`
cy.window().then((win) => {
win.mxMatrixClientPeg.matrixClient.createRoom({}).catch(() => {});
// Stub `window.open` - clicking the primary button below will call it
cy.stub(win, "open").as("windowOpen").returns({});
});
// Accept terms & conditions
cy.get(".mx_QuestionDialog").within(() => {
cy.get("#mx_BaseDialog_title").within(() => {
cy.findByText("Terms and Conditions");
});
cy.findByRole("button", { name: "Review terms and conditions" }).click();
});
cy.get<SinonStub>("@windowOpen").then((stub) => {
const url = stub.getCall(0).args[0];
// Go to Homeserver's consent page and accept it
cy.origin(homeserver.baseUrl, { args: { url } }, ({ url }) => {
cy.visit(url);
cy.get('[type="submit"]').click();
cy.contains("p", "Danke schoen");
});
});
// go back to the app
cy.visit("/");
// wait for the app to re-load
cy.get(".mx_MatrixChat", { timeout: 15000 });
// attempt to perform the same action again and expect it to not fail
cy.createRoom({});
});
});

View file

@ -1,96 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { MatrixClient } from "../../global";
import { HomeserverInstance } from "../../plugins/utils/homeserver";
describe("User Onboarding (new user)", () => {
let homeserver: HomeserverInstance;
const bot1Name = "BotBob";
let bot1: MatrixClient;
beforeEach(() => {
cy.startHomeserver("default").then((data) => {
homeserver = data;
cy.initTestUser(homeserver, "Jane Doe");
cy.window({ log: false }).then((win) => {
win.localStorage.setItem("mx_registration_time", "1656633601");
});
cy.reload().then(() => {
// wait for the app to load
return cy.get(".mx_MatrixChat", { timeout: 15000 });
});
cy.getBot(homeserver, { displayName: bot1Name }).then((_bot1) => {
bot1 = _bot1;
});
cy.get(".mx_UserOnboardingPage").should("exist");
cy.findByRole("button", { name: "Welcome" }).should("exist");
cy.get(".mx_UserOnboardingList")
.should("exist")
.should(($list) => {
const list = $list.get(0);
expect(getComputedStyle(list).opacity).to.be.eq("1");
});
});
});
afterEach(() => {
cy.stopHomeserver(homeserver);
});
it("page is shown and preference exists", () => {
cy.get(".mx_UserOnboardingPage").percySnapshotElement("User onboarding page");
cy.openUserSettings("Preferences");
cy.findByText("Show shortcut to welcome checklist above the room list").should("exist");
});
it("app download dialog", () => {
cy.findByRole("button", { name: "Download apps" }).click();
cy.get("[role=dialog]").get("#mx_BaseDialog_title").findByText("Download Element").should("exist");
cy.get("[role=dialog]").percySnapshotElement("App download dialog", {
widths: [640],
});
});
it("using find friends action should increase progress", () => {
cy.get(".mx_ProgressBar")
.invoke("val")
.then((oldProgress) => {
const findPeopleAction = cy.findByRole("button", { name: "Find friends" });
expect(findPeopleAction).to.exist;
findPeopleAction.click();
cy.get(".mx_InviteDialog_editor").findByRole("textbox").type(bot1.getUserId());
cy.findByRole("button", { name: "Go" }).click();
cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist");
const message = "Hi!";
cy.findByRole("textbox", { name: "Send a message…" }).type(`${message}{enter}`);
cy.get(".mx_MTextBody.mx_EventTile_content").findByText(message);
cy.visit("/#/home");
cy.get(".mx_UserOnboardingPage").should("exist");
cy.findByRole("button", { name: "Welcome" }).should("exist");
cy.get(".mx_UserOnboardingList")
.should("exist")
.should(($list) => {
const list = $list.get(0);
expect(getComputedStyle(list).opacity).to.be.eq("1");
});
cy.get(".mx_ProgressBar").invoke("val").should("be.greaterThan", oldProgress);
});
});
});

View file

@ -1,49 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import { HomeserverInstance } from "../../plugins/utils/homeserver";
describe("User Onboarding (old user)", () => {
let homeserver: HomeserverInstance;
beforeEach(() => {
cy.startHomeserver("default").then((data) => {
homeserver = data;
cy.initTestUser(homeserver, "Jane Doe");
cy.window({ log: false }).then((win) => {
win.localStorage.setItem("mx_registration_time", "2");
});
cy.reload().then(() => {
// wait for the app to load
return cy.get(".mx_MatrixChat", { timeout: 15000 });
});
});
});
afterEach(() => {
cy.visit("/#/home");
cy.stopHomeserver(homeserver);
});
it("page and preference are hidden", () => {
cy.get(".mx_UserOnboardingPage").should("not.exist");
cy.get(".mx_UserOnboardingButton").should("not.exist");
cy.openUserSettings("Preferences");
cy.findByText(/Show shortcut to welcome page above the room list/).should("not.exist");
});
});

View file

@ -46,9 +46,9 @@
"start:all": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:build", "start:all": "echo THIS IS FOR LEGACY PURPOSES ONLY. && yarn start:build",
"start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows", "lint": "yarn lint:types && yarn lint:js && yarn lint:style && yarn lint:workflows",
"lint:js": "eslint --max-warnings 0 src test cypress && prettier --check .", "lint:js": "eslint --max-warnings 0 src test cypress playwright && prettier --check .",
"lint:js-fix": "eslint --fix src test cypress && prettier --loglevel=warn --write .", "lint:js-fix": "eslint --fix src test cypress playwright && prettier --loglevel=warn --write .",
"lint:types": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p cypress", "lint:types": "tsc --noEmit --jsx react && tsc --noEmit --jsx react -p cypress && tsc --noEmit --jsx react -p playwright",
"lint:style": "stylelint \"res/css/**/*.pcss\"", "lint:style": "stylelint \"res/css/**/*.pcss\"",
"test": "jest", "test": "jest",
"test:cypress": "cypress run", "test:cypress": "cypress run",
@ -226,7 +226,7 @@
"stylelint-config-standard": "^34.0.0", "stylelint-config-standard": "^34.0.0",
"stylelint-scss": "^5.0.0", "stylelint-scss": "^5.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.1.6", "typescript": "5.2.2",
"@axe-core/playwright": "^4.8.1", "@axe-core/playwright": "^4.8.1",
"@action-validator/core": "^0.5.3", "@action-validator/core": "^0.5.3",
"@action-validator/cli": "^0.5.3" "@action-validator/cli": "^0.5.3"

View file

@ -0,0 +1,31 @@
/*
Copyright 2023 Suguru Hirahara
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
test.describe("LeftPanel", () => {
test.use({
displayName: "Hanako",
});
test("should render the Rooms list", async ({ page, app, user }) => {
// create rooms and check room names are correct
for (const name of ["Apple", "Pineapple", "Orange"]) {
await app.createRoom({ name });
await expect(page.getByRole("treeitem", { name })).toBeVisible();
}
});
});

View file

@ -0,0 +1,55 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
test.describe("Consent", () => {
test.use({
startHomeserverOpts: "consent",
displayName: "Bob",
});
test("should prompt the user to consent to terms when server deems it necessary", async ({
context,
page,
user,
app,
}) => {
// Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN`
await app.createRoom({}).catch(() => {});
const newPagePromise = new Promise<Page>((resolve) => context.once("page", resolve));
const dialog = page.locator(".mx_QuestionDialog");
// Accept terms & conditions
await expect(dialog.getByRole("heading", { name: "Terms and Conditions" })).toBeVisible();
await page.getByRole("button", { name: "Review terms and conditions" }).click();
const newPage = await newPagePromise;
await newPage.locator('[type="submit"]').click();
await expect(newPage.getByText("Danke schoen")).toBeVisible();
// go back to the app
await page.goto("/");
// wait for the app to re-load
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
// attempt to perform the same action again and expect it to not fail
await app.createRoom({ name: "Test Room" });
await expect(page.getByText("Test Room")).toBeVisible();
});
});

View file

@ -43,7 +43,7 @@ export async function doTokenRegistration(
// Synapse prompts us to pick a user ID // Synapse prompts us to pick a user ID
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
await page.getByRole("textbox", { name: "Username (required)" }).type("alice"); await page.getByRole("textbox", { name: "Username (required)" }).fill("alice");
// wait for username validation to start, and complete // wait for username validation to start, and complete
await expect(page.locator("#field-username-output")).toHaveText(""); await expect(page.locator("#field-username-output")).toHaveText("");

View file

@ -0,0 +1,75 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
test.describe("User Onboarding (new user)", () => {
test.use({
displayName: "Jane Doe",
});
// This first beforeEach happens before the `user` fixture runs
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_registration_time", "1656633601");
});
});
test.beforeEach(async ({ page, user }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible();
await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible();
await expect(page.locator(".mx_UserOnboardingList")).toBeVisible();
});
test("page is shown and preference exists", async ({ page, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toHaveScreenshot();
await app.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible();
});
test("app download dialog", async ({ page }) => {
await page.getByRole("button", { name: "Download apps" }).click();
await expect(
page.getByRole("dialog").getByRole("heading", { level: 2, name: "Download Element" }),
).toBeVisible();
await expect(page.getByRole("dialog")).toHaveScreenshot();
});
test("using find friends action should increase progress", async ({ page, homeserver }) => {
const bot = await homeserver.registerUser("botbob", "password", "BotBob");
const oldProgress = parseFloat(await page.getByRole("progressbar").getAttribute("value"));
await page.getByRole("button", { name: "Find friends" }).click();
await page.locator(".mx_InviteDialog_editor").getByRole("textbox").fill(bot.userId);
await page.getByRole("button", { name: "Go" }).click();
await expect(page.locator(".mx_InviteDialog_buttonAndSpinner")).not.toBeVisible();
const message = "Hi!";
const composer = page.getByRole("textbox", { name: "Send a message…" });
await composer.fill(`${message}`);
await composer.press("Enter");
await expect(page.locator(".mx_MTextBody.mx_EventTile_content", { hasText: message })).toBeVisible();
await page.goto("/#/home");
await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible();
await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible();
await expect(page.locator(".mx_UserOnboardingList")).toBeVisible();
await page.waitForTimeout(500); // await progress bar animation
const progress = parseFloat(await page.getByRole("progressbar").getAttribute("value"));
expect(progress).toBeGreaterThan(oldProgress);
});
});

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
test.describe("User Onboarding (old user)", () => {
test.use({
displayName: "Jane Doe",
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_registration_time", "2");
});
});
test("page and preference are hidden", async ({ page, user, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible();
await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible();
await app.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible();
});
});

View file

@ -23,6 +23,7 @@ import type { IConfigOptions } from "../src/IConfigOptions";
import { Credentials, HomeserverInstance, StartHomeserverOpts } from "./plugins/utils/homeserver"; import { Credentials, HomeserverInstance, StartHomeserverOpts } from "./plugins/utils/homeserver";
import { Synapse } from "./plugins/synapse"; import { Synapse } from "./plugins/synapse";
import { Instance } from "./plugins/mailhog"; import { Instance } from "./plugins/mailhog";
import { ElementAppPage } from "./pages/ElementAppPage";
import { OAuthServer } from "./plugins/oauth_server"; import { OAuthServer } from "./plugins/oauth_server";
import { Toasts } from "./pages/toasts"; import { Toasts } from "./pages/toasts";
@ -60,6 +61,7 @@ export const test = base.extend<
displayName: string; displayName: string;
}; };
displayName?: string; displayName?: string;
app: ElementAppPage;
mailhog?: { api: mailhog.API; instance: Instance }; mailhog?: { api: mailhog.API; instance: Instance };
toasts: Toasts; toasts: Toasts;
} }
@ -150,6 +152,9 @@ export const test = base.extend<
expect(results.violations).toEqual([]); expect(results.violations).toEqual([]);
}), }),
app: async ({ page }, use) => {
await use(new ElementAppPage(page));
},
toasts: async ({ page }, use) => { toasts: async ({ page }, use) => {
await use(new Toasts(page)); await use(new Toasts(page));
}, },

25
playwright/global.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
declare global {
interface Window {
mxMatrixClientPeg: {
get(): MatrixClient;
};
}
}

View file

@ -0,0 +1,68 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Locator, type Page } from "@playwright/test";
import { type ICreateRoomOpts } from "matrix-js-sdk/src/matrix";
export class ElementAppPage {
public constructor(private readonly page: Page) {}
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
*/
public async openUserMenu(): Promise<Locator> {
await this.page.getByRole("button", { name: "User menu" }).click();
const locator = this.page.locator(".mx_ContextualMenu");
await locator.waitFor();
return locator;
}
/**
* Switch settings tab to the one by the given name
* @param tab the name of the tab to switch to.
*/
public async switchTab(tab: string): Promise<void> {
await this.page
.locator(".mx_TabbedView_tabLabels")
.locator(".mx_TabbedView_tabLabel", { hasText: tab })
.click();
}
/**
* Open user settings (via user menu), returns a locator to the dialog
* @param tab the name of the tab to switch to after opening, optional.
*/
public async openUserSettings(tab?: string): Promise<Locator> {
const locator = await this.openUserMenu();
await locator.getByRole("menuitem", { name: "All settings", exact: true }).click();
if (tab) await this.switchTab(tab);
return this.page.locator(".mx_UserSettingsDialog");
}
/**
* Create a room with given options.
* @param options the options to apply when creating the room
* @return the ID of the newly created room
*/
public async createRoom(options: ICreateRoomOpts): Promise<string> {
return this.page.evaluate<Promise<string>, ICreateRoomOpts>(async (options) => {
return window.mxMatrixClientPeg
.get()
.createRoom(options)
.then((res) => res.room_id);
}, options);
}
}

View file

@ -193,6 +193,10 @@ export class Synapse implements Homeserver, HomeserverInstance {
}, },
}); });
if (!res.ok()) {
throw await res.json();
}
const data = await res.json(); const data = await res.json();
return { return {
homeServer: data.home_server, homeServer: data.home_server,

View file

@ -2,11 +2,15 @@
"compilerOptions": { "compilerOptions": {
"target": "es2016", "target": "es2016",
"jsx": "react", "jsx": "react",
"lib": ["es2021", "dom", "dom.iterable"], "lib": ["ESNext", "es2021", "dom", "dom.iterable"],
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "node", "moduleResolution": "node",
"module": "es2022" "module": "es2022"
}, },
"include": ["**/*.ts", "../src/@types/global.d.ts"] "include": [
"**/*.ts",
"../node_modules/matrix-js-sdk/src/@types/*.d.ts",
"../node_modules/matrix-js-sdk/node_modules/@matrix-org/olm/index.d.ts"
]
} }

View file

@ -9986,10 +9986,10 @@ typed-array-length@^1.0.4:
for-each "^0.3.3" for-each "^0.3.3"
is-typed-array "^1.1.9" is-typed-array "^1.1.9"
typescript@5.1.6: typescript@5.2.2:
version "5.1.6" version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA== integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
ua-parser-js@^1.0.2: ua-parser-js@^1.0.2:
version "1.0.37" version "1.0.37"