diff --git a/docs/basic-guides/authorization.mdx b/docs/basic-guides/authorization.mdx new file mode 100644 index 00000000..f92dc1ea --- /dev/null +++ b/docs/basic-guides/authorization.mdx @@ -0,0 +1,318 @@ +import Admonition from "@theme/Admonition"; + +# Authorization in Tests + + + +- How the `saveState` and `restoreState` commands work +- How to save an authentication session and reuse it across tests +- How to work with multiple accounts and roles +- Best practices for storing state and running tests in parallel + + + +## Introduction + +When tests start, the browser has no authentication data. Logging in within every test is inefficient: it increases the overall run time and creates a dependency on the stability of the login form. + +The [`saveState`](../commands/browser/saveState.mdx) and [`restoreState`](../commands/browser/restoreState.mdx) commands let you save the browser state (cookies, localStorage, sessionStorage) after a single login and restore it in subsequent tests. + +## How the commands work + +### saveState + +The [`saveState`](../commands/browser/saveState.mdx) command saves the current browser state: cookies, localStorage, and sessionStorage. The data can be saved to a file or to a variable. + +```typescript +// Save to a file +await browser.saveState({ path: "./state.json" }); + +// Save to a variable +const state = await browser.saveState(); +``` + +For pages with iframes, storage data is saved separately for each origin. IndexedDB is not included in the saved state. + +### restoreState + +The [`restoreState`](../commands/browser/restoreState.mdx) command restores a previously saved state. + +```typescript +// Restore from a file +await browser.restoreState({ path: "./state.json" }); + +// Restore from a variable +await browser.restoreState({ data: state }); +``` + + + Before calling `restoreState`, open a page on the target domain using the + [`url`](../commands/browser/url.mdx) command. This is a browser limitation: localStorage and + sessionStorage are bound to the origin, and cookies can only be set for the current domain. + + +By default, after restoring the state the page is reloaded (`refresh: true`). This is required so that the application can "see" the restored data. + +### Working with cookies directly + +If authentication is stored only in cookies and localStorage/sessionStorage are not needed, you can use the [`getCookies`](../commands/browser/getCookies.mdx), [`setCookies`](../commands/browser/setCookies.mdx), and [`deleteCookies`](../commands/browser/deleteCookies.mdx) commands: + +```typescript +// Get cookies +const cookies = await browser.getCookies(); + +// Set cookies +await browser.setCookies([{ name: "session", value: "abc123", domain: ".example.com" }]); + +// Delete a specific cookie +await browser.deleteCookies("session"); +``` + +## Basic scenario: single account + +In this approach, authentication is performed once in `beforeAll`, and the state is saved to a file. Before each test, the [`@testplane/global-hook`](../plugins/testplane-global-hook.mdx) plugin opens the application and restores the saved state, so tests start with an already authenticated session. + +
+ + Configuration example + + ```typescript title="testplane.config.ts" + + import path from "node:path"; + import type { ConfigInput, WdioBrowser } from "testplane"; + import { launchBrowser } from "testplane/unstable"; + + const baseUrl = "http://localhost:3000"; + const statePath = path.resolve(process.cwd(), ".testplane", "states", "user.json"); + + async function login(browser: WdioBrowser) { + await browser.url(`${baseUrl}/login`); + await browser.$("#email").setValue(process.env.E2E_USER_EMAIL); + await browser.$("#password").setValue(process.env.E2E_USER_PASSWORD); + await browser.$("#submit").click(); + await browser.$("#welcome").waitForDisplayed(); + } + + export default { + gridUrl: "local", + + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome" + } + } + }, + + sets: { + desktop: { + files: ["testplane/**/*.e2e.ts"], + browsers: ["chrome"] + } + }, + + beforeAll: async ({ config }) => { + const browser = await launchBrowser(config.browsers.chrome!); + + await login(browser); + await browser.saveState({ path: statePath }); + + await browser.deleteSession(); + }, + + plugins: { + "@testplane/global-hook": { + enabled: true, + + beforeEach: async ({ browser }: { browser: WdioBrowser }) => { + await browser.url(baseUrl); + await browser.restoreState({ path: statePath }); + } + } + } + + } satisfies ConfigInput; + ``` + +
+ +
+ + Test example + + ```typescript title="example.testplane.ts" + const baseUrl = "http://localhost:3000"; + + describe("authenticated user", () => { + it("opens the dashboard without re-logging in", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("user"); + }); + + it("restores localStorage and sessionStorage", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + const email = await browser.execute(() => localStorage.getItem("auth.email")); + const role = await browser.execute(() => localStorage.getItem("auth.role")); + const bannerDismissed = await browser.execute(() => + sessionStorage.getItem("feature.bannerDismissed") + ); + + expect(email).toBe(process.env.E2E_USER_EMAIL); + expect(role).toBe("user"); + expect(bannerDismissed).toBe("true"); + }); + }); + ``` + +
+ +- `beforeAll` performs the login only once and saves a browser snapshot to a file. +- `@testplane/global-hook` moves the repetitive state-restoration logic out of the tests themselves into a shared `beforeEach`. +- Before `restoreState`, `browser.url(baseUrl)` is always called — otherwise the browser cannot correctly restore cookies and storage for the required origin. + +## Multiple accounts (admin + user) + +In this approach, two state snapshots are prepared in `beforeAll`: one for a regular user and one for an administrator. The required snapshot is selected in `beforeEach`, and the tests can verify differences in permissions and UI elements. + +
+ + Configuration example + + ```typescript title="testplane.config.ts" + + import path from "node:path"; + import type { ConfigInput, WdioBrowser } from "testplane"; + import { launchBrowser } from "testplane/unstable"; + + const baseUrl = "http://localhost:3000"; + const stateDir = path.resolve(process.cwd(), ".testplane", "states"); + const userStatePath = path.join(stateDir, "user.json"); + const adminStatePath = path.join(stateDir, "admin.json"); + + async function login(browser: WdioBrowser, email: string, password: string) { + await browser.url(`${baseUrl}/login`); + await browser.$("#email").setValue(email); + await browser.$("#password").setValue(password); + await browser.$("#submit").click(); + await browser.$("#welcome").waitForDisplayed(); + } + + async function clearClientState(browser: WdioBrowser) { + await browser.url(`${baseUrl}/login`); + await browser.execute(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await browser.deleteCookies(); + } + + export default { + gridUrl: "local", + + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome" + } + } + }, + + sets: { + desktop: { + files: ["testplane/**/*.e2e.ts"], + browsers: ["chrome"] + } + }, + + beforeAll: async ({ config }) => { + const browser = await launchBrowser(config.browsers.chrome!); + + await login( + browser, + process.env.E2E_USER_EMAIL, + process.env.E2E_USER_PASSWORD + ); + await browser.saveState({ path: userStatePath }); + + await clearClientState(browser); + + await login( + browser, + process.env.E2E_ADMIN_EMAIL, + process.env.E2E_ADMIN_PASSWORD + ); + await browser.saveState({ path: adminStatePath }); + + await browser.deleteSession(); + }, + + plugins: { + "@testplane/global-hook": { + enabled: true, + + beforeEach: async ({ browser, currentTest }: { + browser: WdioBrowser; + currentTest: { title: string }; + }) => { + const statePath = currentTest.title.includes("[admin]") + ? adminStatePath + : userStatePath; + + await browser.url(baseUrl); + await browser.restoreState({ path: statePath }); + } + } + } + } satisfies ConfigInput; + ``` + +
+ +
+ + Test example + + ```typescript title="example.testplane.ts" + const baseUrl = "http://localhost:3000"; + + describe("accounts with different roles", () => { + it("opens the dashboard for a regular user", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("user"); + await expect(browser.$("#no-admin-message")).toBeDisplayed(); + }); + + it("[admin] opens the dashboard for an administrator", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("admin"); + await expect(browser.$("#admin-link")).toBeDisplayed(); + }); + + it("denies the user access to the admin page", async ({ browser }) => { + await browser.url(`${baseUrl}/admin`); + await expect(browser.$("#forbidden-message")).toBeDisplayed(); + }); + + it("[admin] allows the administrator access to the admin page", async ({ browser }) => { + await browser.url(`${baseUrl}/admin`); + await expect(browser.$("#manage-users")).toBeDisplayed(); + }); + }); + ``` + +
+ +- In `beforeAll`, two separate state files are saved — one per role — so they can be reused across tests. +- In `beforeEach`, the required state snapshot is selected; in this example the choice is based on the test title, but in a real project you can use any scheme that works for you. +- This approach clearly demonstrates that the same set of tests can be run under different roles without re-logging in before each case. + +## Best practices + +- Don't commit state files to the repository: they may contain cookies and other sensitive data. +- Store logins and passwords in environment variables, not in test code. +- Keep the session lifetime in mind: if a state snapshot becomes stale, it must be recreated. +- If tests modify data on the server side, the same account can become a source of conflicts when run in parallel. In such cases, it's better to prepare separate accounts for each conflicting scenario. diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/basic-guides/authorization.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/basic-guides/authorization.mdx new file mode 100644 index 00000000..2215fcfb --- /dev/null +++ b/i18n/ru/docusaurus-plugin-content-docs/current/basic-guides/authorization.mdx @@ -0,0 +1,318 @@ +import Admonition from "@theme/Admonition"; + +# Авторизация в тестах + + + +- Как работают команды `saveState` и `restoreState` +- Как сохранить сессию авторизации и переиспользовать ее в тестах +- Как работать с несколькими аккаунтами и ролями +- Лучшие практики хранения состояния и работы с параллельными тестами + + + +## Введение + +При запуске тестов браузер не содержит данных авторизации. Выполнять вход в каждом тесте неэффективно: это увеличивает время прогона и создает зависимость от стабильности формы логина. + +Команды [`saveState`](../commands/browser/saveState.mdx) и [`restoreState`](../commands/browser/restoreState.mdx) позволяют сохранить состояние браузера (cookies, localStorage, sessionStorage) после однократной авторизации и восстанавливать его в последующих тестах. + +## Как работают команды + +### saveState + +Команда [`saveState`](../commands/browser/saveState.mdx) сохраняет текущее состояние браузера: cookies, localStorage и sessionStorage. Данные можно сохранить в файл или в переменную. + +```typescript +// Сохранение в файл +await browser.saveState({ path: "./state.json" }); + +// Сохранение в переменную +const state = await browser.saveState(); +``` + +Для страниц с iframe данные storage сохраняются отдельно для каждого origin. IndexedDB в сохраняемое состояние не входит. + +### restoreState + +Команда [`restoreState`](../commands/browser/restoreState.mdx) восстанавливает ранее сохраненное состояние. + +```typescript +// Восстановление из файла +await browser.restoreState({ path: "./state.json" }); + +// Восстановление из переменной +await browser.restoreState({ data: state }); +``` + + + Перед вызовом `restoreState` откройте страницу нужного домена командой + [`url`](../commands/browser/url.mdx). Это техническое ограничение браузера: localStorage и + sessionStorage привязаны к origin, а cookies можно установить только для текущего домена. + + +По умолчанию после восстановления состояния страница перезагружается (`refresh: true`). Это нужно, чтобы приложение «увидело» восстановленные данные. + +### Работа с cookies напрямую + +Если авторизация хранится только в cookies и localStorage/sessionStorage не нужны, можно использовать команды [`getCookies`](../commands/browser/getCookies.mdx), [`setCookies`](../commands/browser/setCookies.mdx) и [`deleteCookies`](../commands/browser/deleteCookies.mdx): + +```typescript +// Получить cookies +const cookies = await browser.getCookies(); + +// Установить cookies +await browser.setCookies([{ name: "session", value: "abc123", domain: ".example.com" }]); + +// Удалить конкретный cookie +await browser.deleteCookies("session"); +``` + +## Базовый сценарий: один аккаунт + +В этом варианте авторизация выполняется один раз в `beforeAll`, после чего состояние сохраняется в файл. Перед каждым тестом плагин [`@testplane/global-hook`](../plugins/testplane-global-hook.mdx) открывает приложение и восстанавливает сохраненное состояние, чтобы тесты начинались уже с готовой авторизацией. + +
+ + Пример конфигурации + + ```typescript title="testplane.config.ts" + + import path from "node:path"; + import type { ConfigInput, WdioBrowser } from "testplane"; + import { launchBrowser } from "testplane/unstable"; + + const baseUrl = "http://localhost:3000"; + const statePath = path.resolve(process.cwd(), ".testplane", "states", "user.json"); + + async function login(browser: WdioBrowser) { + await browser.url(`${baseUrl}/login`); + await browser.$("#email").setValue(process.env.E2E_USER_EMAIL); + await browser.$("#password").setValue(process.env.E2E_USER_PASSWORD); + await browser.$("#submit").click(); + await browser.$("#welcome").waitForDisplayed(); + } + + export default { + gridUrl: "local", + + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome" + } + } + }, + + sets: { + desktop: { + files: ["testplane/**/*.e2e.ts"], + browsers: ["chrome"] + } + }, + + beforeAll: async ({ config }) => { + const browser = await launchBrowser(config.browsers.chrome!); + + await login(browser); + await browser.saveState({ path: statePath }); + + await browser.deleteSession(); + }, + + plugins: { + "@testplane/global-hook": { + enabled: true, + + beforeEach: async ({ browser }: { browser: WdioBrowser }) => { + await browser.url(baseUrl); + await browser.restoreState({ path: statePath }); + } + } + } + + } satisfies ConfigInput; + ``` + +
+ +
+ + Пример теста + + ```typescript title="example.testplane.ts" + const baseUrl = "http://localhost:3000"; + + describe("авторизованный пользователь", () => { + it("открывает дашборд без повторного логина", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("user"); + }); + + it("восстанавливает localStorage и sessionStorage", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + const email = await browser.execute(() => localStorage.getItem("auth.email")); + const role = await browser.execute(() => localStorage.getItem("auth.role")); + const bannerDismissed = await browser.execute(() => + sessionStorage.getItem("feature.bannerDismissed") + ); + + expect(email).toBe(process.env.E2E_USER_EMAIL); + expect(role).toBe("user"); + expect(bannerDismissed).toBe("true"); + }); + }); + ``` + +
+ +- `beforeAll` выполняет логин только один раз и сохраняет снимок состояния браузера в файл. +- `@testplane/global-hook` выносит повторяющуюся логику восстановления состояния из самих тестов в общий `beforeEach`. +- Перед `restoreState` обязательно вызывается `browser.url(baseUrl)`, иначе браузер не сможет корректно восстановить cookies и storage для нужного origin. + +## Несколько аккаунтов (admin + user) + +В этом варианте в `beforeAll` подготавливаются два снимка состояния: один для обычного пользователя, второй для администратора. Нужный снимок выбирается в `beforeEach`, а в тестах можно проверять различия в правах доступа и элементах интерфейса. + +
+ + Пример конфигурации + + ```typescript title="testplane.config.ts" + + import path from "node:path"; + import type { ConfigInput, WdioBrowser } from "testplane"; + import { launchBrowser } from "testplane/unstable"; + + const baseUrl = "http://localhost:3000"; + const stateDir = path.resolve(process.cwd(), ".testplane", "states"); + const userStatePath = path.join(stateDir, "user.json"); + const adminStatePath = path.join(stateDir, "admin.json"); + + async function login(browser: WdioBrowser, email: string, password: string) { + await browser.url(`${baseUrl}/login`); + await browser.$("#email").setValue(email); + await browser.$("#password").setValue(password); + await browser.$("#submit").click(); + await browser.$("#welcome").waitForDisplayed(); + } + + async function clearClientState(browser: WdioBrowser) { + await browser.url(`${baseUrl}/login`); + await browser.execute(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await browser.deleteCookies(); + } + + export default { + gridUrl: "local", + + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome" + } + } + }, + + sets: { + desktop: { + files: ["testplane/**/*.e2e.ts"], + browsers: ["chrome"] + } + }, + + beforeAll: async ({ config }) => { + const browser = await launchBrowser(config.browsers.chrome!); + + await login( + browser, + process.env.E2E_USER_EMAIL, + process.env.E2E_USER_PASSWORD + ); + await browser.saveState({ path: userStatePath }); + + await clearClientState(browser); + + await login( + browser, + process.env.E2E_ADMIN_EMAIL, + process.env.E2E_ADMIN_PASSWORD + ); + await browser.saveState({ path: adminStatePath }); + + await browser.deleteSession(); + }, + + plugins: { + "@testplane/global-hook": { + enabled: true, + + beforeEach: async ({ browser, currentTest }: { + browser: WdioBrowser; + currentTest: { title: string }; + }) => { + const statePath = currentTest.title.includes("[admin]") + ? adminStatePath + : userStatePath; + + await browser.url(baseUrl); + await browser.restoreState({ path: statePath }); + } + } + } + } satisfies ConfigInput; + ``` + +
+ +
+ + Пример теста + + ```typescript title="example.testplane.ts" + const baseUrl = "http://localhost:3000"; + + describe("аккаунты с разными ролями", () => { + it("открывает дашборд для обычного пользователя", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("user"); + await expect(browser.$("#no-admin-message")).toBeDisplayed(); + }); + + it("[admin] открывает дашборд для администратора", async ({ browser }) => { + await browser.url(`${baseUrl}/dashboard`); + + await expect(browser.$("#role")).toHaveTextContaining("admin"); + await expect(browser.$("#admin-link")).toBeDisplayed(); + }); + + it("запрещает пользователю доступ к странице администратора", async ({ browser }) => { + await browser.url(`${baseUrl}/admin`); + await expect(browser.$("#forbidden-message")).toBeDisplayed(); + }); + + it("[admin] разрешает администратору доступ к странице администратора", async ({ browser }) => { + await browser.url(`${baseUrl}/admin`); + await expect(browser.$("#manage-users")).toBeDisplayed(); + }); + }); + ``` + +
+ +- В `beforeAll` сохраняются два отдельных файла состояния: по одному на роль, чтобы затем переиспользовать их в тестах. +- В `beforeEach` выбирается нужный снимок состояния; в примере выбор завязан на название теста, но в реальном проекте это может быть любая удобная схема. +- Такой подход позволяет наглядно показать, что один и тот же набор тестов можно запускать под разными ролями без повторного логина перед каждым кейсом. + +## Лучшие практики + +- Не коммитьте файлы состояния в репозиторий: в них могут содержаться cookies и другие чувствительные данные. +- Храните логины и пароли в переменных окружения, а не в коде тестов. +- Учитывайте срок жизни сессии: если снимок состояния устарел, его придется пересоздать. +- Если тесты изменяют данные на стороне сервера, один и тот же аккаунт может стать источником конфликтов при параллельном запуске. В таких случаях лучше готовить отдельные аккаунты на каждый конфликтующий сценарий.