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