mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
/**
|
|||
|
|
* Real device-emulated mobile tests.
|
||
|
|
*
|
||
|
|
* Runs on mobile-chromium (Pixel 7) and mobile-webkit (iPhone 14) projects
|
||
|
|
* with real touch, mobile UA, DPR, and (for iPhone) the WebKit engine.
|
||
|
|
* Replaces the old resized-desktop approach in gui-visual-mobile / gui-responsive.
|
||
|
|
*
|
||
|
|
* All tests are tagged @mobile so the device projects' grep filter picks them up.
|
||
|
|
*/
|
||
|
|
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Core flow: load -> navigate -> upload -> process -> download
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile Core flow", () => {
|
||
|
|
test("navigate to tool, upload, and process", async ({ loggedInPage: page }) => {
|
||
|
|
// Navigate to resize tool from home
|
||
|
|
await page.goto("/image/resize");
|
||
|
|
await expect(page.getByText("Resize").first()).toBeVisible();
|
||
|
|
|
||
|
|
// Upload a fixture
|
||
|
|
await uploadTestImage(page);
|
||
|
|
await page.waitForTimeout(500);
|
||
|
|
|
||
|
|
// Verify file appeared in the preview area
|
||
|
|
await expect(page.locator("img").first()).toBeVisible();
|
||
|
|
|
||
|
|
// On mobile, the "Process" section at the bottom expands the settings
|
||
|
|
// bottom sheet. Click it to open settings.
|
||
|
|
const processSection = page.getByText("Process").last();
|
||
|
|
await processSection.click();
|
||
|
|
await page.waitForTimeout(300);
|
||
|
|
|
||
|
|
// Set a width to enable the Resize button (it's disabled without dimensions)
|
||
|
|
const widthInput = page.getByRole("spinbutton", { name: /width/i });
|
||
|
|
await widthInput.fill("50");
|
||
|
|
await page.waitForTimeout(200);
|
||
|
|
|
||
|
|
// The action button is labeled with the tool name ("Resize")
|
||
|
|
const actionBtn = page.getByRole("button", { name: /^resize$/i }).last();
|
||
|
|
await expect(actionBtn).toBeEnabled({ timeout: 5000 });
|
||
|
|
await actionBtn.click();
|
||
|
|
|
||
|
|
// Wait for processing to complete
|
||
|
|
await waitForProcessing(page);
|
||
|
|
await page.waitForTimeout(2000);
|
||
|
|
|
||
|
|
// After processing, a download button or link should appear
|
||
|
|
const downloadBtn = page
|
||
|
|
.locator("button, a")
|
||
|
|
.filter({ hasText: /download/i })
|
||
|
|
.first();
|
||
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Responsive chrome: mobile-bottom-nav, hamburger, tool-grid, footer hidden
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile Responsive chrome", () => {
|
||
|
|
test("bottom navigation bar is visible with correct items", async ({ loggedInPage: page }) => {
|
||
|
|
const bottomNav = page.locator("nav.fixed");
|
||
|
|
await expect(bottomNav).toBeVisible();
|
||
|
|
|
||
|
|
await expect(bottomNav.getByText("Tools")).toBeVisible();
|
||
|
|
await expect(bottomNav.getByText("Automate")).toBeVisible();
|
||
|
|
await expect(bottomNav.getByText("Files")).toBeVisible();
|
||
|
|
await expect(bottomNav.getByText("Settings")).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("desktop sidebar is hidden on mobile", async ({ loggedInPage: page }) => {
|
||
|
|
await expect(page.locator("aside")).not.toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("tool grid and search visible on mobile home", async ({ loggedInPage: page }) => {
|
||
|
|
await page.goto("/");
|
||
|
|
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
|
||
|
|
|
||
|
|
// No horizontal overflow
|
||
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||
|
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("footer theme/language buttons are hidden on mobile", async ({ loggedInPage: page }) => {
|
||
|
|
await expect(page.locator("button[title='Toggle Theme']")).not.toBeVisible();
|
||
|
|
await expect(page.locator("button[title='Language']")).not.toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("bottom nav navigates correctly", async ({ loggedInPage: page }) => {
|
||
|
|
const bottomNav = page.locator("nav.fixed");
|
||
|
|
|
||
|
|
// Navigate to Automate
|
||
|
|
await bottomNav.getByText("Automate").click();
|
||
|
|
await expect(page).toHaveURL("/automate");
|
||
|
|
|
||
|
|
// Navigate to Files
|
||
|
|
await bottomNav.getByText("Files").click();
|
||
|
|
await expect(page).toHaveURL("/files");
|
||
|
|
|
||
|
|
// Navigate back to Tools (home)
|
||
|
|
await bottomNav.getByText("Tools").click();
|
||
|
|
await expect(page).toHaveURL("/");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("settings opens from bottom nav", async ({ loggedInPage: page }) => {
|
||
|
|
const bottomNav = page.locator("nav.fixed");
|
||
|
|
await bottomNav.getByText("Settings").click();
|
||
|
|
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("login page hides marketing panel on mobile", async ({ browser }) => {
|
||
|
|
const context = await browser.newContext({
|
||
|
|
storageState: { cookies: [], origins: [] },
|
||
|
|
});
|
||
|
|
const page = await context.newPage();
|
||
|
|
await page.goto("/login");
|
||
|
|
|
||
|
|
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
|
||
|
|
// Marketing panel hidden on narrow viewport
|
||
|
|
await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
|
||
|
|
|
||
|
|
await context.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("no horizontal overflow across key pages", async ({ loggedInPage: page }) => {
|
||
|
|
const pages = ["/", "/image/resize", "/automate", "/files", "/editor"];
|
||
|
|
for (const p of pages) {
|
||
|
|
await page.goto(p);
|
||
|
|
await page.waitForTimeout(300);
|
||
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||
|
|
expect(scrollWidth, `overflow on ${p}`).toBeLessThanOrEqual(clientWidth);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Touch interactions
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile Touch interactions", () => {
|
||
|
|
test("before-after slider responds to touch drag", async ({ loggedInPage: page }) => {
|
||
|
|
await page.goto("/image/compress");
|
||
|
|
await uploadTestImage(page);
|
||
|
|
|
||
|
|
// Wait for processing to complete and the slider to appear
|
||
|
|
const processBtn = page.getByRole("button", { name: /process|apply/i }).first();
|
||
|
|
if (await processBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
|
|
await processBtn.click();
|
||
|
|
}
|
||
|
|
await waitForProcessing(page);
|
||
|
|
|
||
|
|
// Look for the before-after comparison area
|
||
|
|
const slider = page
|
||
|
|
.locator("[class*='before-after'], [class*='BeforeAfter'], [class*='comparison']")
|
||
|
|
.first();
|
||
|
|
await slider.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {});
|
||
|
|
|
||
|
|
if (await slider.isVisible()) {
|
||
|
|
const box = await slider.boundingBox();
|
||
|
|
if (box) {
|
||
|
|
// Simulate a touch drag across the slider
|
||
|
|
const startX = box.x + box.width * 0.3;
|
||
|
|
const endX = box.x + box.width * 0.7;
|
||
|
|
const y = box.y + box.height / 2;
|
||
|
|
|
||
|
|
await page.touchscreen.tap(startX, y);
|
||
|
|
// Touch drag: move from left to right
|
||
|
|
await page.mouse.move(startX, y);
|
||
|
|
await page.mouse.down();
|
||
|
|
await page.mouse.move(endX, y, { steps: 10 });
|
||
|
|
await page.mouse.up();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// If slider not visible, processing may have been too fast for comparison mode
|
||
|
|
// -- this is acceptable; the test validates the touch mechanism when it appears
|
||
|
|
});
|
||
|
|
|
||
|
|
test("crop canvas accepts touch input", async ({ loggedInPage: page }) => {
|
||
|
|
await page.goto("/image/crop");
|
||
|
|
await uploadTestImage(page);
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
const canvas = page.locator("canvas").first();
|
||
|
|
await canvas.waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
|
||
|
|
|
||
|
|
if (await canvas.isVisible()) {
|
||
|
|
const box = await canvas.boundingBox();
|
||
|
|
if (box) {
|
||
|
|
// Tap to interact with crop canvas
|
||
|
|
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Editor: phone asserts the mobile gate message
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile Editor gate", () => {
|
||
|
|
test("editor shows desktop-recommended gate on phone", async ({ loggedInPage: page }) => {
|
||
|
|
await page.goto("/editor");
|
||
|
|
await page.waitForLoadState("networkidle");
|
||
|
|
|
||
|
|
// The mobile gate message from editor-page.tsx
|
||
|
|
await expect(page.getByText("Desktop Recommended")).toBeVisible();
|
||
|
|
await expect(page.getByText(/works best on desktop|larger display/i)).toBeVisible();
|
||
|
|
|
||
|
|
// Editor canvas should NOT be present
|
||
|
|
await expect(page.locator(".konvajs-content")).not.toBeVisible();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// SSE visibility-recovery regression
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile SSE visibility recovery", () => {
|
||
|
|
test("backgrounding and restoring tab triggers health check", async ({ loggedInPage: page }) => {
|
||
|
|
// Navigate to a tool that processes files
|
||
|
|
await page.goto("/image/resize");
|
||
|
|
await uploadTestImage(page);
|
||
|
|
|
||
|
|
// Simulate backgrounding the tab (visibility hidden)
|
||
|
|
await page.evaluate(() => {
|
||
|
|
Object.defineProperty(document, "visibilityState", {
|
||
|
|
value: "hidden",
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.waitForTimeout(500);
|
||
|
|
|
||
|
|
// Simulate restoring the tab (visibility visible)
|
||
|
|
await page.evaluate(() => {
|
||
|
|
Object.defineProperty(document, "visibilityState", {
|
||
|
|
value: "visible",
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.waitForTimeout(500);
|
||
|
|
|
||
|
|
// The page should still be functional after visibility recovery.
|
||
|
|
// The connection monitor fires a health check on visibility restore,
|
||
|
|
// which is the Android-kill fix mechanism. Verify the page recovered.
|
||
|
|
await expect(page.getByText("Resize").first()).toBeVisible();
|
||
|
|
|
||
|
|
// Upload area or processed result should still be accessible
|
||
|
|
const uploadVisible = await page
|
||
|
|
.getByText("Upload from computer")
|
||
|
|
.isVisible({ timeout: 2000 })
|
||
|
|
.catch(() => false);
|
||
|
|
const imgVisible = await page
|
||
|
|
.locator("img")
|
||
|
|
.first()
|
||
|
|
.isVisible({ timeout: 2000 })
|
||
|
|
.catch(() => false);
|
||
|
|
expect(uploadVisible || imgVisible).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// RTL (Arabic locale) responsive check
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
test.describe("@mobile RTL responsive", () => {
|
||
|
|
test("Arabic locale renders RTL layout without overflow", async ({ loggedInPage: page }) => {
|
||
|
|
// Switch to Arabic locale via localStorage (how the app stores locale)
|
||
|
|
await page.evaluate(() => {
|
||
|
|
localStorage.setItem("snapotter-locale", "ar");
|
||
|
|
});
|
||
|
|
await page.reload();
|
||
|
|
await page.waitForLoadState("networkidle");
|
||
|
|
|
||
|
|
// Verify RTL direction is applied
|
||
|
|
const dir = await page.evaluate(() => document.documentElement.dir || document.body.dir || "");
|
||
|
|
expect(dir).toBe("rtl");
|
||
|
|
|
||
|
|
// No horizontal overflow
|
||
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||
|
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
||
|
|
|
||
|
|
// Bottom nav still visible in RTL
|
||
|
|
const bottomNav = page.locator("nav.fixed");
|
||
|
|
await expect(bottomNav).toBeVisible();
|
||
|
|
|
||
|
|
// Reset locale for other tests
|
||
|
|
await page.evaluate(() => {
|
||
|
|
localStorage.setItem("snapotter-locale", "en");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|