2026-06-20 02:15:50 +08:00
|
|
|
/**
|
|
|
|
|
* Real device-emulated tablet tests.
|
|
|
|
|
*
|
|
|
|
|
* Runs on tablet-webkit (iPad gen 7, 810x1080) and tablet-chromium
|
|
|
|
|
* (Galaxy Tab S9, 640x1024) with real touch, tablet UA, and DPR.
|
|
|
|
|
*
|
|
|
|
|
* Important breakpoint finding:
|
|
|
|
|
* - iPad (gen 7): 810px viewport width -> NOT mobile (>= 768px breakpoint)
|
|
|
|
|
* -> editor renders canvas, sidebar may be visible
|
|
|
|
|
* - Galaxy Tab S9: 640px viewport width -> IS mobile (< 768px breakpoint)
|
|
|
|
|
* -> editor shows mobile gate, bottom-nav visible, sidebar hidden
|
|
|
|
|
*
|
|
|
|
|
* All tests are tagged @tablet so the device projects' grep filter picks them up.
|
|
|
|
|
*/
|
2026-06-28 18:57:53 +08:00
|
|
|
import { expect, openSettings, test, uploadTestImage, waitForProcessing } from "./helpers";
|
2026-06-20 02:15:50 +08:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Core flow: load -> navigate -> upload -> process -> download
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test.describe("@tablet Core flow", () => {
|
|
|
|
|
test("navigate to tool, upload, process, and download", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/image/resize");
|
|
|
|
|
await expect(page.getByText("Resize").first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await uploadTestImage(page);
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
|
|
|
|
|
// Verify file appeared
|
|
|
|
|
await expect(page.locator("img").first()).toBeVisible();
|
|
|
|
|
|
2026-06-28 18:57:53 +08:00
|
|
|
// In mobile mode (Galaxy Tab S9 at 640px) the settings live in a bottom
|
|
|
|
|
// sheet that the "Process" section expands; on iPad the panel is already
|
|
|
|
|
// open. Expand it if present so the width input is reachable.
|
|
|
|
|
const processSection = page.getByText("Process").last();
|
|
|
|
|
if (await processSection.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
|
|
|
await processSection.click();
|
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set a width so the submit button enables, then process.
|
|
|
|
|
await page.getByRole("spinbutton", { name: /width/i }).first().fill("200");
|
|
|
|
|
await page.getByTestId("resize-submit").click();
|
2026-06-20 02:15:50 +08:00
|
|
|
|
|
|
|
|
await waitForProcessing(page);
|
|
|
|
|
|
2026-06-28 18:57:53 +08:00
|
|
|
// Download affordance: side-by-side tools expose an <a data-testid>, others
|
|
|
|
|
// the review panel's [data-download-button].
|
|
|
|
|
await expect(
|
|
|
|
|
page.locator("[data-download-button], a[data-testid$='-download']").first(),
|
|
|
|
|
).toBeVisible({ timeout: 15_000 });
|
2026-06-20 02:15:50 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Responsive chrome
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test.describe("@tablet Responsive chrome", () => {
|
|
|
|
|
test("no horizontal overflow on 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("tool page layout fits tablet viewport", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/image/resize");
|
|
|
|
|
await uploadTestImage(page);
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
|
|
|
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
|
|
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
|
|
|
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("home page renders with search accessible", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/");
|
|
|
|
|
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("login page renders form without overflow", 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();
|
|
|
|
|
await expect(page.getByLabel("Username")).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
|
|
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
|
|
|
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
|
|
|
|
|
|
|
|
|
await context.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("settings dialog fits within tablet viewport", async ({ loggedInPage: page }) => {
|
2026-06-28 18:57:53 +08:00
|
|
|
// openSettings handles both layouts: Galaxy Tab S9 at 640px is mobile
|
|
|
|
|
// (bottom nav), iPad at 810px is desktop (top-nav avatar dropdown).
|
|
|
|
|
await openSettings(page);
|
2026-06-20 02:15:50 +08:00
|
|
|
|
|
|
|
|
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
|
|
|
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
|
|
|
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Touch interactions on tablet
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test.describe("@tablet Touch interactions", () => {
|
|
|
|
|
test("before-after slider responds to touch", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/image/compress");
|
|
|
|
|
await uploadTestImage(page);
|
|
|
|
|
|
|
|
|
|
const processBtn = page.getByRole("button", { name: /process|apply/i }).first();
|
|
|
|
|
if (await processBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
|
|
|
await processBtn.click();
|
|
|
|
|
}
|
|
|
|
|
await waitForProcessing(page);
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
// Touch tap on the slider
|
|
|
|
|
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("crop canvas accepts touch on tablet", 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) {
|
|
|
|
|
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Editor on tablet
|
|
|
|
|
//
|
|
|
|
|
// useMobile() breakpoint is max-width:767px.
|
|
|
|
|
// - iPad (gen 7) viewport: 810px -> NOT mobile -> editor renders canvas
|
|
|
|
|
// - Galaxy Tab S9 viewport: 640px -> IS mobile -> editor shows gate
|
|
|
|
|
//
|
|
|
|
|
// This test handles both cases: it checks which mode the device is in
|
|
|
|
|
// and validates accordingly.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test.describe("@tablet Editor", () => {
|
|
|
|
|
test("editor renders appropriately for tablet viewport width", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/editor");
|
|
|
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
|
|
|
|
|
|
// Detect which mode we're in based on viewport width vs. 768px breakpoint
|
|
|
|
|
const viewportWidth = page.viewportSize()?.width ?? 0;
|
|
|
|
|
|
|
|
|
|
if (viewportWidth >= 768) {
|
|
|
|
|
// iPad (gen 7) at 810px: editor canvas should render
|
|
|
|
|
// The mobile gate should NOT be visible
|
|
|
|
|
await expect(page.getByText("Desktop Recommended")).not.toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Editor UI elements should be present (menu bar, canvas area)
|
|
|
|
|
const editorContent = page
|
|
|
|
|
.locator(".konvajs-content, [class*='editor'], [class*='Editor']")
|
|
|
|
|
.first();
|
|
|
|
|
const menuBar = page.getByText(/file|edit|view|layer/i).first();
|
|
|
|
|
const eitherVisible =
|
|
|
|
|
(await editorContent.isVisible({ timeout: 5000 }).catch(() => false)) ||
|
|
|
|
|
(await menuBar.isVisible({ timeout: 2000 }).catch(() => false));
|
|
|
|
|
expect(eitherVisible).toBe(true);
|
|
|
|
|
} else {
|
|
|
|
|
// Galaxy Tab S9 at 640px: mobile gate should show
|
|
|
|
|
await expect(page.getByText("Desktop Recommended")).toBeVisible();
|
|
|
|
|
await expect(page.getByText(/works best on desktop|larger display/i)).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// SSE visibility-recovery regression on tablet
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test.describe("@tablet SSE visibility recovery", () => {
|
|
|
|
|
test("backgrounding and restoring tab triggers health check", async ({ loggedInPage: page }) => {
|
|
|
|
|
await page.goto("/image/resize");
|
|
|
|
|
await uploadTestImage(page);
|
|
|
|
|
|
|
|
|
|
// Simulate backgrounding
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
Object.defineProperty(document, "visibilityState", {
|
|
|
|
|
value: "hidden",
|
|
|
|
|
writable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
|
|
|
|
|
// Simulate restoring
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
Object.defineProperty(document, "visibilityState", {
|
|
|
|
|
value: "visible",
|
|
|
|
|
writable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
|
|
|
|
|
|
// Page should still be functional
|
|
|
|
|
await expect(page.getByText("Resize").first()).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|