Skip to content

Commit 8618a20

Browse files
committed
test(dashboard): harden e2e coverage and helpers
1 parent afd3a2f commit 8618a20

8 files changed

Lines changed: 286 additions & 84 deletions

File tree

apps/dashboard/test/e2e/STANDARDS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Every E2E test must be safe to run in parallel.
2828
Required:
2929

3030
- Use `apps/dashboard/test/e2e/fixtures.ts` unless there is a clear reason not to.
31+
- Import local E2E fixtures/helpers through the dashboard `@/test/e2e/...` alias, not deep `../../` paths.
3132
- Create isolated users, organizations, websites, and seeded analytics data through the E2E bootstrap fixtures.
3233
- Keep tests independent. No test may depend on another test's data or execution order.
3334
- Prefer per-test state over shared mutable fixtures.
Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,82 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
2+
import {
3+
createLinkFolder,
4+
createShortLink,
5+
escapedText,
6+
idFromPath,
7+
linkRow,
8+
openLinkActions,
9+
scopeSuffix,
10+
} from "@/test/e2e/utils/dashboard";
211

3-
async function openLinkActions(
4-
page: import("@playwright/test").Page,
5-
linkName: string
6-
): Promise<void> {
7-
const rowLink = page.getByRole("link", { name: new RegExp(linkName) });
8-
await rowLink.hover();
9-
await page.getByRole("button", { name: `Actions for ${linkName}` }).click();
10-
}
12+
const SHORT_LINK_LABEL_RE = /Short Link/;
13+
const SLUG_CONFLICT_RE = /slug.*(taken|exists)/i;
1114

1215
test(
13-
"creates, updates, opens, and deletes a short link",
16+
"creates, filters, updates, opens, and deletes short links",
1417
{ tag: "@core" },
1518
async ({ authenticatedPage, e2eSession }) => {
16-
const suffix = e2eSession.userId.slice(0, 8).toLowerCase();
19+
const suffix = scopeSuffix(e2eSession);
1720
const folderName = `E2E Folder ${suffix}`;
18-
const linkName = `E2E Link ${suffix}`;
19-
const updatedName = `${linkName} Updated`;
20-
const slug = `e2e-${suffix}`;
21+
const primaryToken = `primary-${suffix}`;
22+
const primaryName = `E2E Link ${primaryToken}`;
23+
const secondaryName = `E2E Other ${suffix}`;
24+
const updatedName = `${primaryName} Updated`;
25+
const primarySlug = `e2e-${primaryToken}`;
26+
const secondarySlug = `e2e-other-${suffix}`;
2127
const targetUrl = `e2e-${suffix}.local/start`;
2228
const updatedTargetUrl = `e2e-${suffix}.local/updated`;
2329

2430
await authenticatedPage.goto("/links");
2531
await expect(
2632
authenticatedPage.getByRole("heading", { name: "Links" })
2733
).toBeVisible();
28-
await authenticatedPage.getByRole("button", { name: "Folder" }).click();
29-
await expect(
30-
authenticatedPage.getByRole("heading", { name: "Create Folder" })
31-
).toBeVisible();
32-
await authenticatedPage
33-
.getByRole("textbox", { name: "Folder Name" })
34-
.fill(folderName);
35-
await authenticatedPage.getByRole("button", { name: "Create Folder" }).click();
34+
35+
await createLinkFolder(authenticatedPage, folderName);
3636
await expect(
3737
authenticatedPage.getByRole("button", { name: new RegExp(folderName) })
3838
).toBeVisible();
3939

40-
await authenticatedPage.getByRole("button", { name: "New Link" }).click();
40+
const primaryRow = await createShortLink(authenticatedPage, {
41+
folderName,
42+
name: primaryName,
43+
slug: primarySlug,
44+
targetUrl,
45+
});
46+
await expect(primaryRow).toBeVisible();
47+
await expect(authenticatedPage.getByText(escapedText(primarySlug))).toBeVisible();
4148
await expect(
42-
authenticatedPage.getByRole("heading", { name: "Create Link" })
49+
authenticatedPage.getByRole("button", {
50+
name: new RegExp(`${folderName}\\s+1`),
51+
})
4352
).toBeVisible();
4453

45-
await authenticatedPage
46-
.getByRole("textbox", { name: "Destination URL" })
47-
.fill(targetUrl);
48-
await authenticatedPage.getByRole("textbox", { name: "Name" }).fill(linkName);
49-
await authenticatedPage
50-
.getByRole("textbox", { name: /Short Link/ })
51-
.fill(slug);
52-
const createDialog = authenticatedPage.getByRole("dialog", {
53-
name: "Create Link",
54+
const secondaryRow = await createShortLink(authenticatedPage, {
55+
name: secondaryName,
56+
slug: secondarySlug,
57+
targetUrl: `other-${targetUrl}`,
5458
});
55-
await createDialog
56-
.getByRole("button", { name: "Folder: Unfiled" })
57-
.click();
58-
await authenticatedPage.getByRole("menuitem", { name: folderName }).click();
59-
await authenticatedPage.getByRole("button", { name: "Create Link" }).click();
60-
61-
const linkRow = authenticatedPage.getByRole("link", {
62-
name: new RegExp(linkName),
63-
});
64-
await expect(linkRow).toBeVisible();
65-
await expect(authenticatedPage.getByText(new RegExp(slug))).toBeVisible();
59+
await expect(secondaryRow).toBeVisible();
6660
await expect(
67-
authenticatedPage.getByRole("button", { name: new RegExp(`${folderName}\\s+1`) })
61+
authenticatedPage.getByRole("button", { name: /Unfiled\s+1/ })
6862
).toBeVisible();
6963

70-
await linkRow.click();
64+
await authenticatedPage
65+
.getByRole("textbox", { name: "Search links" })
66+
.fill(primaryToken);
67+
await expect(linkRow(authenticatedPage, primaryName)).toBeVisible();
68+
await expect(linkRow(authenticatedPage, secondaryName)).toBeHidden();
69+
await authenticatedPage.getByRole("button", { name: "Clear search" }).click();
70+
await expect(linkRow(authenticatedPage, secondaryName)).toBeVisible();
71+
72+
await linkRow(authenticatedPage, primaryName).click();
7173
await expect(authenticatedPage).toHaveURL(/\/links\/[A-Za-z0-9_-]+/);
72-
await expect(authenticatedPage.getByText(linkName)).toBeVisible();
74+
expect(idFromPath(authenticatedPage.url(), "links")).toBeTruthy();
75+
await expect(authenticatedPage.getByText(primaryName)).toBeVisible();
7376
await expect(authenticatedPage.getByText("Total Clicks")).toBeVisible();
7477

7578
await authenticatedPage.goto("/links");
76-
await openLinkActions(authenticatedPage, linkName);
79+
await openLinkActions(authenticatedPage, primaryName);
7780
await authenticatedPage.getByRole("menuitem", { name: "Edit" }).click();
7881
await expect(
7982
authenticatedPage.getByRole("heading", { name: "Edit Link" })
@@ -88,9 +91,10 @@ test(
8891
await expect(
8992
authenticatedPage.getByRole("heading", { name: "Edit Link" })
9093
).toBeHidden();
94+
await expect(linkRow(authenticatedPage, updatedName)).toBeVisible();
9195
await expect(
92-
authenticatedPage.getByRole("link", { name: new RegExp(updatedName) })
93-
).toBeVisible();
96+
authenticatedPage.getByText(primaryName, { exact: true })
97+
).toBeHidden();
9498

9599
await openLinkActions(authenticatedPage, updatedName);
96100
await authenticatedPage.getByRole("menuitem", { name: "Delete" }).click();
@@ -102,8 +106,59 @@ test(
102106
.getByRole("button", { name: "Delete Link" })
103107
.click();
104108

109+
await expect(linkRow(authenticatedPage, updatedName)).toBeHidden();
110+
await expect(linkRow(authenticatedPage, secondaryName)).toBeVisible();
111+
}
112+
);
113+
114+
test(
115+
"validates short link slugs and rejects duplicates",
116+
{ tag: "@core" },
117+
async ({ authenticatedPage, e2eSession }) => {
118+
const suffix = scopeSuffix(e2eSession);
119+
const name = `Slug Edge ${suffix}`;
120+
const slug = `slug-edge-${suffix}`;
121+
const targetUrl = `slug-edge-${suffix}.local/start`;
122+
123+
await authenticatedPage.goto("/links");
124+
await authenticatedPage.getByRole("button", { name: "New Link" }).click();
125+
const dialog = authenticatedPage.getByRole("dialog", { name: "Create Link" });
126+
await dialog.getByRole("textbox", { name: "Destination URL" }).fill(targetUrl);
127+
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
128+
129+
const invalidCases = [
130+
{ error: "Slug must be at least 3 characters", value: "ab" },
131+
{ error: "Only letters, numbers, hyphens, and underscores", value: "bad/slug" },
132+
];
133+
for (const { error, value } of invalidCases) {
134+
await dialog
135+
.getByRole("textbox", { name: SHORT_LINK_LABEL_RE })
136+
.fill(value);
137+
await expect(dialog.getByText(error)).toBeVisible();
138+
await expect(dialog.getByRole("button", { name: "Create Link" })).toBeDisabled();
139+
}
140+
141+
await dialog.getByRole("textbox", { name: SHORT_LINK_LABEL_RE }).fill(slug);
142+
await dialog.getByRole("button", { name: "Create Link" }).click();
143+
await expect(linkRow(authenticatedPage, name)).toBeVisible();
144+
145+
await authenticatedPage.getByRole("button", { name: "New Link" }).click();
146+
const duplicateDialog = authenticatedPage.getByRole("dialog", {
147+
name: "Create Link",
148+
});
149+
await duplicateDialog
150+
.getByRole("textbox", { name: "Destination URL" })
151+
.fill(`duplicate-${targetUrl}`);
152+
await duplicateDialog
153+
.getByRole("textbox", { name: "Name" })
154+
.fill(`${name} duplicate`);
155+
await duplicateDialog
156+
.getByRole("textbox", { name: SHORT_LINK_LABEL_RE })
157+
.fill(slug);
158+
await duplicateDialog.getByRole("button", { name: "Create Link" }).click();
105159
await expect(
106-
authenticatedPage.getByRole("link", { name: new RegExp(updatedName) })
107-
).toBeHidden();
160+
authenticatedPage.getByText(SLUG_CONFLICT_RE).first()
161+
).toBeVisible();
162+
await expect(linkRow(authenticatedPage, `${name} duplicate`)).toBeHidden();
108163
}
109164
);
Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,36 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
2+
import {
3+
createWebsite,
4+
expectDashboardReady,
5+
idFromPath,
6+
scopeSuffix,
7+
websiteCard,
8+
} from "@/test/e2e/utils/dashboard";
29

3-
function websiteIdFromUrl(url: string): string {
4-
const match = new URL(url).pathname.match(/\/websites\/([^/]+)/);
5-
if (!match?.[1]) {
6-
throw new Error(`Could not read website id from URL: ${url}`);
7-
}
8-
return match[1];
9-
}
10+
const DUPLICATE_DOMAIN_RE = /domain.*already exists/i;
1011

1112
test(
1213
"creates, updates, and deletes a website",
1314
{ tag: "@core" },
1415
async ({ authenticatedPage, e2eSession }) => {
15-
const suffix = e2eSession.userId.slice(0, 8);
16+
const suffix = scopeSuffix(e2eSession);
1617
const websiteName = `E2E Website ${suffix}`;
1718
const updatedName = `${websiteName} Updated`;
1819
const domain = `e2e-${suffix}.local`;
1920

2021
await authenticatedPage.goto("/websites");
21-
await expect(
22-
authenticatedPage.getByRole("button", { name: /Workspace|organization/i })
23-
).toBeVisible();
24-
await authenticatedPage.getByRole("button", { name: "New Website" }).click();
25-
await expect(
26-
authenticatedPage.getByRole("heading", { name: "Create a new website" })
27-
).toBeVisible();
22+
await expectDashboardReady(authenticatedPage);
2823

29-
await authenticatedPage.getByRole("textbox", { name: "Name" }).fill(websiteName);
30-
await authenticatedPage.getByRole("textbox", { name: "Domain" }).fill(domain);
31-
await authenticatedPage
32-
.getByRole("button", { name: "Create website" })
33-
.click();
34-
35-
const websiteLink = authenticatedPage.getByRole("link", {
36-
name: `Open ${websiteName} analytics`,
24+
const createdWebsite = await createWebsite(authenticatedPage, {
25+
domain,
26+
name: websiteName,
3727
});
38-
await expect(websiteLink).toBeVisible();
28+
await expect(createdWebsite).toBeVisible();
3929
await expect(authenticatedPage.getByText(domain)).toBeVisible();
4030

41-
await websiteLink.click();
31+
await createdWebsite.click();
4232
await expect(authenticatedPage).toHaveURL(/\/websites\/[A-Za-z0-9_-]+/);
43-
const websiteId = websiteIdFromUrl(authenticatedPage.url());
33+
const websiteId = idFromPath(authenticatedPage.url(), "websites");
4434

4535
await authenticatedPage.goto(`/websites/${websiteId}/settings/general`);
4636
await expect(authenticatedPage.getByText(websiteName)).toBeVisible();
@@ -50,7 +40,9 @@ test(
5040
await expect(
5141
authenticatedPage.getByRole("heading", { name: "Edit Website" })
5242
).toBeVisible();
53-
await authenticatedPage.getByRole("textbox", { name: "Name" }).fill(updatedName);
43+
await authenticatedPage
44+
.getByRole("textbox", { name: "Name" })
45+
.fill(updatedName);
5446
await authenticatedPage.getByRole("button", { name: "Save changes" }).click();
5547
await expect(
5648
authenticatedPage.getByRole("heading", { name: "Edit Website" })
@@ -73,3 +65,58 @@ test(
7365
await expect(authenticatedPage.getByText(updatedName)).toBeHidden();
7466
}
7567
);
68+
69+
test(
70+
"validates, normalizes, and rejects duplicate website domains",
71+
{ tag: "@core" },
72+
async ({ authenticatedPage, e2eSession }) => {
73+
const suffix = scopeSuffix(e2eSession);
74+
const domain = `edge-${suffix}.local`;
75+
const firstName = `Edge Website ${suffix}`;
76+
const duplicateName = `Duplicate Website ${suffix}`;
77+
78+
await authenticatedPage.goto("/websites");
79+
await expectDashboardReady(authenticatedPage);
80+
await authenticatedPage.getByRole("button", { name: "New Website" }).click();
81+
82+
const dialog = authenticatedPage.getByRole("dialog", {
83+
name: "Create a new website",
84+
});
85+
await dialog.getByRole("textbox", { name: "Name" }).fill("Bad !");
86+
await dialog.getByRole("textbox", { name: "Domain" }).fill("not-a-domain");
87+
await expect(dialog.getByText("Use alphanumeric, spaces, -, _")).toBeVisible();
88+
await expect(dialog.getByText("Invalid domain format")).toBeVisible();
89+
await expect(
90+
dialog.getByRole("button", { name: "Create website" })
91+
).toBeDisabled();
92+
93+
await dialog.getByRole("textbox", { name: "Name" }).fill(firstName);
94+
await dialog
95+
.getByRole("textbox", { name: "Domain" })
96+
.fill(`https://www.${domain}/ignored-path?utm=e2e`);
97+
await expect(dialog.getByRole("textbox", { name: "Domain" })).toHaveValue(
98+
domain
99+
);
100+
await dialog.getByRole("button", { name: "Create website" }).click();
101+
await expect(websiteCard(authenticatedPage, firstName)).toBeVisible();
102+
await expect(authenticatedPage.getByText(domain)).toBeVisible();
103+
104+
await authenticatedPage.getByRole("button", { name: "New Website" }).click();
105+
await authenticatedPage
106+
.getByRole("dialog", { name: "Create a new website" })
107+
.getByRole("textbox", { name: "Name" })
108+
.fill(duplicateName);
109+
await authenticatedPage
110+
.getByRole("dialog", { name: "Create a new website" })
111+
.getByRole("textbox", { name: "Domain" })
112+
.fill(domain);
113+
await authenticatedPage
114+
.getByRole("dialog", { name: "Create a new website" })
115+
.getByRole("button", { name: "Create website" })
116+
.click();
117+
await expect(
118+
authenticatedPage.getByText(DUPLICATE_DOMAIN_RE).first()
119+
).toBeVisible();
120+
await expect(websiteCard(authenticatedPage, duplicateName)).toBeHidden();
121+
}
122+
);

apps/dashboard/test/e2e/specs/regressions/api-keys.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
22

33
test(
44
"creates and deletes an API key without leaving confirmation dialogs open",

apps/dashboard/test/e2e/specs/regressions/website-analytics.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
22

33
test(
44
"renders analytics controls in the website topbar",

apps/dashboard/test/e2e/specs/smoke/account.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
22

33
test(
44
"updates the signed-in user's profile name",

apps/dashboard/test/e2e/specs/smoke/auth.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "../../fixtures";
1+
import { expect, test } from "@/test/e2e/fixtures";
22

33
test(
44
"redirects unauthenticated visitors to sign in",

0 commit comments

Comments
 (0)