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
+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"