Skip to content
Closed
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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 23 additions & 4 deletions scripts/docs-site/analytics-behavior.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
88 changes: 72 additions & 16 deletions scripts/docs-site/visual-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand All @@ -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");
Expand All @@ -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();
Expand All @@ -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();
}
Expand Down
Loading