This commit is contained in:
Daniil
2026-04-04 14:51:40 +03:00
parent 10a1d28f77
commit 0523ef3d72
191 changed files with 12065 additions and 2658 deletions
@@ -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()
})
})
+110
View File
@@ -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()
})
})
})