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
48 changes: 42 additions & 6 deletions e2e/e2e-mock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,55 @@ fi
COMPOSE_PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$COMPOSE_PROJECT_DIR/docker-compose.e2e.yml"

STRIDE=1000

# True if something is already listening on the given TCP port (any owner: the dev
# stack, a leaked mock-server from a prior run, or another e2e instance). Uses bash's
# /dev/tcp so it needs no lsof/netstat and behaves the same on Git Bash and Linux CI.
port_in_use() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&- && return 0
return 1
}

# Every host port a given instance would bind. The mock-rss (3001) / mock-discord
# (3002) servers are started by Playwright on the HOST, so they must be checked here
# too — checking only the compose project name let instance 0 collide with whatever
# already held 3001/3002.
instance_ports() {
local off=$(($1 * STRIDE))
echo "$((8100 + off)) $((3100 + off)) $((27019 + off)) $((3001 + off)) $((3002 + off))"
}

instance_is_free() {
local candidate="$1"
local project="monitorss-e2e$([ "$candidate" = 0 ] && echo '' || echo "-$candidate")"
if echo "$USED_COMPOSE_PROJECTS" | grep -qx "$project"; then
return 1
fi
local port
for port in $(instance_ports "$candidate"); do
if port_in_use "$port"; then
return 1
fi
done
return 0
}

# A single integer (E2E_INSTANCE) isolates a run so multiple suites can run at once.
# When unset, pick the lowest instance whose compose project isn't already running.
# Instance 0 uses today's ports/names verbatim, so default behavior is unchanged.
# When unset, pick the lowest instance whose compose project AND all host ports are
# free, so a run can never collide with the dev stack, a leaked mock server, or
# another concurrent e2e run. Instance 0 uses today's ports/names verbatim.
USED_COMPOSE_PROJECTS="$(docker compose ls --format json 2>/dev/null \
| grep -oE 'monitorss-e2e(-[0-9]+)?' || true)"
if [ -z "${E2E_INSTANCE:-}" ]; then
used="$(docker compose ls --format json 2>/dev/null \
| grep -oE 'monitorss-e2e(-[0-9]+)?' || true)"
E2E_INSTANCE=0
while echo "$used" | grep -qx "monitorss-e2e$([ "$E2E_INSTANCE" = 0 ] && echo '' || echo "-$E2E_INSTANCE")"; do
while ! instance_is_free "$E2E_INSTANCE"; do
E2E_INSTANCE=$((E2E_INSTANCE + 1))
done
elif ! instance_is_free "$E2E_INSTANCE"; then
echo "WARNING: E2E_INSTANCE=$E2E_INSTANCE has a busy compose project or port ($(instance_ports "$E2E_INSTANCE")); the run may fail." >&2
fi

STRIDE=1000
OFF=$((E2E_INSTANCE * STRIDE))
export E2E_INSTANCE
export E2E_BACKEND_PORT=$((8100 + OFF))
Expand Down
23 changes: 23 additions & 0 deletions e2e/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ export async function bulkDeleteFeeds(
}
}

export async function bulkDisableFeeds(
page: Page,
feedIds: string[],
): Promise<void> {
if (!feedIds.length) {
return;
}

const response = await page.request.patch("/api/v1/user-feeds", {
data: {
op: "bulk-disable",
data: { feeds: feedIds.map((id) => ({ id })) },
},
});

if (!response.ok()) {
const text = await response.text();
throw new Error(
`Failed to bulk-disable feeds: ${response.status()} - ${text}`,
);
}
}

export async function createConnection(
page: Page,
feedId: string,
Expand Down
89 changes: 89 additions & 0 deletions e2e/tests/billing/paddle-retain-cancellation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,93 @@ test.describe("Paddle Subscription Cancellation", () => {
page.getByRole("button", { name: "Resume subscription" }),
).toBeVisible({ timeout: 10000 });
});

test("shows an inline error in the dialog (not a toast) when cancellation fails", async ({
page,
}) => {
test.setTimeout(180_000);

await page.goto("/feeds");
await page.getByRole("button", { name: /account settings/i }).click();
await page.getByRole("menuitem", { name: "Account Settings" }).click();
await expect(
page.getByRole("heading", { name: "Account Settings" }),
).toBeVisible({ timeout: 10000 });

await page.getByRole("button", { name: "Manage Subscription" }).click();

const cancelButton = page.getByRole("button", {
name: "Cancel Subscription",
});
await expect(cancelButton).toBeVisible({ timeout: 15000 });
await cancelButton.click();

const confirmDialog = page.getByRole("dialog", {
name: "Confirm Subscription Changes",
});
await expect(confirmDialog).toBeVisible({ timeout: 30000 });

await page.route(
"**/api/v1/subscription-products/cancel",
async (route) => {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
isStandardized: true,
code: "ADDRESS_LOCATION_NOT_ALLOWED",
message:
"Your location is not supported for billing. This may be due to regional restrictions. If you believe this is an error, please contact support@monitorss.xyz.",
timestamp: Date.now(),
errors: [],
}),
});
},
);

await confirmDialog
.getByRole("button", { name: "Confirm Downgrade" })
.click();

const dialogError = confirmDialog.getByRole("alert");
await expect(dialogError).toContainText(/location is not supported/i, {
timeout: 15000,
});

await expect(confirmDialog).toBeVisible();
await expect(
confirmDialog.getByRole("button", { name: "Cancel" }),
).toBeVisible();
});

test("returns focus to the triggering button after backing out of the cancellation dialog", async ({
page,
}) => {
test.setTimeout(180_000);

await page.goto("/feeds");
await page.getByRole("button", { name: /account settings/i }).click();
await page.getByRole("menuitem", { name: "Account Settings" }).click();
await expect(
page.getByRole("heading", { name: "Account Settings" }),
).toBeVisible({ timeout: 10000 });

await page.getByRole("button", { name: "Manage Subscription" }).click();

const cancelButton = page.getByRole("button", {
name: "Cancel Subscription",
});
await expect(cancelButton).toBeVisible({ timeout: 15000 });
await cancelButton.click();

const confirmDialog = page.getByRole("dialog", {
name: "Confirm Subscription Changes",
});
await expect(confirmDialog).toBeVisible({ timeout: 30000 });

await confirmDialog.getByRole("button", { name: "Cancel" }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 15000 });

await expect(cancelButton).toBeFocused({ timeout: 15000 });
});
});
27 changes: 27 additions & 0 deletions e2e/tests/billing/pricing-dialog-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from "../../fixtures/test-fixtures";

test.describe("Pricing dialog focus management", () => {
test("stays open when tabbing after being opened via the keyboard", async ({
page,
}) => {
await page.goto("/feeds");
await page.getByRole("button", { name: /account settings/i }).click();
await page.getByRole("menuitem", { name: "Account Settings" }).click();
await expect(
page.getByRole("heading", { name: "Account Settings" }),
).toBeVisible({ timeout: 10000 });

await page
.getByRole("button", { name: "Manage Subscription" })
.press("Enter");

const pricingHeading = page
.getByRole("dialog")
.getByRole("heading", { name: "Pricing", level: 1 });
await expect(pricingHeading).toBeVisible({ timeout: 15000 });

await page.keyboard.press("Tab");

await expect(pricingHeading).toBeVisible();
});
});
66 changes: 64 additions & 2 deletions e2e/tests/connections/connection-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,9 @@ test.describe("Connection Settings", () => {
});

await page.getByRole("tab", { name: "Custom Placeholders" }).click();
await expect(page.getByText("clonedPlaceholder").first()).toBeVisible({
await expect(
page.getByRole("tabpanel").getByText("clonedPlaceholder").first(),
).toBeVisible({
timeout: 10000,
});
} finally {
Expand Down Expand Up @@ -400,6 +402,64 @@ test.describe("Connection Settings", () => {
}
});

test("can expand split settings and edit split inputs", async ({
page,
testFeedWithConnection,
}) => {
const { feed, connection } = testFeedWithConnection;

await page.goto(
`/feeds/${feed.id}/discord-channel-connections/${connection.id}`,
);

await expect(
page.getByRole("heading", { name: connection.name }),
).toBeVisible({ timeout: 10000 });

await page.getByRole("tab", { name: "Message Format" }).click();

await expect(
page.getByRole("heading", { name: "Message Format", exact: true }),
).toBeVisible({ timeout: 10000 });

// Enable Split Content, which un-disables the Split Settings accordion.
// The visible Chakra switch control intercepts pointer events on the hidden
// input, so toggle via keyboard instead of a pointer click.
const splitToggle = page.getByRole("checkbox", { name: "Split Content" });
await expect(splitToggle).toBeVisible({ timeout: 10000 });
await splitToggle.focus();
await page.keyboard.press("Space");
await expect(splitToggle).toBeChecked();

// Expand the Split Settings accordion. Regression guard: it must STAY open
// (it previously opened then immediately collapsed due to a double-toggle).
const splitSettingsTrigger = page.getByRole("button", {
name: "Split Settings",
});
await splitSettingsTrigger.click();

const splitTextInput = page.getByLabel("Split text");
await expect(splitTextInput).toBeVisible({ timeout: 10000 });
// Re-assert after a beat to catch an immediate re-collapse.
await page.waitForTimeout(500);
await expect(splitTextInput).toBeVisible();

// The inputs are editable and retain typed values.
await splitTextInput.fill("|");
await expect(splitTextInput).toHaveValue("|");

const appendTextInput = page.getByLabel("Append text");
await appendTextInput.fill(">>");
await expect(appendTextInput).toHaveValue(">>");

const prependTextInput = page.getByLabel("Prepend text");
await prependTextInput.fill("<<");
await expect(prependTextInput).toHaveValue("<<");

// Accordion is still open after typing.
await expect(splitTextInput).toBeVisible();
});

test("can delete a connection", async ({ page, testFeedWithConnection }) => {
const { feed, connection } = testFeedWithConnection;

Expand Down Expand Up @@ -577,7 +637,9 @@ test.describe("Copy Connection Settings", () => {

// Verify custom placeholders were copied
await page.getByRole("tab", { name: "Custom Placeholders" }).click();
await expect(page.getByText("copiedPlaceholder").first()).toBeVisible({
await expect(
page.getByRole("tabpanel").getByText("copiedPlaceholder").first(),
).toBeVisible({
timeout: 10000,
});

Expand Down
18 changes: 17 additions & 1 deletion e2e/tests/feeds-table/column-visibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ const COLUMNS = [
] as const;

test.describe("Column Visibility", () => {
// v3 menus animate on open; under parallel load that animation runs long
// enough that the menu item is still moving when the test clicks it, so the
// Ark checkbox item never registers the toggle. Reduced motion skips the
// animation, keeping the item stable for deterministic interaction.
test.use({ contextOptions: { reducedMotion: "reduce" } });

async function setupColumnTest(page: Page) {
const feed = await createFeed(page, {
title: "Column Visibility Test Feed",
Expand Down Expand Up @@ -63,7 +69,17 @@ test.describe("Column Visibility", () => {
.locator(`[role="menuitemcheckbox"]:has-text("${columnLabel}")`)
.first();
await checkbox.waitFor({ state: "visible", timeout: 10000 });
await checkbox.click({ force: true });
// Toggle via Ark's own keyboard navigation rather than a pointer click.
// Under parallel load the open menu keeps animating/repositioning, so a
// pointer click lands on a moving target and the Ark checkbox item never
// registers the toggle. ArrowDown highlights items through Ark's focus
// manager (position-independent); Enter then toggles the highlighted item.
const index = COLUMNS.findIndex((c) => c.label === columnLabel);
for (let i = 0; i <= index; i += 1) {
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(50);
}
await page.keyboard.press("Enter");
await page.waitForTimeout(500);
}

Expand Down
Loading
Loading