Files
pleno-vue/tests/e2e/errorReports.spec.ts
T
2026-07-06 14:28:24 +02:00

429 lines
15 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
function json(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
function isApiUrl(url: string) {
return /https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i.test(
url
);
}
const html2CanvasModulePattern = /\/(?:node_modules\/.*)?html2canvas(?:\.[\w-]+)?\.js(?:\?.*)?$/i;
async function mockHtml2Canvas(page, { alwaysFail = false } = {}) {
await page.unroute(html2CanvasModulePattern).catch(() => {});
await page.route(html2CanvasModulePattern, async (route) => {
await route.fulfill({
contentType: "text/javascript",
body: `
const alwaysFail = ${alwaysFail ? "true" : "false"};
let html2canvasAttempts = 0;
export default async function html2canvas() {
html2canvasAttempts += 1;
if (alwaysFail || html2canvasAttempts === 1) {
throw new Error(alwaysFail ? "Simulated permanent capture failure" : "Simulated first capture failure");
}
const canvas = document.createElement("canvas");
canvas.width = 8;
canvas.height = 8;
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, 8, 8);
context.fillStyle = "#dc2626";
context.fillRect(0, 0, 4, 4);
return canvas;
}
`,
});
});
}
async function openErrorReportLauncher(page) {
const reportButton = page.getByTestId("error-report-button");
const viewportWidth = page.viewportSize()?.width ?? 0;
if (viewportWidth <= 768) {
const burgerButton = page.locator(".mobile-navbar-burger[aria-expanded='false']").first();
await expect(burgerButton).toBeVisible();
await expect(reportButton).toHaveCount(0);
await burgerButton.click();
await expect(page.locator(".mobile-menu-open")).toBeVisible();
await expect(reportButton).toHaveCount(1);
await expect(reportButton).toBeVisible();
await reportButton.click();
await expect(page.locator(".mobile-menu-open")).toHaveCount(0);
return;
}
if (viewportWidth >= 1024) {
const desktopNavigation = page.getByTestId("desktop-buefy-navigation").first();
const desktopReportButton = desktopNavigation.getByTestId("error-report-button");
await expect(desktopNavigation).toBeVisible();
await expect(reportButton).toHaveCount(1);
await expect(desktopReportButton).toBeVisible();
await expect(desktopReportButton).toHaveClass(/desktop-buefy-error-report-button/);
await expect(desktopReportButton).toHaveClass(/is-text/);
await expect(desktopReportButton).not.toHaveClass(/is-danger/);
await desktopReportButton.hover();
await expect(desktopReportButton).toHaveCSS("color", "rgb(180, 35, 24)");
await desktopReportButton.click();
return;
}
await expect(reportButton).toHaveCount(1);
await expect(reportButton).toBeVisible();
await reportButton.click();
}
test.describe("Authenticated error reports", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockHtml2Canvas(page);
await mockApi(page, {
authenticated: true,
sessionData: {
display_name: "Error Reporter",
permissions: ["user", "superuser"],
},
});
});
test("captures required fields, consent, screenshot, and recent errors on submit", async ({ page }) => {
const submittedBodies: Array<Record<string, unknown>> = [];
await page.route(/\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "POST") {
await route.fallback();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
submittedBodies.push(body);
await route.fulfill(
json(
{
success: true,
data: {
id: 99,
status: "open",
},
},
201
)
);
});
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" });
await openErrorReportLauncher(page);
await expect(page.getByTestId("error-report-modal")).toBeVisible();
await expect(page.getByTestId("error-report-submit")).toBeDisabled();
const beforeErrorField = page.getByLabel("What were you doing before the error occurred?");
const expectedField = page.getByLabel("What did you expect would happen?");
const actualField = page.getByLabel("What actually happened?");
await expect(beforeErrorField).toHaveAttribute(
"placeholder",
"Describe the action you were taking, for example opening orders or selecting a customer."
);
await expect(expectedField).toHaveAttribute("placeholder", "Describe the result you expected to see.");
await expect(actualField).toHaveAttribute(
"placeholder",
"Describe what you saw instead, including any error text."
);
await beforeErrorField.fill("Opening the orders page");
await expectedField.fill("The orders should load");
await actualField.fill("The page showed an error");
await expect(page.getByTestId("error-report-submit")).toBeDisabled();
await page.getByTestId("error-report-consent").check();
await page.getByTestId("error-report-submit").click();
await expect.poll(() => submittedBodies.length).toBe(1);
const body = submittedBodies[0];
expect(body.before_error).toBe("Opening the orders page");
expect(body.expected).toBe("The orders should load");
expect(body.actual).toBe("The page showed an error");
expect(body.data_collection_accepted).toBe(true);
expect(String(body.screenshot || "")).toMatch(/^data:image\/(png|webp);base64,/);
expect(Array.isArray(body.request_errors)).toBe(true);
expect(Array.isArray(body.vue_errors)).toBe(true);
expect(body.context).toMatchObject({
data_collection_policy_version: "error-report-v1",
screenshot_attachment: {
status: "stored",
attached: true,
},
});
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
});
test("submits without a screenshot when capture never succeeds", async ({ page }) => {
await mockHtml2Canvas(page, { alwaysFail: true });
const submittedBodies: Array<Record<string, unknown>> = [];
await page.route(/\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "POST") {
await route.fallback();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
submittedBodies.push(body);
await route.fulfill(
json(
{
success: true,
data: {
id: 100,
status: "open",
screenshot: null,
},
},
201
)
);
});
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" });
await openErrorReportLauncher(page);
await page.getByLabel("What were you doing before the error occurred?").fill("Opening the orders page");
await page.getByLabel("What did you expect would happen?").fill("The orders should load");
await page.getByLabel("What actually happened?").fill("The page showed an error");
await page.getByTestId("error-report-consent").check();
await page.getByTestId("error-report-submit").click();
await expect.poll(() => submittedBodies.length).toBe(1);
const body = submittedBodies[0];
expect(body.screenshot).toBeNull();
expect(body.context).toMatchObject({
screenshot_attachment: {
status: "capture_failed",
attached: false,
},
});
await expect(page.getByTestId("error-report-capture-warning")).toContainText(
"The screen capture failed. The report will be sent without a screenshot."
);
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
});
test("lets superusers inspect and resolve submitted reports", async ({ page }) => {
const report: Record<string, any> = {
id: 12,
status: "open",
reporter: {
type: "user",
user_id: 7,
customer_number: 12345,
name: "Error Reporter",
email: "reporter@example.test",
},
route_path: "/user/orders",
page_url: "https://app.example.test/user/orders",
release_trace_id: "trace-123",
frontend_version: "front-1",
api_version: "api-1",
screenshot: {
mime_type: "image/png",
size_bytes: 68,
url: "data:image/png;base64,iVBORw0KGgo=",
},
answers: {
before_error: "Opening the orders page",
expected: "Orders should load",
actual: "The table stayed empty",
},
request_error_count: 1,
vue_error_count: 1,
request_errors: [{ method: "GET", url: "/orders", statusCode: 500 }],
vue_errors: [{ type: "vue_component_error", payload: { message: "Render failed" } }],
runtime_context: { viewport: { width: 1280, height: 720 } },
created_at: "2026-05-19 08:00:00",
updated_at: "2026-05-19 08:00:00",
resolved_at: null,
resolved_by_user_id: null,
resolution_note: null,
};
const statusUpdates: Array<Record<string, unknown>> = [];
await page.route(/\/superuser\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
success: true,
data: {
items: [report],
counts: {
open: report.status === "open" ? 1 : 0,
resolved: report.status === "resolved" ? 1 : 0,
all: 1,
},
limit: 100,
offset: 0,
},
})
);
});
await page.route(/\/superuser\/error-reports\/12$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
await route.fulfill(json({ success: true, data: report }));
});
await page.route(/\/superuser\/error-reports\/12\/status$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
statusUpdates.push(body);
report.status = String(body.status);
report.resolved_at = "2026-05-19 08:15:00";
report.resolution_note = String(body.resolution_note || "");
await route.fulfill(json({ success: true, data: report }));
});
await primeMockSession(page, { token: "error-report-superuser-token", bootPath: "/superuser/error-reports" });
await expect(page.getByTestId("error-reports-page")).toBeVisible();
await expect(page.getByTestId("pagination-search-input")).toBeVisible();
await expect(page.getByTestId("table-labeled-pagination-filters")).toBeVisible();
await expect(page.getByTestId("error-report-status-filter")).toHaveValue("open");
await expect(page.getByTestId("error-report-list")).toContainText("/user/orders");
await page.getByTestId("error-report-view").click();
await expect(page.getByTestId("error-report-detail")).toContainText("Opening the orders page");
await expect(page.getByTestId("error-report-detail")).toContainText("trace-123");
await page.getByLabel("Resolution note").fill("Fixed missing null guard");
await page.getByTestId("error-report-resolve").click();
await expect
.poll(() => statusUpdates)
.toEqual([
{
status: "resolved",
resolution_note: "Fixed missing null guard",
},
]);
await expect(page.getByTestId("error-report-detail")).toContainText("resolved");
});
test("lets superusers inspect submitted reports without screenshots", async ({ page }) => {
const report: Record<string, any> = {
id: 13,
status: "open",
reporter: {
type: "user",
user_id: 7,
customer_number: 12345,
name: "Error Reporter",
email: "reporter@example.test",
},
route_path: "/user/orders",
page_url: "https://app.example.test/user/orders",
release_trace_id: "trace-no-screenshot",
frontend_version: "front-1",
api_version: "api-1",
screenshot: null,
answers: {
before_error: "Opening the orders page",
expected: "Orders should load",
actual: "The table stayed empty",
},
request_error_count: 0,
vue_error_count: 0,
request_errors: [],
vue_errors: [],
runtime_context: {
screenshot_attachment: {
status: "capture_failed",
attached: false,
mime_type: null,
size_bytes: 0,
},
},
created_at: "2026-05-19 08:00:00",
updated_at: "2026-05-19 08:00:00",
resolved_at: null,
resolved_by_user_id: null,
resolution_note: null,
};
await page.route(/\/superuser\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
success: true,
data: {
items: [report],
counts: {
open: 1,
resolved: 0,
all: 1,
},
limit: 100,
offset: 0,
},
})
);
});
await page.route(/\/superuser\/error-reports\/13$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
await route.fulfill(json({ success: true, data: report }));
});
await primeMockSession(page, { token: "error-report-superuser-token", bootPath: "/superuser/error-reports" });
await expect(page.getByTestId("error-reports-page")).toBeVisible();
await expect(page.getByTestId("error-report-list")).toContainText("/user/orders");
await page.getByTestId("error-report-view").click();
const detail = page.getByTestId("error-report-detail");
await expect(detail).toContainText("Opening the orders page");
await expect(detail).toContainText("trace-no-screenshot");
await expect(detail.locator("img")).toHaveCount(0);
});
});