diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4fe19e9..b3d5aed3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,30 @@ jobs: with: name: route-width-screenshots path: test-results/route-width - if-no-files-found: error + if-no-files-found: warn + + pwa: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Use Node.js 20.x + uses: actions/setup-node@v6 + with: + node-version: 20.x + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build exported app + run: npm run build + + - name: Install Playwright Browser + run: npx playwright install --with-deps chromium - name: Run PWA Smoke run: npm run test:e2e:pwa diff --git a/docs/accessibility/audit-23-25-26-manual-closure-runbook.md b/docs/accessibility/audit-23-25-26-manual-closure-runbook.md index 42701678..009cfabe 100644 --- a/docs/accessibility/audit-23-25-26-manual-closure-runbook.md +++ b/docs/accessibility/audit-23-25-26-manual-closure-runbook.md @@ -80,7 +80,9 @@ Routes: - `/en/all-tools` - `/en/data-code-formats` - `/en/json-formatter` +- `/en/qr-code-generator` - `/en/base64-encode-decode` +- `/en/csv-json-converter` - `/en/jwt-decoder` - `/en/regex-tester` - `/en/pipeline-builder` @@ -100,11 +102,15 @@ Software keyboard: - On `/en/json-formatter`, focus the input, type invalid JSON, fix it, and run format. - Confirm the keyboard does not hide the active input or primary action in a way that prevents completion. - On `/en/base64-encode-decode`, enter a long URL-like value and copy output. +- On `/en/qr-code-generator`, edit QR content, download PNG, and confirm copy feedback with the keyboard open. +- On `/en/csv-json-converter`, convert a short CSV payload, copy the result, and confirm the Convert action remains reachable. - On `/en/regex-tester`, edit pattern and sample text with the keyboard open. Pipeline Builder: - Open `/en/pipeline-builder`. +- Use the compact Steps, Input/output, and Inspector section jumps in portrait and landscape. +- Select a step, edit its label in Inspector, and return to the input without searching through the full page. - Confirm the mobile diagnostics are readable without forcing page-level horizontal scrolling. - Run the default sample or a local sample. - Confirm run status and output are visible. diff --git a/docs/accessibility/audit-25-26-mobile-a11y-matrix.md b/docs/accessibility/audit-25-26-mobile-a11y-matrix.md index d1be8be5..669a6e98 100644 --- a/docs/accessibility/audit-25-26-mobile-a11y-matrix.md +++ b/docs/accessibility/audit-25-26-mobile-a11y-matrix.md @@ -12,6 +12,11 @@ Mobile layout routes: - `/en/all-tools` - `/en/data-code-formats` - `/en/json-formatter` +- `/en/qr-code-generator` +- `/en/base64-encode-decode` +- `/en/csv-json-converter` +- `/en/jwt-decoder` +- `/en/regex-tester` - `/en/pipeline-builder` - `/en/trust-center` - `/en/install-app` @@ -31,6 +36,20 @@ Automated checks: - Page-level horizontal overflow stays within the 2px tolerance. - Runtime, console, and same-origin critical resource errors fail the smoke. +Core mobile workflows run at both `390x844` and `430x932`: + +- QR content update, copy feedback, and PNG download. +- CSV-to-JSON conversion, copy feedback, and JSON download. +- Pipeline sample loading, section jumps, Inspector editing, execution, output, and copy feedback. +- JSON, Base64, JWT, Markdown, Regex, Cron, Image Resizer, JSON Diff, and Text Diff core interactions. + +Viewport-pressure checks run for QR, CSV, and Pipeline Builder: + +- A focused input remains visible in a reduced-height `390x420` keyboard proxy. +- The primary action remains reachable at `390x420`. +- Portrait-to-landscape transition to `844x390` does not introduce page-level horizontal overflow. +- The primary action remains reachable after the landscape transition. + Accessibility routes: - `/en` @@ -46,13 +65,14 @@ Automated checks: - Color contrast is covered by token-level guards, because runtime CSS variables are not reliable in every axe environment. - Skip link, header language menu, mobile navigation focus restoration, command palette, copy feedback, and Pipeline Builder workflows are covered by Playwright smoke journeys. -## Validation Run +## Validation Commands -The June 27, 2026 audit batch passed: +Run the full candidate commit through: - `npm test -- --run tests/guards/mobile-a11y-audit-issues-262-263.test.ts tests/guards/playwright-smoke-matrix-guard.test.ts tests/guards/a11y-mobile-baseline.test.ts tests/guards/bf-037-a11y-qa-guard.test.ts tests/guards/bf-036-bf-038-accessibility-visual-qa-guard.test.ts` - `npm test -- --run tests/component/representative-a11y-axe.test.tsx tests/component/phase3-pipeline-builder-page.test.tsx` - `npm run test:e2e:smoke` +- `npm run test:e2e:mobile` - `npm run test:e2e:pwa` - `npm run validate` - `npm run lint` @@ -69,5 +89,6 @@ The following checks still require real device or assistive-technology verificat - Lighthouse accessibility score verification on representative pages in a stable Chrome/Lighthouse environment. - Screen-reader output in NVDA, VoiceOver, or TalkBack for Pipeline Builder and external-request confirmation flows. - Software keyboard overlap on physical mobile devices for multiline tool inputs. +- Real orientation-change behavior and browser chrome/visual viewport interaction on physical devices. Use `docs/accessibility/audit-23-25-26-manual-closure-runbook.md` for the required closure procedure and issue comment template. diff --git a/package.json b/package.json index d9710c50..9ac1e1fc 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,10 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e:smoke": "node scripts/e2e/run-playwright-smoke.js", + "test:e2e:mobile": "node scripts/e2e/run-playwright-smoke.js --mobile-only", "test:e2e:first-load": "node scripts/e2e/run-playwright-smoke.js --first-load-only --first-load-artifacts", "test:e2e:route-width": "node scripts/e2e/run-route-width-matrix.js", - "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa", + "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa-only", "check:i18n": "node scripts/gates/check-i18n.js", "check:i18n-qa": "node scripts/gates/check-i18n-qa.js", "check:i18n:ratchet": "node scripts/gates/check-i18n-untranslated-ratchet.js --mode=fail", diff --git a/public/pwa-screenshots/home-en-1280x720.png b/public/pwa-screenshots/home-en-1280x720.png index bc168881..3a970183 100644 Binary files a/public/pwa-screenshots/home-en-1280x720.png and b/public/pwa-screenshots/home-en-1280x720.png differ diff --git a/public/pwa-screenshots/install-android.png b/public/pwa-screenshots/install-android.png index bc168881..a22a947e 100644 Binary files a/public/pwa-screenshots/install-android.png and b/public/pwa-screenshots/install-android.png differ diff --git a/public/pwa-screenshots/install-chrome-desktop.png b/public/pwa-screenshots/install-chrome-desktop.png index bc168881..aab8479e 100644 Binary files a/public/pwa-screenshots/install-chrome-desktop.png and b/public/pwa-screenshots/install-chrome-desktop.png differ diff --git a/public/pwa-screenshots/install-edge.png b/public/pwa-screenshots/install-edge.png index bc168881..924e5e0e 100644 Binary files a/public/pwa-screenshots/install-edge.png and b/public/pwa-screenshots/install-edge.png differ diff --git a/public/pwa-screenshots/install-firefox.png b/public/pwa-screenshots/install-firefox.png index bc168881..25cdeb28 100644 Binary files a/public/pwa-screenshots/install-firefox.png and b/public/pwa-screenshots/install-firefox.png differ diff --git a/public/pwa-screenshots/install-ios-safari.png b/public/pwa-screenshots/install-ios-safari.png index bc168881..28816ab6 100644 Binary files a/public/pwa-screenshots/install-ios-safari.png and b/public/pwa-screenshots/install-ios-safari.png differ diff --git a/public/pwa-screenshots/install-safari-desktop.png b/public/pwa-screenshots/install-safari-desktop.png new file mode 100644 index 00000000..841f01e6 Binary files /dev/null and b/public/pwa-screenshots/install-safari-desktop.png differ diff --git a/public/pwa-screenshots/json-formatter-en-1280x720.png b/public/pwa-screenshots/json-formatter-en-1280x720.png index bc168881..fd325c59 100644 Binary files a/public/pwa-screenshots/json-formatter-en-1280x720.png and b/public/pwa-screenshots/json-formatter-en-1280x720.png differ diff --git a/public/runtime/theme-manifest-bootstrap.js b/public/runtime/theme-manifest-bootstrap.js index c9a19e82..50544377 100644 --- a/public/runtime/theme-manifest-bootstrap.js +++ b/public/runtime/theme-manifest-bootstrap.js @@ -1,4 +1,34 @@ (function () { + var installPromptBridgeReadySlot = "__byteflowPwaInstallPromptBridgeReady"; + var installPromptChangeEvent = "byteflow:pwa-install-prompt-change"; + var installPromptInstalledKey = "byteflow:pwa-install:installed"; + var installPromptSlot = "__byteflowPwaInstallPrompt"; + + function notifyInstallPromptChange() { + try { + window.dispatchEvent(new CustomEvent(installPromptChangeEvent)); + } catch {} + } + + if (!window[installPromptBridgeReadySlot]) { + window[installPromptBridgeReadySlot] = true; + window.addEventListener("beforeinstallprompt", function (event) { + event.preventDefault(); + try { + localStorage.removeItem(installPromptInstalledKey); + } catch {} + window[installPromptSlot] = event; + notifyInstallPromptChange(); + }); + window.addEventListener("appinstalled", function () { + try { + localStorage.setItem(installPromptInstalledKey, "1"); + } catch {} + window[installPromptSlot] = null; + notifyInstallPromptChange(); + }); + } + try { var locales = ["en","zh-CN","zh-TW","ja","ko","de","fr"]; var p = window.location.pathname || "/"; diff --git a/scripts/e2e/playwright-smoke-args.js b/scripts/e2e/playwright-smoke-args.js new file mode 100644 index 00000000..a2622809 --- /dev/null +++ b/scripts/e2e/playwright-smoke-args.js @@ -0,0 +1,79 @@ +const EXCLUSIVE_MODE_FLAGS = new Map([ + ["--pwa-only", "pwaOnly"], + ["--first-load-only", "firstLoadOnly"], + ["--input-intents-only", "inputIntentsOnly"], + ["--mobile-only", "mobileOnly"], +]); + +export function parsePlaywrightSmokeArgs(argv, { defaultPort = 4173 } = {}) { + const args = { + port: defaultPort, + baseUrl: "", + skipServer: false, + includePwa: false, + pwaOnly: false, + firstLoadOnly: false, + writeFirstLoadArtifacts: false, + inputIntentsOnly: false, + mobileOnly: false, + }; + const activeExclusiveFlags = new Set(); + const unknownArgs = new Set(); + + for (const arg of argv) { + const exclusiveMode = EXCLUSIVE_MODE_FLAGS.get(arg); + if (exclusiveMode) { + args[exclusiveMode] = true; + activeExclusiveFlags.add(arg); + continue; + } + + if (arg === "--skip-server") { + args.skipServer = true; + continue; + } + + if (arg === "--pwa") { + args.includePwa = true; + continue; + } + + if (arg === "--first-load-artifacts") { + args.writeFirstLoadArtifacts = true; + continue; + } + + if (arg.startsWith("--port=")) { + const parsed = Number(arg.slice("--port=".length)); + if (Number.isFinite(parsed) && parsed > 0) { + args.port = parsed; + } + continue; + } + + if (arg.startsWith("--base-url=")) { + args.baseUrl = arg.slice("--base-url=".length).trim(); + continue; + } + + unknownArgs.add(arg); + } + + const validationErrors = []; + if (unknownArgs.size > 0) { + validationErrors.push(`Unknown argument(s): ${[...unknownArgs].join(", ")}`); + } + if (activeExclusiveFlags.size > 1) { + validationErrors.push(`Conflicting --*-only flags: ${[...activeExclusiveFlags].join(", ")}`); + } + if (validationErrors.length > 0) { + throw new Error(`[playwright-smoke] Invalid arguments:\n- ${validationErrors.join("\n- ")}`); + } + + if (!args.baseUrl) { + args.baseUrl = `http://127.0.0.1:${args.port}`; + } + + args.baseUrl = args.baseUrl.replace(/\/+$/, ""); + return args; +} diff --git a/scripts/e2e/promise-timeout.js b/scripts/e2e/promise-timeout.js new file mode 100644 index 00000000..85a8cc07 --- /dev/null +++ b/scripts/e2e/promise-timeout.js @@ -0,0 +1,19 @@ +export async function raceWithTimeout( + promise, + timeoutMs, + timeoutMessage, + timerApi = globalThis, +) { + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = timerApi.setTimeout(() => { + reject(new Error(timeoutMessage)) + }, timeoutMs) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutId !== undefined) timerApi.clearTimeout(timeoutId) + } +} diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index bb0d939f..16614d30 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -6,6 +6,8 @@ import process from "node:process"; import axe from "axe-core"; import { chromium } from "playwright"; import serveHandler from "serve-handler"; +import { parsePlaywrightSmokeArgs } from "./playwright-smoke-args.js"; +import { raceWithTimeout } from "./promise-timeout.js"; const DEFAULT_PORT = 4173; const DEFAULT_ROUTES = [ @@ -35,6 +37,11 @@ const MOBILE_REVIEW_ROUTES = [ "/en/all-tools", "/en/data-code-formats", "/en/json-formatter", + "/en/qr-code-generator", + "/en/base64-encode-decode", + "/en/csv-json-converter", + "/en/jwt-decoder", + "/en/regex-tester", "/en/pipeline-builder", "/en/trust-center", "/en/install-app", @@ -65,6 +72,7 @@ const ALL_TOOLS_MOBILE_SCROLL_BUDGET_MS = 3500; const ALL_TOOLS_MOBILE_MAX_FRAME_DELTA_MS = 500; const FIRST_LOAD_CLS_BUDGET = 0.1; const FIRST_LOAD_SETTLE_MS = 2_600; +const PWA_SERVICE_WORKER_READY_TIMEOUT_MS = 20_000; const FIRST_LOAD_ARTIFACT_DIR = path.resolve(process.cwd(), "output/first-load-audit"); const FIRST_LOAD_AUDIT_CASES = [ { @@ -199,64 +207,6 @@ function createRuntimeObserver(page, label, baseUrl = "") { }; } -function parseArgs(argv) { - const args = { - port: DEFAULT_PORT, - baseUrl: "", - skipServer: false, - includePwa: false, - firstLoadOnly: false, - writeFirstLoadArtifacts: false, - inputIntentsOnly: false, - }; - - for (const arg of argv) { - if (arg === "--skip-server") { - args.skipServer = true; - continue; - } - - if (arg === "--pwa") { - args.includePwa = true; - continue; - } - - if (arg === "--first-load-only") { - args.firstLoadOnly = true; - continue; - } - - if (arg === "--first-load-artifacts") { - args.writeFirstLoadArtifacts = true; - continue; - } - - if (arg === "--input-intents-only") { - args.inputIntentsOnly = true; - continue; - } - - if (arg.startsWith("--port=")) { - const parsed = Number(arg.slice("--port=".length)); - if (Number.isFinite(parsed) && parsed > 0) { - args.port = parsed; - } - continue; - } - - if (arg.startsWith("--base-url=")) { - args.baseUrl = arg.slice("--base-url=".length).trim(); - } - } - - if (!args.baseUrl) { - args.baseUrl = `http://127.0.0.1:${args.port}`; - } - - args.baseUrl = args.baseUrl.replace(/\/+$/, ""); - return args; -} - async function wait(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1052,6 +1002,49 @@ async function assertNoHorizontalOverflow(page, routeLabel) { } } +async function assertLocatorReachable(page, locator, label, { focus = false, scroll = true } = {}) { + await locator.waitFor({ state: "visible", timeout: 15_000 }); + if (scroll) { + await locator.scrollIntoViewIfNeeded(); + await locator.evaluate((element) => element.scrollIntoView({ block: "center", inline: "nearest" })); + } + if (focus) await locator.focus(); + + const measurement = await locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewportTop = window.visualViewport?.offsetTop || 0; + const viewportHeight = window.visualViewport?.height || window.innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const visibleTop = Math.max(rect.top, viewportTop); + const visibleBottom = Math.min(rect.bottom, viewportBottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const hitX = Math.min(window.innerWidth - 1, Math.max(0, rect.left + Math.min(rect.width / 2, 24))); + const hitY = Math.min(viewportBottom - 1, Math.max(viewportTop, visibleTop + (visibleHeight / 2))); + const hit = document.elementFromPoint(hitX, hitY); + + return { + active: document.activeElement === element || element.contains(document.activeElement), + height: rect.height, + hitTarget: hit === element || element.contains(hit), + visibleHeight, + width: rect.width, + }; + }); + + if (measurement.visibleHeight + 0.5 < Math.min(44, measurement.height)) { + throw new Error(`${label} is not sufficiently visible in the active viewport (${Math.round(measurement.visibleHeight)}px visible).`); + } + if (measurement.width + 0.5 < 44 || measurement.height + 0.5 < 44) { + throw new Error(`${label} is smaller than the mobile target floor (${Math.round(measurement.width)}x${Math.round(measurement.height)}).`); + } + if (!measurement.hitTarget) { + throw new Error(`${label} is covered by another element after scrolling into view.`); + } + if (focus && !measurement.active) { + throw new Error(`${label} did not retain focus under reduced-height viewport pressure.`); + } +} + async function assertMobileTouchTargets(page, routeLabel) { const violations = await page.evaluate(() => { const controls = Array.from(document.querySelectorAll("button, input, select, textarea, [role='button']")); @@ -1175,7 +1168,25 @@ async function assertInputIntentSizingMatrix(browser, baseUrl) { try { await page.goto(`${baseUrl}${audit.route}`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("main", { timeout: 15_000 }); - await page.locator("[data-input-intent]").first().waitFor({ state: "attached", timeout: 15_000 }); + await page.waitForFunction((requiredIntents) => { + const visibleIntents = new Set( + Array.from(document.querySelectorAll("[data-input-intent]")) + .filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0 + ); + }) + .map((element) => element.getAttribute("data-input-intent")) + .filter(Boolean), + ); + return requiredIntents.every((intent) => visibleIntents.has(intent)); + }, audit.requiredIntents, { timeout: 15_000 }); const measurements = await page.evaluate(({ mobile }) => { const isVisible = (element) => { @@ -1495,6 +1506,80 @@ async function assertMobileTextDiffChecker(page) { await page.getByText(/Modified/i).first().waitFor({ state: "visible", timeout: 15_000 }); } +async function assertMobileQrCodeGenerator(page) { + const content = page.locator("#qr-content"); + const payload = "https://byteflow.tools/mobile-qr-smoke"; + await content.fill(payload); + await page.waitForFunction(() => { + const canvas = document.querySelector("canvas"); + const pngButton = Array.from(document.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "PNG"); + return canvas instanceof HTMLCanvasElement && canvas.width > 0 && pngButton && !pngButton.disabled; + }, null, { timeout: 15_000 }); + + await clickCopyAndExpectToast( + page, + page.getByRole("button", { name: /^Copy$/ }).first(), + "mobile QR data URL copy", + ); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "PNG", exact: true }).first().click(); + await assertDownloadedFile(await downloadPromise, "qr-code.png", "png"); +} + +async function assertMobileCsvJsonConverter(page) { + const input = page.locator("textarea").first(); + await input.waitFor({ state: "visible", timeout: 15_000 }); + await input.fill("id,name\n1,Ada\n2,Lin"); + await page.getByRole("button", { name: /^Convert$/ }).first().click(); + await expectTextareaValue(page, /"name": "Ada"/, "mobile CSV to JSON output"); + + await clickCopyAndExpectToast( + page, + page.getByRole("button", { name: /^Copy$/ }).first(), + "mobile CSV converter output copy", + ); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: /^Download$/ }).first().click(); + await assertDownloadedFile(await downloadPromise, "converted.json", "json"); +} + +async function assertMobilePipelineBuilder(page) { + await page.getByRole("button", { name: /^Sample$/ }).first().click(); + + const stepsJump = page.locator('[data-pipeline-jump="steps"]'); + await assertLocatorReachable(page, stepsJump, "Pipeline steps jump"); + await stepsJump.click(); + await page.waitForFunction(() => window.location.hash === "#pipeline-steps", null, { timeout: 5_000 }); + await assertLocatorReachable(page, page.locator("#pipeline-steps"), "Pipeline steps section", { scroll: false }); + await page.locator("#pipeline-steps button[aria-pressed]").first().click(); + + const inspectorJump = page.locator('[data-pipeline-jump="inspector"]'); + await assertLocatorReachable(page, inspectorJump, "Pipeline inspector jump"); + await inspectorJump.click(); + await page.waitForFunction(() => window.location.hash === "#pipeline-inspector", null, { timeout: 5_000 }); + await assertLocatorReachable(page, page.locator("#pipeline-inspector"), "Pipeline inspector section", { scroll: false }); + await page.getByLabel("Step label").fill("Mobile JSON cleanup"); + + const inputOutputJump = page.locator('[data-pipeline-jump="input-output"]'); + await assertLocatorReachable(page, inputOutputJump, "Pipeline input/output jump"); + await inputOutputJump.click(); + await page.waitForFunction(() => window.location.hash === "#pipeline-input-output", null, { timeout: 5_000 }); + await assertLocatorReachable(page, page.locator("#pipeline-input-output"), "Pipeline input/output section", { scroll: false }); + await page.getByLabel("Initial input").fill('{ "mobile": true, "ok": true }'); + + await page.getByRole("button", { name: /Run Recipe/i }).first().click(); + await expectTextareaValue(page, /"mobile": true/, "mobile Pipeline output"); + await page.getByText(/^OK$/).first().waitFor({ state: "visible", timeout: 15_000 }); + await clickCopyAndExpectToast( + page, + page.getByRole("button", { name: /^Copy$/ }).first(), + "mobile Pipeline output copy", + ); +} + async function assertMobileToolPageJourneys(browser, baseUrl) { const journeys = [ { route: "/en/all-tools", run: assertMobileAllTools }, @@ -1507,6 +1592,9 @@ async function assertMobileToolPageJourneys(browser, baseUrl) { { route: "/en/image-resizer", run: assertMobileImageResizer }, { route: "/en/json-diff-viewer", run: assertMobileJsonDiffViewer }, { route: "/en/text-diff-checker", run: assertMobileTextDiffChecker }, + { route: "/en/qr-code-generator", run: assertMobileQrCodeGenerator }, + { route: "/en/csv-json-converter", run: assertMobileCsvJsonConverter }, + { route: "/en/pipeline-builder", run: assertMobilePipelineBuilder }, ]; for (const viewport of MOBILE_TOOL_VIEWPORTS) { @@ -1527,6 +1615,73 @@ async function assertMobileToolPageJourneys(browser, baseUrl) { } } +async function assertMobileViewportPressure(browser, baseUrl) { + const cases = [ + { + route: "/en/qr-code-generator", + input: (page) => page.locator("#qr-content"), + primaryAction: (page) => page.getByRole("button", { name: "PNG", exact: true }).first(), + prepare: async (page) => { + await page.locator("#qr-content").fill("https://byteflow.tools/mobile-viewport-pressure"); + await page.waitForFunction(() => { + const button = Array.from(document.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.trim() === "PNG"); + return Boolean(button && !button.disabled); + }, null, { timeout: 15_000 }); + }, + }, + { + route: "/en/csv-json-converter", + input: (page) => page.locator("textarea").first(), + primaryAction: (page) => page.getByRole("button", { name: /^Convert$/ }).first(), + prepare: async (page) => { + await page.locator("textarea").first().fill("id,name\n1,Ada"); + }, + }, + { + route: "/en/pipeline-builder", + input: (page) => page.getByLabel("Initial input"), + primaryAction: (page) => page.getByRole("button", { name: /Run Recipe/i }).first(), + prepare: async (page) => { + await page.getByRole("button", { name: /^Sample$/ }).first().click(); + }, + }, + ]; + + for (const auditCase of cases) { + const context = await browser.newContext({ + serviceWorkers: "block", + viewport: { width: 390, height: 844 }, + isMobile: true, + }); + const page = await context.newPage(); + const runtime = createRuntimeObserver(page, `${auditCase.route} viewport pressure`, baseUrl); + + try { + await page.goto(`${baseUrl}${auditCase.route}`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + await auditCase.prepare(page); + + const input = auditCase.input(page); + await input.focus(); + await page.setViewportSize({ width: 390, height: 420 }); + await page.waitForTimeout(100); + await assertLocatorReachable(page, input, `${auditCase.route} focused input at 390x420`, { focus: true }); + await assertLocatorReachable(page, auditCase.primaryAction(page), `${auditCase.route} primary action at 390x420`); + await assertNoHorizontalOverflow(page, `${auditCase.route} reduced-height 390x420`); + + await page.setViewportSize({ width: 844, height: 390 }); + await page.waitForTimeout(100); + await assertNoHorizontalOverflow(page, `${auditCase.route} landscape 844x390`); + await assertLocatorReachable(page, auditCase.primaryAction(page), `${auditCase.route} primary action at 844x390`); + runtime.assertClean(); + } finally { + await page.close(); + await context.close(); + } + } +} + async function assertBase64PipelineSafeNavigationJourney(context, baseUrl) { const page = await context.newPage(); const runtime = createRuntimeObserver(page, "Base64 -> Pipeline Builder safe navigation", baseUrl); @@ -1876,14 +2031,18 @@ async function assertPwaShellJourney(browser, baseUrl) { throw new Error(`Expected default manifest link to be /manifest.json, found ${manifestHref || "none"}`); } - const registration = await page.evaluate(async () => { - if (!("serviceWorker" in navigator)) return null; - const ready = await navigator.serviceWorker.ready; - return { - scope: ready.scope, - activeScriptUrl: ready.active?.scriptURL || "", - }; - }); + const registration = await raceWithTimeout( + page.evaluate(async () => { + if (!("serviceWorker" in navigator)) return null; + const ready = await navigator.serviceWorker.ready; + return { + scope: ready.scope, + activeScriptUrl: ready.active?.scriptURL || "", + }; + }), + PWA_SERVICE_WORKER_READY_TIMEOUT_MS, + `Timed out after ${PWA_SERVICE_WORKER_READY_TIMEOUT_MS}ms waiting for navigator.serviceWorker.ready at ${baseUrl}. Check /sw.js registration and browser errors.`, + ); if (!registration?.activeScriptUrl.endsWith("/sw.js")) { throw new Error("Service worker did not become active for the PWA shell."); } @@ -2147,7 +2306,10 @@ async function runSmoke(baseUrl, { writeFirstLoadArtifacts = false } = {}) { console.log("[playwright-smoke] PASS mobile journey: command palette -> /en/base64-encode-decode"); await assertMobileToolPageJourneys(browser, baseUrl); - console.log("[playwright-smoke] PASS mobile journeys: JSON/Base64/JWT/Regex/Cron tool pages"); + console.log("[playwright-smoke] PASS mobile journeys: core tools plus QR/CSV/Pipeline at 390x844 and 430x932"); + + await assertMobileViewportPressure(browser, baseUrl); + console.log("[playwright-smoke] PASS mobile viewport pressure: focused inputs and primary actions remain reachable"); await assertMobileReviewMatrix(browser, baseUrl); console.log("[playwright-smoke] PASS mobile review matrix: no overflow or touch-target regressions"); @@ -2192,16 +2354,30 @@ async function runInputIntentSmoke(baseUrl) { } } +async function runMobileSmoke(baseUrl) { + const browser = await chromium.launch({ headless: true }); + try { + await assertMobileToolPageJourneys(browser, baseUrl); + await assertMobileViewportPressure(browser, baseUrl); + await assertMobileReviewMatrix(browser, baseUrl); + console.log("[playwright-smoke] PASS mobile-only: workflows, viewport pressure, touch targets, and overflow"); + } finally { + await browser.close(); + } +} + async function main() { const { baseUrl, firstLoadOnly, includePwa, inputIntentsOnly, + mobileOnly, port, + pwaOnly, skipServer, writeFirstLoadArtifacts, - } = parseArgs(process.argv.slice(2)); + } = parsePlaywrightSmokeArgs(process.argv.slice(2), { defaultPort: DEFAULT_PORT }); let serverHandle = null; try { @@ -2211,17 +2387,21 @@ async function main() { console.log(`[playwright-smoke] Static server ready at ${baseUrl}`); } - if (inputIntentsOnly) { + if (pwaOnly) { + await runPwaSmoke(baseUrl); + } else if (mobileOnly) { + await runMobileSmoke(baseUrl); + } else if (inputIntentsOnly) { await runInputIntentSmoke(baseUrl); } else if (firstLoadOnly) { await runFirstLoadAudit(baseUrl, { writeArtifacts: writeFirstLoadArtifacts }); } else { await runSmoke(baseUrl, { writeFirstLoadArtifacts }); } - if (includePwa && !firstLoadOnly && !inputIntentsOnly) { + if (includePwa && !firstLoadOnly && !inputIntentsOnly && !mobileOnly && !pwaOnly) { await runPwaSmoke(baseUrl); } - if (!firstLoadOnly && !inputIntentsOnly) { + if (!firstLoadOnly && !inputIntentsOnly && !mobileOnly && !pwaOnly) { console.log("[playwright-smoke] PASS: critical routes render and navigate correctly"); } } catch (error) { @@ -2247,4 +2427,8 @@ async function main() { } } -main(); +main().catch((error) => { + console.error("[playwright-smoke] FAILED"); + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/generators/generate-runtime-scripts.js b/scripts/generators/generate-runtime-scripts.js index 75106b84..cf692860 100644 --- a/scripts/generators/generate-runtime-scripts.js +++ b/scripts/generators/generate-runtime-scripts.js @@ -10,6 +10,7 @@ const CHECK_ONLY = process.argv.includes("--check") const I18N_SOURCE_PATH = path.join(ROOT, "src/core/i18n/i18n.ts") const PWA_CONSTANTS_PATH = path.join(ROOT, "src/core/pwa/constants.ts") +const PWA_INSTALL_PROMPT_STORE_PATH = path.join(ROOT, "src/core/pwa/install-prompt-store.ts") const ROOT_LOCALE_REDIRECT_PATH = path.join(ROOT, "public/runtime/root-locale-redirect.js") const THEME_MANIFEST_BOOTSTRAP_PATH = path.join(ROOT, "public/runtime/theme-manifest-bootstrap.js") @@ -46,6 +47,7 @@ function parseLocales(source) { function readRuntimeScriptConfig() { const i18nSource = readSource(I18N_SOURCE_PATH) const pwaSource = readSource(PWA_CONSTANTS_PATH) + const installPromptStoreSource = readSource(PWA_INSTALL_PROMPT_STORE_PATH) const locales = parseLocales(i18nSource) const defaultLocale = parseStringLiteral(i18nSource, "DEFAULT_LOCALE", I18N_SOURCE_PATH) const darkThemeColor = parseStringLiteral(pwaSource, "PWA_THEME_COLOR", PWA_CONSTANTS_PATH) @@ -60,6 +62,10 @@ function readRuntimeScriptConfig() { defaultLocale, darkThemeColor, lightThemeColor, + installPromptBridgeReadySlot: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_BRIDGE_READY_SLOT", PWA_INSTALL_PROMPT_STORE_PATH), + installPromptChangeEvent: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_CHANGE_EVENT", PWA_INSTALL_PROMPT_STORE_PATH), + installPromptInstalledKey: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_INSTALLED_KEY", PWA_INSTALL_PROMPT_STORE_PATH), + installPromptSlot: parseStringLiteral(installPromptStoreSource, "PWA_INSTALL_PROMPT_SLOT", PWA_INSTALL_PROMPT_STORE_PATH), } } @@ -131,8 +137,42 @@ function buildThemeManifestBootstrapScript(config) { const defaultLocale = json(config.defaultLocale) const lightThemeColor = json(config.lightThemeColor) const darkThemeColor = json(config.darkThemeColor) + const installPromptBridgeReadySlot = json(config.installPromptBridgeReadySlot) + const installPromptChangeEvent = json(config.installPromptChangeEvent) + const installPromptInstalledKey = json(config.installPromptInstalledKey) + const installPromptSlot = json(config.installPromptSlot) return `(function () { + var installPromptBridgeReadySlot = ${installPromptBridgeReadySlot}; + var installPromptChangeEvent = ${installPromptChangeEvent}; + var installPromptInstalledKey = ${installPromptInstalledKey}; + var installPromptSlot = ${installPromptSlot}; + + function notifyInstallPromptChange() { + try { + window.dispatchEvent(new CustomEvent(installPromptChangeEvent)); + } catch {} + } + + if (!window[installPromptBridgeReadySlot]) { + window[installPromptBridgeReadySlot] = true; + window.addEventListener("beforeinstallprompt", function (event) { + event.preventDefault(); + try { + localStorage.removeItem(installPromptInstalledKey); + } catch {} + window[installPromptSlot] = event; + notifyInstallPromptChange(); + }); + window.addEventListener("appinstalled", function () { + try { + localStorage.setItem(installPromptInstalledKey, "1"); + } catch {} + window[installPromptSlot] = null; + notifyInstallPromptChange(); + }); + } + try { var locales = ${locales}; var p = window.location.pathname || "/"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd8a4df9..2edbb0e8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -39,7 +39,7 @@ export default function RootLayout({ - {/* This tiny same-origin script must block first paint so saved light themes cannot flash dark. */} + {/* This same-origin bootstrap applies theme and owns pre-hydration PWA install events. */} {/* eslint-disable-next-line @next/next/no-sync-scripts */}