Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { pickOption } from "./helpers";

/** A seeded partner, used for the attachment this test makes and then removes. */
const PARTNER = "Acme Retail";
Expand Down Expand Up @@ -31,20 +32,17 @@ test("API gateway: create, attach partner, create subscription detour, edit atta
await page.getByRole("button", { name: "Attach partner" }).click();
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/);

await page.getByRole("combobox", { name: "Partner" }).click();
await page.getByRole("option", { name: new RegExp(PARTNER) }).click();
await pickOption(page, "Partner", new RegExp(PARTNER));
await expect(page.getByRole("combobox", { name: "Partner" })).toHaveValue(PARTNER);

const subscriptionName = `Playwright GW Subscription ${Date.now()}`;
await page.getByRole("button", { name: "New subscription" }).click();
// The picked partner rides along as a query param through the detour.
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach\/new-subscription/);
await page.fill("#ngi-name", subscriptionName);
await page.getByRole("combobox", { name: "Information type" }).click();
await page.getByRole("option", { name: /Shipment order/ }).click();
await pickOption(page, "Information type", /Shipment order/);
await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/);
await page.getByRole("combobox", { name: "handler adapter" }).click();
await page.getByRole("option", { name: "NativeHttpHandler" }).click();
await pickOption(page, "handler adapter", "NativeHttpHandler");
await page.locator("#prop-Url").fill("https://example.com/sink");
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink");
await page.getByRole("button", { name: "Create subscription" }).click();
Expand Down Expand Up @@ -93,8 +91,7 @@ test("Bus gateway: create, add route with match expression, edit route, remove,
await page.goto("bus-gateways/new");
await page.fill("#nbg-name", name);
// Bus-enabled types only, and this one is the one no seeded gateway already listens for.
await page.getByRole("combobox", { name: "Information type" }).click();
await page.getByRole("option", { name: /Delivery proof/ }).click();
await pickOption(page, "Information type", /Delivery proof/);
await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Delivery proof/);
await page.getByRole("button", { name: "Create gateway" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+$/);
Expand All @@ -115,8 +112,7 @@ test("Bus gateway: create, add route with match expression, edit route, remove,

// Its delivery is a node on the same canvas.
await page.getByRole("button", { name: /^Delivery/ }).click();
await page.getByRole("combobox", { name: "handler adapter" }).click();
await page.getByRole("option", { name: "NativeHttpHandler" }).click();
await pickOption(page, "handler adapter", "NativeHttpHandler");
await page.locator("#prop-Url").fill("https://example.com/sink");
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink");

Expand Down
37 changes: 36 additions & 1 deletion SW.Bitween.Web/ClientApp/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";

/** Checkbox labels carry their description in the accessible name, so anchor at the start. */
export const startsWith = (text: string) =>
Expand All @@ -11,6 +11,41 @@ export const ADMIN_PASSWORD = "Mtm@dmin!2";
export const FIRST_PASSWORD = "Pl4ywright!1";
export const ROTATED_PASSWORD = "R0tated!Pass2";

/**
* Choose an option from a SearchSelect, and leave the page interactive.
*
* SearchSelect is a Headless UI `Combobox` with `immediate`, so it opens its
* listbox whenever the input takes focus — and Headless UI restores focus to
* that input a few milliseconds after the list unmounts. So every close
* schedules a reopen, and a test that moves on inside that window gets its
* focus stolen and then wedged: an open listbox marks the rest of the page
* `inert`, and an inert page can never be clicked to dismiss the listbox. The
* failure reads as "<section> intercepts pointer events" and never recovers.
*
* A person never sees this — their next action is far slower than the restore,
* which is why the app behaves correctly by hand. This is the test catching up
* with the UI, so it dismisses the list until it stays shut rather than
* assuming one Escape is enough.
*/
export async function pickOption(page: Page, combobox: string | RegExp, option: string | RegExp) {
await page.getByRole("combobox", { name: combobox }).click();
await page.getByRole("option", { name: option }).click();

const listbox = page.locator('[role="listbox"]');
const settled = async () => {
await page.waitForTimeout(SETTLE_MS);
return (await listbox.count()) === 0;
};
for (let attempt = 0; attempt < 10; attempt++) {
if (await listbox.count()) await page.keyboard.press("Escape");
if ((await settled()) && (await settled())) return;
}
throw new Error(`the ${combobox} listbox would not stay closed`);
}

/** Long enough for Headless UI's deferred focus restore to have fired. */
const SETTLE_MS = 120;

export async function signIn(page: Page, email: string, password: string) {
await page.goto("login");
await page.fill("#login-email", email);
Expand Down
22 changes: 10 additions & 12 deletions SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { pickOption } from "./helpers";

const ADMIN_EMAIL = "admin@Bitween.systems";
const ADMIN_PASSWORD = "Mtm@dmin!2";
Expand All @@ -19,28 +20,23 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete",

// The information type is a searchable picker, and the stages below it are cards that open
// in place — there is no wizard to "Continue" through any more.
await page.getByRole("combobox", { name: "Information type" }).click();
await page.getByRole("option", { name: /Shipment order/ }).click();
// Wait for the pick to land: without this the next click can run first and the form is still
// missing its information type, leaving "Create job" disabled.
await pickOption(page, "Information type", /Shipment order/);
await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/);

// Source — open by default. Receiver adapter plus its one required prop.
await page.getByRole("combobox", { name: "receiver adapter" }).click();
await page.getByRole("option", { name: "NativeHttpReceiver" }).click();
await pickOption(page, "receiver adapter", "NativeHttpReceiver");
await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("NativeHttpReceiver");
await page.locator("#prop-Url").fill("https://example.com/feed");
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed");

// Collapse Source before opening Delivery: its property form renders asynchronously and the
// cards below it keep moving while it does, so clicking straight through hits a moving target.
// Collapse Source before opening Delivery: only one stage is open at a time, which is what
// keeps #prop-Url unambiguous below.
await page.getByRole("button", { name: "Close this step" }).click();

// Delivery — handler adapter and its required prop (transformation stays "Passes through").
// Only one stage is open at a time, so #prop-Url is unambiguous here.
await page.getByRole("button", { name: /^Delivery/ }).click();
await page.getByRole("combobox", { name: "handler adapter" }).click();
await page.getByRole("option", { name: "NativeHttpHandler" }).click();
await pickOption(page, "handler adapter", "NativeHttpHandler");
await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("NativeHttpHandler");
await page.locator("#prop-Url").fill("https://example.com/sink");
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink");
Expand Down Expand Up @@ -87,8 +83,10 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete",
await expect(row).toBeVisible({ timeout: 15000 });
await expect(row.getByText("undefined")).toHaveCount(0);

// The whole row is the link on this table — there is no separate open button.
await row.click();
// The name, not the row's centre. The whole row opens the subscription, but it also
// carries links of its own — information type, partner — and which one sits under the
// centre depends on how wide the columns happen to be.
await row.getByText(name).click();
await expect(page).toHaveURL(/\/subscriptions\/\d+$/);
await page.getByRole("button", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete subscription" }).click();
Expand Down
222 changes: 222 additions & 0 deletions SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { test, expect, type Page } from "@playwright/test";
import { signInAsAdmin } from "./helpers";

/**
* The layout contracts the tables have to keep, whatever is in them.
*
* These are all data-shape problems: a customer with 60-character subscription
* names, ten promoted properties, a 4KB minified payload and 45 subscriptions on
* one information type. None of that exists in a dev database, so every test
* here rewrites the API response on the way past rather than seeding rows —
* nothing is written, and the assertions don't drift with whatever the local
* data happens to be.
*/

const LONG_NAMES = [
"Customer Aggregation Trace Out Manifest - Sodexi Cassini EDI",
"Agent Tracing - Colissimo EDI Daily Reconciliation",
// No spaces anywhere: there is nothing for the browser to break on when it
// works out the column's intrinsic minimum, which is what `wrap-anywhere`
// exists to handle. With `break-words` this one alone widened Aggregations by
// nearly 900px.
"Customer_Aggregation_Scan_Out_CUSTOMS_Chronopost_Returns",
];

/** Replaces every `name` the API returns with a production-length one. */
async function withLongNames(page: Page) {
let n = 0;
await page.route("**/api/**", async (route) => {
const res = await route.fetch();
if (!(res.headers()["content-type"] ?? "").includes("json")) return route.fulfill({ response: res });
let body: unknown;
try {
body = await res.json();
} catch {
return route.fulfill({ response: res });
}
const walk = (v: unknown): void => {
if (Array.isArray(v)) return v.forEach(walk);
if (v && typeof v === "object")
for (const k of Object.keys(v as Record<string, unknown>)) {
const o = v as Record<string, unknown>;
if (k === "name" && typeof o[k] === "string" && o[k]) o[k] = LONG_NAMES[n++ % LONG_NAMES.length];
else walk(o[k]);
}
};
walk(body);
await route.fulfill({ response: res, json: body });
});
}

/** Every table on the page that is wider than the box it scrolls inside. */
const overflowing = (page: Page) =>
page.evaluate(() =>
[...document.querySelectorAll("table")]
.map((t) => {
const box = t.closest("div[class*=overflow-x-auto]") ?? t.parentElement!;
return { head: [...t.querySelectorAll("th")].map((h) => h.textContent!.trim()).join("/"),
over: t.scrollWidth - box.clientWidth };
})
.filter((r) => r.over > 1),
);

/** Leaf elements whose text is cut off — an ellipsis the reader can't get past. */
const clipped = (page: Page) =>
page.evaluate(() =>
[...document.querySelectorAll("td")]
.flatMap((td) => [td, ...td.querySelectorAll("*")])
.filter((e) => e.children.length === 0 && e.scrollWidth > e.clientWidth + 1)
.map((e) => e.textContent!.trim().slice(0, 40)),
);

test.beforeEach(({ page }) => signInAsAdmin(page));

const LIST_PAGES = [
"subscriptions", "aggregations", "scheduled-jobs", "bus-gateways", "api-gateways",
"partners", "information-types", "work-groups", "retry-policies", "exchanges", "queue-health",
];

test("no list table is wider than the card it sits in", async ({ page }) => {
// The densest pages carry twelve columns; the padding and the wrap floors are
// tuned so they still land inside a 1440px window with nothing off the right.
await page.setViewportSize({ width: 1440, height: 900 });
for (const slug of LIST_PAGES) {
await page.goto(slug);
await page.waitForTimeout(1200);
expect(await overflowing(page), `${slug} has a table wider than its card`).toEqual([]);
}
});

test("long names wrap rather than collapsing into a row of ellipses", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await withLongNames(page);

for (const slug of ["subscriptions", "aggregations", "scheduled-jobs"]) {
await page.goto(slug);
await page.waitForTimeout(1500);
// Exceptions and joined key lists are allowed to clip — they carry a title.
// A name never is.
const cut = (await clipped(page)).filter((t) => LONG_NAMES.some((n) => n.startsWith(t.replace(/…$/, ""))));
expect(cut, `${slug} clipped a name`).toEqual([]);
expect(await overflowing(page), `${slug} widened past its card`).toEqual([]);
}
});

test("promoted properties open in a panel, not just a tooltip", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);

await page.route("**/xchanges?**", async (route) => {
const res = await route.fetch();
let body: any;
try { body = await res.json(); } catch { return route.fulfill({ response: res }); }
// Ten properties, one value too long for a chip, and a null — the value
// shape that used to take the page down on paging.
for (const row of body.result ?? [])
row.promotedProperties = {
"Trace Code": "SHOR020", "Agent Code": null, "First Time": "True",
CreatedBy: "madebydaily.shopify.com", "Order Ref": "SO-2026-0088341-RETURN-LINE-2",
Weight: "2.4kg", Destination: "FR-75011", Service: "EXPRESS", Attempt: "3", Manifest: "M-88214",
};
await route.fulfill({ response: res, json: body });
});

await page.goto("exchanges");
const trigger = page.getByRole("button", { name: "Show all 10 promoted properties" }).first();
await trigger.click();

// Every property, in full — including the one too long to have fitted a chip.
await expect(page.getByText("10 promoted properties")).toBeVisible();
await expect(page.getByText("SO-2026-0088341-RETURN-LINE-2")).toBeVisible();

// Opening the panel is not a request to expand the row underneath it.
await expect(page.getByText("EXCHANGE ID")).toHaveCount(0);

await page.getByRole("button", { name: "Copy all" }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toContain("Order Ref=SO-2026-0088341-RETURN-LINE-2");
expect(copied.split("\n")).toHaveLength(10);
});

test("paging the exchanges list survives a null promoted value", async ({ page }) => {
await page.route("**/xchanges?**", async (route) => {
const res = await route.fetch();
let body: any;
try { body = await res.json(); } catch { return route.fulfill({ response: res }); }
// A promoted path that resolved to nothing arrives as null, not "".
for (const row of body.result ?? [])
row.promotedProperties = { "Agent Code": null, "Trace Code": null, "First Time": "True" };
await route.fulfill({ response: res, json: body });
});

const crashes: string[] = [];
page.on("pageerror", (e) => crashes.push(e.message));

await page.goto("exchanges");
const next = page.getByRole("button", { name: "Next" }).first();
if (!(await next.isDisabled())) {
await next.click();
await expect(page.getByText("Unexpected Application Error")).toHaveCount(0);
}
expect(crashes).toEqual([]);
});

test("a long payload doesn't stretch the exchanges table", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const payload = JSON.stringify({
Shipment: {
Uid: "STF.365673", Number: "3304169024", Account: "VFS-FR-UKEMB-PAR",
Pieces: Array.from({ length: 12 }, (_, i) => ({ Barcode: `STF36567300${i}`, WeightKg: 2.4 + i })),
},
});
await page.route("**/bitweendocs**", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ key: "k", data: payload }) }));

await page.goto("exchanges");
await page.waitForTimeout(1200);
const before = await page.evaluate(() => document.querySelector("table")!.scrollWidth);

// The expander, not the row: the row's cells carry links of their own.
await page.locator("tbody tr").first().locator("td").last().click();
await expect(page.getByRole("button", { name: "Download this document" })).toBeVisible();

// Laid out over lines, and the one line of minified JSON never sets the width.
await expect(page.getByText('"Shipment": {')).toBeVisible();
expect(await overflowing(page)).toEqual([]);
expect(await page.evaluate(() => document.querySelector("table")!.scrollWidth)).toBe(before);

// Raw shows it exactly as it arrived, and still can't stretch anything.
await page.getByRole("button", { name: "Raw" }).click();
await expect(page.getByText('{"Shipment":{"Uid":"STF.365673"', { exact: false })).toBeVisible();
expect(await overflowing(page)).toEqual([]);
expect(await page.evaluate(() => document.querySelector("table")!.scrollWidth)).toBe(before);
});

test("a panel list pages and filters once it runs long", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.route("**/subscriptions?filter=DocumentId*", async (route) => {
const res = await route.fetch();
let body: any;
try { body = await res.json(); } catch { return route.fulfill({ response: res }); }
const one = body.result?.[0];
if (one)
body.result = Array.from({ length: 45 }, (_, i) => ({
...one, id: 900000 + i, name: `${LONG_NAMES[i % LONG_NAMES.length]} ${i}`,
}));
await route.fulfill({ response: res, json: body });
});

await page.goto("information-types");
await page.locator("tbody tr").first().click();
await expect(page).toHaveURL(/\/information-types\/\d+$/);

// Long names in a ~360px panel used to push Type off the right-hand edge.
await expect(page.getByRole("columnheader", { name: "Type" }).first()).toBeVisible();
expect(await overflowing(page)).toEqual([]);

await expect(page.getByText("1–10 of 45")).toBeVisible();
const box = page.getByPlaceholder("Search 45 subscriptions");
await box.fill(LONG_NAMES[0].slice(0, 20));
// Filtering to one page takes the pager away but leaves the box that got you there.
await expect(page.getByText(/of 45$/)).toHaveCount(0);
await expect(box).toBeVisible();
});
4 changes: 2 additions & 2 deletions SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface RawXchangeRow {
inputKey: string | null;
outputKey: string | null;
responseKey: string | null;
promotedProperties: Record<string, string> | null;
promotedProperties: Record<string, string | null> | null;
retryFor: string | null;
aggregationXchangeId: string | null;
responseBad: boolean | null;
Expand All @@ -50,7 +50,7 @@ interface RawDelayedRetryRow {
documentName: string;
exception: string | null;
startedOn: string;
promotedProperties: Record<string, string> | null;
promotedProperties: Record<string, string | null> | null;
retryPolicyId: number | null;
retryPolicyName: string | null;
}
Expand Down
Loading
Loading