Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own MobilityOpsClient against production Fleet Ops: real operations summary, real most-pressing vehicle, real grounded knowledge answer with citations, real correlation IDs verified end-to-end in Fleet Ops's own audit log. No write actions performed. Runbook and full output in docs/final-integrations/ai-operations-brief-runbook.md. Ran the full Playwright e2e suite against the live deployed instance and fixed two pre-existing fragile locators unrelated to this session's feature work (both broke because Automation now legitimately has two tables sharing the same generic selectors, exposed by running the full suite rather than individual files) plus one pre-existing untranslated-loanword false positive. All specs pass. artifacts/final-integrations/final-summary.md has the complete evidence write-up: repository/deployment state, what was fixed vs. handed off, test results, and known limitations stated plainly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
|
|
|
async function resetDemoData(request: APIRequestContext) {
|
|
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
|
await request.post("/api/v1/demo/reset");
|
|
}
|
|
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
|
// app's default for a fresh session, so force English explicitly rather than rewriting
|
|
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
|
await page.goto("/login");
|
|
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
});
|
|
|
|
test("all seven nav items navigate correctly", async ({ page }) => {
|
|
const items: [string, RegExp][] = [
|
|
["Overview", /\/dashboard$/],
|
|
["Fleet", /\/vehicles$/],
|
|
["Bookings", /\/bookings$/],
|
|
["Data quality", /\/data-quality$/],
|
|
["Knowledge", /\/knowledge$/],
|
|
["Integrations", /\/automation$/],
|
|
["Audit trail", /\/audit$/],
|
|
];
|
|
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
|
|
for (const [label, urlPattern] of items) {
|
|
await primaryNavigation.getByRole("link", { name: label, exact: true }).click();
|
|
await expect(page).toHaveURL(urlPattern);
|
|
}
|
|
});
|
|
|
|
test("vehicles page: status filter and attention-only checkbox both work", async ({ page }) => {
|
|
await page.goto("/vehicles");
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
|
|
await page.getByLabel("Status").selectOption("maintenance");
|
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
|
const statuses = await page.locator(".data-table tbody tr td:nth-child(4)").allTextContents();
|
|
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
|
|
|
|
await page.getByLabel("Status").selectOption("");
|
|
await page.getByLabel("Attention only").check();
|
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
|
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
|
|
expect(attentionCells.every((c) => c.includes("Needs attention"))).toBeTruthy();
|
|
});
|
|
|
|
test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => {
|
|
await page.goto("/vehicles");
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
const totalRows = await page.locator(".data-table tbody tr").count();
|
|
expect(totalRows).toBeGreaterThan(1);
|
|
|
|
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
|
|
await searchBox.fill("MO-001");
|
|
await expect(async () => {
|
|
const rows = await page.locator(".data-table tbody tr").count();
|
|
expect(rows).toBe(1);
|
|
}).toPass({ timeout: 5000 });
|
|
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
|
expect(refs).toEqual(["MO-001"]);
|
|
|
|
await searchBox.fill("");
|
|
await expect(async () => {
|
|
const rows = await page.locator(".data-table tbody tr").count();
|
|
expect(rows).toBe(totalRows);
|
|
}).toPass({ timeout: 5000 });
|
|
});
|
|
|
|
test("vehicle detail: all tabs render distinct content", async ({ page }) => {
|
|
await page.goto("/vehicles/MO-016");
|
|
await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible();
|
|
for (const tab of ["Overview", "Bookings", "Inspections", "Maintenance", "Quality"]) {
|
|
await page.getByRole("tab", { name: tab }).click();
|
|
await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true");
|
|
}
|
|
});
|
|
|
|
test("bookings page: status filter works", async ({ page }) => {
|
|
await page.goto("/bookings");
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
await page.getByLabel("Status").selectOption("returned");
|
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
|
const statuses = await page.locator(".data-table tbody tr td:nth-child(5)").allTextContents();
|
|
expect(statuses.every((s) => s.includes("returned"))).toBeTruthy();
|
|
});
|
|
|
|
test("bookings page: pagination renders at most 25 rows and page 2 differs from page 1", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/bookings");
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
|
|
const page1Count = await page.locator(".data-table tbody tr").count();
|
|
expect(page1Count).toBeLessThanOrEqual(25);
|
|
const page1Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
|
|
|
const nextButton = page.getByRole("button", { name: "Next" });
|
|
await expect(nextButton).toBeEnabled();
|
|
await nextButton.click();
|
|
|
|
await expect(async () => {
|
|
const page2Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
|
expect(page2Refs.length).toBeGreaterThan(0);
|
|
expect(page2Refs).not.toEqual(page1Refs);
|
|
}).toPass({ timeout: 5000 });
|
|
|
|
const page2Count = await page.locator(".data-table tbody tr").count();
|
|
expect(page2Count).toBeLessThanOrEqual(25);
|
|
|
|
const prevButton = page.getByRole("button", { name: "Previous" });
|
|
await expect(prevButton).toBeEnabled();
|
|
});
|
|
|
|
test("return preview correctly reports blocked (not maintenance) for damage reported", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
|
await page.getByLabel("End odometer (km)").fill("55000");
|
|
await page.getByLabel("Fuel level (%)").fill("40");
|
|
await page.getByRole("checkbox", { name: "Damage reported" }).check();
|
|
await page.getByRole("button", { name: "Review return" }).click();
|
|
|
|
// The preview is the server's authoritative evaluation: damage always routes to
|
|
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
|
|
// The localized reason is the primary text; the raw code sits behind "Technical
|
|
// details" so it isn't visible until expanded.
|
|
await expect(page.getByText("Damage was reported on this return.")).toBeVisible();
|
|
const statusRegion = page.locator(".impact-preview");
|
|
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
|
|
await page.getByText("Technical details").click();
|
|
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
|
|
});
|
|
|
|
test("data quality page: status and rule-type filters work", async ({ page }) => {
|
|
await page.goto("/data-quality");
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
|
|
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(
|
|
"possible_duplicate_customer",
|
|
);
|
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
|
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
|
|
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
|
|
|
|
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
|
|
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
|
|
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
|
});
|
|
|
|
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
|
|
await page.goto("/data-quality");
|
|
await page
|
|
.getByRole("combobox", { name: "Rule type", exact: true })
|
|
.selectOption("missing_required_field");
|
|
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
|
|
const ref = await firstLink.textContent();
|
|
await firstLink.click();
|
|
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Defer" }).click();
|
|
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
|
});
|
|
|
|
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).toBeVisible();
|
|
|
|
await page.getByLabel("Registration number").fill("TST-777");
|
|
await page.getByLabel("Make").fill("TestMake");
|
|
await page.getByLabel("Model").fill("TestModel");
|
|
await page.getByLabel("Location").fill("Depot");
|
|
await page.getByRole("button", { name: "Save and re-check" }).click();
|
|
|
|
await expect(page.getByText("Resolved").first()).toBeVisible();
|
|
});
|
|
|
|
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).toBeVisible();
|
|
|
|
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
|
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
|
|
|
await expect(page.getByText("Resolved").first()).toBeVisible();
|
|
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
|
expect((await booking.json()).status).toBe("blocked");
|
|
});
|
|
|
|
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Review recommendation" }).click();
|
|
await page.getByRole("button", { name: /^Change status to/ }).click();
|
|
|
|
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
|
});
|
|
|
|
test("data quality: retaining canonical resolves an odometer regression issue", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
const issues = await (
|
|
await page.request.get("/api/v1/data-quality/issues", {
|
|
params: { rule_type: "odometer_regression", status: "open" },
|
|
})
|
|
).json();
|
|
const target = issues[0];
|
|
|
|
await page.goto(`/data-quality/${target.public_ref}`);
|
|
await expect(page.getByRole("heading", { name: target.public_ref })).toBeVisible();
|
|
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
|
await page.getByRole("button", { name: "Resolve issue" }).click();
|
|
|
|
await expect(page.getByText("Resolved").first()).toBeVisible();
|
|
});
|
|
|
|
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/data-quality");
|
|
await page.getByRole("button", { name: "Run quality scan" }).click();
|
|
await page.getByRole("button", { name: "Yes, run scan" }).click();
|
|
|
|
await expect(page.getByText(/Scan complete/)).toBeVisible();
|
|
});
|
|
|
|
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/automation");
|
|
const jobsTable = page.getByRole("table", { name: "Automation jobs" });
|
|
await expect(jobsTable).toBeVisible();
|
|
|
|
await page.getByLabel("Status").selectOption("failed");
|
|
await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
|
|
const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
|
|
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
|
await expect(retryButton).toBeVisible();
|
|
await retryButton.click();
|
|
|
|
// Retrying a failed delivery moves it out of the "failed" status, so — while still
|
|
// filtered to "failed" — the row correctly disappears from this view rather than
|
|
// showing "pending" in place. Confirm the filtered list shrank by one.
|
|
await expect(async () => {
|
|
const count = await jobsTable.locator("tbody tr").count();
|
|
expect(count).toBe(failedRowCountBefore - 1);
|
|
}).toPass({ timeout: 5000 });
|
|
});
|
|
|
|
test("audit page: action filter works", async ({ page }) => {
|
|
await page.goto("/audit");
|
|
await expect(page.locator(".audit-group-list")).toBeVisible();
|
|
await page.getByLabel("Action").fill("demo_login");
|
|
await expect(page.locator(".audit-group").first()).toBeVisible();
|
|
const headings = await page.locator(".audit-group-heading strong").allTextContents();
|
|
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
|
|
});
|
|
|
|
test("audit page: shows human-readable before/after and a safe entity link", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
// demo/reset deletes the acting session's own cookie, so submit the return through
|
|
// page.request instead -- it shares the browser context's still-valid OM session from
|
|
// beforeEach rather than the now-logged-out standalone `request` fixture.
|
|
const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", {
|
|
data: {
|
|
end_odometer_km: 60000,
|
|
fuel_level_percent: 55,
|
|
cleanliness_ok: true,
|
|
damage_reported: false,
|
|
technical_warning: false,
|
|
},
|
|
headers: { "Idempotency-Key": "e2e-audit-before-after-check" },
|
|
});
|
|
expect(submitted.ok()).toBeTruthy();
|
|
|
|
await page.goto("/audit");
|
|
await page.getByLabel("Action").fill("return_registered");
|
|
const firstGroup = page.locator(".audit-group").first();
|
|
await expect(firstGroup).toBeVisible();
|
|
|
|
const changeDiff = firstGroup.locator(".change-diff");
|
|
await expect(changeDiff).toContainText(/status/i);
|
|
await expect(changeDiff).toContainText("returned");
|
|
|
|
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
|
});
|
|
|
|
test("knowledge page: form submits and clears input", async ({ page }) => {
|
|
await page.goto("/knowledge");
|
|
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
|
|
await input.fill("What must I do when a vehicle returns with damage?");
|
|
await page.getByRole("button", { name: "Ask" }).click();
|
|
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
|
await expect(input).toHaveValue("");
|
|
});
|
|
|
|
test("switch role button logs out and returns to login", async ({ page }) => {
|
|
await page.getByRole("button", { name: "Switch role" }).click();
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
test("session survives a page refresh and restores the correct role", async ({ page }) => {
|
|
await page.goto("/vehicles");
|
|
await page.reload();
|
|
await expect(page).toHaveURL(/\/vehicles$/);
|
|
await expect(page.getByText("Operations manager")).toBeVisible();
|
|
await expect(page.locator(".data-table")).toBeVisible();
|
|
});
|
|
|
|
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
|
|
await page.goto("/dashboard");
|
|
await page.getByRole("button", { name: "Switch role" }).click();
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
|
|
// Directly re-requesting a protected route after logout must not restore access from a
|
|
// stale client cache; the server-side cookie is gone.
|
|
await page.goto("/dashboard");
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
test("direct navigation to a protected route without a session redirects to login", async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
await context.clearCookies();
|
|
await page.goto("/vehicles");
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
|
|
page,
|
|
}) => {
|
|
await page.getByRole("button", { name: "Switch role" }).click();
|
|
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
|
|
// Manager-only nav items are not shown at all, not merely disabled.
|
|
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
|
|
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
|
|
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
|
|
|
|
// Direct URL navigation is still blocked server-side and shows the same restricted
|
|
// message as a defense-in-depth measure, not just a hidden button.
|
|
await page.goto("/automation");
|
|
await expect(
|
|
page.getByText("Automation is visible to Operations Managers only.").first(),
|
|
).toBeVisible();
|
|
|
|
await page.goto("/data-quality");
|
|
await expect(
|
|
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
|
|
).toBeVisible();
|
|
|
|
await page.goto("/audit");
|
|
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
|
|
|
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
|
});
|
|
|
|
test("operations manager can reset demo data and is returned to login", async ({ page }) => {
|
|
await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Reset demo data" }).click();
|
|
await page.getByRole("button", { name: "Yes, reset" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
|
|
// The reset must not have affected the ability to log back in against fresh data.
|
|
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
});
|
|
|
|
test("automation page shows the aggregate n8n integration status, not just the latest event", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await resetDemoData(request);
|
|
await page.goto("/automation");
|
|
await expect(page.locator(".integration-cards")).toContainText("succeeded");
|
|
await expect(page.locator(".integration-cards")).toContainText("failed");
|
|
});
|
|
|
|
test("rental employee direct API access to manager-only endpoints is rejected", async ({
|
|
page,
|
|
}) => {
|
|
await page.getByRole("button", { name: "Switch role" }).click();
|
|
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
|
|
// page.request shares the browser context's cookies, and (via the web container's
|
|
// nginx /api/ proxy) works identically against localhost and the deployed server --
|
|
// the backend API itself is never exposed directly on either.
|
|
for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) {
|
|
const response = await page.request.get(path);
|
|
expect(response.status(), path).toBe(403);
|
|
}
|
|
});
|