diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 0e6e2d96..85d15dd8 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -82,6 +82,7 @@ interface NodeWebProbeObserveOptions { tailLines: number; maxFiles: number; collectFile: string | null; + collectFinding: string | null; analyzeArchivePrefix: string | null; full: boolean; stateDir: string | null; @@ -7266,6 +7267,7 @@ function parseNodeWebProbeObserveOptions( "--tail-lines", "--max-files", "--file", + "--finding", "--archive-prefix", "--state-dir", "--job-id", @@ -7284,6 +7286,8 @@ function parseNodeWebProbeObserveOptions( if (jobId !== null && !isSafeWebObserveJobId(jobId)) throw new Error(`unsafe web-probe observe --job-id: ${jobId}`); const collectFile = optionValue(args, "--file") ?? null; if (collectFile !== null && !isSafeWebObserveCollectFile(collectFile)) throw new Error(`unsafe web-probe observe --file: ${collectFile}`); + const collectFinding = optionValue(args, "--finding") ?? null; + if (collectFinding !== null && !isSafeWebObserveFindingId(collectFinding)) throw new Error(`unsafe web-probe observe --finding: ${collectFinding}`); const analyzeArchivePrefix = optionValue(args, "--archive-prefix") ?? null; if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`); if (observeActionRaw !== "start" && stateDir === null && jobId === null) { @@ -7317,6 +7321,7 @@ function parseNodeWebProbeObserveOptions( tailLines: positiveIntegerOption(args, "--tail-lines", 5, 200), maxFiles: positiveIntegerOption(args, "--max-files", 80, 5000), collectFile, + collectFinding, analyzeArchivePrefix, full: args.includes("--full"), stateDir, @@ -8188,7 +8193,7 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec const script = [ "set -eu", nodeWebObserveResolveStateDirShell(options), - nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile), + nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding), ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const collect = parseJsonObject(result.stdout); @@ -8227,6 +8232,8 @@ function renderWebObserveCollectTable(value: Record): string { const jsonContentPreview = file.jsonContent === undefined || file.jsonContent === null ? "" : JSON.stringify(file.jsonContent, null, 2); const jsonRunnerErrors = Array.isArray(jsonSummary.runnerErrors) ? jsonSummary.runnerErrors.map(record).filter((item): item is Record => item !== null).slice(-8) : []; const jsonFindings = Array.isArray(jsonSummary.findings) ? jsonSummary.findings.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; + const jsonFindingDetail = record(jsonSummary.findingDetail); + const jsonFindingSamples = Array.isArray(jsonFindingDetail?.samples) ? jsonFindingDetail.samples.map(record).filter((item): item is Record => item !== null).slice(0, 12) : []; const lines = [ `hwlab nodes web-probe observe collect (${webObserveText(value.status)})`, "", @@ -8307,6 +8314,31 @@ function renderWebObserveCollectTable(value: Record): string { "", ); } + if (jsonFindingDetail !== null) { + lines.push( + "Finding detail:", + webObserveTable(["ID", "SEVERITY", "COUNT", "SAMPLE_SOURCE", "SUMMARY"], [[ + webObserveShort(webObserveText(jsonFindingDetail.id), 48), + webObserveText(jsonFindingDetail.severity), + webObserveText(jsonFindingDetail.count), + webObserveText(jsonFindingDetail.sampleSource), + webObserveShort(webObserveText(jsonFindingDetail.summary), 96), + ]]), + "", + "Finding samples:", + webObserveTable(["SEQ", "TS", "KIND", "CONTROL", "OBSERVER", "SESSION", "PATH", "DETAIL"], jsonFindingSamples.length > 0 ? jsonFindingSamples.map((item) => [ + webObserveText(item.seq ?? item.sampleSeq ?? item.fromSeq), + webObserveShort(webObserveText(item.ts ?? item.sampleTs ?? item.fromTs), 24), + webObserveShort(webObserveText(item.diffKind ?? item.kind ?? item.type ?? item.metric ?? item.anomaly), 28), + webObserveShort(webObserveText(item.control ?? item.controlValue ?? item.beforeValue), 28), + webObserveShort(webObserveText(item.observer ?? item.observerValue ?? item.afterValue), 28), + webObserveShort(webObserveText(item.sessionId ?? item.routeSessionId ?? item.activeSessionId), 36), + webObserveShort(webObserveText(item.path ?? item.urlPath ?? item.url), 48), + webObserveShort(webObserveText(item.detail ?? item.summary ?? item.message ?? item.preview), 96), + ]) : [["-", "-", "-", "-", "-", "-", "-", "-"]]), + "", + ); + } if (jsonlTail.length > 0) { lines.push( "JSONL tail:", @@ -9894,10 +9926,10 @@ console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir: `)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)}`; } -function nodeWebObserveCollectNodeScript(maxFiles: number, collectFile: string | null): string { +function nodeWebObserveCollectNodeScript(maxFiles: number, collectFile: string | null, collectFinding: string | null): string { return `node -e ${shellQuote(` const fs=require('fs'),path=require('path'),crypto=require('crypto'); -const dir=process.argv[1]; const selected=process.argv[2]||''; const maxFiles=${maxFiles}; const maxReadBytes=64*1024; const maxJsonlTailBytes=1024*1024; const maxTextPreviewBytes=4096; +const dir=process.argv[1]; const selected=process.argv[2]||''; const findingFilter=process.argv[3]||''; const maxFiles=${maxFiles}; const maxReadBytes=64*1024; const maxJsonlTailBytes=1024*1024; const maxTextPreviewBytes=4096; const shaFile=(file)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); const safeRel=(value)=>Boolean(value)&&!path.isAbsolute(value)&&!value.includes('..')&&!value.includes('\\\\')&&value.split('/').every((part)=>part&&part!=='.'&&part!=='..'); const short=(value,max=180)=>value===undefined||value===null?undefined:String(value).replace(/\\s+/g,' ').slice(0,max); @@ -9942,6 +9974,33 @@ const slimFinding=(item)=>{ groupRows:groups }; }; +const firstArray=(...values)=>values.find((value)=>Array.isArray(value))||[]; +const compactMetric=(value)=>value===undefined||value===null?undefined:(typeof value==='object'?short(JSON.stringify(slimObject(value,8)),160):short(value,160)); +const compactProjectionPair=(messageCount,traceCount)=>messageCount===undefined&&traceCount===undefined?undefined:'msg='+String(messageCount??'-')+' trace='+String(traceCount??'-'); +const slimFindingSample=(value)=>{ + if(!value||typeof value!=='object') return null; + const controlCount=compactProjectionPair(value.controlMessageCount,value.controlTraceRowCount)??value.controlValue??value.beforeMessageCount??value.beforeValue; + const observerCount=compactProjectionPair(value.observerMessageCount,value.observerTraceRowCount)??value.observerValue??value.afterMessageCount??value.afterValue; + return { + seq:value.seq??value.sampleSeq??value.fromSeq??value.controlSeq??null, + ts:short(value.ts??value.sampleTs??value.fromTs??value.controlTs,32), + diffKind:short(value.diffKind??value.kind??value.type??value.metric??value.anomaly,48), + control:compactMetric(controlCount), + observer:compactMetric(observerCount), + sessionId:short(value.sessionId??value.routeSessionId??value.activeSessionId??value.controlSessionId??value.observerSessionId,64), + path:short(value.path??value.urlPath??value.url??value.controlPath??value.observerPath,120), + detail:short(value.detail??(value.messageTextDigestDiff?'messageDigest '+String(value.controlMessageDigest??'-')+' != '+String(value.observerMessageDigest??'-'):undefined)??value.summary??value.message??value.preview??value.expectedPattern,180), + valuesRedacted:true + }; +}; +const slimFindingDetail=(parsed,id)=>{ + if(!id||!parsed||typeof parsed!=='object'||!Array.isArray(parsed.findings)) return null; + const finding=parsed.findings.find((item)=>item&&typeof item==='object'&&String(item.id??item.kind??item.code??'')===id); + if(!finding) return {id,found:false,samples:[],valuesRedacted:true}; + const sampleSource=Array.isArray(finding.samples)?'samples':Array.isArray(finding.groups)?'groups':Array.isArray(finding.examples)?'examples':Array.isArray(finding.rows)?'rows':'none'; + const samples=firstArray(finding.samples,finding.groups,finding.examples,finding.rows).slice(0,12).map(slimFindingSample).filter(Boolean); + return {id:short(finding.id??finding.kind??finding.code,80),found:true,severity:short(finding.severity??finding.level,24),count:finding.count??finding.sampleCount??samples.length,summary:short(finding.summary??finding.message,180),sampleSource,samples,valuesRedacted:true}; +}; const slimRunnerError=(item)=>item&&typeof item==='object'?{ts:short(item.ts||item.at,32),type:short(item.type,48),retry:item.retry||item.lastRetryLabel||null,retryExhausted:item.retryExhausted===true,attemptCount:item.attemptCount??(Array.isArray(item.attempts)?item.attempts.length:null),lastFailureKind:short(item.lastFailureKind||item.failureKind,80),lastReadinessReason:short(item.lastReadinessReason,80),message:short(item.message||item.lastError,240)}:null; const slimJsonl=(value)=>{ if(!value||typeof value!=='object'||Array.isArray(value)) return {value:short(value)}; @@ -9982,9 +10041,9 @@ if(selected){ const lines=text.split(/\\r?\\n/).filter(Boolean); if(isJsonl&&truncated&&lines.length>0) lines.shift(); let jsonSummary=null; let jsonContent=null; - if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); jsonContent=raw.length<=4096?slimJsonValue(parsed):null; jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,24):[],counts:slimObject(parsed&&parsed.counts,16),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics,16),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts,16),pagePerformance:slimObject(parsed&&parsed.pagePerformance,16),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-4).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,6).map(slimFinding).filter(Boolean):[]}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}} + if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); jsonContent=raw.length<=4096?slimJsonValue(parsed):null; jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,24):[],counts:slimObject(parsed&&parsed.counts,16),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics,16),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts,16),pagePerformance:slimObject(parsed&&parsed.pagePerformance,16),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-4).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,6).map(slimFinding).filter(Boolean):[],findingDetail:slimFindingDetail(parsed,findingFilter)}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}} const jsonlTail=isJsonl?lines.slice(-8).map((line,index)=>{try{return slimJsonl(JSON.parse(line))}catch(error){return {parseError:true,index,lineTail:line.slice(-240),error:String(error&&error.message||error).slice(0,160)}}}):null; - console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||isJson||jsonSummary?undefined:text.slice(0,maxTextPreviewBytes),contentTruncated:!isJsonl&&!isJson&&!jsonSummary&&text.length>maxTextPreviewBytes,jsonlTail,jsonSummary,jsonContent,lineCount:lines.length},valuesRedacted:true},null,2)); + console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||isJson||jsonSummary?undefined:text.slice(0,maxTextPreviewBytes),contentTruncated:!isJsonl&&!isJson&&!jsonSummary&&text.length>maxTextPreviewBytes,jsonlTail,jsonSummary,jsonContent,lineCount:lines.length},valuesRedacted:true})); process.exit(0); } const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}}; @@ -9992,8 +10051,8 @@ walk(dir); const selectedFiles=out.slice(0,maxFiles); const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}}); const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0); -console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true},null,2)); -`)} "$state_dir" ${shellQuote(collectFile ?? "")}`; +console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true})); +`)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")}`; } function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number): string { @@ -10048,6 +10107,13 @@ function isSafeWebObserveCollectFile(value: string): boolean { && /^[A-Za-z0-9_.\/-]+$/u.test(value); } +function isSafeWebObserveFindingId(value: string): boolean { + return value.length > 0 + && value.length <= 120 + && !value.includes("\0") + && /^[A-Za-z0-9_.:-]+$/u.test(value); +} + function isSafeWebObserveArchivePrefix(value: string): boolean { return value.length > 0 && value.length <= 120 diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index e2f5fdf2..99289192 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -1541,6 +1541,42 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag const title = element.getAttribute("title") || ""; return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title); }; + const stableMessageText = (element) => { + const bodySelectors = [ + ".message-markdown.message-text", + ".message-text", + "[data-message-body]", + "[data-testid='message-body']", + "[data-testid*='message-text' i]", + "[data-testid*='final-response' i]" + ]; + const parts = []; + for (const selector of bodySelectors) { + for (const candidate of Array.from(element.querySelectorAll(selector))) { + if (!visible(candidate)) continue; + const text = trim(candidate.textContent || "", 1200); + if (text && !parts.includes(text)) parts.push(text); + } + } + if (parts.length > 0) return parts.join(" "); + const clone = element.cloneNode(true); + for (const selector of [ + ".message-duration-meta", + ".message-activity-meta", + ".api-error-diagnostic", + "[class*='diagnostic' i]", + "[class*='trace' i]", + "[data-trace-id]", + "[data-testid*='trace' i]", + "[data-testid*='event' i]", + "[role='status']", + "[role='alert']", + "button" + ]) { + for (const child of Array.from(clone.querySelectorAll(selector))) child.remove(); + } + return trim(clone.textContent || "", 1200); + }; const summarize = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { const rect = element.getBoundingClientRect(); return { @@ -1557,6 +1593,22 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }); + const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { + const rect = element.getBoundingClientRect(); + return { + index, + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null, + sessionId: element.getAttribute("data-session-id") || null, + messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null, + traceId: element.getAttribute("data-trace-id") || null, + turnId: element.getAttribute("data-turn-id") || null, + text: stableMessageText(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }; + }); const resourceTimingSample = (entry) => ({ name: entry.name.split(/[?#]/u)[0].slice(0, 240), initiatorType: entry.initiatorType, @@ -1592,7 +1644,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]'; const traceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]'; - const messages = summarize(messageSelector, 20); + const messages = summarizeMessages(messageSelector, 20); const traceRows = summarize(traceSelector, 30); const loadings = collectLoadingNodes(); const sessionRail = collectSessionRailTitles(); @@ -4714,8 +4766,11 @@ function detectCrossPageProjectionDiffs(samples) { const controlZero = detectTerminalZeroElapsed([control]).length > 0; const observerZero = detectTerminalZeroElapsed([observer]).length > 0; const sameSession = (control.routeSessionId || control.activeSessionId || null) && (control.routeSessionId || control.activeSessionId || null) === (observer.routeSessionId || observer.activeSessionId || null); - const projectionDivergent = sameSession && (missingInObserver.length > 0 || Math.abs(controlMessages - observerMessages) > 0 || controlZero !== observerZero || digestMessageTexts(control) !== digestMessageTexts(observer)); - const traceVisibilityDivergent = sameSession && !projectionDivergent && Math.abs(controlTraceRows - observerTraceRows) > 0; + const controlMessageDigest = digestMessageTexts(control); + const observerMessageDigest = digestMessageTexts(observer); + const messageTextDigestDiff = controlMessageDigest !== observerMessageDigest; + const projectionDivergent = sameSession && (Math.abs(controlMessages - observerMessages) > 0 || controlZero !== observerZero || messageTextDigestDiff); + const traceVisibilityDivergent = sameSession && !projectionDivergent && (missingInObserver.length > 0 || Math.abs(controlTraceRows - observerTraceRows) > 0); if (!projectionDivergent && !traceVisibilityDivergent) continue; rows.push({ sampleGroupSeq, @@ -4727,7 +4782,10 @@ function detectCrossPageProjectionDiffs(samples) { observerMessageCount: observerMessages, controlTraceRowCount: controlTraceRows, observerTraceRowCount: observerTraceRows, - terminalZeroElapsedDiff: controlZero !== observerZero + terminalZeroElapsedDiff: controlZero !== observerZero, + messageTextDigestDiff, + controlMessageDigest, + observerMessageDigest }); } return rows; @@ -4779,8 +4837,11 @@ function detectAdjacentCrossPageProjectionDiffs(samples) { const controlZero = detectTerminalZeroElapsed([control]).length > 0; const observerZero = detectTerminalZeroElapsed([observer]).length > 0; const missingInObserver = [...visibleTraceIds(control)].filter((item) => !visibleTraceIds(observer).has(item)); - const projectionDivergent = missingInObserver.length > 0 || controlMessages !== observerMessages || controlZero !== observerZero || digestMessageTexts(control) !== digestMessageTexts(observer); - const traceVisibilityDivergent = !projectionDivergent && controlTraceRows !== observerTraceRows; + const controlMessageDigest = digestMessageTexts(control); + const observerMessageDigest = digestMessageTexts(observer); + const messageTextDigestDiff = controlMessageDigest !== observerMessageDigest; + const projectionDivergent = controlMessages !== observerMessages || controlZero !== observerZero || messageTextDigestDiff; + const traceVisibilityDivergent = !projectionDivergent && (missingInObserver.length > 0 || controlTraceRows !== observerTraceRows); if (!projectionDivergent && !traceVisibilityDivergent) continue; rows.push({ sampleGroupSeq: control.sampleGroupSeq ?? observer.sampleGroupSeq ?? null, @@ -4793,7 +4854,10 @@ function detectAdjacentCrossPageProjectionDiffs(samples) { observerMessageCount: observerMessages, controlTraceRowCount: controlTraceRows, observerTraceRowCount: observerTraceRows, - terminalZeroElapsedDiff: controlZero !== observerZero + terminalZeroElapsedDiff: controlZero !== observerZero, + messageTextDigestDiff, + controlMessageDigest, + observerMessageDigest }); } return rows;