iter 2
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/real-auth"
|
||||
|
||||
test.describe("Login (Integration)", () => {
|
||||
test("should login with registered credentials and stay authenticated after reload", async ({
|
||||
realLoginPage,
|
||||
}) => {
|
||||
const { page, user } = realLoginPage
|
||||
|
||||
await realLoginPage.login()
|
||||
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: new RegExp(`Добро пожаловать, ${user.firstName}`),
|
||||
}),
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(() => realLoginPage.getCookie("access_token"))
|
||||
.toBeTruthy()
|
||||
await expect
|
||||
.poll(() => realLoginPage.getCookie("refresh_token"))
|
||||
.toBeTruthy()
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: new RegExp(`Добро пожаловать, ${user.firstName}`),
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should keep user on login page and avoid auth cookies for invalid password", async ({
|
||||
realLoginPage,
|
||||
}) => {
|
||||
const { page, user } = realLoginPage
|
||||
const wrongPassword = `${user.password}_wrong`
|
||||
|
||||
await realLoginPage.login(wrongPassword)
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
await expect(page.getByRole("button", { name: "Войти" })).toBeEnabled()
|
||||
await expect(page.getByRole("textbox", { name: "Логин" })).toHaveValue(
|
||||
user.username,
|
||||
)
|
||||
await expect(page.getByLabel("Пароль")).toHaveValue(wrongPassword)
|
||||
await expect
|
||||
.poll(() => realLoginPage.getCookie("access_token"))
|
||||
.toBeFalsy()
|
||||
await expect
|
||||
.poll(() => realLoginPage.getCookie("refresh_token"))
|
||||
.toBeFalsy()
|
||||
})
|
||||
|
||||
test("should submit the login form with Enter from password field", async ({
|
||||
realLoginPage,
|
||||
}) => {
|
||||
const { page, user } = realLoginPage
|
||||
|
||||
await realLoginPage.submitWithEnter()
|
||||
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: new RegExp(`Добро пожаловать, ${user.firstName}`),
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/auth"
|
||||
|
||||
test.describe("Login Page", () => {
|
||||
test("should display login form with all fields", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Логин" }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Пароль" }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Войти" }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole("link", { name: /Зарегистрироваться/ }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should have link to registration page", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await page.getByRole("link", { name: /Зарегистрироваться/ }).click()
|
||||
await expect(page).toHaveURL(/\/register/)
|
||||
})
|
||||
|
||||
test("should login successfully and set auth cookies", async ({
|
||||
loginPage,
|
||||
}) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await loginPage.mockLoginSuccess()
|
||||
await loginPage.login("testuser", "password123")
|
||||
|
||||
// Verify the mutation succeeded by checking auth cookies are set
|
||||
await expect(async () => {
|
||||
const cookies = await page.evaluate(() => document.cookie)
|
||||
expect(cookies).toContain("access_token=fake-access-jwt")
|
||||
expect(cookies).toContain("refresh_token=fake-refresh-jwt")
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test("should show error on invalid credentials", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
const consoleErrors: string[] = []
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
await loginPage.mockLoginError(401)
|
||||
await loginPage.login("wronguser", "wrongpassword")
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
expect(consoleErrors.some((e) => e.includes("Login failed"))).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle network error gracefully", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await loginPage.mockLoginNetworkError()
|
||||
await loginPage.login("testuser", "password123")
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test("should handle server error (500)", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await loginPage.mockLoginError(500, {
|
||||
detail: "Internal server error",
|
||||
})
|
||||
await loginPage.login("testuser", "password123")
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test("should submit with empty fields", async ({ loginPage }) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await loginPage.mockLoginError(422, { detail: "Validation error" })
|
||||
await page.getByRole("button", { name: "Войти" }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test("should disable submit button while request is pending", async ({
|
||||
loginPage,
|
||||
}) => {
|
||||
const { page } = loginPage
|
||||
|
||||
await loginPage.mockLoginDelayed(2000)
|
||||
|
||||
await page.getByRole("textbox", { name: "Логин" }).fill("testuser")
|
||||
await page.getByRole("textbox", { name: "Пароль" }).fill("password123")
|
||||
|
||||
const submitButton = page.getByRole("button", { name: "Войти" })
|
||||
await submitButton.click()
|
||||
|
||||
await expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
const USER_ID = "00000000-0000-0000-0000-000000000001"
|
||||
const PROJECT_ID = "65df675b-013b-4b1f-ab2d-075dadbcd0d9"
|
||||
const CAPTION_PRESET_ID = "00000000-0000-0000-0000-000000000010"
|
||||
const TRANSCRIPTION_ARTIFACT_ID =
|
||||
"00000000-0000-0000-0000-000000000020"
|
||||
const TRANSCRIPTION_ID = "00000000-0000-0000-0000-000000000030"
|
||||
const CAPTION_JOB_ID = "00000000-0000-0000-0000-000000000040"
|
||||
const PRIMARY_FILE_KEY = "projects/test/video.mp4"
|
||||
|
||||
const DEFAULT_USER = {
|
||||
id: USER_ID,
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
phone_number: null,
|
||||
avatar: null,
|
||||
email_verified: true,
|
||||
phone_verified: false,
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
date_joined: "2025-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
test.describe("Caption Settings Step", () => {
|
||||
test("should recover a missing transcription artifact from project data", async ({
|
||||
page,
|
||||
}) => {
|
||||
let project: Record<string, unknown> = {
|
||||
id: PROJECT_ID,
|
||||
owner_id: USER_ID,
|
||||
name: "Тестовый проект",
|
||||
description: null,
|
||||
language: "auto",
|
||||
folder: null,
|
||||
status: "DRAFT",
|
||||
workspace_state: {
|
||||
wizard: {
|
||||
current_step: "caption-settings",
|
||||
completed_steps: [
|
||||
"upload",
|
||||
"verify",
|
||||
"silence-settings",
|
||||
"processing",
|
||||
"fragments",
|
||||
"transcription-settings",
|
||||
"transcription-processing",
|
||||
"subtitle-revision",
|
||||
],
|
||||
primary_file_key: PRIMARY_FILE_KEY,
|
||||
video_url: "http://localhost:9000/projects/test/video.mp4",
|
||||
silence_settings: {
|
||||
min_silence_duration_ms: 200,
|
||||
silence_threshold_db: 16,
|
||||
padding_ms: 100,
|
||||
},
|
||||
active_job_id: null,
|
||||
active_job_type: null,
|
||||
silence_job_id: null,
|
||||
transcription_artifact_id: null,
|
||||
caption_preset_id: CAPTION_PRESET_ID,
|
||||
caption_style_config: null,
|
||||
captioned_video_path: null,
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
created_at: "2025-06-01T00:00:00Z",
|
||||
updated_at: "2025-06-01T00:00:00Z",
|
||||
}
|
||||
let savedWizardState: Record<string, unknown> | null = null
|
||||
let generateRequestBody: Record<string, unknown> | null = null
|
||||
let generateRequestCount = 0
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "access_token",
|
||||
value: "fake-access-jwt",
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
name: "refresh_token",
|
||||
value: "fake-refresh-jwt",
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
])
|
||||
|
||||
await page.route("**/api/users/me/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(DEFAULT_USER),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route(`**/api/projects/${PROJECT_ID}/`, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
workspace_state?: { wizard?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
savedWizardState = body.workspace_state?.wizard ?? null
|
||||
project = {
|
||||
...project,
|
||||
workspace_state: body.workspace_state ?? project.workspace_state,
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fallback()
|
||||
})
|
||||
|
||||
await page.route("**/api/media/artifacts/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: TRANSCRIPTION_ARTIFACT_ID,
|
||||
project_id: PROJECT_ID,
|
||||
file_id: null,
|
||||
media_file_id: null,
|
||||
artifact_type: "TRANSCRIPTION_JSON",
|
||||
is_deleted: false,
|
||||
is_active: true,
|
||||
created_at: "2025-06-01T00:00:00Z",
|
||||
updated_at: "2025-06-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route(
|
||||
`**/api/transcribe/transcriptions/by-artifact/${TRANSCRIPTION_ARTIFACT_ID}/`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: TRANSCRIPTION_ID,
|
||||
artifact_id: TRANSCRIPTION_ARTIFACT_ID,
|
||||
}),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await page.route("**/api/captions/presets/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: CAPTION_PRESET_ID,
|
||||
user_id: null,
|
||||
name: "Системный пресет",
|
||||
description: null,
|
||||
is_system: true,
|
||||
style_config: {},
|
||||
preview_url: null,
|
||||
created_at: "2025-06-01T00:00:00Z",
|
||||
updated_at: "2025-06-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/tasks/captions-generate/", async (route) => {
|
||||
generateRequestCount += 1
|
||||
generateRequestBody = route.request().postDataJSON() as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: CAPTION_JOB_ID }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "RUNNING",
|
||||
progress_pct: 0,
|
||||
output_data: null,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(`/projects/${PROJECT_ID}`)
|
||||
|
||||
const captionStep = page.locator("[data-testid='CaptionSettingsStep']")
|
||||
const generateButton = captionStep.getByRole("button", {
|
||||
name: "Генерировать",
|
||||
})
|
||||
|
||||
await expect(captionStep).toBeVisible()
|
||||
await expect(captionStep.getByText("Системный пресет")).toBeVisible()
|
||||
await expect(generateButton).toBeEnabled()
|
||||
|
||||
await expect
|
||||
.poll(() => savedWizardState?.transcription_artifact_id ?? null)
|
||||
.toBe(TRANSCRIPTION_ARTIFACT_ID)
|
||||
|
||||
await generateButton.click()
|
||||
|
||||
expect(generateRequestBody).toMatchObject({
|
||||
video_s3_path: PRIMARY_FILE_KEY,
|
||||
transcription_id: TRANSCRIPTION_ID,
|
||||
project_id: PROJECT_ID,
|
||||
preset_id: CAPTION_PRESET_ID,
|
||||
})
|
||||
expect(generateRequestCount).toBe(1)
|
||||
|
||||
await expect
|
||||
.poll(() => savedWizardState?.current_step ?? null)
|
||||
.toBe("caption-processing")
|
||||
await expect
|
||||
.poll(() => savedWizardState?.active_job_id ?? null)
|
||||
.toBe(CAPTION_JOB_ID)
|
||||
|
||||
await expect(page.locator("[data-testid='ProcessingStep']")).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/real-backend"
|
||||
|
||||
// These tests run against the real backend (localhost:8000).
|
||||
// Each test gets a fresh user with real JWT tokens.
|
||||
// Created projects are cleaned up automatically after each test.
|
||||
|
||||
test.describe("Create Project (Integration)", () => {
|
||||
test("should create project and see it in the list", async ({
|
||||
realProjectsPage,
|
||||
}) => {
|
||||
const { page, modal } = realProjectsPage
|
||||
|
||||
// Empty state
|
||||
await expect(page.getByText("У вас пока нет проектов")).toBeVisible()
|
||||
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Интеграционный тест")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("Интеграционный тест")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should create project with description", async ({
|
||||
realProjectsPage,
|
||||
}) => {
|
||||
const { page, modal } = realProjectsPage
|
||||
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Проект с описанием")
|
||||
await modal
|
||||
.locator("#project_description")
|
||||
.fill("Описание для интеграционного теста")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("Проект с описанием")).toBeVisible()
|
||||
|
||||
// Verify via API that description was saved
|
||||
const projects = await realProjectsPage.getProjects()
|
||||
const created = projects.find(
|
||||
(p: { name: string }) => p.name === "Проект с описанием",
|
||||
)
|
||||
expect(created).toBeTruthy()
|
||||
})
|
||||
|
||||
test("should create project with Russian language", async ({
|
||||
realProjectsPage,
|
||||
}) => {
|
||||
const { page, modal } = realProjectsPage
|
||||
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Русский проект")
|
||||
|
||||
await modal.locator("button").filter({ hasText: "Авто" }).click()
|
||||
await page.locator("[role=option]", { hasText: "Русский" }).click()
|
||||
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
|
||||
// Verify language via API
|
||||
const projects = await realProjectsPage.getProjects()
|
||||
const created = projects.find(
|
||||
(p: { name: string }) => p.name === "Русский проект",
|
||||
) as { language: string } | undefined
|
||||
expect(created?.language).toBe("ru")
|
||||
})
|
||||
|
||||
test("should create multiple projects", async ({ realProjectsPage }) => {
|
||||
const { page, modal } = realProjectsPage
|
||||
|
||||
// Create first project
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Первый проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("Первый проект")).toBeVisible()
|
||||
|
||||
// Create second project
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Второй проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("Второй проект")).toBeVisible()
|
||||
|
||||
// Both visible
|
||||
await expect(page.getByText("Первый проект")).toBeVisible()
|
||||
await expect(page.getByText("Второй проект")).toBeVisible()
|
||||
|
||||
// Verify via API
|
||||
const projects = await realProjectsPage.getProjects()
|
||||
expect(projects.length).toBe(2)
|
||||
})
|
||||
|
||||
test("should persist project after page reload", async ({
|
||||
realProjectsPage,
|
||||
}) => {
|
||||
const { page, modal } = realProjectsPage
|
||||
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Персистентный проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("Персистентный проект")).toBeVisible()
|
||||
|
||||
// Reload and verify project is still there
|
||||
await page.reload()
|
||||
await page.getByRole("heading", { name: "Мои проекты" }).waitFor()
|
||||
await expect(page.getByText("Персистентный проект")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should show validation error for empty name (client-side)", async ({
|
||||
realProjectsPage,
|
||||
}) => {
|
||||
const { modal } = realProjectsPage
|
||||
|
||||
await realProjectsPage.openCreateModal()
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(
|
||||
modal.getByText("Введите название проекта"),
|
||||
).toBeVisible()
|
||||
await expect(modal).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import { expect, test } from "#tests/e2e/fixtures/projects"
|
||||
|
||||
interface CreateProjectRequestBody {
|
||||
description?: string
|
||||
language?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function requirePostBody(
|
||||
postBody: CreateProjectRequestBody | null,
|
||||
): CreateProjectRequestBody {
|
||||
if (!postBody) {
|
||||
throw new Error("Expected create project request body to be captured")
|
||||
}
|
||||
|
||||
return postBody
|
||||
}
|
||||
|
||||
// Note: ReactModal sets aria-modal="true" on the dialog, which makes
|
||||
// Playwright's getByRole() unable to find elements inside or outside the modal.
|
||||
// All modal content is accessed via CSS locators scoped through `modal`.
|
||||
|
||||
test.describe("Create Project Modal", () => {
|
||||
test.describe("Rendering & UI", () => {
|
||||
test("should display create project modal with all fields", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { modal } = projectsPage
|
||||
await projectsPage.openCreateModal()
|
||||
|
||||
await expect(modal.locator("h2")).toHaveText("Создать проект")
|
||||
await expect(modal.locator("p").first()).toHaveText(
|
||||
"Заполните основные поля проекта",
|
||||
)
|
||||
await expect(modal.locator("#project_name")).toHaveValue("")
|
||||
await expect(modal.locator("#project_description")).toHaveValue("")
|
||||
await expect(modal.locator("button", { hasText: "Отмена" })).toBeVisible()
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Создать" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should show default language as Авто", async ({ projectsPage }) => {
|
||||
const { modal } = projectsPage
|
||||
await projectsPage.openCreateModal()
|
||||
|
||||
const selectTrigger = modal.locator("button").filter({ hasText: "Авто" })
|
||||
await expect(selectTrigger).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Happy Path", () => {
|
||||
test("should create project with name only", async ({ projectsPage }) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
let postBody: CreateProjectRequestBody | null = null
|
||||
await projectsPage.mockCreateSuccess({ name: "Мой проект" })
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/projects/") && req.method() === "POST") {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Мой проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
// Modal should close
|
||||
await expect(modal).toBeHidden()
|
||||
|
||||
const requestBody = requirePostBody(postBody)
|
||||
expect(requestBody.name).toBe("Мой проект")
|
||||
expect(requestBody.language).toBe("auto")
|
||||
})
|
||||
|
||||
test("should create project with name and description", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
let postBody: CreateProjectRequestBody | null = null
|
||||
await projectsPage.mockCreateSuccess({
|
||||
name: "Мой проект",
|
||||
description: "Описание проекта",
|
||||
})
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/projects/") && req.method() === "POST") {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Мой проект")
|
||||
await modal.locator("#project_description").fill("Описание проекта")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
|
||||
const requestBody = requirePostBody(postBody)
|
||||
expect(requestBody.description).toBe("Описание проекта")
|
||||
})
|
||||
|
||||
test("should create project with different language", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
let postBody: CreateProjectRequestBody | null = null
|
||||
await projectsPage.mockCreateSuccess({
|
||||
name: "Мой проект",
|
||||
language: "ru",
|
||||
})
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/projects/") && req.method() === "POST") {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Мой проект")
|
||||
|
||||
// Open language select and pick Russian
|
||||
await modal.locator("button").filter({ hasText: "Авто" }).click()
|
||||
await page.locator("[role=option]", { hasText: "Русский" }).click()
|
||||
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
|
||||
const requestBody = requirePostBody(postBody)
|
||||
expect(requestBody.language).toBe("ru")
|
||||
})
|
||||
|
||||
test("should refresh projects list after creation", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
// Verify empty state
|
||||
await expect(page.getByText("У вас пока нет проектов")).toBeVisible()
|
||||
|
||||
await projectsPage.mockCreateSuccess({ name: "Новый проект" })
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Новый проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
// Modal closes and project appears in list
|
||||
await expect(modal).toBeHidden()
|
||||
await expect(page.getByText("У вас пока нет проектов")).toBeHidden()
|
||||
await expect(page.getByText("Новый проект")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Validation", () => {
|
||||
test("should show validation error for empty name", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
let postFired = false
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/projects/") && req.method() === "POST") {
|
||||
postFired = true
|
||||
}
|
||||
})
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal.getByText("Введите название проекта")).toBeVisible()
|
||||
expect(postFired).toBe(false)
|
||||
|
||||
// Modal stays open
|
||||
await expect(modal).toBeVisible()
|
||||
})
|
||||
|
||||
test("should show validation error for whitespace-only name", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { modal } = projectsPage
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill(" ")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal.getByText("Введите название проекта")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should clear validation error after correcting name", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { modal } = projectsPage
|
||||
await projectsPage.mockCreateSuccess({ name: "Проект" })
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
|
||||
// Trigger validation error
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
await expect(modal.getByText("Введите название проекта")).toBeVisible()
|
||||
|
||||
// Fix and resubmit
|
||||
await modal.locator("#project_name").fill("Проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Error States", () => {
|
||||
test("should keep modal open on server error (500)", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
const consoleErrors: string[] = []
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
await projectsPage.mockCreateError(500)
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
// Wait for error to be processed
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Modal stays open, buttons re-enable
|
||||
await expect(modal).toBeVisible()
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Создать" }),
|
||||
).toBeEnabled()
|
||||
await expect(modal.locator("button", { hasText: "Отмена" })).toBeEnabled()
|
||||
|
||||
expect(
|
||||
consoleErrors.some((e) => e.includes("Create project failed")),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("should keep modal open on network error", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
await projectsPage.mockCreateNetworkError()
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
await expect(modal).toBeVisible()
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Создать" }),
|
||||
).toBeEnabled()
|
||||
await expect(modal.locator("button", { hasText: "Отмена" })).toBeEnabled()
|
||||
})
|
||||
|
||||
test("should allow retry after failure", async ({ projectsPage }) => {
|
||||
const { page, modal } = projectsPage
|
||||
|
||||
// First attempt: 500 error
|
||||
await projectsPage.mockCreateError(500)
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Создать" }),
|
||||
).toBeEnabled()
|
||||
|
||||
// Second attempt: success (re-mock the route)
|
||||
await projectsPage.mockCreateSuccess({ name: "Проект" })
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Form Behavior", () => {
|
||||
test("should reset form when modal is closed and reopened", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { modal } = projectsPage
|
||||
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Черновик")
|
||||
await modal.locator("#project_description").fill("Описание черновика")
|
||||
|
||||
// Close with Cancel
|
||||
await modal.locator("button", { hasText: "Отмена" }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
|
||||
// Reopen
|
||||
await projectsPage.openCreateModal()
|
||||
await expect(modal.locator("#project_name")).toHaveValue("")
|
||||
await expect(modal.locator("#project_description")).toHaveValue("")
|
||||
await expect(
|
||||
modal.locator("button").filter({ hasText: "Авто" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should disable buttons while request is pending", async ({
|
||||
projectsPage,
|
||||
}) => {
|
||||
const { modal } = projectsPage
|
||||
|
||||
await projectsPage.mockCreateDelayed(2000)
|
||||
await projectsPage.openCreateModal()
|
||||
await modal.locator("#project_name").fill("Проект")
|
||||
await modal.locator("button", { hasText: "Создать" }).click()
|
||||
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Создать" }),
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
modal.locator("button", { hasText: "Отмена" }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
const USER_ID = "00000000-0000-0000-0000-000000000001"
|
||||
const PROJECT_ID = "75df675b-013b-4b1f-ab2d-075dadbcd0d9"
|
||||
const DETECT_JOB_ID = "00000000-0000-0000-0000-000000000050"
|
||||
const APPLY_JOB_ID = "00000000-0000-0000-0000-000000000051"
|
||||
const TRANSCRIPTION_JOB_ID = "00000000-0000-0000-0000-000000000052"
|
||||
const ORIGINAL_FILE_KEY = "projects/test/original-video.mp4"
|
||||
const ORIGINAL_FILE_URL = "http://localhost:4444/files/original-video.mp4"
|
||||
const CUT_FILE_KEY = "projects/test/cut-video.mp4"
|
||||
const CUT_FILE_URL = "http://localhost:4444/files/cut-video.mp4"
|
||||
|
||||
const DEFAULT_USER = {
|
||||
id: USER_ID,
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
phone_number: null,
|
||||
avatar: null,
|
||||
email_verified: true,
|
||||
phone_verified: false,
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
date_joined: "2025-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
const MOCK_SEGMENTS = [
|
||||
{ start_ms: 5000, end_ms: 8000 },
|
||||
{ start_ms: 15000, end_ms: 19000 },
|
||||
]
|
||||
|
||||
test.describe("Silence Apply Flow", () => {
|
||||
test("should show processing for cut application and transcribe the processed video", async ({
|
||||
page,
|
||||
}) => {
|
||||
let project: Record<string, unknown> = {
|
||||
id: PROJECT_ID,
|
||||
owner_id: USER_ID,
|
||||
name: "Тестовый проект",
|
||||
description: null,
|
||||
language: "auto",
|
||||
folder: null,
|
||||
status: "DRAFT",
|
||||
workspace_state: {
|
||||
wizard: {
|
||||
current_step: "fragments",
|
||||
completed_steps: [
|
||||
"upload",
|
||||
"verify",
|
||||
"silence-settings",
|
||||
"processing",
|
||||
],
|
||||
primary_file_key: ORIGINAL_FILE_KEY,
|
||||
video_url: ORIGINAL_FILE_URL,
|
||||
original_file_name: "original-video.mp4",
|
||||
silence_settings: {
|
||||
min_silence_duration_ms: 200,
|
||||
silence_threshold_db: 16,
|
||||
padding_ms: 100,
|
||||
},
|
||||
active_job_id: null,
|
||||
active_job_type: null,
|
||||
silence_job_id: DETECT_JOB_ID,
|
||||
transcription_artifact_id: null,
|
||||
caption_preset_id: null,
|
||||
caption_style_config: null,
|
||||
captioned_video_path: null,
|
||||
captioned_video_file_id: null,
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
created_at: "2025-06-01T00:00:00Z",
|
||||
updated_at: "2025-06-01T00:00:00Z",
|
||||
}
|
||||
let savedWizardState: Record<string, unknown> | null = null
|
||||
let applyStatus = "RUNNING"
|
||||
let transcriptionRequestBody: Record<string, unknown> | null = null
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "access_token",
|
||||
value: "fake-access-jwt",
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
name: "refresh_token",
|
||||
value: "fake-refresh-jwt",
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
])
|
||||
|
||||
await page.route("**/api/users/me/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(DEFAULT_USER),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route(`**/api/projects/${PROJECT_ID}/`, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
workspace_state?: { wizard?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
savedWizardState = body.workspace_state?.wizard ?? null
|
||||
project = {
|
||||
...project,
|
||||
workspace_state: body.workspace_state ?? project.workspace_state,
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fallback()
|
||||
})
|
||||
|
||||
await page.route("**/api/files/get_file/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filePath = url.searchParams.get("file_path")
|
||||
|
||||
const fileUrl =
|
||||
filePath === CUT_FILE_KEY ? CUT_FILE_URL : ORIGINAL_FILE_URL
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
file_url: fileUrl,
|
||||
file_path: filePath,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
const url = route.request().url()
|
||||
|
||||
if (url.includes(DETECT_JOB_ID)) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
job_type: "SILENCE_DETECT",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: 30000,
|
||||
},
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (url.includes(APPLY_JOB_ID)) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: applyStatus,
|
||||
job_type: "SILENCE_APPLY",
|
||||
progress_pct: applyStatus === "DONE" ? 100 : 30,
|
||||
output_data:
|
||||
applyStatus === "DONE"
|
||||
? {
|
||||
file_path: CUT_FILE_KEY,
|
||||
file_url: CUT_FILE_URL,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "RUNNING",
|
||||
progress_pct: 0,
|
||||
output_data: null,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/tasks/silence-apply/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: APPLY_JOB_ID }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/tasks/transcription-generate/", async (route) => {
|
||||
transcriptionRequestBody = route.request().postDataJSON() as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: TRANSCRIPTION_JOB_ID }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(`/projects/${PROJECT_ID}`)
|
||||
|
||||
const fragmentsStep = page.locator("[data-testid='FragmentsStep']")
|
||||
await expect(fragmentsStep).toBeVisible()
|
||||
|
||||
await fragmentsStep.getByRole("button", { name: "Применить" }).click()
|
||||
|
||||
await expect(page.locator("[data-testid='ProcessingStep']")).toBeVisible()
|
||||
await expect
|
||||
.poll(() => savedWizardState?.active_job_type ?? null)
|
||||
.toBe("SILENCE_APPLY")
|
||||
await expect
|
||||
.poll(() => savedWizardState?.current_step ?? null)
|
||||
.toBe("processing")
|
||||
|
||||
applyStatus = "DONE"
|
||||
|
||||
const transcriptionStep = page.locator(
|
||||
"[data-testid='TranscriptionSettingsStep']",
|
||||
)
|
||||
await expect(transcriptionStep).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await expect
|
||||
.poll(() => savedWizardState?.primary_file_key ?? null)
|
||||
.toBe(CUT_FILE_KEY)
|
||||
|
||||
await transcriptionStep
|
||||
.getByRole("button", { name: "Сгенерировать субтитры" })
|
||||
.click()
|
||||
|
||||
expect(transcriptionRequestBody).toMatchObject({
|
||||
file_key: CUT_FILE_KEY,
|
||||
project_id: PROJECT_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
MOCK_SEGMENTS,
|
||||
MOCK_DURATION_MS,
|
||||
MOCK_TOTAL_REMOVED_MS,
|
||||
} from "#tests/e2e/fixtures/fragments"
|
||||
|
||||
test.describe("Fragments Step (Integration)", () => {
|
||||
test.describe("Initial State", () => {
|
||||
test("should display the fragments step", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
await expect(fragmentsStep).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the correct fragment count", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText(`Фрагментов: ${MOCK_SEGMENTS.length}`),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the correct total removal duration", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const totalSec = Math.floor(MOCK_TOTAL_REMOVED_MS / 1000)
|
||||
await expect(
|
||||
fragmentsStep.getByText(`Будет удалено: ${totalSec}с`),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the video player", async ({ fragmentsPage }) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
await expect(
|
||||
fragmentsStep.locator("media-player"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should display zoom controls", async ({ fragmentsPage }) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
await expect(fragmentsStep.getByText("Масштаб")).toBeVisible()
|
||||
await expect(
|
||||
fragmentsStep.locator("button").filter({ hasText: "-" }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
fragmentsStep.locator("button").filter({ hasText: "+" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the apply button when regions exist", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Применить" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the cancel button", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const cancelButton = fragmentsStep.getByRole("button", {
|
||||
name: "Отмена",
|
||||
})
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await expect(cancelButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Zoom Controls", () => {
|
||||
test("should increase timeline width when + is clicked", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const timelineInner = fragmentsStep
|
||||
.locator("[class*='timelineInner']")
|
||||
.first()
|
||||
|
||||
// Wait for initial render
|
||||
await timelineInner.waitFor({ timeout: 5_000 })
|
||||
const initialWidth = await timelineInner.evaluate(
|
||||
(el) => el.clientWidth,
|
||||
)
|
||||
|
||||
// Click zoom in multiple times
|
||||
const zoomIn = fragmentsStep
|
||||
.locator("button")
|
||||
.filter({ hasText: "+" })
|
||||
await zoomIn.click()
|
||||
await zoomIn.click()
|
||||
await zoomIn.click()
|
||||
|
||||
const newWidth = await timelineInner.evaluate(
|
||||
(el) => el.clientWidth,
|
||||
)
|
||||
expect(newWidth).toBeGreaterThan(initialWidth)
|
||||
})
|
||||
|
||||
test("should decrease timeline width when - is clicked", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const timelineInner = fragmentsStep
|
||||
.locator("[class*='timelineInner']")
|
||||
.first()
|
||||
|
||||
// Zoom in first to have room to zoom out
|
||||
const zoomIn = fragmentsStep
|
||||
.locator("button")
|
||||
.filter({ hasText: "+" })
|
||||
await zoomIn.click()
|
||||
await zoomIn.click()
|
||||
await zoomIn.click()
|
||||
|
||||
await timelineInner.waitFor({ timeout: 5_000 })
|
||||
const widthAfterZoomIn = await timelineInner.evaluate(
|
||||
(el) => el.clientWidth,
|
||||
)
|
||||
|
||||
// Zoom out
|
||||
const zoomOut = fragmentsStep
|
||||
.locator("button")
|
||||
.filter({ hasText: "-" })
|
||||
await zoomOut.click()
|
||||
await zoomOut.click()
|
||||
|
||||
const widthAfterZoomOut = await timelineInner.evaluate(
|
||||
(el) => el.clientWidth,
|
||||
)
|
||||
expect(widthAfterZoomOut).toBeLessThan(widthAfterZoomIn)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Context Menu — Add Region", () => {
|
||||
test("should show context menu with add option on right-click on timeline", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const timeline = fragmentsStep
|
||||
.locator("[class*='timelineContainer']")
|
||||
.first()
|
||||
await timeline.click({ button: "right", position: { x: 50, y: 50 } })
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText("Добавить новый"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should add a new region via context menu", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const timeline = fragmentsStep
|
||||
.locator("[class*='timelineContainer']")
|
||||
.first()
|
||||
await timeline.click({ button: "right", position: { x: 50, y: 50 } })
|
||||
|
||||
await fragmentsStep.getByText("Добавить новый").click()
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText(
|
||||
`Фрагментов: ${MOCK_SEGMENTS.length + 1}`,
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Context Menu — Delete Region", () => {
|
||||
test("should show delete and add options when right-clicking a cut region", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.waitFor({ timeout: 5_000 })
|
||||
await region.click({ button: "right", force: true })
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText("Удалить"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
fragmentsStep.getByText("Добавить новый"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should delete a region via context menu", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
const region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.waitFor({ timeout: 5_000 })
|
||||
await region.click({ button: "right", force: true })
|
||||
|
||||
await fragmentsStep.getByText("Удалить").click()
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText(
|
||||
`Фрагментов: ${MOCK_SEGMENTS.length - 1}`,
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should update info bar correctly after multiple operations", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
// Delete first region
|
||||
let region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.waitFor({ timeout: 5_000 })
|
||||
await region.click({ button: "right", force: true })
|
||||
await fragmentsStep.getByText("Удалить").click()
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText(
|
||||
`Фрагментов: ${MOCK_SEGMENTS.length - 1}`,
|
||||
),
|
||||
).toBeVisible()
|
||||
|
||||
// Delete second region
|
||||
region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.click({ button: "right", force: true })
|
||||
await fragmentsStep.getByText("Удалить").click()
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText(
|
||||
`Фрагментов: ${MOCK_SEGMENTS.length - 2}`,
|
||||
),
|
||||
).toBeVisible()
|
||||
|
||||
// Add a new one
|
||||
const timeline = fragmentsStep
|
||||
.locator("[class*='timelineContainer']")
|
||||
.first()
|
||||
await timeline.click({ button: "right", position: { x: 100, y: 50 } })
|
||||
await fragmentsStep.getByText("Добавить новый").click()
|
||||
|
||||
// 4 - 2 + 1 = 3
|
||||
await expect(
|
||||
fragmentsStep.getByText(
|
||||
`Фрагментов: ${MOCK_SEGMENTS.length - 2 + 1}`,
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Skip When No Regions", () => {
|
||||
test("should show skip button when all regions are removed", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { fragmentsStep } = fragmentsPage
|
||||
|
||||
// Remove all regions one by one
|
||||
for (let i = 0; i < MOCK_SEGMENTS.length; i++) {
|
||||
const region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.waitFor({ timeout: 5_000 })
|
||||
await region.click({ button: "right", force: true })
|
||||
await fragmentsStep.getByText("Удалить").click()
|
||||
}
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByText("Фрагментов: 0"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
fragmentsStep.getByText("Будет удалено: 0с"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Пропустить" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should advance to transcription settings when skip is clicked", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
// Remove all regions
|
||||
for (let i = 0; i < MOCK_SEGMENTS.length; i++) {
|
||||
const region = fragmentsStep
|
||||
.locator("[data-testid='cut-region']")
|
||||
.first()
|
||||
await region.waitFor({ timeout: 5_000 })
|
||||
await region.click({ button: "right", force: true })
|
||||
await fragmentsStep.getByText("Удалить").click()
|
||||
}
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Пропустить" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='TranscriptionSettingsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Apply with Regions", () => {
|
||||
test("should send correct request body when apply is clicked", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep, projectId } = fragmentsPage
|
||||
|
||||
let postBody: Record<string, unknown> | null = null
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/tasks/silence-apply/") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.click()
|
||||
|
||||
// Wait for request to be sent
|
||||
await expect
|
||||
.poll(() => postBody, { timeout: 5_000 })
|
||||
.not.toBeNull()
|
||||
|
||||
expect(postBody!.project_id).toBe(projectId)
|
||||
expect(postBody!.file_key).toBeTruthy()
|
||||
expect((postBody!.output_name as string)).toContain("Без тишины")
|
||||
expect((postBody!.cuts as unknown[]).length).toBe(
|
||||
MOCK_SEGMENTS.length,
|
||||
)
|
||||
})
|
||||
|
||||
test("should disable buttons while apply is pending", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
// Delay the apply response
|
||||
await page.route(
|
||||
"**/api/tasks/silence-apply/**",
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "fake-apply-job" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
"**/api/tasks/silence-apply/",
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "fake-apply-job" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Применить" }),
|
||||
).toBeDisabled({ timeout: 2_000 })
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Отмена" }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test("should show processing step after successful apply submission", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
// Mock successful apply response
|
||||
await page.route("**/api/tasks/silence-apply/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "apply-job-123" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/api/tasks/silence-apply/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "apply-job-123" }),
|
||||
})
|
||||
})
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should advance to transcription settings and submit processed file after apply completes", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
const applyJobId = "apply-job-123"
|
||||
const processedFilePath =
|
||||
"users/test-user/output_files/silent/Без тишины test-video.mp4"
|
||||
const processedFileUrl = "https://example.com/processed-video.mp4"
|
||||
let transcriptionBody: Record<string, unknown> | null = null
|
||||
|
||||
await page.route("**/api/tasks/silence-apply/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: applyJobId }),
|
||||
})
|
||||
})
|
||||
await page.route("**/api/tasks/silence-apply/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: applyJobId }),
|
||||
})
|
||||
})
|
||||
await page.unroute("**/api/tasks/status/**")
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes(applyJobId)) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
job_type: "SILENCE_APPLY",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
file_path: processedFilePath,
|
||||
file_url: processedFileUrl,
|
||||
},
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
job_type: "SILENCE_DETECT",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: MOCK_DURATION_MS,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/tasks/transcription-generate/") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
transcriptionBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
await page.route(
|
||||
"**/api/tasks/transcription-generate/**",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "transcription-job-123" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
"**/api/tasks/transcription-generate/",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "transcription-job-123" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
await expect(
|
||||
page.locator("[data-testid='TranscriptionSettingsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page
|
||||
.locator("[data-testid='TranscriptionSettingsStep']")
|
||||
.getByRole("button", { name: "Сгенерировать субтитры" })
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() => transcriptionBody, { timeout: 5_000 })
|
||||
.not.toBeNull()
|
||||
expect(
|
||||
(transcriptionBody as { file_key?: string } | null)?.file_key,
|
||||
).toBe(processedFilePath)
|
||||
})
|
||||
|
||||
test("should stay on fragments step when apply fails", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
// Mock failed apply response
|
||||
await page.route("**/api/tasks/silence-apply/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Internal Server Error" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/api/tasks/silence-apply/", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Internal Server Error" }),
|
||||
})
|
||||
})
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.click()
|
||||
|
||||
// Should stay on fragments step
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(fragmentsStep).toBeVisible()
|
||||
|
||||
// Buttons should be re-enabled
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Применить" }),
|
||||
).toBeEnabled({ timeout: 5_000 })
|
||||
await expect(
|
||||
fragmentsStep.getByRole("button", { name: "Отмена" }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test("should navigate back when cancel button is clicked", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
await fragmentsStep
|
||||
.getByRole("button", { name: "Отмена" })
|
||||
.click()
|
||||
|
||||
// Should go back to the previous step
|
||||
await expect(fragmentsStep).not.toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// The previous step in the wizard should be visible
|
||||
const processingOrSettings = page
|
||||
.locator(
|
||||
"[data-testid='ProcessingStep'], [data-testid='SilenceSettingsStep']",
|
||||
)
|
||||
.first()
|
||||
await expect(processingOrSettings).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("State Persistence", () => {
|
||||
test("should restore fragments step after page reload", async ({
|
||||
fragmentsPage,
|
||||
}) => {
|
||||
const { page, fragmentsStep } = fragmentsPage
|
||||
|
||||
// Wait for debounced save
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// Reload page — re-add route interceptor for task status
|
||||
await page.reload()
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: MOCK_DURATION_MS,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page
|
||||
.locator("[data-testid='ProjectWizard']")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Should restore to fragments step
|
||||
await expect(
|
||||
page.locator("[data-testid='FragmentsStep']"),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Regions should re-load from mock data
|
||||
await expect(
|
||||
page.getByText(`Фрагментов: ${MOCK_SEGMENTS.length}`),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,429 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/processing"
|
||||
|
||||
const MOCK_SEGMENTS = [
|
||||
{ start_ms: 5000, end_ms: 8000 },
|
||||
{ start_ms: 15000, end_ms: 19000 },
|
||||
{ start_ms: 32000, end_ms: 35000 },
|
||||
{ start_ms: 45000, end_ms: 50000 },
|
||||
]
|
||||
const MOCK_DURATION_MS = 60000
|
||||
|
||||
function buildNotification(
|
||||
jobId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
event: "task_update",
|
||||
notification_id: null,
|
||||
job_id: jobId,
|
||||
project_id: null,
|
||||
job_type: "SILENCE_DETECT",
|
||||
status: "RUNNING",
|
||||
progress_pct: 0,
|
||||
message: null,
|
||||
title: null,
|
||||
created_at: new Date().toISOString(),
|
||||
is_read: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Processing Step (Integration)", () => {
|
||||
test.describe("Initial State", () => {
|
||||
test("should display the processing step with progress and status label", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { processingStep } = processingPage
|
||||
|
||||
await expect(processingStep).toBeVisible()
|
||||
await expect(processingStep.getByText("0%")).toBeVisible()
|
||||
await expect(processingStep.getByText("АНАЛИЗ")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the default status message", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { processingStep } = processingPage
|
||||
|
||||
await expect(
|
||||
processingStep.getByText("Подождите, идёт обработка..."),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the info card about server processing", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { processingStep } = processingPage
|
||||
|
||||
await expect(
|
||||
processingStep.getByText(
|
||||
"Обработка выполняется на сервере. Вы можете покинуть страницу — прогресс сохранится.",
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should display the cancel button", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { processingStep } = processingPage
|
||||
|
||||
const cancelButton = processingStep.getByRole("button", {
|
||||
name: "Отменить обработку",
|
||||
})
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await expect(cancelButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Progress Updates", () => {
|
||||
test("should display updated progress percentage from notifications", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep, jobId } = processingPage
|
||||
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
progress_pct: 45,
|
||||
message: "Анализируем аудио...",
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(processingStep.getByText("45%")).toBeVisible()
|
||||
await expect(
|
||||
processingStep.getByText("Анализируем аудио..."),
|
||||
).toBeVisible()
|
||||
await expect(processingStep.getByText("АНАЛИЗ")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should update progress when notification changes", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep, jobId } = processingPage
|
||||
|
||||
// First update: 25%
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
progress_pct: 25,
|
||||
message: "Обработка...",
|
||||
}),
|
||||
)
|
||||
await expect(processingStep.getByText("25%")).toBeVisible()
|
||||
|
||||
// Second update: 75%
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
progress_pct: 75,
|
||||
message: "Почти готово...",
|
||||
}),
|
||||
)
|
||||
await expect(processingStep.getByText("75%")).toBeVisible()
|
||||
await expect(
|
||||
processingStep.getByText("Почти готово..."),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Auto-Advance on Completion", () => {
|
||||
test("should auto-advance to fragments step when task status returns DONE", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page } = processingPage
|
||||
|
||||
// Replace the route to return DONE
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: MOCK_DURATION_MS,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for auto-advance (polls every 2s)
|
||||
await expect(
|
||||
page.locator("[data-testid='FragmentsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should auto-advance when notification reports DONE", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, jobId } = processingPage
|
||||
|
||||
// Dispatch DONE notification (auto-advance also checks notifications)
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
}),
|
||||
)
|
||||
|
||||
// The WizardContext auto-advance also needs the polling to confirm,
|
||||
// so update the route too
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: MOCK_DURATION_MS,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='FragmentsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Failure State", () => {
|
||||
test("should display error state when notification status is FAILED", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep, jobId } = processingPage
|
||||
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
status: "FAILED",
|
||||
message: "Файл повреждён",
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(processingStep.getByText("ОШИБКА")).toBeVisible()
|
||||
await expect(
|
||||
processingStep.getByText("Файл повреждён"),
|
||||
).toBeVisible()
|
||||
|
||||
// Button should change from "Отменить обработку" to "Назад"
|
||||
await expect(
|
||||
processingStep.getByRole("button", { name: "Назад" }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
processingStep.getByRole("button", {
|
||||
name: "Отменить обработку",
|
||||
}),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should show default error message when FAILED notification has no message", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep, jobId } = processingPage
|
||||
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
status: "FAILED",
|
||||
message: null,
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
processingStep.getByText("Произошла ошибка при обработке"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should navigate back to silence settings when clicking back in error state", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep, jobId } = processingPage
|
||||
|
||||
await page.evaluate(
|
||||
(payload) => {
|
||||
;(window as any).__REDUX_STORE__?.dispatch({
|
||||
type: "notifications/addNotification",
|
||||
payload,
|
||||
})
|
||||
},
|
||||
buildNotification(jobId, {
|
||||
status: "FAILED",
|
||||
message: "Ошибка обработки",
|
||||
}),
|
||||
)
|
||||
|
||||
await processingStep
|
||||
.getByRole("button", { name: "Назад" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='SilenceSettingsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
await expect(processingStep).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Cancel", () => {
|
||||
test("should navigate back to silence settings when cancel button is clicked", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep } = processingPage
|
||||
|
||||
await processingStep
|
||||
.getByRole("button", { name: "Отменить обработку" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='SilenceSettingsStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
await expect(processingStep).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should start a new job when clicking Далее again after cancel", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page, processingStep } = processingPage
|
||||
|
||||
// Cancel
|
||||
await processingStep
|
||||
.getByRole("button", { name: "Отменить обработку" })
|
||||
.click()
|
||||
|
||||
const silenceStep = page.locator(
|
||||
"[data-testid='SilenceSettingsStep']",
|
||||
)
|
||||
await silenceStep.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Capture new request
|
||||
let newPostMade = false
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/tasks/silence-detect/") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
newPostMade = true
|
||||
}
|
||||
})
|
||||
|
||||
// Click "Далее" again
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
expect(newPostMade).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("State Persistence", () => {
|
||||
test("should restore processing step after page reload", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page } = processingPage
|
||||
|
||||
// Wait for debounced save
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// Reload — route intercepts are lost, re-add before reload completes
|
||||
await page.reload()
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "RUNNING",
|
||||
progress_pct: 0,
|
||||
output_data: null,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page
|
||||
.locator("[data-testid='ProjectWizard']")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should resume polling and auto-advance after page reload", async ({
|
||||
processingPage,
|
||||
}) => {
|
||||
const { page } = processingPage
|
||||
|
||||
// Wait for debounced save
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// Reload and set up route to return DONE
|
||||
await page.reload()
|
||||
await page.route("**/api/tasks/status/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
output_data: {
|
||||
silent_segments: MOCK_SEGMENTS,
|
||||
duration_ms: MOCK_DURATION_MS,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page
|
||||
.locator("[data-testid='ProjectWizard']")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Should auto-advance to fragments after polling picks up DONE
|
||||
await expect(
|
||||
page.locator("[data-testid='FragmentsStep']"),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,450 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/silence"
|
||||
|
||||
test.describe("Silence Settings Step (Integration)", () => {
|
||||
test.describe("Initial State", () => {
|
||||
test("should display the title and description", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
await expect(
|
||||
silenceStep.getByText("Параметры обнаружения тишины"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
silenceStep.getByText(
|
||||
"Настройте параметры для автоматического обнаружения тихих участков в видео",
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should show all three sliders with default values", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
// Min silence duration slider: default 200 ms
|
||||
const durationSlider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Мин. длительность тишины" })
|
||||
await expect(durationSlider).toBeVisible()
|
||||
await expect(durationSlider.getByText("200 мс")).toBeVisible()
|
||||
await expect(
|
||||
durationSlider.locator("input[type='range']"),
|
||||
).toHaveValue("200")
|
||||
|
||||
// Silence threshold slider: default 16 dB
|
||||
const thresholdSlider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Порог тишины" })
|
||||
await expect(thresholdSlider).toBeVisible()
|
||||
await expect(thresholdSlider.getByText("16 дБ")).toBeVisible()
|
||||
await expect(
|
||||
thresholdSlider.locator("input[type='range']"),
|
||||
).toHaveValue("16")
|
||||
|
||||
// Padding slider: default 100 ms
|
||||
const paddingSlider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Отступ" })
|
||||
await expect(paddingSlider).toBeVisible()
|
||||
await expect(paddingSlider.getByText("100 мс")).toBeVisible()
|
||||
await expect(
|
||||
paddingSlider.locator("input[type='range']"),
|
||||
).toHaveValue("100")
|
||||
})
|
||||
|
||||
test("should display help texts for each slider", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
await expect(
|
||||
silenceStep.getByText(
|
||||
"Минимальная длительность тихого участка для обнаружения",
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
silenceStep.getByText(
|
||||
"Уровень громкости ниже которого звук считается тишиной",
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
silenceStep.getByText(
|
||||
"Дополнительный отступ по краям тихих участков",
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should show back and forward navigation buttons", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
const backButton = silenceStep.getByRole("button", {
|
||||
name: "Назад",
|
||||
})
|
||||
const forwardButton = silenceStep.getByRole("button", {
|
||||
name: "Далее",
|
||||
})
|
||||
|
||||
await expect(backButton).toBeVisible()
|
||||
await expect(backButton).toBeEnabled()
|
||||
await expect(forwardButton).toBeVisible()
|
||||
await expect(forwardButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Slider Interactions", () => {
|
||||
test("should update min silence duration slider value when changed", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
const slider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Мин. длительность тишины" })
|
||||
const input = slider.locator("input[type='range']")
|
||||
|
||||
await input.fill("500")
|
||||
|
||||
await expect(input).toHaveValue("500")
|
||||
await expect(slider.getByText("500 мс")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should update silence threshold slider value when changed", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
const slider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Порог тишины" })
|
||||
const input = slider.locator("input[type='range']")
|
||||
|
||||
await input.fill("24")
|
||||
|
||||
await expect(input).toHaveValue("24")
|
||||
await expect(slider.getByText("24 дБ")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should update padding slider value when changed", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { silenceStep } = silencePage
|
||||
|
||||
const slider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Отступ" })
|
||||
const input = slider.locator("input[type='range']")
|
||||
|
||||
await input.fill("250")
|
||||
|
||||
await expect(input).toHaveValue("250")
|
||||
await expect(slider.getByText("250 мс")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Successful Submission", () => {
|
||||
test("should submit with default values and navigate to Processing step", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Should navigate to Processing step
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should send correct request body with default values to the API", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, projectId, silenceStep } = silencePage
|
||||
|
||||
let postBody: Record<string, unknown> | null = null
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/tasks/silence-detect/") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
expect(postBody).not.toBeNull()
|
||||
expect(postBody!.project_id).toBe(projectId)
|
||||
expect(postBody!.min_silence_duration_ms).toBe(200)
|
||||
expect(postBody!.silence_threshold_db).toBe(16)
|
||||
expect(postBody!.padding_ms).toBe(100)
|
||||
// file_key should be a non-empty string populated from the uploaded file
|
||||
expect(postBody!.file_key).toBeTruthy()
|
||||
expect(typeof postBody!.file_key).toBe("string")
|
||||
})
|
||||
|
||||
test("should send correct request body with modified slider values", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
// Modify all three sliders
|
||||
await silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Мин. длительность тишины" })
|
||||
.locator("input[type='range']")
|
||||
.fill("800")
|
||||
|
||||
await silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Порог тишины" })
|
||||
.locator("input[type='range']")
|
||||
.fill("30")
|
||||
|
||||
await silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Отступ" })
|
||||
.locator("input[type='range']")
|
||||
.fill("375")
|
||||
|
||||
let postBody: Record<string, unknown> | null = null
|
||||
page.on("request", (req) => {
|
||||
if (
|
||||
req.url().includes("/api/tasks/silence-detect/") &&
|
||||
req.method() === "POST"
|
||||
) {
|
||||
postBody = req.postDataJSON()
|
||||
}
|
||||
})
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
expect(postBody).not.toBeNull()
|
||||
expect(postBody!.min_silence_duration_ms).toBe(800)
|
||||
expect(postBody!.silence_threshold_db).toBe(30)
|
||||
expect(postBody!.padding_ms).toBe(375)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test("should navigate back to Verify step when back button is clicked", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Назад" })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Silence Settings step should no longer be visible
|
||||
await expect(silenceStep).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Error States", () => {
|
||||
test("should stay on silence settings step when API returns network error", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
await page.route("**/api/tasks/silence-detect/**", (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
await page.route("**/api/tasks/silence-detect/", (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Should NOT navigate to Processing step
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).not.toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Should remain on Silence Settings step
|
||||
await expect(silenceStep).toBeVisible()
|
||||
})
|
||||
|
||||
test("should stay on silence settings step when API returns 500", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
await page.route("**/api/tasks/silence-detect/**", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
detail: "Internal Server Error",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/tasks/silence-detect/", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
detail: "Internal Server Error",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Should NOT navigate to Processing step
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).not.toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Should remain on Silence Settings step
|
||||
await expect(silenceStep).toBeVisible()
|
||||
})
|
||||
|
||||
test("should allow retrying after a network failure", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
// First attempt: abort the request
|
||||
await page.route("**/api/tasks/silence-detect/**", (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
await page.route("**/api/tasks/silence-detect/", (route) =>
|
||||
route.abort(),
|
||||
)
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Should stay on silence settings
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).not.toBeVisible({ timeout: 5_000 })
|
||||
await expect(silenceStep).toBeVisible()
|
||||
|
||||
// Remove intercepts and retry
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Should succeed and navigate to Processing step
|
||||
await expect(
|
||||
page.locator("[data-testid='ProcessingStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should show pending text and disable buttons while submission is in flight", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
// Delay the API response to observe the pending state
|
||||
await page.route(
|
||||
"**/api/tasks/silence-detect/**",
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "fake-job-id" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
"**/api/tasks/silence-detect/",
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "fake-job-id" }),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await silenceStep
|
||||
.getByRole("button", { name: "Далее" })
|
||||
.click()
|
||||
|
||||
// Submit button should show "Запуск..." and be disabled
|
||||
const submitButton = silenceStep.getByRole("button", {
|
||||
name: "Запуск...",
|
||||
})
|
||||
await expect(submitButton).toBeVisible({ timeout: 2_000 })
|
||||
await expect(submitButton).toBeDisabled()
|
||||
|
||||
// Back button should also be disabled during pending state
|
||||
const backButton = silenceStep.getByRole("button", {
|
||||
name: "Назад",
|
||||
})
|
||||
await expect(backButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("State Persistence", () => {
|
||||
test("should persist modified silence settings across page reloads", async ({
|
||||
silencePage,
|
||||
}) => {
|
||||
const { page, silenceStep } = silencePage
|
||||
|
||||
// Change the min silence duration slider
|
||||
const durationSlider = silenceStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Мин. длительность тишины" })
|
||||
await durationSlider.locator("input[type='range']").fill("750")
|
||||
|
||||
// Verify the value changed
|
||||
await expect(durationSlider.getByText("750 мс")).toBeVisible()
|
||||
|
||||
// Wait for the debounced save to persist (1000ms debounce + network)
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await page.locator("[data-testid='ProjectWizard']").waitFor()
|
||||
|
||||
// Should restore to the silence settings step with persisted value
|
||||
const restoredStep = page.locator(
|
||||
"[data-testid='SilenceSettingsStep']",
|
||||
)
|
||||
await expect(restoredStep).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const restoredSlider = restoredStep
|
||||
.locator("[data-testid='Slider']")
|
||||
.filter({ hasText: "Мин. длительность тишины" })
|
||||
await expect(restoredSlider.getByText("750 мс")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect(
|
||||
restoredSlider.locator("input[type='range']"),
|
||||
).toHaveValue("750")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/upload"
|
||||
|
||||
test.describe("File Type and Extension Validation (Integration)", () => {
|
||||
test.describe("Input Accept Attribute", () => {
|
||||
test("should restrict file input to video/* MIME types", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
await expect(uploadPage.fileInput).toHaveAttribute(
|
||||
"accept",
|
||||
"video/*",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Valid Video Files", () => {
|
||||
test("should accept and upload an MP4 file", async ({ uploadPage }) => {
|
||||
const { page, testVideoPath } = uploadPage
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.getByText("Готово к обработке"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("should attempt upload for a WebM file", async ({ uploadPage }) => {
|
||||
const { page, dropZone } = uploadPage
|
||||
|
||||
// Create a minimal buffer pretending to be WebM
|
||||
const webmBuffer = Buffer.from([
|
||||
0x1a, 0x45, 0xdf, 0xa3, // EBML header magic
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f,
|
||||
])
|
||||
|
||||
await uploadPage.uploadBuffer("test.webm", "video/webm", webmBuffer)
|
||||
|
||||
// Upload should begin (progress or error — both prove the file was sent)
|
||||
const uploadStarted = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Загрузка файла...")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
])
|
||||
|
||||
expect(uploadStarted).toBe(true)
|
||||
})
|
||||
|
||||
test("should attempt upload for an MOV file", async ({ uploadPage }) => {
|
||||
const { page, dropZone } = uploadPage
|
||||
|
||||
// Minimal ftyp atom for MOV
|
||||
const movBuffer = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x14, // size: 20
|
||||
0x66, 0x74, 0x79, 0x70, // ftyp
|
||||
0x71, 0x74, 0x20, 0x20, // qt (QuickTime)
|
||||
0x00, 0x00, 0x00, 0x00, // minor version
|
||||
0x71, 0x74, 0x20, 0x20, // compatible brand
|
||||
])
|
||||
|
||||
await uploadPage.uploadBuffer(
|
||||
"test.mov",
|
||||
"video/quicktime",
|
||||
movBuffer,
|
||||
)
|
||||
|
||||
const uploadStarted = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Загрузка файла...")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
])
|
||||
|
||||
expect(uploadStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Non-Video Files (Bypass via setInputFiles)", () => {
|
||||
test("should attempt to upload a non-video file (no client-side validation)", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone } = uploadPage
|
||||
|
||||
// setInputFiles bypasses the accept attribute — proves no JS validation
|
||||
const txtBuffer = Buffer.from("Hello, this is a text file")
|
||||
await uploadPage.uploadBuffer(
|
||||
"document.txt",
|
||||
"text/plain",
|
||||
txtBuffer,
|
||||
)
|
||||
|
||||
// Upload should begin regardless — component has no file type check
|
||||
const uploadStarted = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Загрузка файла...")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
])
|
||||
|
||||
expect(uploadStarted).toBe(true)
|
||||
})
|
||||
|
||||
test("should attempt to upload a PDF file", async ({ uploadPage }) => {
|
||||
const { page, dropZone } = uploadPage
|
||||
|
||||
const pdfBuffer = Buffer.from("%PDF-1.4 fake content")
|
||||
await uploadPage.uploadBuffer(
|
||||
"document.pdf",
|
||||
"application/pdf",
|
||||
pdfBuffer,
|
||||
)
|
||||
|
||||
const uploadStarted = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Загрузка файла...")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
])
|
||||
|
||||
expect(uploadStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Edge Cases — File Names", () => {
|
||||
test("should handle a file with Unicode characters in the name", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
const fs = await import("node:fs")
|
||||
|
||||
const videoContent = fs.readFileSync(testVideoPath)
|
||||
|
||||
await uploadPage.uploadBuffer(
|
||||
"видео_тест_2026.mp4",
|
||||
"video/mp4",
|
||||
videoContent,
|
||||
)
|
||||
|
||||
// Should upload successfully
|
||||
const result = await Promise.race([
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "verify" as const),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "error" as const),
|
||||
])
|
||||
|
||||
// Both outcomes are valid — we verify no crash
|
||||
expect(["verify", "error"]).toContain(result)
|
||||
})
|
||||
|
||||
test("should handle a file with special characters in the name", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
const fs = await import("node:fs")
|
||||
|
||||
const videoContent = fs.readFileSync(testVideoPath)
|
||||
|
||||
await uploadPage.uploadBuffer(
|
||||
"test file (1) [final].mp4",
|
||||
"video/mp4",
|
||||
videoContent,
|
||||
)
|
||||
|
||||
const result = await Promise.race([
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "verify" as const),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "error" as const),
|
||||
])
|
||||
|
||||
expect(["verify", "error"]).toContain(result)
|
||||
})
|
||||
|
||||
test("should handle a zero-byte video file", async ({ uploadPage }) => {
|
||||
const { page, dropZone } = uploadPage
|
||||
|
||||
const emptyBuffer = Buffer.alloc(0)
|
||||
await uploadPage.uploadBuffer("empty.mp4", "video/mp4", emptyBuffer)
|
||||
|
||||
// Should attempt upload, likely fail server-side
|
||||
const result = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 15_000 })
|
||||
.then(() => "error" as const),
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 15_000 })
|
||||
.then(() => "verify" as const),
|
||||
])
|
||||
|
||||
expect(["error", "verify"]).toContain(result)
|
||||
})
|
||||
|
||||
test("should handle a file with no extension", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
const fs = await import("node:fs")
|
||||
|
||||
const videoContent = fs.readFileSync(testVideoPath)
|
||||
|
||||
await uploadPage.uploadBuffer(
|
||||
"videofile",
|
||||
"video/mp4",
|
||||
videoContent,
|
||||
)
|
||||
|
||||
const result = await Promise.race([
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "verify" as const),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "error" as const),
|
||||
])
|
||||
|
||||
expect(["verify", "error"]).toContain(result)
|
||||
})
|
||||
|
||||
test("should handle a file with double extension", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
const fs = await import("node:fs")
|
||||
|
||||
const videoContent = fs.readFileSync(testVideoPath)
|
||||
|
||||
await uploadPage.uploadBuffer(
|
||||
"video.mp4.mp4",
|
||||
"video/mp4",
|
||||
videoContent,
|
||||
)
|
||||
|
||||
const result = await Promise.race([
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "verify" as const),
|
||||
dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "error" as const),
|
||||
])
|
||||
|
||||
expect(["verify", "error"]).toContain(result)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("FormData Payload Verification", () => {
|
||||
test("should send file in FormData and include correct folder path", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, projectId, testVideoPath } = uploadPage
|
||||
|
||||
let requestFired = false
|
||||
let hasAuthHeader = false
|
||||
let requestUrl = ""
|
||||
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/files/upload")) {
|
||||
requestFired = true
|
||||
requestUrl = req.url()
|
||||
hasAuthHeader = !!req.headers()["authorization"]
|
||||
}
|
||||
})
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
// Wait for the request to fire
|
||||
await expect(async () => {
|
||||
expect(requestFired).toBe(true)
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
expect(requestUrl).toContain("/api/files/upload")
|
||||
expect(hasAuthHeader).toBe(true)
|
||||
|
||||
// Wait for upload to complete (verify or error)
|
||||
await Promise.race([
|
||||
page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 }),
|
||||
uploadPage.dropZone
|
||||
.getByText("Не удалось загрузить файл")
|
||||
.waitFor({ timeout: 30_000 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test, expect } from "#tests/e2e/fixtures/upload"
|
||||
|
||||
test.describe("File Upload (Integration)", () => {
|
||||
test.describe("Initial State", () => {
|
||||
test("should display the upload drop zone with correct instructions", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { dropZone } = uploadPage
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Перетащите видеофайл сюда"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
dropZone.getByText("или нажмите для выбора файла"),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
dropZone.locator("button", { hasText: "Выбрать файл" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("should have a file input that accepts only video types", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { fileInput } = uploadPage
|
||||
|
||||
await expect(fileInput).toHaveAttribute("accept", "video/*")
|
||||
await expect(fileInput).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test("should not show progress bar or error in initial state", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { dropZone } = uploadPage
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Загрузка файла..."),
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
dropZone.getByText("Не удалось загрузить файл"),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Successful Upload", () => {
|
||||
test("should upload a valid video file and advance to the Verify step", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, testVideoPath } = uploadPage
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
// Wait for wizard to advance to Verify step
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
test("should show upload progress during file upload", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { dropZone, testVideoPath } = uploadPage
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
// Progress UI should appear (may be brief for small files)
|
||||
// We check that either progress appeared or the step already advanced
|
||||
const progressOrVerify = await Promise.race([
|
||||
dropZone
|
||||
.getByText("Загрузка файла...")
|
||||
.waitFor({ timeout: 5_000 })
|
||||
.then(() => "progress" as const)
|
||||
.catch(() => null),
|
||||
uploadPage.page
|
||||
.locator("[data-testid='VerifyStep']")
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.then(() => "verify" as const),
|
||||
])
|
||||
|
||||
expect(["progress", "verify"]).toContain(progressOrVerify)
|
||||
})
|
||||
|
||||
test("should show media info on Verify step after upload", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, testVideoPath } = uploadPage
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
const verifyStep = page.locator("[data-testid='VerifyStep']")
|
||||
await expect(verifyStep).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Badge should show "Готово к обработке" for MP4
|
||||
await expect(
|
||||
verifyStep.getByText("Готово к обработке"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// File info card should show the filename
|
||||
await expect(verifyStep.getByText("Файл")).toBeVisible()
|
||||
await expect(verifyStep.getByText("Размер и формат")).toBeVisible()
|
||||
})
|
||||
|
||||
test("should persist wizard state after upload completes", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, testVideoPath } = uploadPage
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Wait for debounced state save (1000ms debounce + network)
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
await page.reload()
|
||||
await page.locator("[data-testid='ProjectWizard']").waitFor()
|
||||
|
||||
// Should remain on Verify step after reload
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Error States", () => {
|
||||
test("should show error message when upload fails due to network error", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
|
||||
// Intercept the upload XHR endpoint to abort
|
||||
await page.route("**/api/files/upload/**", (route) => route.abort())
|
||||
await page.route("**/api/files/upload/", (route) => route.abort())
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Не удалось загрузить файл"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Wizard stays on upload step
|
||||
await expect(dropZone).toBeVisible()
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should show error message when server returns 500", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
|
||||
await page.route("**/api/files/upload/**", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Internal Server Error" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/files/upload/", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Internal Server Error" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Не удалось загрузить файл"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Stays on upload step
|
||||
await expect(dropZone).toBeVisible()
|
||||
})
|
||||
|
||||
test("should allow retrying upload after a failure", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
|
||||
// First attempt: network error
|
||||
await page.route("**/api/files/upload/**", (route) => route.abort())
|
||||
await page.route("**/api/files/upload/", (route) => route.abort())
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Не удалось загрузить файл"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Remove intercepts and retry
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
// Should succeed now and advance to Verify
|
||||
await expect(
|
||||
page.locator("[data-testid='VerifyStep']"),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
test("should show error message when server returns 413", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, testVideoPath } = uploadPage
|
||||
|
||||
await page.route("**/api/files/upload/**", (route) =>
|
||||
route.fulfill({
|
||||
status: 413,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "File too large" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/files/upload/", (route) =>
|
||||
route.fulfill({
|
||||
status: 413,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "File too large" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
await expect(
|
||||
dropZone.getByText("Не удалось загрузить файл"),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Edge Cases", () => {
|
||||
test("should disable file input during active upload", async ({
|
||||
uploadPage,
|
||||
}) => {
|
||||
const { page, dropZone, fileInput, testVideoPath } = uploadPage
|
||||
|
||||
// Delay the upload response to observe the uploading state
|
||||
await page.route("**/api/files/upload/**", async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
file_path: "projects/test/video.mp4",
|
||||
file_url: "http://localhost:9000/projects/test/video.mp4",
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route("**/api/files/upload/", async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
file_path: "projects/test/video.mp4",
|
||||
file_url: "http://localhost:9000/projects/test/video.mp4",
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await uploadPage.uploadFile(testVideoPath)
|
||||
|
||||
// During upload, the file input should be disabled
|
||||
await expect(
|
||||
dropZone.getByText("Загрузка файла..."),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
await expect(fileInput).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user