diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 269ca5eb..59fe1931 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -468,7 +468,7 @@ async function serveCloudWeb() { } const routePath = url.pathname.replace(/\/+$/, "") || "/"; - const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" ? "index.html" : url.pathname.slice(1); + const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" ? "index.html" : url.pathname.slice(1); for (const root of roots) { const candidate = path.resolve(root, relativePath); if (!candidate.startsWith(root)) continue; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index bdf21c7c..a2e1b58d 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -64,6 +64,7 @@ const diagnosticsRequiredTerms = Object.freeze([ ]); const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]); +const helpRouteAliases = Object.freeze(["/help"]); const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]); @@ -253,6 +254,11 @@ function runStaticSmoke() { evidence: gateRouteAliases }); + addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", { + blocker: "runtime_blocker", + evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"] + }); + addCheck(checks, blockers, "feedback-120-wiring-table", wiringTableContract(files.html), "Patch-panel wiring panel is a table with source, target, status, evidence source, and trace/evidence columns.", { blocker: "contract_blocker", evidence: requiredWiringColumns @@ -966,6 +972,19 @@ function gateRouteAliasesAreServed(app, artifactPublisher, buildScript, distCont ); } +function helpRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) { + const routeBody = functionBody(app, "routeFromLocation"); + return ( + /helpPathnames\(\)\.has/u.test(routeBody) && + helpRouteAliases.every((route) => app.includes(`"${route}"`)) && + helpRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) && + /buildCloudWebDist/u.test(buildScript) && + /help\/index\.html/u.test(distContractScript) && + /return "help"/u.test(routeBody) && + finalRouteFallback(app) === "workspace" + ); +} + function wiringTableContract(html) { const wiringPanel = html.match(/]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? ""; return requiredWiringColumns.every((column) => wiringPanel.includes(`${column}`)); @@ -1321,7 +1340,7 @@ function inspectHelpRoute(html, app) { /data-route=["']help["']/u.test(html) || /data-view=["']help["']/u.test(html) || /#help\b/u.test(html + app) || - /pathname[\s\S]{0,160}\/help/u.test(app); + /pathname[\s\S]{0,220}\/help/u.test(app); const finalFallback = finalRouteFallback(app); const helpViewHidden = helpViewTag ? /\bhidden\b/u.test(helpViewTag) : false; return { @@ -1983,6 +2002,12 @@ export async function runDevCloudWorkbenchMobileSmoke() { await page.click('[data-route="help"]'); await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 }); const help = await inspectHelpMarkdownRoute(page); + await page.goto(new URL("/#/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 }); + await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 }); + const slashHashHelp = await inspectHelpMarkdownRoute(page); + await page.goto(new URL("/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 }); + await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 }); + const directHelp = await inspectHelpMarkdownRoute(page); await page.click('[data-route="workspace"]'); await page.waitForFunction(() => document.querySelector('[data-view="workspace"]')?.hidden === false, null, { timeout: 8000 }); await page.click("#explorer-toggle"); @@ -2018,10 +2043,22 @@ export async function runDevCloudWorkbenchMobileSmoke() { }, { id: "mobile-help-markdown-route", - status: help.nonDefaultMarkdownHelp && help.termsPresent ? "pass" : "blocked", - summary: "Help opens as a non-default internal route rendered from help.md by the vendored Markdown renderer.", + status: help.nonDefaultMarkdownHelp && help.routeIsHashHelp && help.termsPresent ? "pass" : "blocked", + summary: "Help opens from the rail as a non-default hash route rendered from help.md by the vendored Markdown renderer.", observations: help }, + { + id: "mobile-slash-hash-help-route", + status: slashHashHelp.nonDefaultMarkdownHelp && slashHashHelp.routeIsSlashHashHelp && slashHashHelp.termsPresent ? "pass" : "blocked", + summary: "Direct #/help opens the user-facing help view.", + observations: slashHashHelp + }, + { + id: "mobile-direct-help-route", + status: directHelp.nonDefaultMarkdownHelp && directHelp.routeIsDirectHelp && directHelp.termsPresent ? "pass" : "blocked", + summary: "Direct /help opens the user-facing help view instead of raw API 404 JSON.", + observations: directHelp + }, { id: "mobile-default-hit-test", status: closed.primaryControlsReachable ? "pass" : "blocked", @@ -2109,7 +2146,7 @@ async function startStaticWebServer(options = {}) { return; } let pathname = decodeURIComponent(url.pathname); - if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate") { + if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate" || pathname === "/help") { pathname = "/index.html"; } const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, "")); @@ -2490,6 +2527,7 @@ async function inspectHelpMarkdownRoute(page) { document.body.scrollHeight <= document.body.clientHeight + 2; return { hash: window.location.hash, + pathname: window.location.pathname, helpState: helpContent?.dataset.helpState ?? null, workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gate ? gate.hidden : null, @@ -2504,8 +2542,11 @@ async function inspectHelpMarkdownRoute(page) { helpPanelScrolls: Boolean(helpScroll && helpScroll.scrollHeight > helpScroll.clientHeight && helpScroll.after > helpScroll.before), rootStillLocked, helpScroll, + routeIsHashHelp: window.location.hash === "#help", + routeIsSlashHashHelp: window.location.hash === "#/help", + routeIsDirectHelp: (window.location.pathname.replace(/\/+$/, "") || "/") === "/help", nonDefaultMarkdownHelp: - window.location.hash === "#help" && + (window.location.hash === "#help" || window.location.hash === "#/help" || (window.location.pathname.replace(/\/+$/, "") || "/") === "/help") && workspace?.hidden === true && gate?.hidden === true && help?.hidden === false && diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index e736ca2d..3178db70 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -136,8 +136,9 @@ function initRoutes() { } function routeFromLocation() { - const hashRoute = window.location.hash.replace(/^#/, ""); + const hashRoute = window.location.hash.replace(/^#\/?/, ""); if (viewIds.has(hashRoute)) return hashRoute; + if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help"; if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate"; return "workspace"; } @@ -1822,6 +1823,10 @@ function internalGatePathnames() { return new Set(["/gate", "/diagnostics/gate"]); } +function helpPathnames() { + return new Set(["/help"]); +} + function userFacingSummary(text) { return String(text ?? "") .replaceAll("Public DEV route/frp", "公共 DEV 路由/frp") diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 24e57c59..c1986314 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -217,6 +217,9 @@ assert.match(app, /marked\.parse\(/); assert.doesNotMatch(app, /function\s+parseMarkdown|markedRegex|markdownRegex/); assert.match(app, /fetch\("\/help\.md"/); assert.match(app, /帮助内容加载失败/); +assert.match(functionBody(app, "routeFromLocation"), /replace\(\/\^#\\\/\?\//); +assert.match(functionBody(app, "routeFromLocation"), /helpPathnames\(\)\.has/); +assert.match(functionBody(app, "routeFromLocation"), /return "help"/); assert.match(app, /return "workspace";/); assert.match(styles, /\.help-view\s*{[^}]*overflow:\s*hidden;/s); assert.match(styles, /\.help-panel\s*{[^}]*overflow:\s*hidden;/s); @@ -283,6 +286,7 @@ assert.match(distContractScript, /"third_party\/marked\/marked\.esm\.js"/); assert.match(distContractScript, /"third_party\/marked\/LICENSE"/); assert.match(distContractScript, /gate\/index\.html/); assert.match(distContractScript, /diagnostics\/gate\/index\.html/); +assert.match(distContractScript, /help\/index\.html/); assert.match(markedLicense, /MarkedJS/); assert.match(markedLicense, /Permission is hereby granted, free of charge/); assert.match(html, /live-status/); @@ -395,6 +399,7 @@ assert.match(artifactPublisher, /cloud_api_proxy_failed/); assert.match(artifactPublisher, /url\.pathname\.replace\(\/\\\/\+\$\/,\s*""\) \|\| "\/"/); assert.match(artifactPublisher, /routePath === "\/gate"/); assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/); +assert.match(artifactPublisher, /routePath === "\/help"/); assert.match(app, /degraded/); assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/); assert.doesNotMatch(app, /callRpc\("hardware\./); diff --git a/web/hwlab-cloud-web/scripts/dist-contract.mjs b/web/hwlab-cloud-web/scripts/dist-contract.mjs index cf9ab008..fc75d77e 100644 --- a/web/hwlab-cloud-web/scripts/dist-contract.mjs +++ b/web/hwlab-cloud-web/scripts/dist-contract.mjs @@ -16,7 +16,7 @@ export const cloudWebDistRuntimeFiles = Object.freeze([ "third_party/marked/marked.esm.js", "third_party/marked/LICENSE" ]); -export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html"]); +export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html"]); function absolutePath(rootDir, relativePath) { return path.resolve(rootDir, relativePath);