Skip to content

Commit 687ada5

Browse files
authored
Add: Add test for service detail page (#135)
* Add: Add test for service detail page * fix timeout
1 parent 1b01e1b commit 687ada5

File tree

2 files changed

+374
-1
lines changed

2 files changed

+374
-1
lines changed

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
jobs:
1010
e2e:
1111
runs-on: ubuntu-latest
12-
timeout-minutes: 30
12+
timeout-minutes: 45
1313

1414
steps:
1515
- name: Checkout

e2e/services-detail.spec.ts

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
import { test, expect, Page } from "@playwright/test";
2+
import { ServiceSlug } from "@/app/[lang]/(hyperjump)/data";
3+
4+
// Base URL
5+
const baseURL = "http://localhost:3000";
6+
7+
const services = Object.entries(ServiceSlug).map(([key, slug]) => ({
8+
title: key.replace(/([A-Z])/g, " $1").trim(),
9+
slug
10+
}));
11+
12+
const locales = [
13+
...services.map((i) => ({
14+
code: "en",
15+
title: i.title,
16+
slug: i.slug,
17+
path: `/en/services/${i.slug}`
18+
})),
19+
...services.map((i) => ({
20+
code: "id",
21+
title: i.title,
22+
slug: i.slug,
23+
path: `/id/services/${i.slug}`
24+
}))
25+
] as const;
26+
27+
const viewports = [
28+
{ name: "mobile", size: { width: 360, height: 740 } },
29+
{ name: "tablet", size: { width: 820, height: 1180 } },
30+
{ name: "desktop", size: { width: 1280, height: 800 } },
31+
{ name: "large", size: { width: 1536, height: 960 } }
32+
] as const;
33+
34+
// Utility: assert all images load (no broken images)
35+
export async function expectAllImagesLoaded(page: Page) {
36+
await page.waitForLoadState("networkidle");
37+
const main = page.locator("main");
38+
const images = main.locator('img, [style*="background-image"], image');
39+
const count = await images.count();
40+
41+
if (count === 0) return;
42+
43+
for (let i = 0; i < count; i++) {
44+
const el = images.nth(i);
45+
const tag = await el.evaluate((n) => n.tagName.toLowerCase());
46+
47+
await expect(el, `Image #${i} not visible`).toBeVisible({ timeout: 5000 });
48+
49+
if (tag === "img" || tag === "image") {
50+
const ok = await el.evaluate(
51+
(img: HTMLImageElement | SVGImageElement) => {
52+
// @ts-ignore — handle both <img> and SVG <image>
53+
const nw = (img as any).naturalWidth ?? 1;
54+
// @ts-ignore
55+
const nh = (img as any).naturalHeight ?? 1;
56+
// @ts-ignore
57+
const href = (img as any).href?.baseVal ?? (img as any).src ?? null;
58+
return (nw > 0 && nh > 0) || Boolean(href);
59+
}
60+
);
61+
expect(
62+
ok,
63+
`Image #${i} failed to load or has 0x0 natural size`
64+
).toBeTruthy();
65+
}
66+
}
67+
}
68+
69+
// Utility: get nav and footer link locators
70+
function getHeader(page: import("@playwright/test").Page) {
71+
// Header is sticky nav; fall back to first header/nav region
72+
const header = page.locator("header, nav").first();
73+
return header;
74+
}
75+
76+
function getMenuNav(page: import("@playwright/test").Page) {
77+
return page.locator('nav[aria-label="Main"]');
78+
}
79+
80+
function getFooter(page: import("@playwright/test").Page) {
81+
return page.getByRole("contentinfo");
82+
}
83+
84+
// Utility: navigate and ensure route
85+
async function gotoAndWait(page: import("@playwright/test").Page, url: string) {
86+
await page.goto(url, { waitUntil: "domcontentloaded" });
87+
await expect(page).toHaveURL(
88+
new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
89+
); // escape
90+
}
91+
92+
// Shared assertions for header nav links based on code in app/[lang]/(hyperjump)/components/nav.tsx
93+
const expectedMenuPaths = (locale: string) => [
94+
`/${locale}/services`,
95+
`/${locale}/products`,
96+
`/${locale}/case-studies`,
97+
`/${locale}#faqs`
98+
];
99+
100+
// Hero and list sections selectors from page.tsx
101+
const selectors = {
102+
hero: "#hero",
103+
heroHeading:
104+
"#hero div div[dangerouslysetinnerhtml], #hero h1, #hero .text-3xl",
105+
heroDesc: "#hero p",
106+
cardsSection: "section div.grid",
107+
card: "div.grid > div, div.grid > article, div.grid > li",
108+
cardButton: "section > div a, section > div button"
109+
};
110+
111+
for (const { code: locale, path, title, slug } of locales) {
112+
test.describe("Services Detail Page", () => {
113+
test.describe(`/${slug} -${locale.toUpperCase()} locale`, () => {
114+
test.beforeEach(async ({ page }) => {
115+
await gotoAndWait(page, `${baseURL}${path}`);
116+
});
117+
118+
// 1. Navigation & Links
119+
test.describe("Navigation & Links", () => {
120+
test("header nav links route correctly", async ({ page }) => {
121+
const header = getHeader(page);
122+
await expect(header).toBeVisible();
123+
124+
const menuNav = getMenuNav(page);
125+
const expected = expectedMenuPaths(locale);
126+
for (const href of expected) {
127+
await expect(
128+
menuNav.locator(`a[href='${href}']`).first()
129+
).toBeVisible();
130+
}
131+
132+
// Click-through checks for non-anchor-with-fragment links
133+
for (const href of expected) {
134+
menuNav.locator(`a[href='${href}']`).first().click();
135+
await expect(page).toHaveURL(`${baseURL}${href}`);
136+
// Go back to subject page
137+
if (href !== `/${locale}/services/${slug}`) {
138+
await page.goBack();
139+
await expect(page).toHaveURL(`${baseURL}${path}`);
140+
}
141+
}
142+
});
143+
144+
test("footer links and social icons are visible and valid", async ({
145+
page
146+
}) => {
147+
const footer = getFooter(page);
148+
await expect(footer).toBeVisible();
149+
150+
const footerLinks = footer.getByRole("link");
151+
await expect(footerLinks.first()).toBeVisible();
152+
153+
// Ensure each link has href
154+
const count = await footerLinks.count();
155+
for (let i = 0; i < count; i++) {
156+
const link = footerLinks.nth(i);
157+
const href = await link.getAttribute("href");
158+
expect(href, "footer link should have href").toBeTruthy();
159+
}
160+
});
161+
162+
test("content buttons link to intended destinations", async ({
163+
page
164+
}) => {
165+
const cardLinks = page.locator(selectors.cardButton);
166+
const n = await cardLinks.count();
167+
for (let i = 0; i < n; i++) {
168+
const link = cardLinks.nth(i);
169+
await expect(link).toBeVisible();
170+
const href = await link.getAttribute("href");
171+
expect(href).toBeTruthy();
172+
// Only test one click to avoid navigating away multiple times unnecessarily
173+
if (i === 0 && href) {
174+
await link.click();
175+
await expect(page).toHaveURL(
176+
new RegExp(`${href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)
177+
);
178+
await page.goBack();
179+
await expect(page).toHaveURL(`${baseURL}${path}`);
180+
}
181+
}
182+
});
183+
});
184+
185+
// 2. Branding
186+
test.describe("Branding", () => {
187+
test("logo visible in header and links to home", async ({ page }) => {
188+
const header = getHeader(page);
189+
const logo = header
190+
.getByRole("link")
191+
.filter({ has: page.getByAltText("Hyperjump Logo") })
192+
.first();
193+
await expect(logo).toBeVisible();
194+
195+
const href = await logo.getAttribute("href");
196+
expect(href).toBe(`/${locale}`);
197+
});
198+
199+
test("logo visible in footer and links to home", async ({ page }) => {
200+
const footer = getFooter(page);
201+
const logo = footer
202+
.getByRole("link")
203+
.filter({ has: page.getByAltText("Hyperjump Logo") })
204+
.first();
205+
await expect(logo).toBeVisible();
206+
const href = await logo.getAttribute("href");
207+
expect(href).toBe(`/${locale}`);
208+
});
209+
});
210+
211+
// 3. Language Switching
212+
test.describe("Language Switching", () => {
213+
test("switch language to the other locale and back", async ({
214+
page
215+
}) => {
216+
const footer = getFooter(page);
217+
const select = footer.getByRole("combobox");
218+
await expect(select).toBeVisible();
219+
220+
// Current value should be locale
221+
await expect(select).toHaveValue(locale);
222+
223+
const other = locale === "en" ? "id" : "en";
224+
await select.selectOption(other);
225+
await page.waitForURL(new RegExp(`/(${other})/services`));
226+
227+
// Verify content changes (hero heading changes with locale)
228+
const heading = page
229+
.locator("#hero")
230+
.locator(
231+
".text-3xl, .text-4xl, [class*='text-'][class*='font-medium']"
232+
)
233+
.first();
234+
await expect(heading).toBeVisible();
235+
236+
// Switch back
237+
const select2 = getFooter(page).getByRole("combobox");
238+
await select2.selectOption(locale);
239+
await page.waitForURL(new RegExp(`/(${locale})/services`));
240+
});
241+
});
242+
243+
// 4. Images
244+
test.describe("Images", () => {
245+
test("all images load without errors", async ({ page }) => {
246+
await expectAllImagesLoaded(page);
247+
});
248+
});
249+
250+
// 5. Text & Content
251+
test.describe("Text & Content", () => {
252+
test("hero section visible with heading and description", async ({
253+
page
254+
}) => {
255+
const hero = page.locator(selectors.hero);
256+
await expect(hero).toBeVisible();
257+
});
258+
259+
test("content sections are visible with text", async ({ page }) => {
260+
const section = page.locator(selectors.cardsSection);
261+
await expect(section.first()).toBeVisible();
262+
263+
const headings = section.locator("h2");
264+
const headingCount = await headings.count();
265+
expect(headingCount).toBeGreaterThan(0);
266+
267+
for (let i = 0; i < headingCount; i++) {
268+
const h2 = headings.nth(i);
269+
await expect(h2).toBeVisible();
270+
await expect(h2).not.toBeEmpty();
271+
}
272+
273+
const paragraphs = section.locator("p");
274+
const paragraphCount = await paragraphs.count();
275+
expect(paragraphCount).toBeGreaterThan(0);
276+
277+
for (let i = 0; i < paragraphCount; i++) {
278+
const p = paragraphs.nth(i);
279+
await expect(p).toBeVisible();
280+
await expect(p).not.toBeEmpty();
281+
}
282+
});
283+
284+
test("footer text visible", async ({ page }) => {
285+
const footer = getFooter(page);
286+
await expect(footer).toBeVisible();
287+
288+
const firstParagraph = footer.locator("p").first();
289+
await expect(firstParagraph).toBeVisible();
290+
await expect(firstParagraph).not.toBeEmpty();
291+
});
292+
});
293+
294+
// 6. SEO & Metadata
295+
test.describe("SEO & Metadata", () => {
296+
test("meta title and description are set correctly", async ({
297+
page
298+
}) => {
299+
const title = await page.title();
300+
expect(title).toMatch(title);
301+
302+
const metaDesc = await page
303+
.locator('head meta[name="description"]')
304+
.getAttribute("content");
305+
expect(metaDesc).toBeTruthy();
306+
// Basic sanity: should include hero description text excerpt
307+
const bodyText = await page.locator("body").innerText();
308+
expect(bodyText.length).toBeGreaterThan(50);
309+
});
310+
});
311+
312+
// 7. Test Structure by UI sections
313+
test.describe("Sections", () => {
314+
test("Header", async ({ page }) => {
315+
const header = getHeader(page);
316+
await expect(header).toBeVisible();
317+
});
318+
319+
test("Hero Section", async ({ page }) => {
320+
const hero = page.locator(selectors.hero);
321+
await expect(hero).toBeVisible();
322+
});
323+
324+
test("Services Section", async ({ page }) => {
325+
const grid = page.locator("section div.grid");
326+
await expect(grid.first()).toBeVisible();
327+
328+
const itemCount = await grid
329+
.first()
330+
.locator("div, article, li")
331+
.count();
332+
expect(itemCount).toBeGreaterThan(0);
333+
});
334+
335+
test("Footer", async ({ page }) => {
336+
const footer = getFooter(page);
337+
await expect(footer).toBeVisible();
338+
});
339+
});
340+
341+
// 8. Responsive Design
342+
test.describe("Responsive Design", () => {
343+
for (const viewport of viewports) {
344+
test(`layout renders correctly at ${viewport.name} (${viewport.size.width}x${viewport.size.height})`, async ({
345+
browser
346+
}) => {
347+
const context = await browser.newContext({
348+
viewport: viewport.size
349+
});
350+
const page = await context.newPage();
351+
await page.goto(`${baseURL}${path}`, {
352+
waitUntil: "domcontentloaded"
353+
});
354+
355+
const header = getHeader(page);
356+
await expect(header).toBeVisible();
357+
358+
const hero = page.locator(selectors.hero);
359+
await expect(hero).toBeVisible();
360+
361+
const grid = page.locator("section div.grid").first();
362+
await expect(grid).toBeVisible();
363+
364+
const footer = getFooter(page);
365+
await expect(footer).toBeVisible();
366+
367+
await context.close();
368+
});
369+
}
370+
});
371+
});
372+
});
373+
}

0 commit comments

Comments
 (0)