429 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|