mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import {
|
|
JobDetailFixture,
|
|
JobFixture,
|
|
fulfillJson,
|
|
mockEngineStatus,
|
|
mockJobDetails,
|
|
} from "./helpers";
|
|
|
|
const queuedJob: JobFixture = {
|
|
id: 1,
|
|
input_path: "/media/queued.mkv",
|
|
output_path: "/output/queued-av1.mkv",
|
|
status: "queued",
|
|
priority: 0,
|
|
progress: 0,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
|
|
const failedJob: JobFixture = {
|
|
id: 2,
|
|
input_path: "/media/failed.mkv",
|
|
output_path: "/output/failed-av1.mkv",
|
|
status: "failed",
|
|
priority: 5,
|
|
progress: 100,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-02T00:00:00Z",
|
|
decision_reason: "transcode_failed|ffmpeg exited 1",
|
|
};
|
|
|
|
const cancelledJob: JobFixture = {
|
|
id: 3,
|
|
input_path: "/media/cancelled.mkv",
|
|
output_path: "/output/cancelled-av1.mkv",
|
|
status: "cancelled",
|
|
priority: 1,
|
|
progress: 12,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-03T00:00:00Z",
|
|
};
|
|
|
|
const completedJob: JobFixture = {
|
|
id: 4,
|
|
input_path: "/media/completed.mkv",
|
|
output_path: "/output/completed-av1.mkv",
|
|
status: "completed",
|
|
priority: 0,
|
|
progress: 100,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-04T00:00:00Z",
|
|
vmaf_score: 95.4,
|
|
};
|
|
|
|
const failedDetail: JobDetailFixture = {
|
|
job: failedJob,
|
|
metadata: {
|
|
duration_secs: 90,
|
|
codec_name: "h264",
|
|
width: 1920,
|
|
height: 1080,
|
|
bit_depth: 8,
|
|
size_bytes: 2_000_000_000,
|
|
video_bitrate_bps: 8_000_000,
|
|
container_bitrate_bps: 8_200_000,
|
|
fps: 23.976,
|
|
container: "mkv",
|
|
audio_codec: "aac",
|
|
audio_channels: 2,
|
|
dynamic_range: "sdr",
|
|
},
|
|
job_logs: [
|
|
{
|
|
id: 1,
|
|
level: "info",
|
|
message: "frame=5 fps=10",
|
|
created_at: "2025-01-02T00:00:01Z",
|
|
},
|
|
{
|
|
id: 2,
|
|
level: "error",
|
|
message: "Unknown encoder 'missing_encoder'",
|
|
created_at: "2025-01-02T00:00:02Z",
|
|
},
|
|
],
|
|
job_failure_summary: "Unknown encoder 'missing_encoder'",
|
|
};
|
|
|
|
const completedDetail: JobDetailFixture = {
|
|
job: completedJob,
|
|
metadata: {
|
|
duration_secs: 120,
|
|
codec_name: "hevc",
|
|
width: 3840,
|
|
height: 2160,
|
|
bit_depth: 10,
|
|
size_bytes: 4_000_000_000,
|
|
video_bitrate_bps: 15_000_000,
|
|
container_bitrate_bps: 15_500_000,
|
|
fps: 24,
|
|
container: "mkv",
|
|
audio_codec: "aac",
|
|
audio_channels: 6,
|
|
dynamic_range: "hdr10",
|
|
},
|
|
encode_stats: {
|
|
input_size_bytes: 4_000_000_000,
|
|
output_size_bytes: 1_800_000_000,
|
|
compression_ratio: 0.45,
|
|
encode_time_seconds: 3600,
|
|
encode_speed: 1.25,
|
|
avg_bitrate_kbps: 7000,
|
|
vmaf_score: 95.4,
|
|
},
|
|
job_logs: [
|
|
{
|
|
id: 10,
|
|
level: "info",
|
|
message: "Transcode completed successfully",
|
|
created_at: "2025-01-04T00:00:02Z",
|
|
},
|
|
],
|
|
};
|
|
|
|
test.use({ storageState: undefined });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockEngineStatus(page);
|
|
});
|
|
|
|
test("search requests are debounced and failed job details show summary and logs", async ({
|
|
page,
|
|
}) => {
|
|
const requests: URL[] = [];
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
requests.push(url);
|
|
await fulfillJson(route, 200, [failedJob]);
|
|
});
|
|
await mockJobDetails(page, { 2: failedDetail });
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByPlaceholder("Search files...").first().fill("failed");
|
|
|
|
await expect
|
|
.poll(() => requests.some((url) => url.searchParams.get("search") === "failed"))
|
|
.toBe(true);
|
|
|
|
await page.getByTitle("/media/failed.mkv").click();
|
|
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await expect(page.getByText("Failure Reason")).toBeVisible();
|
|
await expect(page.getByText("Unknown encoder 'missing_encoder'").first()).toBeVisible();
|
|
await page.getByText(/Show FFmpeg output \(2 lines\)/).click();
|
|
await expect(page.getByText("frame=5 fps=10")).toBeVisible();
|
|
});
|
|
|
|
test("batch cancel, restart, delete, and clear completed update the job table", async ({
|
|
page,
|
|
}) => {
|
|
let jobs = [queuedJob, failedJob, cancelledJob, completedJob];
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, jobs);
|
|
});
|
|
await page.route("**/api/jobs/batch", async (route) => {
|
|
const body = route.request().postDataJSON() as {
|
|
action: "cancel" | "restart" | "delete";
|
|
ids: number[];
|
|
};
|
|
if (body.action === "cancel") {
|
|
jobs = jobs.map((job) =>
|
|
body.ids.includes(job.id) ? { ...job, status: "cancelled", updated_at: "2025-01-05T00:00:00Z" } : job,
|
|
);
|
|
} else if (body.action === "restart") {
|
|
jobs = jobs.map((job) =>
|
|
body.ids.includes(job.id) ? { ...job, status: "queued", progress: 0 } : job,
|
|
);
|
|
} else {
|
|
jobs = jobs.filter((job) => !body.ids.includes(job.id));
|
|
}
|
|
await fulfillJson(route, 200, { count: body.ids.length });
|
|
});
|
|
await page.route("**/api/jobs/clear-completed", async (route) => {
|
|
const count = jobs.filter((job) => job.status === "completed").length;
|
|
jobs = jobs.filter((job) => job.status !== "completed");
|
|
await fulfillJson(route, 200, {
|
|
count,
|
|
message: "Cleared 1 completed job from the queue. Historical stats were preserved.",
|
|
});
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
|
|
const queuedRow = page.locator("tbody tr").filter({ has: page.getByTitle("/media/queued.mkv") });
|
|
await queuedRow.locator("input[type='checkbox']").check();
|
|
await page.getByRole("button", { name: "Cancel" }).first().click();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Cancel" }).last().click();
|
|
await expect(queuedRow.getByText("cancelled")).toBeVisible();
|
|
|
|
const failedRow = page.locator("tbody tr").filter({ has: page.getByTitle("/media/failed.mkv") });
|
|
await failedRow.locator("input[type='checkbox']").check();
|
|
await page.getByRole("button", { name: "Restart" }).first().click();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Restart" }).last().click();
|
|
await expect(failedRow.getByText("queued")).toBeVisible();
|
|
|
|
const cancelledRow = page
|
|
.locator("tbody tr")
|
|
.filter({ has: page.getByTitle("/media/cancelled.mkv") });
|
|
await cancelledRow.locator("input[type='checkbox']").check();
|
|
await page.getByRole("button", { name: "Delete" }).first().click();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Delete" }).last().click();
|
|
await expect(page.getByTitle("/media/cancelled.mkv")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: /Clear Completed/i }).click();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Clear" }).click();
|
|
await expect(page.getByTitle("/media/completed.mkv")).toHaveCount(0);
|
|
await expect(
|
|
page
|
|
.getByText("Cleared 1 completed job from the queue. Historical stats were preserved.")
|
|
.first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("row menu conflicts surface blocked job details from the API", async ({ page }) => {
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, [cancelledJob]);
|
|
});
|
|
await page.route("**/api/jobs/3/restart", async (route) => {
|
|
await fulfillJson(route, 409, {
|
|
message: "restart is blocked while the job is active",
|
|
blocked: [{ id: 3, status: "encoding" }],
|
|
});
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("Actions").click();
|
|
await page.getByRole("button", { name: "Retry" }).click();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click();
|
|
|
|
await expect(
|
|
page.getByText("restart is blocked while the job is active: #3 (encoding)").first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("detail modal delete action removes the job and closes the modal", async ({ page }) => {
|
|
let jobs = [completedJob];
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, jobs);
|
|
});
|
|
await mockJobDetails(page, { 4: completedDetail });
|
|
await page.route("**/api/jobs/4/delete", async (route) => {
|
|
jobs = [];
|
|
await fulfillJson(route, 200, { status: "ok" });
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("/media/completed.mkv").click();
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /^Delete$/ }).last().click();
|
|
await page
|
|
.getByRole("dialog", { name: "Delete job" })
|
|
.getByRole("button", { name: "Delete" })
|
|
.click();
|
|
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
await expect(page.getByTitle("/media/completed.mkv")).toHaveCount(0);
|
|
});
|
|
|
|
test("queued job with no metadata shows waiting for analysis placeholder", async ({ page }) => {
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, [queuedJob]);
|
|
});
|
|
await mockJobDetails(page, {
|
|
1: {
|
|
job: queuedJob,
|
|
job_logs: [],
|
|
},
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("/media/queued.mkv").click();
|
|
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await expect(page.getByText("Waiting in queue")).toBeVisible();
|
|
await expect(page.getByText("Unknown bit depth")).not.toBeVisible();
|
|
});
|
|
|
|
test("analyzed queued job shows real metadata in detail modal", async ({ page }) => {
|
|
const analyzedJob: JobFixture = {
|
|
...queuedJob,
|
|
id: 10,
|
|
input_path: "/media/analyzed.mkv",
|
|
output_path: "/output/analyzed-av1.mkv",
|
|
};
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, [analyzedJob]);
|
|
});
|
|
await mockJobDetails(page, {
|
|
10: {
|
|
job: analyzedJob,
|
|
metadata: {
|
|
duration_secs: 3600,
|
|
codec_name: "h264",
|
|
width: 1920,
|
|
height: 1080,
|
|
bit_depth: 8,
|
|
size_bytes: 4_000_000_000,
|
|
video_bitrate_bps: 8_000_000,
|
|
container_bitrate_bps: 8_200_000,
|
|
fps: 23.976,
|
|
container: "mkv",
|
|
audio_codec: "aac",
|
|
audio_channels: 2,
|
|
dynamic_range: "sdr",
|
|
},
|
|
job_logs: [],
|
|
},
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("/media/analyzed.mkv").click();
|
|
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await expect(page.getByText("h264")).toBeVisible();
|
|
await expect(page.getByText("1920x1080")).toBeVisible();
|
|
await expect(page.getByText("Waiting for analysis")).not.toBeVisible();
|
|
});
|
|
|
|
test("skipped job shows humanized skip reason with measured values", async ({ page }) => {
|
|
const skippedJob: JobFixture = {
|
|
id: 20,
|
|
input_path: "/media/already-small.mkv",
|
|
output_path: "/output/already-small-av1.mkv",
|
|
status: "skipped",
|
|
priority: 0,
|
|
progress: 0,
|
|
decision_reason: "bpp_below_threshold|bpp=0.043,threshold=0.050",
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, [skippedJob]);
|
|
});
|
|
await mockJobDetails(page, {
|
|
20: {
|
|
job: skippedJob,
|
|
metadata: {
|
|
duration_secs: 1800,
|
|
codec_name: "hevc",
|
|
width: 1920,
|
|
height: 1080,
|
|
bit_depth: 10,
|
|
size_bytes: 2_000_000_000,
|
|
fps: 24,
|
|
container: "mkv",
|
|
dynamic_range: "sdr",
|
|
},
|
|
job_logs: [],
|
|
},
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("/media/already-small.mkv").click();
|
|
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await expect(page.getByText("Already efficiently compressed")).toBeVisible();
|
|
await expect(page.getByText("0.043", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("0.050", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("bpp_below_threshold|")).not.toBeVisible();
|
|
});
|
|
|
|
test("failed job with no failure summary shows fallback message", async ({ page }) => {
|
|
const mysteryFailJob: JobFixture = {
|
|
id: 30,
|
|
input_path: "/media/mystery.mkv",
|
|
output_path: "/output/mystery-av1.mkv",
|
|
status: "failed",
|
|
priority: 0,
|
|
progress: 0,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
|
|
await page.route("**/api/jobs/table**", async (route) => {
|
|
await fulfillJson(route, 200, [mysteryFailJob]);
|
|
});
|
|
await mockJobDetails(page, {
|
|
30: {
|
|
job: mysteryFailJob,
|
|
metadata: undefined,
|
|
encode_stats: undefined,
|
|
job_logs: [],
|
|
job_failure_summary: null,
|
|
} as unknown as JobDetailFixture,
|
|
});
|
|
|
|
await page.goto("/jobs");
|
|
await page.getByTitle("/media/mystery.mkv").click();
|
|
|
|
await expect(page.getByRole("dialog")).toBeVisible();
|
|
await expect(page.getByText("Failure Reason")).toBeVisible();
|
|
await expect(page.getByText(/No error details captured/)).toBeVisible();
|
|
});
|