diff --git a/package-lock.json b/package-lock.json index c78e775d03..327bd1f878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "markdown-it-anchor": "10.0.0", "mermaid": "11.17.2", "pagefind": "1.5.2", - "playwright": "^1.63.0", + "playwright": "^1.64.0", "tsx": "4.23.15", "web-vitals": "6.2.3", "yaml": "2.9.1" @@ -3744,13 +3744,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -3760,9 +3760,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 7a3f0d1449..bb7a6e91bd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "markdown-it-anchor": "10.0.0", "mermaid": "11.17.2", "pagefind": "1.5.2", - "playwright": "^1.63.0", + "playwright": "^1.64.0", "tsx": "4.23.15", "web-vitals": "6.2.3", "yaml": "2.9.1" diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs index ea3865ea84..3bdba94035 100644 --- a/scripts/docs-site/analytics-behavior.test.mjs +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -93,10 +93,15 @@ test("selection placement and sanitized destination share one event without chan articleLinks: `\n[Community](${destination})\n\n[External reference](https://example.org/guide?ref=docs#intro)\n\n[Product](https://openclaw.ai/install?ref=docs#intro)\n`, }); page.setDefaultTimeout(5000); - // A 204 keeps same-tab external navigation on the fixture. No destination or - // collection request is forwarded and the actual anchor href is unchanged. - await context.route("**/*", route => route.request().isNavigationRequest() && new URL(route.request().url()).origin !== origin - ? route.fulfill({ status: 204 }) : route.fallback()); + // Abort only after observing native navigation so the fixture stays loaded. + // A 204 response leaves Playwright 1.64's Chromium navigation waiter pending. + const navigations = []; + await page.route("**/*", route => { + const request = route.request(); + if (!request.isNavigationRequest() || new URL(request.url()).origin === origin) return route.fallback(); + navigations.push(request.url()); + return route.abort("aborted"); + }); context.on("page", popup => popup.close().catch(() => {})); const count = (name, url) => wireEvents(collected).filter(event => event.en === name && (!url || event["ep.link_url"] === url)); async function clickAndObserve(selector, placement, contentType, contentId, url, native = false) { @@ -105,7 +110,21 @@ test("selection placement and sanitized destination share one event without chan const before = (await events("select_content")).length; const nativeBefore = count("click", url).length; const selectionBefore = count("select_content", url).length; + const sameTab = ["", "_self"].includes(await link.getAttribute("target") || ""); + const navigationBefore = navigations.length; + const pageUrl = page.url(); await link.click(); + if (sameTab) { + assert.equal(navigations.length, navigationBefore + 1, "native same-tab navigation is observed before cancellation"); + const requested = new URL(navigations.at(-1)); + const expected = new URL(href, origin); + for (const destination of [requested, expected]) { + destination.searchParams.delete("_gl"); + destination.hash = ""; + } + assert.equal(requested.href, expected.href); + assert.equal(page.url(), pageUrl, "cancelled navigation leaves the fixture loaded"); + } const selected = await events("select_content"); assert.equal(selected.length, before + 1); assert.equal(selected.at(-1).ui_location, placement); diff --git a/scripts/docs-site/visual-smoke.mjs b/scripts/docs-site/visual-smoke.mjs index aafa403eba..0f075cacd6 100644 --- a/scripts/docs-site/visual-smoke.mjs +++ b/scripts/docs-site/visual-smoke.mjs @@ -4,6 +4,11 @@ import http from "node:http"; import path from "node:path"; import { chromium } from "playwright"; +const accordionOnly = process.argv.includes("--accordion-only"); +if (process.argv.slice(2).some((arg) => arg !== "--accordion-only")) { + throw new Error("usage: visual-smoke.mjs [--accordion-only]"); +} + const root = process.cwd(); const site = path.join(root, "dist", "docs-site"); const artifacts = process.env.DOCS_VISUAL_ARTIFACT_DIR || path.join(root, ".cache", "docs-visual"); @@ -34,22 +39,25 @@ const base = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch({ headless: true }); try { - await checkLanguageSelection(); - await checkAccordionNavigation(); - await checkLandingNavigation(); - await checkPageBackground(); - await checkCommunityBanner(); - await checkHeaderSurface(); - await checkFooterSocials(); - await checkDesktop(); - await checkTocScrollspy(); - await checkTocRailScrolling(); - await checkCompactToc(); - await checkAmbientCodePage(); - await checkMobile(); - await checkRtlNavigationResize(); - await checkLightMode(); - console.log(`docs visual smoke ok: screenshots in ${path.relative(root, artifacts)}`); + const checks = accordionOnly ? [checkAccordionNavigation] : [ + checkLanguageSelection, + checkAccordionNavigation, + checkLandingNavigation, + checkPageBackground, + checkCommunityBanner, + checkHeaderSurface, + checkFooterSocials, + checkDesktop, + checkTocScrollspy, + checkTocRailScrolling, + checkCompactToc, + checkAmbientCodePage, + checkMobile, + checkRtlNavigationResize, + checkLightMode, + ]; + for (const check of checks) await check(); + console.log(`docs ${accordionOnly ? "accordion" : "visual"} smoke ok: screenshots in ${path.relative(root, artifacts)}`); } finally { await browser.close(); server.close(); @@ -111,6 +119,20 @@ async function checkAccordionNavigation() { for (const reducedMotion of ["no-preference", "reduce"]) { for (const hash of ["#nested-disclosure-target", "#release-source-target"]) { const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion }); + let phase = "toc navigation"; + await page.addInitScript(() => { + const events = window.__accordionEvents = []; + const record = (event) => { + if (event.type !== "scroll" && !event.target?.closest?.(".release-source-toggle")) return; + const source = document.querySelector(".release-source-toggle"); + events.push({ type: event.type, key: event.key, code: event.code, + open: source?.open, focus: document.activeElement?.tagName, scrollY }); + if (events.length > 32) events.shift(); + }; + for (const type of ["keydown", "keypress", "keyup", "click", "toggle", "scroll"]) { + document.addEventListener(type, record, { capture: true, passive: true }); + } + }); const assertTargetVisible = async () => { await page.waitForFunction((hash) => { const target = document.getElementById(hash.slice(1)); @@ -128,10 +150,12 @@ async function checkAccordionNavigation() { await page.locator(`.toc a[href="${hash}"]`).click(); await assertTargetVisible(); + phase = "direct fragment navigation"; await page.goto("about:blank"); await page.goto(`${base}/__elements${hash}`, { waitUntil: "networkidle" }); await assertTargetVisible(); + phase = "PJAX fragment navigation"; await page.goto(`${base}/`, { waitUntil: "networkidle" }); await page.evaluate((hash) => { const link = document.createElement("a"); @@ -144,6 +168,11 @@ async function checkAccordionNavigation() { await assertTargetVisible(); if (hash === "#release-source-target") { const summary = page.locator(".release-source-toggle > summary"); + phase = "pointer close and reopen"; + // Fragment navigation leaves this summary under the sticky header. Keep the control and + // body in view before interacting; click auto-scrolling can align only the summary. + await summary.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); + await assertTargetVisible(); // Interrupt closing with another pointer click, then check the nested body is not clipped. await summary.click(); await summary.click(); @@ -152,12 +181,39 @@ async function checkAccordionNavigation() { const body = source.querySelector(".release-source-content"); return source.open && body.getBoundingClientRect().bottom <= source.getBoundingClientRect().bottom + 1; }); + await assertTargetVisible(); + phase = "keyboard close"; await summary.focus(); await page.keyboard.press("Enter"); if (await page.locator(".release-source-toggle").getAttribute("open") !== null) throw new Error("Sources must close with the keyboard"); + phase = "keyboard reopen"; await page.keyboard.press("Space"); await assertTargetVisible(); } + console.log(`accordion navigation ok: ${JSON.stringify({ browser: browser.version(), reducedMotion, hash })}`); + } catch (error) { + const state = await page.evaluate((hash) => { + const target = document.getElementById(hash.slice(1)); + const ancestors = []; + for (let node = target?.parentElement; node && ancestors.length < 8; node = node.parentElement) { + if (node.tagName !== "DETAILS") continue; + const content = node.querySelector(":scope>.oc-accordion-content,:scope>.release-source-content"); + const style = getComputedStyle(node, "::details-content"); + ancestors.push({ className: node.className, open: node.open, + rect: node.getBoundingClientRect().toJSON(), content: content?.getBoundingClientRect().toJSON(), + measuredHeight: node.style.getPropertyValue("--accordion-content-height"), + height: style.height, contentVisibility: style.contentVisibility, overflow: style.overflow }); + } + const summary = document.querySelector(".release-source-toggle > summary"); + return { url: location.href, scrollY, viewport: [innerWidth, innerHeight], + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + target: target?.getBoundingClientRect().toJSON(), header: document.querySelector(".site-header")?.getBoundingClientRect().toJSON(), + summary: summary?.getBoundingClientRect().toJSON(), summaryFocused: document.activeElement === summary, + focusVisible: summary?.matches(":focus-visible"), ancestors, events: window.__accordionEvents }; + }, hash).catch((diagnosticError) => ({ diagnosticError: diagnosticError.message })); + console.error(`accordion navigation failed: ${JSON.stringify({ browser: browser.version(), reducedMotion, hash, phase, state })}`); + await page.screenshot({ path: path.join(artifacts, `accordion-${reducedMotion}-${hash.slice(1)}.png`) }).catch(() => {}); + throw error; } finally { await page.close(); }