326 lines
15 KiB
TypeScript
326 lines
15 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
|
// Responsibility: Runner low-level IO, URL, YAML-derived policy parsing, redaction, and timeout utility source fragment.
|
|
|
|
export function nodeWebObserveRunnerUtilitySource(): string {
|
|
return String.raw`async function appendJsonl(file, value) {
|
|
await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 });
|
|
}
|
|
|
|
async function fileMeta(file) {
|
|
const [buffer, stats] = await Promise.all([readFile(file), stat(file)]);
|
|
return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") };
|
|
}
|
|
|
|
function currentPageUrl() {
|
|
return pageUrl(page);
|
|
}
|
|
|
|
function pageUrl(targetPage) {
|
|
try { return targetPage && !targetPage.isClosed() ? targetPage.url() : null; } catch { return null; }
|
|
}
|
|
|
|
function routeSessionIdFromUrl(value) {
|
|
try {
|
|
const pathname = new URL(String(value || ""), baseUrl).pathname;
|
|
const match = pathname.match(/\/workbench\/sessions\/([^/?#]+)/u);
|
|
return match ? decodeURIComponent(match[1] || "") : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeFrameUrl(frame) {
|
|
try { return frame ? safeUrl(frame.url()) : null; } catch { return null; }
|
|
}
|
|
|
|
function safeUrl(value) {
|
|
try {
|
|
const url = new URL(String(value), baseUrl);
|
|
for (const key of Array.from(url.searchParams.keys())) {
|
|
if (/token|key|secret|password|auth|cookie/iu.test(key)) url.searchParams.set(key, "[redacted]");
|
|
}
|
|
return url.toString();
|
|
} catch {
|
|
return truncate(String(value || ""), 300);
|
|
}
|
|
}
|
|
|
|
function safeUrlPath(value) {
|
|
try {
|
|
return new URL(String(value || ""), baseUrl).pathname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const raw = value || "http://127.0.0.1:3000";
|
|
const url = new URL(raw);
|
|
return url.origin;
|
|
}
|
|
|
|
function parseViewport(value) {
|
|
const match = String(value).match(/^(\d{3,5})x(\d{3,5})$/u);
|
|
return match ? { width: Number(match[1]), height: Number(match[2]) } : { width: 1440, height: 900 };
|
|
}
|
|
|
|
function positiveInteger(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
|
|
}
|
|
|
|
function boundedInteger(value, fallback, min, max) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
const integer = Math.floor(parsed);
|
|
if (integer < min || integer > max) return fallback;
|
|
return integer;
|
|
}
|
|
|
|
function positiveNumber(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function numberOrNull(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function requiredPositiveThreshold(raw, key) {
|
|
const parsed = Number(raw?.[key]);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseAlertThresholds(value) {
|
|
if (!value) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
|
|
}
|
|
const raw = (() => {
|
|
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
|
})();
|
|
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
|
|
if (sessionRailFallbackRatio > 1) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
|
|
}
|
|
return {
|
|
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
|
|
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
|
|
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
|
|
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
|
|
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
|
|
turnElapsedSevereTimeoutSeconds: requiredPositiveThreshold(raw, "turnElapsedSevereTimeoutSeconds"),
|
|
domEvaluateTimeoutRedCount: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedCount"),
|
|
domEvaluateTimeoutRedWindowMs: requiredPositiveThreshold(raw, "domEvaluateTimeoutRedWindowMs"),
|
|
screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"),
|
|
pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"),
|
|
longTaskRedMs: requiredPositiveThreshold(raw, "longTaskRedMs"),
|
|
longAnimationFrameRedMs: requiredPositiveThreshold(raw, "longAnimationFrameRedMs"),
|
|
eventLoopGapRedMs: requiredPositiveThreshold(raw, "eventLoopGapRedMs"),
|
|
browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"),
|
|
requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"),
|
|
requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"),
|
|
requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"),
|
|
requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"),
|
|
browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"),
|
|
browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"),
|
|
browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"),
|
|
browserRssGrowthWindowMs: requiredPositiveThreshold(raw, "browserRssGrowthWindowMs"),
|
|
playwrightResponsivenessRedMs: requiredPositiveThreshold(raw, "playwrightResponsivenessRedMs"),
|
|
playwrightResponsivenessTimeoutRedCount: requiredPositiveThreshold(raw, "playwrightResponsivenessTimeoutRedCount"),
|
|
cdpMetricsTimeoutRedCount: requiredPositiveThreshold(raw, "cdpMetricsTimeoutRedCount"),
|
|
uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"),
|
|
scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"),
|
|
scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"),
|
|
scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"),
|
|
sessionRailFallbackRatio,
|
|
crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")),
|
|
source: "yaml-env",
|
|
};
|
|
}
|
|
|
|
function parseBrowserFreezePolicy(value) {
|
|
if (!value) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.browserFreezePolicy for the selected node/lane");
|
|
}
|
|
const raw = (() => {
|
|
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
|
})();
|
|
const memory = requiredPolicyRecord(raw, "memory", "webProbe.browserFreezePolicy");
|
|
const responsiveness = requiredPolicyRecord(raw, "responsiveness", "webProbe.browserFreezePolicy");
|
|
const cdp = requiredPolicyRecord(raw, "cdp", "webProbe.browserFreezePolicy");
|
|
const kill = requiredPolicyRecord(raw, "kill", "webProbe.browserFreezePolicy");
|
|
return {
|
|
enabled: requiredPolicyBoolean(raw, "enabled", "webProbe.browserFreezePolicy"),
|
|
blockerWindowMs: requiredPolicyPositiveNumber(raw, "blockerWindowMs", "webProbe.browserFreezePolicy"),
|
|
memory: {
|
|
totalRssBlockerMb: requiredPolicyPositiveNumber(memory, "totalRssBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
|
processRssBlockerMb: requiredPolicyPositiveNumber(memory, "processRssBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
|
growthBlockerMb: requiredPolicyPositiveNumber(memory, "growthBlockerMb", "webProbe.browserFreezePolicy.memory"),
|
|
},
|
|
responsiveness: {
|
|
latencyBlockerMs: requiredPolicyPositiveNumber(responsiveness, "latencyBlockerMs", "webProbe.browserFreezePolicy.responsiveness"),
|
|
eventBlockerCount: requiredPolicyPositiveNumber(responsiveness, "eventBlockerCount", "webProbe.browserFreezePolicy.responsiveness"),
|
|
},
|
|
cdp: {
|
|
metricsTimeoutBlockerCount: requiredPolicyPositiveNumber(cdp, "metricsTimeoutBlockerCount", "webProbe.browserFreezePolicy.cdp"),
|
|
},
|
|
kill: {
|
|
enabled: requiredPolicyBoolean(kill, "enabled", "webProbe.browserFreezePolicy.kill"),
|
|
gracefulSignal: requiredPolicySignal(kill, "gracefulSignal", "webProbe.browserFreezePolicy.kill", "SIGTERM"),
|
|
forceSignal: requiredPolicySignal(kill, "forceSignal", "webProbe.browserFreezePolicy.kill", "SIGKILL"),
|
|
graceMs: requiredPolicyIntegerInRange(kill, "graceMs", "webProbe.browserFreezePolicy.kill", 1, 120000),
|
|
pollIntervalMs: requiredPolicyIntegerInRange(kill, "pollIntervalMs", "webProbe.browserFreezePolicy.kill", 1, 10000),
|
|
exitCode: requiredPolicyIntegerInRange(kill, "exitCode", "webProbe.browserFreezePolicy.kill", 1, 125),
|
|
},
|
|
source: "yaml-env",
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function requiredPolicyRecord(raw, key, pathLabel) {
|
|
const value = raw?.[key];
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires object " + pathLabel + "." + key);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredPolicyBoolean(raw, key, pathLabel) {
|
|
const value = raw?.[key];
|
|
if (typeof value !== "boolean") {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires boolean " + pathLabel + "." + key);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredPolicyPositiveNumber(raw, key, pathLabel) {
|
|
const value = Number(raw?.[key]);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires positive number " + pathLabel + "." + key);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredPolicyIntegerInRange(raw, key, pathLabel, min, max) {
|
|
const value = Number(raw?.[key]);
|
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires integer " + pathLabel + "." + key + " between " + min + " and " + max);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredPolicySignal(raw, key, pathLabel, expected) {
|
|
const value = String(raw?.[key] || "");
|
|
if (value !== expected) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON requires " + pathLabel + "." + key + "=" + expected);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseProjectManagementConfig(value) {
|
|
if (!value || value === "null") {
|
|
return {
|
|
enabled: false,
|
|
targetPaths: [],
|
|
readinessSelectors: [],
|
|
naturalApiPathPrefixes: [],
|
|
commandAllowlist: [],
|
|
launchRoute: "",
|
|
slowApiBudgetMs: 0,
|
|
source: "yaml-env",
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
const raw = (() => {
|
|
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
|
})();
|
|
const stringList = (key) => {
|
|
const list = raw?.[key];
|
|
if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
|
return list;
|
|
};
|
|
const positive = (key) => {
|
|
const numeric = Number(raw?.[key]);
|
|
if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
|
return numeric;
|
|
};
|
|
if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled");
|
|
if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true };
|
|
const targetPaths = stringList("targetPaths");
|
|
const readinessSelectors = stringList("readinessSelectors");
|
|
const naturalApiPathPrefixes = stringList("naturalApiPathPrefixes");
|
|
const commandAllowlist = stringList("commandAllowlist");
|
|
const launchRoute = String(raw.launchRoute || "");
|
|
if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path");
|
|
return {
|
|
enabled: true,
|
|
targetPaths,
|
|
readinessSelectors,
|
|
naturalApiPathPrefixes,
|
|
commandAllowlist,
|
|
launchRoute,
|
|
slowApiBudgetMs: positive("slowApiBudgetMs"),
|
|
source: "yaml-env",
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sha256Text(value) {
|
|
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
|
|
}
|
|
|
|
function safeId(value) {
|
|
return String(value || "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 120) || "item";
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
return String(value).replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
|
|
}
|
|
|
|
function truncate(value, limit) {
|
|
const text = String(value || "");
|
|
return text.length > limit ? text.slice(0, limit) + "..." : text;
|
|
}
|
|
|
|
function sanitize(value) {
|
|
if (value === null || value === undefined) return value;
|
|
if (typeof value === "string") return value === password ? "[redacted]" : value.replaceAll(password, "[redacted]");
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (Array.isArray(value)) return value.map(sanitize);
|
|
if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => /password|cookie|authorization|token|secret/iu.test(key) ? [key, "[redacted]"] : [key, sanitize(item)]));
|
|
return String(value);
|
|
}
|
|
|
|
function errorSummary(error) {
|
|
const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null };
|
|
if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth);
|
|
if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5));
|
|
if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness);
|
|
if (error && error.details) summary.details = sanitize(error.details);
|
|
if (error && error.target) summary.target = sanitize(error.target);
|
|
return summary;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
}
|
|
|
|
function withHardTimeout(promise, timeoutMs, message) {
|
|
const guarded = Promise.resolve(promise);
|
|
let timer = null;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1));
|
|
if (timer && typeof timer.unref === "function") timer.unref();
|
|
});
|
|
return Promise.race([guarded, timeout]).finally(() => {
|
|
if (timer) clearTimeout(timer);
|
|
guarded.catch(() => {});
|
|
});
|
|
}
|
|
`;
|
|
}
|