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
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
import { test as base, type Page, type Route } from "@playwright/test"
const DEFAULT_USER = {
id: "00000000-0000-0000-0000-000000000001",
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 DEFAULT_TOKENS = {
access: "fake-access-jwt",
refresh: "fake-refresh-jwt",
}
interface LoginPage {
page: Page
login(username: string, password: string): Promise<void>
mockLoginSuccess(userData?: Record<string, unknown>): Promise<void>
mockLoginError(status: number, body?: Record<string, unknown>): Promise<void>
mockLoginNetworkError(): Promise<void>
mockLoginDelayed(delayMs?: number): Promise<void>
}
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await page.goto("/login")
await page.getByRole("heading", { name: "Вход" }).waitFor()
const loginPage: LoginPage = {
page,
async login(username: string, password: string) {
await page.getByRole("textbox", { name: "Логин" }).fill(username)
await page.getByRole("textbox", { name: "Пароль" }).fill(password)
await page.getByRole("button", { name: "Войти" }).click()
},
async mockLoginSuccess(userData?: Record<string, unknown>) {
const mockUser = { ...DEFAULT_USER, ...userData }
await page.route("**/auth/login", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user: mockUser,
...DEFAULT_TOKENS,
}),
})
})
await page.route("**/api/users/me/", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockUser),
})
})
await page.route("**/api/projects/*", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
})
})
},
async mockLoginError(
status: number,
body?: Record<string, unknown>,
) {
await page.route("**/auth/login", async (route: Route) => {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(
body ?? { detail: "Invalid credentials" },
),
})
})
},
async mockLoginNetworkError() {
await page.route("**/auth/login", async (route: Route) => {
await route.abort()
})
},
async mockLoginDelayed(delayMs = 2000) {
await page.route("**/auth/login", async (route: Route) => {
await new Promise((r) => setTimeout(r, delayMs))
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user: DEFAULT_USER,
...DEFAULT_TOKENS,
}),
})
})
},
}
await use(loginPage)
},
})
export { expect } from "@playwright/test"
+159
View File
@@ -0,0 +1,159 @@
import path from "node:path"
import { test as base, type Locator, type Page } from "@playwright/test"
import {
createProjectViaApi,
deleteProjectViaApi,
loginAsAdmin,
} from "#tests/e2e/support/auth-api"
export 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 },
]
export const MOCK_DURATION_MS = 60000
export const MOCK_TOTAL_REMOVED_MS = MOCK_SEGMENTS.reduce(
(sum, s) => sum + (s.end_ms - s.start_ms),
0,
)
interface FragmentsPage {
page: Page
projectId: string
fragmentsStep: Locator
jobId: string
}
export const test = base.extend<{ fragmentsPage: FragmentsPage }>({
fragmentsPage: async ({ page }, use) => {
const tokens = await loginAsAdmin()
await page.context().addCookies([
{
name: "access_token",
value: tokens.accessToken,
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: tokens.refreshToken,
domain: "localhost",
path: "/",
},
])
const suffix = Date.now().toString(36)
const projectId = await createProjectViaApi(
tokens.accessToken,
`fragments-test-${suffix}`,
)
// Navigate to project wizard
await page.goto(`/projects/${projectId}`)
await page.locator("[data-testid='ProjectWizard']").waitFor()
// Upload test video file
const testVideoPath = path.resolve(
__dirname,
"../assets/test-video.mp4",
)
const fileInput = page
.locator("[data-testid='UploadStep']")
.locator("input[type='file']")
await fileInput.setInputFiles(testVideoPath)
// Wait for wizard to advance to Verify step
await page
.locator("[data-testid='VerifyStep']")
.waitFor({ timeout: 30_000 })
// Wait for file processing to complete
await page
.locator("[data-testid='VerifyStep']")
.getByText("Готово к обработке")
.waitFor({ timeout: 10_000 })
// Advance to Silence Settings step
await page
.getByRole("button", { name: "Далее: Настройки тишины" })
.click()
const silenceStep = page.locator("[data-testid='SilenceSettingsStep']")
await silenceStep.waitFor({ timeout: 10_000 })
// Intercept task status polling — initially return RUNNING
let taskStatusResponse = {
status: "RUNNING",
progress_pct: 0,
output_data: null as Record<string, unknown> | null,
}
await page.route("**/api/tasks/status/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(taskStatusResponse),
})
})
// Capture job_id from the silence-detect submission
let capturedJobId = ""
page.on("response", async (res) => {
if (
res.url().includes("/api/tasks/silence-detect/") &&
res.request().method() === "POST" &&
res.status() === 200
) {
try {
const body = await res.json()
if (body?.job_id) capturedJobId = body.job_id
} catch {
// ignore
}
}
})
// Click "Далее" to submit silence detection
await silenceStep.getByRole("button", { name: "Далее" }).click()
// Wait for ProcessingStep to appear
await page
.locator("[data-testid='ProcessingStep']")
.waitFor({ timeout: 10_000 })
// Now switch the task status to DONE with mock data to trigger auto-advance
taskStatusResponse = {
status: "DONE",
progress_pct: 100,
output_data: {
silent_segments: MOCK_SEGMENTS,
duration_ms: MOCK_DURATION_MS,
},
}
// Wait for auto-advance to FragmentsStep (polls every 2s)
const fragmentsStep = page.locator("[data-testid='FragmentsStep']")
await fragmentsStep.waitFor({ timeout: 15_000 })
const fragmentsPage: FragmentsPage = {
page,
projectId,
fragmentsStep,
jobId: capturedJobId,
}
await use(fragmentsPage)
// Cleanup: delete project
try {
await deleteProjectViaApi(tokens.accessToken, projectId)
} catch {
// Best-effort cleanup
}
},
})
export { expect } from "@playwright/test"
+138
View File
@@ -0,0 +1,138 @@
import path from "node:path"
import { test as base, type Locator, type Page } from "@playwright/test"
import {
createProjectViaApi,
deleteProjectViaApi,
loginAsAdmin,
} from "#tests/e2e/support/auth-api"
interface ProcessingPage {
page: Page
projectId: string
processingStep: Locator
jobId: string
}
export const test = base.extend<{ processingPage: ProcessingPage }>({
processingPage: async ({ page }, use) => {
const tokens = await loginAsAdmin()
await page.context().addCookies([
{
name: "access_token",
value: tokens.accessToken,
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: tokens.refreshToken,
domain: "localhost",
path: "/",
},
])
const suffix = Date.now().toString(36)
const projectId = await createProjectViaApi(
tokens.accessToken,
`processing-test-${suffix}`,
)
// Navigate to project wizard
await page.goto(`/projects/${projectId}`)
await page.locator("[data-testid='ProjectWizard']").waitFor()
// Upload test video file
const testVideoPath = path.resolve(
__dirname,
"../assets/test-video.mp4",
)
const fileInput = page
.locator("[data-testid='UploadStep']")
.locator("input[type='file']")
await fileInput.setInputFiles(testVideoPath)
// Wait for wizard to advance to Verify step
await page
.locator("[data-testid='VerifyStep']")
.waitFor({ timeout: 30_000 })
// Wait for file processing to complete
await page
.locator("[data-testid='VerifyStep']")
.getByText("Готово к обработке")
.waitFor({ timeout: 10_000 })
// Advance to Silence Settings step
await page
.getByRole("button", { name: "Далее: Настройки тишины" })
.click()
const silenceStep = page.locator("[data-testid='SilenceSettingsStep']")
await silenceStep.waitFor({ timeout: 10_000 })
// Intercept task status polling to keep returning RUNNING (prevent auto-advance)
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,
}),
})
})
// Capture job_id from the silence-detect submission
let capturedJobId = ""
page.on("request", (req) => {
if (
req.url().includes("/api/tasks/silence-detect/") &&
req.method() === "POST"
) {
// We'll get the job_id from the response
}
})
page.on("response", async (res) => {
if (
res.url().includes("/api/tasks/silence-detect/") &&
res.request().method() === "POST" &&
res.status() === 200
) {
try {
const body = await res.json()
if (body?.job_id) capturedJobId = body.job_id
} catch {
// ignore parse errors
}
}
})
// Click "Далее" to submit silence detection
await silenceStep.getByRole("button", { name: "Далее" }).click()
// Wait for ProcessingStep to appear
const processingStep = page.locator("[data-testid='ProcessingStep']")
await processingStep.waitFor({ timeout: 10_000 })
const processingPage: ProcessingPage = {
page,
projectId,
processingStep,
jobId: capturedJobId,
}
await use(processingPage)
// Cleanup: delete project
try {
await deleteProjectViaApi(tokens.accessToken, projectId)
} catch {
// Best-effort cleanup
}
},
})
export { expect } from "@playwright/test"
+179
View File
@@ -0,0 +1,179 @@
import { test as base, type Locator, type Page, type Route } from "@playwright/test"
const DEFAULT_USER = {
id: "00000000-0000-0000-0000-000000000001",
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 DEFAULT_PROJECT = {
id: "00000000-0000-0000-0000-000000000002",
owner_id: "00000000-0000-0000-0000-000000000001",
name: "Тестовый проект",
description: null,
language: "auto",
folder: null,
status: "DRAFT",
workspace_state: null,
is_active: true,
created_at: "2025-06-01T00:00:00Z",
updated_at: "2025-06-01T00:00:00Z",
}
interface ProjectsPage {
page: Page
/** Locator for the modal dialog (aria-modal prevents getByRole from working) */
modal: Locator
openCreateModal(): Promise<void>
mockCreateSuccess(overrides?: Record<string, unknown>): Promise<void>
mockCreateError(status: number, body?: Record<string, unknown>): Promise<void>
mockCreateNetworkError(): Promise<void>
mockCreateDelayed(ms?: number): Promise<void>
}
export const test = base.extend<{ projectsPage: ProjectsPage }>({
projectsPage: async ({ page }, use) => {
let projectsList: Record<string, unknown>[] = []
// Set auth cookies
await page.context().addCookies([
{
name: "access_token",
value: "fake-access-jwt",
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: "fake-refresh-jwt",
domain: "localhost",
path: "/",
},
])
// Mock GET /api/users/me/
await page.route("**/api/users/me/", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(DEFAULT_USER),
})
})
// Mock GET /api/projects/ (stateful)
await page.route("**/api/projects/*", async (route: Route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(projectsList),
})
} else {
await route.fallback()
}
})
await page.route("**/api/projects/", async (route: Route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(projectsList),
})
} else {
await route.fallback()
}
})
await page.goto("/projects")
await page.getByRole("heading", { name: "Мои проекты" }).waitFor()
const modal = page.locator("[role=dialog]")
const projectsPage: ProjectsPage = {
page,
modal,
async openCreateModal() {
await page.getByRole("button", { name: /Создать проект/ }).click()
await modal.waitFor({ state: "visible" })
},
async mockCreateSuccess(overrides?: Record<string, unknown>) {
const project = { ...DEFAULT_PROJECT, ...overrides }
await page.route("**/api/projects/", async (route: Route) => {
if (route.request().method() === "POST") {
projectsList.push(project)
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(project),
})
} else {
await route.fallback()
}
})
},
async mockCreateError(
status: number,
body?: Record<string, unknown>,
) {
await page.route("**/api/projects/", async (route: Route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body ?? { detail: "Server error" }),
})
} else {
await route.fallback()
}
})
},
async mockCreateNetworkError() {
await page.route("**/api/projects/", async (route: Route) => {
if (route.request().method() === "POST") {
await route.abort()
} else {
await route.fallback()
}
})
},
async mockCreateDelayed(ms = 2000) {
const project = { ...DEFAULT_PROJECT }
await page.route("**/api/projects/", async (route: Route) => {
if (route.request().method() === "POST") {
await new Promise((r) => setTimeout(r, ms))
projectsList.push(project)
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(project),
})
} else {
await route.fallback()
}
})
},
}
await use(projectsPage)
},
})
export { expect } from "@playwright/test"
+46
View File
@@ -0,0 +1,46 @@
import { test as base, type Page } from "@playwright/test"
import { registerTestUser, type TestUser } from "#tests/e2e/support/auth-api"
interface RealLoginPage {
page: Page
user: TestUser
login(password?: string): Promise<void>
submitWithEnter(password?: string): Promise<void>
getCookie(name: string): Promise<string | undefined>
}
export const test = base.extend<{ realLoginPage: RealLoginPage }>({
realLoginPage: async ({ page }, use) => {
const user = await registerTestUser()
await page.goto("/login")
await page.getByRole("heading", { name: "Вход" }).waitFor()
const realLoginPage: RealLoginPage = {
page,
user,
async login(password = user.password) {
await page.getByRole("textbox", { name: "Логин" }).fill(user.username)
await page.getByLabel("Пароль").fill(password)
await page.getByRole("button", { name: "Войти" }).click()
},
async submitWithEnter(password = user.password) {
await page.getByRole("textbox", { name: "Логин" }).fill(user.username)
await page.getByLabel("Пароль").fill(password)
await page.getByLabel("Пароль").press("Enter")
},
async getCookie(name: string) {
const cookies = await page.context().cookies()
return cookies.find((cookie) => cookie.name === name)?.value
},
}
await use(realLoginPage)
},
})
export { expect } from "@playwright/test"
+91
View File
@@ -0,0 +1,91 @@
import { test as base, type Locator, type Page } from "@playwright/test"
import {
E2E_API_URL,
registerTestUser,
type TestUser,
} from "#tests/e2e/support/auth-api"
interface RealProjectsPage {
page: Page
modal: Locator
user: TestUser
openCreateModal(): Promise<void>
/** Delete a project by ID via API (for cleanup) */
deleteProject(projectId: string): Promise<void>
/** Get all projects for the test user via API */
getProjects(): Promise<{ id: string; name: string }[]>
}
export const test = base.extend<{ realProjectsPage: RealProjectsPage }>({
realProjectsPage: async ({ page }, use) => {
// Register a fresh user for each test
const user = await registerTestUser()
// Set auth cookies
await page.context().addCookies([
{
name: "access_token",
value: user.accessToken,
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: user.refreshToken,
domain: "localhost",
path: "/",
},
])
await page.goto("/projects")
await page.getByRole("heading", { name: "Мои проекты" }).waitFor()
const modal = page.locator("[role=dialog]")
const realProjectsPage: RealProjectsPage = {
page,
modal,
user,
async openCreateModal() {
await page.getByRole("button", { name: /Создать проект/ }).click()
await modal.waitFor({ state: "visible" })
},
async deleteProject(projectId: string) {
const res = await fetch(`${E2E_API_URL}/api/projects/${projectId}/`, {
method: "DELETE",
headers: { Authorization: `Bearer ${user.accessToken}` },
})
if (!res.ok && res.status !== 404) {
throw new Error(`Delete project failed: ${res.status}`)
}
},
async getProjects() {
const res = await fetch(`${E2E_API_URL}/api/projects/`, {
headers: { Authorization: `Bearer ${user.accessToken}` },
})
if (!res.ok) {
throw new Error(`Get projects failed: ${res.status}`)
}
return res.json()
},
}
await use(realProjectsPage)
// Cleanup: delete all projects created by this test user
try {
const projects = await realProjectsPage.getProjects()
for (const project of projects) {
await realProjectsPage.deleteProject(project.id)
}
} catch {
// Best-effort cleanup
}
},
})
export { expect } from "@playwright/test"
+91
View File
@@ -0,0 +1,91 @@
import path from "node:path"
import { test as base, type Locator, type Page } from "@playwright/test"
import {
createProjectViaApi,
deleteProjectViaApi,
loginAsAdmin,
} from "#tests/e2e/support/auth-api"
interface SilencePage {
page: Page
projectId: string
silenceStep: Locator
}
export const test = base.extend<{ silencePage: SilencePage }>({
silencePage: async ({ page }, use) => {
const tokens = await loginAsAdmin()
await page.context().addCookies([
{
name: "access_token",
value: tokens.accessToken,
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: tokens.refreshToken,
domain: "localhost",
path: "/",
},
])
const suffix = Date.now().toString(36)
const projectId = await createProjectViaApi(
tokens.accessToken,
`silence-test-${suffix}`,
)
// Navigate to project wizard
await page.goto(`/projects/${projectId}`)
await page.locator("[data-testid='ProjectWizard']").waitFor()
// Upload test video file
const testVideoPath = path.resolve(
__dirname,
"../assets/test-video.mp4",
)
const fileInput = page
.locator("[data-testid='UploadStep']")
.locator("input[type='file']")
await fileInput.setInputFiles(testVideoPath)
// Wait for wizard to advance to Verify step
await page
.locator("[data-testid='VerifyStep']")
.waitFor({ timeout: 30_000 })
// Wait for file processing to complete
await page
.locator("[data-testid='VerifyStep']")
.getByText("Готово к обработке")
.waitFor({ timeout: 10_000 })
// Advance to Silence Settings step
await page
.getByRole("button", { name: "Далее: Настройки тишины" })
.click()
const silenceStep = page.locator("[data-testid='SilenceSettingsStep']")
await silenceStep.waitFor({ timeout: 10_000 })
const silencePage: SilencePage = {
page,
projectId,
silenceStep,
}
await use(silencePage)
// Cleanup: delete project
try {
await deleteProjectViaApi(tokens.accessToken, projectId)
} catch {
// Best-effort cleanup
}
},
})
export { expect } from "@playwright/test"
+90
View File
@@ -0,0 +1,90 @@
import path from "node:path"
import { test as base, type Locator, type Page } from "@playwright/test"
import {
createProjectViaApi,
deleteProjectViaApi,
loginAsAdmin,
} from "#tests/e2e/support/auth-api"
interface UploadPage {
page: Page
projectId: string
dropZone: Locator
fileInput: Locator
/** Path to the minimal test MP4 file on disk */
testVideoPath: string
/** Upload a file by setting the hidden input (bypasses accept filter) */
uploadFile(filePath: string): Promise<void>
/** Upload with a synthetic buffer (name + MIME type + content) */
uploadBuffer(name: string, mimeType: string, content: Buffer): Promise<void>
}
export const test = base.extend<{ uploadPage: UploadPage }>({
uploadPage: async ({ page }, use) => {
const tokens = await loginAsAdmin()
await page.context().addCookies([
{
name: "access_token",
value: tokens.accessToken,
domain: "localhost",
path: "/",
},
{
name: "refresh_token",
value: tokens.refreshToken,
domain: "localhost",
path: "/",
},
])
const suffix = Date.now().toString(36)
const projectId = await createProjectViaApi(
tokens.accessToken,
`upload-test-${suffix}`,
)
await page.goto(`/projects/${projectId}`)
await page.locator("[data-testid='ProjectWizard']").waitFor()
const testVideoPath = path.resolve(
__dirname,
"../assets/test-video.mp4",
)
const dropZone = page.locator("[data-testid='UploadStep']")
const fileInput = dropZone.locator("input[type='file']")
const uploadPage: UploadPage = {
page,
projectId,
dropZone,
fileInput,
testVideoPath,
async uploadFile(filePath: string) {
await fileInput.setInputFiles(filePath)
},
async uploadBuffer(name: string, mimeType: string, content: Buffer) {
await fileInput.setInputFiles({
name,
mimeType,
buffer: content,
})
},
}
await use(uploadPage)
// Cleanup
try {
await deleteProjectViaApi(tokens.accessToken, projectId)
} catch {
// Best-effort cleanup
}
},
})
export { expect } from "@playwright/test"
@@ -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()
})
})
})
+130
View File
@@ -0,0 +1,130 @@
import { API_URL } from "./config"
const E2E_API_URL = API_URL
const DEFAULT_PASSWORD = "E2eTestPass123"
export interface TestUser {
id: string
username: string
email: string
firstName: string
lastName: string
password: string
accessToken: string
refreshToken: string
}
interface IRegisterTestUserOptions {
firstName?: string
lastName?: string
password?: string
}
export const registerTestUser = async (
options?: IRegisterTestUserOptions,
): Promise<TestUser> => {
const suffix =
Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const username = `e2e_${suffix}`
const firstName = options?.firstName ?? "E2E"
const lastName = options?.lastName ?? "Test"
const password = options?.password ?? DEFAULT_PASSWORD
const email = `${username}@test.local`
const response = await fetch(`${E2E_API_URL}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
email,
password,
first_name: firstName,
last_name: lastName,
}),
})
if (!response.ok) {
throw new Error(
`Register failed: ${response.status} ${await response.text()}`,
)
}
const data = await response.json()
return {
id: data.user.id,
username: data.user.username,
email,
firstName,
lastName,
password,
accessToken: data.access,
refreshToken: data.refresh,
}
}
/* ------------------------------------------------------------------ */
/* Admin helpers (shared by upload / silence / etc. fixtures) */
/* ------------------------------------------------------------------ */
export interface AuthTokens {
accessToken: string
refreshToken: string
}
export async function loginAsAdmin(): Promise<AuthTokens> {
const res = await fetch(`${E2E_API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
})
if (!res.ok) {
throw new Error(`Admin login failed: ${res.status} ${await res.text()}`)
}
const data = await res.json()
return {
accessToken: data.access,
refreshToken: data.refresh,
}
}
export async function createProjectViaApi(
token: string,
name: string,
): Promise<string> {
const res = await fetch(`${E2E_API_URL}/api/projects/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, language: "auto" }),
})
if (!res.ok) {
throw new Error(
`Create project failed: ${res.status} ${await res.text()}`,
)
}
const data = await res.json()
return data.id
}
export async function deleteProjectViaApi(
token: string,
projectId: string,
): Promise<void> {
const res = await fetch(`${E2E_API_URL}/api/projects/${projectId}/`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok && res.status !== 404) {
throw new Error(`Delete project failed: ${res.status}`)
}
}
export { DEFAULT_PASSWORD as TEST_USER_PASSWORD, E2E_API_URL }
+20
View File
@@ -0,0 +1,20 @@
/**
* Central configuration for all E2E test ports and URLs.
* Change values here instead of hardcoding in fixtures / config.
*/
/** Real backend API */
export const API_PORT = 8000
export const API_URL = `http://localhost:${API_PORT}`
/** Lightweight mock API server (used by unit/component Playwright tests) */
export const MOCK_API_PORT = 4444
export const MOCK_API_URL = `http://localhost:${MOCK_API_PORT}`
/** Frontend dev server for unit/component tests (uses mock API) */
export const FRONTEND_MOCK_PORT = 3005
export const FRONTEND_MOCK_URL = `http://localhost:${FRONTEND_MOCK_PORT}`
/** Frontend dev server for integration tests (uses real backend) */
export const FRONTEND_INTEGRATION_PORT = 3000
export const FRONTEND_INTEGRATION_URL = `http://localhost:${FRONTEND_INTEGRATION_PORT}`
+71
View File
@@ -0,0 +1,71 @@
import { createServer } from "node:http"
import { MOCK_API_PORT } from "./config"
const PORT = MOCK_API_PORT
const DEFAULT_USER = {
id: "00000000-0000-0000-0000-000000000001",
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 json = (res: import("node:http").ServerResponse, status: number, body: unknown) => {
res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(body))
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`)
// CORS headers for browser requests
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
// GET /api/ping/ — server health check
if (url.pathname === "/api/ping/" && req.method === "GET") {
return json(res, 200, { status: "ok" })
}
// GET /api/users/me/ — token verification
if (url.pathname === "/api/users/me/" && req.method === "GET") {
const auth = req.headers.authorization
if (auth?.startsWith("Bearer ")) {
return json(res, 200, DEFAULT_USER)
}
return json(res, 401, { detail: "Not authenticated" })
}
// POST /auth/login — login endpoint
if (url.pathname === "/auth/login" && req.method === "POST") {
return json(res, 200, {
user: DEFAULT_USER,
access: "fake-access-jwt",
refresh: "fake-refresh-jwt",
})
}
// Fallback: 404 for any unhandled route
json(res, 404, { detail: "Not found" })
})
server.listen(PORT, () => {
console.log(`Mock API server running on http://localhost:${PORT}`)
})