feat: add cloud workbench login entry
This commit is contained in:
@@ -72,6 +72,7 @@ const readOnlyRpcMethods = Object.freeze([
|
||||
const requiredWebAssets = Object.freeze([
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"auth.mjs",
|
||||
"app.mjs",
|
||||
"code-agent-facts.mjs",
|
||||
"code-agent-status.mjs",
|
||||
@@ -86,9 +87,10 @@ const requiredWebAssets = Object.freeze([
|
||||
"third_party/marked/LICENSE"
|
||||
]);
|
||||
|
||||
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]);
|
||||
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "auth.mjs", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]);
|
||||
|
||||
const chineseWorkbenchLabels = Object.freeze([
|
||||
"云工作台登录",
|
||||
"HWLAB 云工作台",
|
||||
"硬件资源",
|
||||
"Agent 对话",
|
||||
@@ -98,6 +100,11 @@ const chineseWorkbenchLabels = Object.freeze([
|
||||
"使用说明"
|
||||
]);
|
||||
|
||||
const defaultAuthCredentials = Object.freeze({
|
||||
username: "admin",
|
||||
password: "hwlab2026"
|
||||
});
|
||||
|
||||
const defaultHomepageForbiddenTerms = Object.freeze([
|
||||
"Gate",
|
||||
"诊断",
|
||||
@@ -370,6 +377,7 @@ export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) {
|
||||
export async function runDevCloudWorkbenchSmoke(argv = []) {
|
||||
const args = parseSmokeArgs(argv);
|
||||
if (args.mode === "live") return runLiveSmoke(args);
|
||||
if (args.mode === "auth-fixture") return runDevCloudWorkbenchAuthFixtureSmoke();
|
||||
if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke();
|
||||
if (args.mode === "dom-only") return runLiveDomOnlySmoke(args);
|
||||
if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke();
|
||||
@@ -440,6 +448,8 @@ export function parseSmokeArgs(argv) {
|
||||
args.mode = "local-agent-fixture";
|
||||
} else if (arg === "--quick-prompts-fixture") {
|
||||
args.mode = "quick-prompts-fixture";
|
||||
} else if (arg === "--auth-fixture") {
|
||||
args.mode = "auth-fixture";
|
||||
} else if (arg === "--local-agent-timeout-fixture") {
|
||||
args.mode = "local-agent-timeout-fixture";
|
||||
} else if (arg === "--url") {
|
||||
@@ -476,9 +486,14 @@ function runStaticSmoke() {
|
||||
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Default route is the VS Code-style Cloud Workbench.", {
|
||||
addCheck(checks, blockers, "default-login-entry", hasDefaultLoginEntry(files, artifactPublisher), "Default 16666 route shows the Chinese login page before the Cloud Workbench is exposed.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: ["workspace view is visible by default", "Gate view is hidden by default", ...workbenchMarkers]
|
||||
evidence: ["云工作台登录", "账号或密码不正确,请重新输入。", "data-app-shell hidden", "auth.mjs", "HWLAB_CLOUD_WEB_AUTH_USERNAME", "HWLAB_CLOUD_WEB_AUTH_PASSWORD"]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Authenticated route is the VS Code-style Cloud Workbench.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", {
|
||||
@@ -1657,6 +1672,7 @@ function readStaticFiles() {
|
||||
return {
|
||||
html: readText("web/hwlab-cloud-web/index.html"),
|
||||
styles: readText("web/hwlab-cloud-web/styles.css"),
|
||||
auth: readText("web/hwlab-cloud-web/auth.mjs"),
|
||||
app: readText("web/hwlab-cloud-web/app.mjs"),
|
||||
codeAgentFacts: readText("web/hwlab-cloud-web/code-agent-facts.mjs"),
|
||||
codeAgentStatus: readText("web/hwlab-cloud-web/code-agent-status.mjs"),
|
||||
@@ -1902,15 +1918,50 @@ function assetsExist() {
|
||||
return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
|
||||
}
|
||||
|
||||
function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = "") {
|
||||
const loginShellTag = html.match(/<section\b[^>]*\bid=["']login-shell["'][^>]*>/u)?.[0] ?? "";
|
||||
const appShellTag = html.match(/<main\b[^>]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? "";
|
||||
const loginHtml = html.match(/<section\b[^>]*\bid=["']login-shell["'][\s\S]*?<\/section>/u)?.[0] ?? "";
|
||||
const defaultShellText = visibleTextFromHtml(loginHtml);
|
||||
const authSource = `${auth}\n${app}\n${styles}\n${artifactPublisher}`;
|
||||
return (
|
||||
loginShellTag.includes("data-auth-login") &&
|
||||
!/\bhidden\b/u.test(loginShellTag) &&
|
||||
/\bhidden\b/u.test(appShellTag) &&
|
||||
/data-auth-state=["']checking["']/u.test(html) &&
|
||||
["云工作台登录", "请输入账号和密码。", "用户名", "密码", "登录"].every((term) => defaultShellText.includes(term)) &&
|
||||
!defaultShellText.includes("Gate") &&
|
||||
!defaultShellText.includes("诊断") &&
|
||||
!defaultShellText.includes("验收") &&
|
||||
!defaultShellText.includes("后厨") &&
|
||||
/DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) &&
|
||||
/DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) &&
|
||||
/HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) &&
|
||||
/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) &&
|
||||
/账号或密码不正确,请重新输入。/u.test(auth) &&
|
||||
/ensureWorkbenchAuth/u.test(app) &&
|
||||
/await\s+ensureWorkbenchAuth/u.test(app) &&
|
||||
/initWorkbenchLogout/u.test(app) &&
|
||||
/HWLAB_CLOUD_WEB_AUTH_USERNAME/u.test(authSource) &&
|
||||
/HWLAB_CLOUD_WEB_AUTH_PASSWORD/u.test(authSource) &&
|
||||
/auth_required/u.test(authSource) &&
|
||||
/\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) &&
|
||||
/\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) &&
|
||||
/\.workbench-shell\[hidden\]/u.test(styles)
|
||||
);
|
||||
}
|
||||
|
||||
function hasDefaultWorkbench({ html, app }) {
|
||||
const workspaceTag = firstTagForDataView(html, "workspace");
|
||||
const gateTag = firstTagForDataView(html, "gate");
|
||||
const appShellTag = html.match(/<main\b[^>]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? "";
|
||||
return (
|
||||
labelsPresent(html, workbenchMarkers) &&
|
||||
/<title>\s*HWLAB 云工作台\s*<\/title>/u.test(html) &&
|
||||
/data-route=["']workspace["']/u.test(html) &&
|
||||
workspaceTag !== null &&
|
||||
!/\bhidden\b/u.test(workspaceTag) &&
|
||||
/\bhidden\b/u.test(appShellTag) &&
|
||||
gateTag !== null &&
|
||||
/\bhidden\b/u.test(gateTag) &&
|
||||
finalRouteFallback(app) === "workspace"
|
||||
@@ -2975,6 +3026,9 @@ async function inspectLiveDom(url, options = {}) {
|
||||
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
|
||||
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 8000 });
|
||||
const login = await inspectLoginPage(page);
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
const dom = await page.evaluate(async (forbiddenTerms) => {
|
||||
const workspace = document.querySelector('[data-view="workspace"]');
|
||||
const gate = document.querySelector('[data-view="gate"]');
|
||||
@@ -3075,6 +3129,9 @@ async function inspectLiveDom(url, options = {}) {
|
||||
};
|
||||
}, [...defaultHomepageForbiddenTerms]);
|
||||
const pass =
|
||||
login.loginVisible &&
|
||||
login.workbenchHidden &&
|
||||
login.copyOk &&
|
||||
dom.title === "HWLAB 云工作台" &&
|
||||
dom.bodyOverflow === "hidden" &&
|
||||
dom.htmlOverflow === "hidden" &&
|
||||
@@ -3114,6 +3171,8 @@ async function inspectLiveDom(url, options = {}) {
|
||||
status: pass ? "pass" : "blocked",
|
||||
summary: pass ? "Browser DOM confirms the default workbench, core controls, and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.",
|
||||
evidence: [
|
||||
`loginVisible=${login.loginVisible}`,
|
||||
`workbenchHiddenBeforeLogin=${login.workbenchHidden}`,
|
||||
`title=${dom.title}`,
|
||||
`bodyOverflow=${dom.bodyOverflow}`,
|
||||
`htmlOverflow=${dom.htmlOverflow}`,
|
||||
@@ -3128,6 +3187,7 @@ async function inspectLiveDom(url, options = {}) {
|
||||
...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : [])
|
||||
],
|
||||
observations: {
|
||||
login,
|
||||
...dom,
|
||||
...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {})
|
||||
}
|
||||
@@ -3161,6 +3221,7 @@ async function inspectLiveHelpRoute(url, options = {}) {
|
||||
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
|
||||
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator('[data-route="help"]').click();
|
||||
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
|
||||
const help = await inspectHelpMarkdownRoute(page);
|
||||
@@ -3217,6 +3278,7 @@ async function inspectLiveGateRoute(url, options = {}) {
|
||||
});
|
||||
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
|
||||
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.waitForFunction(() => {
|
||||
const rows = document.querySelectorAll("#gate-review-body tr");
|
||||
const source = document.querySelector("#gate-source-status")?.textContent ?? "";
|
||||
@@ -3323,6 +3385,395 @@ async function inspectLiveGateRoute(url, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDevCloudWorkbenchAuthFixtureSmoke() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await importPlaywright());
|
||||
} catch (error) {
|
||||
const summary = `Auth fixture smoke skipped because Playwright is unavailable: ${error.message}`;
|
||||
return {
|
||||
status: "skip",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "auth-fixture-browser",
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary,
|
||||
checks: [
|
||||
{
|
||||
id: "auth-browser-dependency",
|
||||
status: "skip",
|
||||
summary: playwrightDependencySummary,
|
||||
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
|
||||
}
|
||||
],
|
||||
blockers: [
|
||||
{
|
||||
type: "environment_blocker",
|
||||
scope: "auth-browser-dependency",
|
||||
status: "open",
|
||||
summary
|
||||
}
|
||||
],
|
||||
safety: staticSafety()
|
||||
};
|
||||
}
|
||||
|
||||
const server = await startStaticWebServer({ authFixture: true });
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const desktop = await inspectAuthFixtureViewport(browser, server.url, {
|
||||
width: 1366,
|
||||
height: 768,
|
||||
isMobile: false
|
||||
});
|
||||
const mobile = await inspectAuthFixtureViewport(browser, server.url, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
isMobile: true
|
||||
});
|
||||
const checks = [
|
||||
{
|
||||
id: "auth-login-success-desktop",
|
||||
status: desktop.success.pass ? "pass" : "blocked",
|
||||
viewport: desktop.viewport,
|
||||
summary: "Desktop login accepts admin/hwlab2026 and enters the current Cloud Workbench.",
|
||||
observations: desktop.success
|
||||
},
|
||||
{
|
||||
id: "auth-login-failure-desktop",
|
||||
status: desktop.failure.pass ? "pass" : "blocked",
|
||||
viewport: desktop.viewport,
|
||||
summary: "Desktop login failure stays on the Chinese login page with a bounded error message.",
|
||||
observations: desktop.failure
|
||||
},
|
||||
{
|
||||
id: "auth-refresh-session-desktop",
|
||||
status: desktop.refresh.pass ? "pass" : "blocked",
|
||||
viewport: desktop.viewport,
|
||||
summary: "Desktop refresh preserves the authenticated workbench session.",
|
||||
observations: desktop.refresh
|
||||
},
|
||||
{
|
||||
id: "auth-logout-expiry-desktop",
|
||||
status: desktop.logout.pass && desktop.expired.pass ? "pass" : "blocked",
|
||||
viewport: desktop.viewport,
|
||||
summary: "Desktop logout and expired session both return to the login page.",
|
||||
observations: {
|
||||
logout: desktop.logout,
|
||||
expired: desktop.expired
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "auth-login-success-mobile",
|
||||
status: mobile.success.pass ? "pass" : "blocked",
|
||||
viewport: mobile.viewport,
|
||||
summary: "390x844 mobile login enters the workbench without outer scrolling or hidden controls.",
|
||||
observations: mobile.success
|
||||
},
|
||||
{
|
||||
id: "auth-login-failure-mobile",
|
||||
status: mobile.failure.pass ? "pass" : "blocked",
|
||||
viewport: mobile.viewport,
|
||||
summary: "390x844 mobile login failure keeps the form visible and error text contained.",
|
||||
observations: mobile.failure
|
||||
},
|
||||
{
|
||||
id: "auth-refresh-session-mobile",
|
||||
status: mobile.refresh.pass ? "pass" : "blocked",
|
||||
viewport: mobile.viewport,
|
||||
summary: "390x844 mobile refresh preserves the authenticated session and workbench layout.",
|
||||
observations: mobile.refresh
|
||||
},
|
||||
{
|
||||
id: "auth-logout-expiry-mobile",
|
||||
status: mobile.logout.pass && mobile.expired.pass ? "pass" : "blocked",
|
||||
viewport: mobile.viewport,
|
||||
summary: "390x844 mobile logout and expired session return to login without outer scroll.",
|
||||
observations: {
|
||||
logout: mobile.logout,
|
||||
expired: mobile.expired
|
||||
}
|
||||
}
|
||||
];
|
||||
const blockers = checks
|
||||
.filter((check) => check.status !== "pass")
|
||||
.map((check) => ({
|
||||
type: "runtime_blocker",
|
||||
scope: check.id,
|
||||
status: "open",
|
||||
summary: check.summary,
|
||||
viewport: check.viewport
|
||||
}));
|
||||
return {
|
||||
status: blockers.length === 0 ? "pass" : "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "auth-fixture-browser",
|
||||
url: server.url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary: "Local browser fixture validates the Cloud Workbench login entry, failure message, session refresh, logout, expiry, and desktop/mobile layout.",
|
||||
refs: ["pikasTech/HWLAB#357", "pikasTech/HWLAB#108"],
|
||||
checks,
|
||||
blockers,
|
||||
safety: {
|
||||
...staticSafety(),
|
||||
localFixtureOnly: true,
|
||||
hardwareWriteApis: false,
|
||||
codeAgentPostSent: false,
|
||||
statement: "Auth fixture uses local same-origin /auth endpoints only; it does not call hardware write APIs, post Code Agent chat, read Secrets, or deploy DEV."
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
task: "DC-DCSN-P0-2026-003",
|
||||
mode: "auth-fixture-browser",
|
||||
url: server.url,
|
||||
generatedAt: new Date().toISOString(),
|
||||
evidenceLevel: "SOURCE",
|
||||
devLive: false,
|
||||
summary: `Auth fixture smoke failed: ${error.message}`,
|
||||
checks: [],
|
||||
blockers: [
|
||||
{
|
||||
type: "runtime_blocker",
|
||||
scope: "auth-fixture-browser",
|
||||
status: "open",
|
||||
summary: error.message
|
||||
}
|
||||
],
|
||||
safety: staticSafety()
|
||||
};
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectAuthFixtureViewport(browser, url, viewport) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: viewport.isMobile === true
|
||||
});
|
||||
try {
|
||||
const failurePage = await context.newPage();
|
||||
await failurePage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const initial = await inspectLoginPage(failurePage);
|
||||
await failurePage.locator("#login-username").fill(defaultAuthCredentials.username);
|
||||
await failurePage.locator("#login-password").fill("wrong-password");
|
||||
await failurePage.locator("#login-submit").click();
|
||||
await failurePage.waitForFunction(() => !document.querySelector("#login-error")?.hidden, null, { timeout: 8000 });
|
||||
const failure = await inspectFailedLoginPage(failurePage, viewport);
|
||||
await failurePage.close();
|
||||
|
||||
const successPage = await context.newPage();
|
||||
await successPage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(successPage);
|
||||
const success = await inspectAuthenticatedWorkbench(successPage, viewport);
|
||||
await successPage.reload({ waitUntil: "networkidle", timeout: 15000 });
|
||||
const refresh = await inspectAuthenticatedWorkbench(successPage, viewport);
|
||||
await successPage.locator("#logout-button").click();
|
||||
await successPage.waitForFunction(() => document.querySelector("#login-shell")?.hidden === false, null, { timeout: 8000 });
|
||||
const logout = await inspectLoginPage(successPage, viewport);
|
||||
await successPage.close();
|
||||
|
||||
const expiredPage = await context.newPage();
|
||||
await expiredPage.addInitScript(({ username }) => {
|
||||
window.localStorage.setItem("hwlab.cloudWorkbench.auth.v1", JSON.stringify({
|
||||
version: 1,
|
||||
username,
|
||||
issuedAt: Date.now() - 120000,
|
||||
expiresAt: Date.now() - 1000
|
||||
}));
|
||||
}, { username: defaultAuthCredentials.username });
|
||||
await expiredPage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const expired = await inspectLoginPage(expiredPage, viewport);
|
||||
await expiredPage.close();
|
||||
|
||||
return {
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
initial,
|
||||
failure,
|
||||
success,
|
||||
refresh,
|
||||
logout,
|
||||
expired
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithDefaultCredentials(page) {
|
||||
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
|
||||
if (!loginVisible) return;
|
||||
await page.locator("#login-username").fill(defaultAuthCredentials.username);
|
||||
await page.locator("#login-password").fill(defaultAuthCredentials.password);
|
||||
await page.locator("#login-submit").click();
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
}
|
||||
|
||||
async function inspectLoginPage(page, viewport = null) {
|
||||
return page.evaluate((viewport) => {
|
||||
const login = document.querySelector("#login-shell");
|
||||
const panel = document.querySelector(".login-panel");
|
||||
const appShell = document.querySelector("[data-app-shell]");
|
||||
const title = document.querySelector("#login-title")?.textContent?.trim() ?? "";
|
||||
const text = login?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
|
||||
const panelBox = panel?.getBoundingClientRect();
|
||||
const inputBoxes = [...document.querySelectorAll(".login-form input, #login-submit")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.id,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth
|
||||
};
|
||||
});
|
||||
const forbiddenTerms = ["Gate", "诊断", "验收", "后厨", "BLOCKED", "SOURCE", "DEV-LIVE", "trace", "Secret"];
|
||||
const rootScrollLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2;
|
||||
const noHorizontalOverflow =
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
|
||||
document.body.scrollWidth <= document.body.clientWidth + 2 &&
|
||||
inputBoxes.every((box) => box.scrollWidth <= box.clientWidth + 2);
|
||||
const panelInViewport = Boolean(panelBox) &&
|
||||
panelBox.left >= -1 &&
|
||||
panelBox.right <= window.innerWidth + 1 &&
|
||||
panelBox.top >= -1 &&
|
||||
panelBox.bottom <= window.innerHeight + 1;
|
||||
const copyOk =
|
||||
title === "云工作台登录" &&
|
||||
["请输入账号和密码。", "用户名", "密码", "登录"].every((term) => text.includes(term)) &&
|
||||
forbiddenTerms.every((term) => !text.includes(term));
|
||||
const loginVisible = Boolean(login && login.hidden === false && panelBox && panelBox.width > 0 && panelBox.height > 0);
|
||||
const workbenchHidden = appShell ? appShell.hidden === true : false;
|
||||
return {
|
||||
viewport: viewport ?? { width: window.innerWidth, height: window.innerHeight },
|
||||
title,
|
||||
textSample: text.slice(0, 180),
|
||||
bodyAuthState: document.body.dataset.authState ?? "",
|
||||
loginVisible,
|
||||
workbenchHidden,
|
||||
copyOk,
|
||||
rootScrollLocked,
|
||||
noHorizontalOverflow,
|
||||
panelInViewport,
|
||||
inputBoxes,
|
||||
pass: loginVisible && workbenchHidden && copyOk && rootScrollLocked && noHorizontalOverflow && panelInViewport
|
||||
};
|
||||
}, viewport);
|
||||
}
|
||||
|
||||
async function inspectFailedLoginPage(page, viewport) {
|
||||
return page.evaluate((viewport) => {
|
||||
const error = document.querySelector("#login-error");
|
||||
const password = document.querySelector("#login-password");
|
||||
const login = document.querySelector("#login-shell");
|
||||
const appShell = document.querySelector("[data-app-shell]");
|
||||
const errorBox = error?.getBoundingClientRect();
|
||||
const message = error?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
|
||||
const internalLeak = /stack|trace|secret|token|HWLAB_CLOUD|Exception|Error:/iu.test(message);
|
||||
const rootScrollLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2;
|
||||
const errorContained = Boolean(errorBox) &&
|
||||
errorBox.width > 0 &&
|
||||
error.scrollWidth <= error.clientWidth + 2 &&
|
||||
errorBox.left >= 0 &&
|
||||
errorBox.right <= window.innerWidth + 1;
|
||||
const pass =
|
||||
login?.hidden === false &&
|
||||
appShell?.hidden === true &&
|
||||
message === "账号或密码不正确,请重新输入。" &&
|
||||
document.activeElement === password &&
|
||||
!internalLeak &&
|
||||
rootScrollLocked &&
|
||||
errorContained;
|
||||
return {
|
||||
viewport,
|
||||
pass,
|
||||
message,
|
||||
internalLeak,
|
||||
passwordFocused: document.activeElement === password,
|
||||
loginVisible: login?.hidden === false,
|
||||
workbenchHidden: appShell?.hidden === true,
|
||||
rootScrollLocked,
|
||||
errorContained
|
||||
};
|
||||
}, viewport);
|
||||
}
|
||||
|
||||
async function inspectAuthenticatedWorkbench(page, viewport) {
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
return page.evaluate((viewport) => {
|
||||
const login = document.querySelector("#login-shell");
|
||||
const appShell = document.querySelector("[data-app-shell]");
|
||||
const visible = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return false;
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
window.scrollTo(0, 240);
|
||||
document.documentElement.scrollTop = 240;
|
||||
document.body.scrollTop = 240;
|
||||
const rootAfterScrollAttempt = {
|
||||
windowScrollY: window.scrollY,
|
||||
htmlScrollTop: document.documentElement.scrollTop,
|
||||
bodyScrollTop: document.body.scrollTop
|
||||
};
|
||||
const rootScrollLocked =
|
||||
getComputedStyle(document.documentElement).overflow === "hidden" &&
|
||||
getComputedStyle(document.body).overflow === "hidden" &&
|
||||
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
|
||||
document.body.scrollHeight <= document.body.clientHeight + 2 &&
|
||||
rootAfterScrollAttempt.windowScrollY === 0 &&
|
||||
rootAfterScrollAttempt.htmlScrollTop === 0 &&
|
||||
rootAfterScrollAttempt.bodyScrollTop === 0;
|
||||
const noHorizontalOverflow =
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
|
||||
document.body.scrollWidth <= document.body.clientWidth + 2;
|
||||
const controlsVisible = {
|
||||
commandInput: visible("#command-input"),
|
||||
commandSend: visible("#command-send"),
|
||||
logout: visible("#logout-button"),
|
||||
hardwareArea: visible(".hardware-status") && visible("[data-hardware-tab='overview']"),
|
||||
m3Control: visible("#m3-control-form")
|
||||
};
|
||||
const pass =
|
||||
login?.hidden === true &&
|
||||
appShell?.hidden === false &&
|
||||
document.body.dataset.authState === "authenticated" &&
|
||||
rootScrollLocked &&
|
||||
noHorizontalOverflow &&
|
||||
Object.values(controlsVisible).every(Boolean);
|
||||
return {
|
||||
viewport,
|
||||
pass,
|
||||
loginHidden: login?.hidden === true,
|
||||
workbenchVisible: appShell?.hidden === false,
|
||||
bodyAuthState: document.body.dataset.authState ?? "",
|
||||
rootScrollLocked,
|
||||
rootAfterScrollAttempt,
|
||||
noHorizontalOverflow,
|
||||
controlsVisible
|
||||
};
|
||||
}, viewport);
|
||||
}
|
||||
|
||||
async function installNoCodeAgentPostGuard(page) {
|
||||
const attempts = [];
|
||||
await page.route("**/v1/agent/chat", async (route) => {
|
||||
@@ -3363,6 +3814,7 @@ async function inspectLiveCodeAgentE2e(url) {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
const controls = await inspectJourneyControls(page);
|
||||
if (!Object.values(controls).every(Boolean)) {
|
||||
@@ -4003,6 +4455,7 @@ async function runLocalAgentFixtureSmoke({
|
||||
});
|
||||
|
||||
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#live-status")?.textContent?.trim() === "只读模式",
|
||||
@@ -4217,15 +4670,19 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
isMobile: true
|
||||
});
|
||||
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
const closed = await inspectMobileWorkbench(page);
|
||||
const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" });
|
||||
await page.click('[data-route="help"]');
|
||||
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
|
||||
await page.waitForTimeout(150);
|
||||
const help = await inspectHelpMarkdownRoute(page);
|
||||
await page.goto(new URL("/#/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
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 loginWithDefaultCredentials(page);
|
||||
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
|
||||
const directHelp = await inspectHelpMarkdownRoute(page);
|
||||
await page.click('[data-route="workspace"]');
|
||||
@@ -4259,7 +4716,7 @@ export async function runDevCloudWorkbenchMobileSmoke() {
|
||||
},
|
||||
{
|
||||
id: "mobile-help-markdown-route",
|
||||
status: help.nonDefaultMarkdownHelp && help.routeIsHashHelp && help.termsPresent ? "pass" : "blocked",
|
||||
status: help.nonDefaultMarkdownHelp && (help.routeIsHashHelp || help.routeIsSlashHashHelp) && 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
|
||||
},
|
||||
@@ -4492,6 +4949,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) {
|
||||
});
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
const promptResults = [];
|
||||
for (const prompt of codeAgentQuickPromptFixtures) {
|
||||
@@ -4652,8 +5110,12 @@ async function inspectQuickPromptGeometry(page, viewport) {
|
||||
|
||||
async function startStaticWebServer(options = {}) {
|
||||
const rootDir = path.resolve(options.rootDir ?? webRoot);
|
||||
const authFixtureSessions = new Set();
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) {
|
||||
return;
|
||||
}
|
||||
if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) {
|
||||
return;
|
||||
}
|
||||
@@ -4693,6 +5155,64 @@ async function startStaticWebServer(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAuthFixtureApi({ request, response, url, authFixtureSessions }) {
|
||||
if (url.pathname === "/auth/session" && request.method === "GET") {
|
||||
const token = authFixtureCookie(request);
|
||||
jsonResponse(response, 200, token && authFixtureSessions.has(token)
|
||||
? {
|
||||
authenticated: true,
|
||||
user: { username: defaultAuthCredentials.username },
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
: { authenticated: false });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/login" && request.method === "POST") {
|
||||
const body = await readJsonBody(request);
|
||||
if (body?.username !== defaultAuthCredentials.username || body?.password !== defaultAuthCredentials.password) {
|
||||
jsonResponse(response, 401, { authenticated: false, error: "invalid_credentials" });
|
||||
return true;
|
||||
}
|
||||
const token = `auth_fixture_${authFixtureSessions.size + 1}`;
|
||||
authFixtureSessions.add(token);
|
||||
const payload = JSON.stringify({
|
||||
authenticated: true,
|
||||
user: { username: defaultAuthCredentials.username },
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
});
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"set-cookie": `hwlab_cloud_web_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600`
|
||||
});
|
||||
response.end(payload);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/logout" && request.method === "POST") {
|
||||
const token = authFixtureCookie(request);
|
||||
if (token) authFixtureSessions.delete(token);
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"set-cookie": "hwlab_cloud_web_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
});
|
||||
response.end(JSON.stringify({ authenticated: false }));
|
||||
return true;
|
||||
}
|
||||
if (url.pathname.startsWith("/auth/")) {
|
||||
jsonResponse(response, 404, { authenticated: false, error: "not_found" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function authFixtureCookie(request) {
|
||||
const cookie = request.headers.cookie ?? "";
|
||||
for (const part of cookie.split(";")) {
|
||||
const [name, ...value] = part.trim().split("=");
|
||||
if (name === "hwlab_cloud_web_session") return decodeURIComponent(value.join("=") || "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleQuickPromptsFixtureApi({ request, response, url }) {
|
||||
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
|
||||
jsonResponse(response, 200, {
|
||||
@@ -5177,6 +5697,7 @@ async function inspectM3StatusFixtureViewport(browser, url, viewport) {
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
|
||||
for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) {
|
||||
await page.locator(`[data-hardware-tab="${tab}"]`).click();
|
||||
@@ -5264,6 +5785,7 @@ async function inspectM3StatusFixtureRefresh(browser, url) {
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
|
||||
await page.locator('[data-hardware-tab="box1"]').click();
|
||||
const initialBox1 = await hardwarePanelText(page);
|
||||
@@ -5537,6 +6059,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
|
||||
const artifacts = { screenshots: [] };
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForTimeout(150);
|
||||
const defaultLayout = await inspectLayoutState(page, {
|
||||
@@ -5563,6 +6086,7 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
|
||||
});
|
||||
try {
|
||||
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await loginWithDefaultCredentials(page);
|
||||
await page.locator('[data-view="gate"]').waitFor({ state: "visible", timeout: 12000 });
|
||||
await page.waitForTimeout(150);
|
||||
const currentRoute = await inspectGateLayoutState(page, { viewport, mode: "gate-current" });
|
||||
@@ -6904,8 +7428,11 @@ async function inspectHelpMarkdownRoute(page) {
|
||||
helpContent.scrollTop = 0;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const before = helpContent.scrollTop;
|
||||
helpContent.scrollTop = helpContent.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
helpContent.scrollTop = helpContent.scrollHeight;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
if (helpContent.scrollTop > before) break;
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: helpContent.scrollTop,
|
||||
|
||||
Reference in New Issue
Block a user