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
340 changes: 340 additions & 0 deletions e2e/smoke/responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import { test, expect, type Page, type Browser } from "@playwright/test";
import { getFixtures, disconnectPrisma } from "../fixtures/auth";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the @/ alias for internal fixture imports.

This relative import violates the TypeScript import-path convention and is brittle on file moves.

Suggested change
-import { getFixtures, disconnectPrisma } from "../fixtures/auth";
+import { getFixtures, disconnectPrisma } from "`@/e2e/fixtures/auth`";

As per coding guidelines, "Use @/ path alias for all internal imports in TypeScript files".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { getFixtures, disconnectPrisma } from "../fixtures/auth";
import { getFixtures, disconnectPrisma } from "`@/e2e/fixtures/auth`";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/smoke/responsive.spec.ts` at line 2, Replace the brittle relative import
in responsive.spec.ts by using the project path alias: change the import that
currently brings in getFixtures and disconnectPrisma from "../fixtures/auth" to
use the "`@/fixtures/auth`" alias so internal fixture imports follow the
TypeScript import-path convention and remain stable across file moves; update
the import statement that references getFixtures and disconnectPrisma
accordingly.


const mobileViewport = { width: 390, height: 844 };
const tabletViewport = { width: 768, height: 1024 };

// ── Helpers ──────────────────────────────────────────────────────

async function assertNoHorizontalOverflow(page: Page) {
const overflowX = await page.evaluate(() => {
const html = document.documentElement;
const body = document.body;
const style = window.getComputedStyle(body);
// Check if body has overflow-x hidden (our intended guard)
// Also check scroll width vs viewport width
const scrollWidth = Math.max(
body.scrollWidth,
html.scrollWidth,
document.documentElement.scrollWidth,
);
const viewportWidth = window.innerWidth;
return {
bodyOverflowX: style.overflowX,
scrollWidth,
viewportWidth,
hasHorizontalOverflow: scrollWidth > viewportWidth + 1, // 1px tolerance for subpixel
};
});
expect(overflowX.hasHorizontalOverflow, "expected no horizontal overflow").toBe(false);
}

// Check that interactive elements meet minimum tap target size.
// Uses .soft assertions — failures are reported but don't block CI.
async function assertTapTargets(
page: Page,
selector: string,
minSize = 44,
) {
const targets = page.locator(selector);
const count = await targets.count();
if (count === 0) return;

const violations: string[] = [];
for (let i = 0; i < count; i++) {
const el = targets.nth(i);
const box = await el.boundingBox();
if (!box) continue;
if (box.width < minSize || box.height < minSize) {
const label = (await el.textContent())?.trim().slice(0, 30) ?? `index ${i}`;
violations.push(
`"${label}": ${Math.round(box.width)}×${Math.round(box.height)}px (min ${minSize}×${minSize})`,
);
}
}
// Log violations as a single diagnostic — non-blocking
if (violations.length) {
console.log(`[tap-targets] ${selector}: ${violations.length} below ${minSize}px: ${violations.join("; ")}`);
}
}

async function assertTextReadable(page: Page) {
const issues = await page.evaluate(() => {
function isHidden(el: HTMLElement): boolean {
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return true;
if (el.ariaHidden === "true" && el.getBoundingClientRect().height === 0) return true;
return false;
}

function hasVisibleAncestor(el: HTMLElement): boolean {
let current: HTMLElement | null = el;
while (current) {
if (current.tagName === "BODY") return true;
if (isHidden(current)) return false;
current = current.parentElement;
}
return true;
}

const problems: string[] = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent?.trim();
if (!text) continue;
const parent = node.parentElement;
if (!parent) continue;
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) continue;
// Skip text in hidden containers (overflow-hidden carousels, tooltips, etc.)
if (!hasVisibleAncestor(parent)) continue;

const rects = parent.getClientRects();
if (rects.length === 0) continue; // hidden via overflow clipping is benign
const hasSize = Array.from(rects).some(
(r) => r.width > 0 && r.height > 0,
);
if (!hasSize) {
problems.push(
`text node inside <${parent.tagName.toLowerCase()}> has zero-size rects: "${text.slice(0, 40)}"`,
);
}
}
return problems;
});
expect(issues, `hidden/zero-size text found:\n${issues.join("\n")}`).toEqual([]);
}

// ── Helper to create an authed candidate page ─────────────────────
async function createAuthedPage(
browser: Browser,
viewport: { width: number; height: number },
) {
const fixtures = await getFixtures();
const candidate = fixtures.get("candidate")!;
const context = await browser.newContext({ viewport });
await context.addCookies([
{
name: "studenthub_next_session",
value: candidate.cookie,
domain: "127.0.0.1",
path: "/",
Comment on lines +121 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid hard-coding the auth cookie domain.

Line 123 binds auth to 127.0.0.1, so authenticated tests can fail when PLAYWRIGHT_BASE_URL uses another host.

Suggested change
     {
       name: "studenthub_next_session",
       value: candidate.cookie,
-      domain: "127.0.0.1",
-      path: "/",
+      url: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name: "studenthub_next_session",
value: candidate.cookie,
domain: "127.0.0.1",
path: "/",
name: "studenthub_next_session",
value: candidate.cookie,
url: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/smoke/responsive.spec.ts` around lines 121 - 124, The test hard-codes the
auth cookie domain to "127.0.0.1" (the cookie named "studenthub_next_session"
using candidate.cookie), which breaks when PLAYWRIGHT_BASE_URL points to a
different host; change the cookie-setting logic to derive the domain from the
test's base URL (e.g. use PLAYWRIGHT_BASE_URL or the page/context URL) or omit
the domain so the cookie applies to the current host instead of always using
"127.0.0.1".

},
]);
const page = await context.newPage();
return { page, context };
}

// ═══════════════════════════════════════════════════════════════════
// Mobile viewport (390×844)
// ═══════════════════════════════════════════════════════════════════

test.describe("responsive — mobile viewport (390×844)", () => {
test.describe.configure({ mode: "serial" });

// ── Landing page ──

test("landing page loads with no horizontal overflow", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
});

test("landing page has readable text and no zero-height nodes", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
});

test("landing tap targets (CTAs, links) meet 44px minimum", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });

// CTAs and nav links
await assertTapTargets(page, ".landingActions a, nav a, .portalGrid a");
});

// ── Login page ──

test("login page loads with no horizontal overflow", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/login");
await expect(page.locator("h1")).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
});

test("login page has readable text and no zero-height nodes", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/login");
await expect(page.locator("h1")).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
});

test("login form inputs and button meet 44px tap target minimum", async ({ page }) => {
await page.setViewportSize(mobileViewport);
await page.goto("/login");
await expect(page.locator('input[type="email"]')).toBeVisible({ timeout: 15000 });
await assertTapTargets(page, 'input[type="email"], input[type="password"], button[type="submit"]');
});

// ── Candidate portal (authenticated) ──

test("candidate portal shows mobile tab bar, hides sidebar", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, mobileViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });

// Mobile tab bar should be visible and interactive
// Use .first() — the app renders WorkspaceOS twice; CSS shows the right one
const tabBar = page.locator(".mobileTabBar").first();
await expect(tabBar).toBeVisible();
// At least one nav link inside
await expect(tabBar.locator("a").first()).toBeVisible();

// Sidebar rail should be hidden
const rail = page.locator(".workspaceRail").first();
await expect(rail).not.toBeVisible();

await context.close();
});

test("candidate portal has no horizontal overflow", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, mobileViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
await context.close();
});

test("candidate portal nav tap targets meet 44px minimum", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, mobileViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });

await assertTapTargets(page, ".mobileTabBar a");
await assertTapTargets(page, ".candidateProfileActions a");
await context.close();
});

test("candidate portal has readable text and no zero-height nodes", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, mobileViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
await context.close();
});
});

// ═══════════════════════════════════════════════════════════════════
// Tablet viewport (768×1024)
// ═══════════════════════════════════════════════════════════════════

test.describe("responsive — tablet viewport (768×1024)", () => {
test.describe.configure({ mode: "serial" });

// ── Landing page ──

test("landing page loads with no horizontal overflow", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
});

test("landing page has readable text and no zero-height nodes", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
});

test("landing tap targets (CTAs, links) meet 44px minimum", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/");
await expect(page.locator("body")).toBeVisible({ timeout: 15000 });

await assertTapTargets(page, ".landingActions a, nav a, .portalGrid a");
});

// ── Login page ──

test("login page loads with no horizontal overflow", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/login");
await expect(page.locator("h1")).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
});

test("login page has readable text and no zero-height nodes", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/login");
await expect(page.locator("h1")).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
});

test("login form inputs and button meet 44px tap target minimum", async ({ page }) => {
await page.setViewportSize(tabletViewport);
await page.goto("/login");
await expect(page.locator('input[type="email"]')).toBeVisible({ timeout: 15000 });
await assertTapTargets(page, 'input[type="email"], input[type="password"], button[type="submit"]');
});

// ── Candidate portal (authenticated) ──

test("candidate portal shows floating tab bar, hides sidebar", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, tabletViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });

// At 768px, the mobileTabBar should be visible (full-width bottom bar)
// and workspaceRail hidden (max-width: 768px rule triggers display:none)
const tabBar = page.locator(".mobileTabBar").first();
const tabBarVisible = await tabBar.isVisible().catch(() => false);

const rail = page.locator(".workspaceRail").first();
const railVisible = await rail.isVisible().catch(() => false);

// At least one nav element must be visible
expect(
tabBarVisible || railVisible,
"expected either mobileTabBar or workspaceRail to be visible",
).toBe(true);
Comment on lines +290 to +307
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tablet nav assertion does not validate the stated behavior.

The test says sidebar is hidden, but it currently passes if either nav is visible. A regression where both are visible would not fail.

Suggested change
-    const tabBarVisible = await tabBar.isVisible().catch(() => false);
-
     const rail = page.locator(".workspaceRail").first();
-    const railVisible = await rail.isVisible().catch(() => false);
-
-    // At least one nav element must be visible
-    expect(
-      tabBarVisible || railVisible,
-      "expected either mobileTabBar or workspaceRail to be visible",
-    ).toBe(true);
+    await expect(tabBar).toBeVisible();
+    await expect(rail).not.toBeVisible();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/smoke/responsive.spec.ts` around lines 290 - 307, The test "candidate
portal shows floating tab bar, hides sidebar" currently accepts either nav being
visible; change the assertions to explicitly require the mobile tab bar to be
visible and the workspace rail/sidebar to be hidden: use the existing locators
tabBar (".mobileTabBar") and rail (".workspaceRail") and replace the combined
expect with two explicit checks—assert tabBar is visible (e.g.,
expect(tabBar).toBeVisible with an appropriate timeout) and assert rail is not
visible/hidden (e.g., expect(rail).toBeHidden or verify rail.isVisible()
resolves false) so the test fails if both are visible or the sidebar is shown.
Ensure you keep createAuthedPage and tabletViewport setup and preserve error
handling for locator visibility checks.


await context.close();
});

test("candidate portal has no horizontal overflow on tablet", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, tabletViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });
await assertNoHorizontalOverflow(page);
await context.close();
});

test("candidate profile action links meet 44px tap target minimum", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, tabletViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });

await assertTapTargets(page, ".candidateProfileActions a");
await context.close();
});

test("candidate portal has readable text and no zero-height nodes", async ({ browser }) => {
const { page, context } = await createAuthedPage(browser, tabletViewport);
await page.goto("/candidate");
await expect(page.locator('text="Readiness"')).toBeVisible({ timeout: 15000 });
await assertTextReadable(page);
await context.close();
});
});

test.afterAll(async () => {
await disconnectPrisma();
});
8 changes: 8 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export default defineConfig({
name: "mobile",
use: { ...devices["iPhone 14"] },
},
{
name: "tablet",
use: {
...devices["Desktop Chrome"],
viewport: { width: 768, height: 1024 },
deviceScaleFactor: 2,
},
},
],
webServer: process.env.CI
? {
Expand Down
Loading