import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test"; // Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks. test.use({ ...devices["Pixel 7"] }); const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; const ROW_VERSION = "AAAAAAAAB9E="; const TEST_USER = { token: "wo-completion-uploads-e2e-token", expiration: "2030-01-01T00:00:00.000Z", email: "admin@seahavenind.com", userRoles: "Admin", phoneNumber: "5551234567", fullname: "Work Order E2E Admin", id: "1", }; // Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf). const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1"); const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex"); const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex"); const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex"); type BoardRowOptions = { id: number; woNumber: string; workOrderType: number }; function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) { return { id, woNumber, workOrderType, siteCode: "BK5", lifecycleStatusLabel: "In Progress", scheduledDate: "2026-08-19", dispatcherId: "1", dispatcherName: "Pat Dispatcher", pm: "HVAC", docStatus: "No", rowVersion: ROW_VERSION, }; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } type UploadSessionFixture = { uploadId: string; state: "Created" | "Uploading" | "Completed"; chunkSizeBytes: number; sizeBytes: number; receivedChunks: number[]; expiresAt: string; attachment?: { id: number }; }; function multipartBoundary(request: Request): string | undefined { const contentType = request.headers()["content-type"] ?? ""; return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; } async function hideQueryDevtools(page: Page) { await page.addStyleTag({ content: ".tsqd-parent-container { display: none !important; pointer-events: none !important; }", }); await page.locator(".tsqd-parent-container").evaluateAll((nodes) => { for (const node of nodes) { node.remove(); } }); } async function mockBoard(page: Page, row: ReturnType) { await page.route("**/api/**", (route) => { const pathname = new URL(route.request().url()).pathname; if (!pathname.startsWith("/api/")) return route.fallback(); return fulfillJson(route, { data: [] }); }); await page.route("**/api/workorders/board*", (route) => { if (route.request().url().includes("board/search")) return route.fallback(); return fulfillJson(route, { weekStart: "2026-08-17", weekEnd: "2026-08-21", counts: { returned: 1, total: 1 }, unscheduled: [], scheduled: [row], }); }); await page.route("**/api/workorders/board/search*", (route) => fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }), ); } async function openCompletionTab(page: Page, woNumber: string) { await page.clock.install({ time: new Date(FROZEN_NOW) }); await page.clock.resume(); await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); await page.goto("/workorders"); await hideQueryDevtools(page); const row = page.getByRole("row").filter({ hasText: woNumber }); await row.getByRole("button", { name: "View details" }).click(); const panel = page.getByRole("dialog", { name: "Work order details" }); await expect(panel).toBeVisible({ timeout: 15_000 }); await panel.getByRole("tab", { name: "Completion Doc" }).click(); return panel; } test.describe("Completion uploads on a mobile viewport", () => { test("uploads a signed PDF as multipart with the file and work order version", async ({ page, }) => { const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 }); await mockBoard(page, row); await page.route("**/api/workorders/71/detail", (route) => fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), ); let upload: Request | undefined; await page.route("**/api/workorders/71/completion-doc", (route) => { upload = route.request(); return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" }); }); const panel = await openCompletionTab(page, row.woNumber); const chooser = page.waitForEvent("filechooser"); await panel.getByRole("button", { name: "Upload signed PDF" }).click(); await ( await chooser ).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF }); await expect.poll(() => upload?.method()).toBe("POST"); const boundary = multipartBoundary(upload!); expect(boundary, upload!.headers()["content-type"]).toBeTruthy(); const body = upload!.postDataBuffer()?.toString("latin1") ?? ""; expect(body).toContain(`--${boundary}`); expect(body).toContain('name="file"; filename="Signed Completion.pdf"'); expect(body).toContain("%PDF-1.7"); expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); }); test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", async ({ page }) => { const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 }); await mockBoard(page, row); await page.route("**/api/workorders/72/detail", (route) => fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), ); const sessions = new Map(); const creates: Record[] = []; const chunks: { body: Buffer; headers: Record }[] = []; const commits: string[] = []; await page.route("**/api/workorders/72/media/policy", (route) => fulfillJson(route, { workOrderType: "Reactive", canUpload: true, limits: { photoMaxBytes: 10_000_000, videoMaxBytes: 100_000_000, videoMaxSeconds: 90, maxPhotos: 10, maxVideos: 3, }, remaining: { photos: 10, videos: 3 }, }), ); await page.route("**/api/workorders/72/media/uploads", async (route) => { const request = route.request(); const dto = request.postDataJSON() as Record; creates.push(dto); const uploadId = `media-${creates.length}`; const session: UploadSessionFixture = { uploadId, state: "Created", chunkSizeBytes: 5_242_880, sizeBytes: Number(dto.sizeBytes), receivedChunks: [], expiresAt: "2026-08-20T15:00:00.000Z", }; sessions.set(uploadId, session); await fulfillJson(route, session); }); await page.route("**/api/workorders/72/media/uploads/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path); if (!match) return route.fallback(); const uploadId = match[1]!; const index = match[2]; const session = sessions.get(uploadId)!; if (request.method() === "PUT" && index !== undefined) { const body = request.postDataBuffer() ?? Buffer.alloc(0); chunks.push({ body, headers: request.headers() }); session.receivedChunks = [...new Set([...session.receivedChunks, Number(index)])]; session.state = "Uploading"; await fulfillJson(route, { index: Number(index), sha256: request.headers()["x-chunk-sha256"], sizeBytes: body.length, receivedChunks: session.receivedChunks, }); return; } if (request.method() === "POST" && path.endsWith("/commit")) { commits.push(uploadId); session.state = "Completed"; session.attachment = { id: 900 + commits.length }; await fulfillJson(route, session); return; } if (request.method() === "GET") { await fulfillJson(route, session); return; } if (request.method() === "DELETE") { await fulfillJson(route, {}); return; } await route.fallback(); }); const panel = await openCompletionTab(page, row.woNumber); const chooser = page.waitForEvent("filechooser"); await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click(); const files = [ { name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG }, { name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 }, { name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV }, ]; await (await chooser).setFiles(files); await expect.poll(() => creates.length).toBe(files.length); await expect.poll(() => chunks.length).toBe(files.length); await expect.poll(() => commits.length).toBe(files.length); expect(creates.map((dto) => dto.surface)).toEqual( Array.from({ length: files.length }, () => "CompletionMedia"), ); expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual( files.map((file) => file.buffer.length).sort(), ); expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual( Array.from({ length: files.length }, () => "application/octet-stream"), ); expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual( files.map((file) => file.buffer.toString("hex")).sort(), ); expect([...sessions.values()].every((session) => session.state === "Completed")).toBe(true); await expect(page.getByText("File uploaded").first()).toBeVisible(); }); });