fix: clarify workbench trace event diagnostics
This commit is contained in:
@@ -623,6 +623,103 @@ test("workbench read model does not expose trace-only memory without visible ses
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench trace events reports metadata gap when turn projection is visible", async () => {
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
const traceId = "trc_workbench_trace_metadata_gap";
|
||||
const session = {
|
||||
id: "ses_workbench_trace_metadata_gap",
|
||||
projectId: "prj_hwpod_workbench",
|
||||
agentId: "hwlab-code-agent",
|
||||
status: "completed",
|
||||
ownerUserId: ACTOR.id,
|
||||
conversationId: "cnv_workbench_trace_metadata_gap",
|
||||
threadId: "thread-workbench-trace-metadata-gap",
|
||||
lastTraceId: traceId,
|
||||
updatedAt: "2026-06-20T13:00:00.000Z",
|
||||
session: { sessionStatus: "completed", lastTraceId: traceId, messages: [{ role: "user", text: "metadata gap", traceId }, { role: "agent", text: "OK", traceId }] }
|
||||
};
|
||||
const accessController = {
|
||||
store: {},
|
||||
async ensureBootstrap() {},
|
||||
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
|
||||
};
|
||||
const runtimeStore = createDurableFactsRuntimeStore({
|
||||
sessions: [{ session, events: [], status: "completed", finalText: "OK", runId: "run_workbench_trace_metadata_gap", commandId: "cmd_workbench_trace_metadata_gap", lastProjectedSeq: 2, omitSessions: true }]
|
||||
});
|
||||
const server = createCloudApiServer({ accessController, traceStore, runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
|
||||
assert.equal(turn.status, 200);
|
||||
assert.equal(turn.body.turn.status, "completed");
|
||||
|
||||
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
|
||||
assert.equal(trace.status, 404);
|
||||
assert.equal(trace.body.error.code, "workbench_trace_metadata_missing");
|
||||
assert.equal(trace.body.error.message, "Workbench trace metadata is missing from the trace events read model.");
|
||||
assert.equal(trace.body.error.projectionStatus, "blocked");
|
||||
assert.equal(trace.body.error.projectionHealth, "degraded");
|
||||
assert.equal(trace.body.error.lastProjectedSeq, 2);
|
||||
assert.equal(trace.body.error.sourceRunId, "run_workbench_trace_metadata_gap");
|
||||
assert.equal(trace.body.error.sourceCommandId, "cmd_workbench_trace_metadata_gap");
|
||||
assert.equal(trace.body.error.blocker.code, "workbench_trace_metadata_missing");
|
||||
assert.notEqual(trace.body.error.message, "Workbench trace is not visible to the current actor.");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench trace events reports missing event page when session projection is visible", async () => {
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
const traceId = "trc_workbench_trace_events_gap";
|
||||
const session = {
|
||||
id: "ses_workbench_trace_events_gap",
|
||||
projectId: "prj_hwpod_workbench",
|
||||
agentId: "hwlab-code-agent",
|
||||
status: "completed",
|
||||
ownerUserId: ACTOR.id,
|
||||
conversationId: "cnv_workbench_trace_events_gap",
|
||||
threadId: "thread-workbench-trace-events-gap",
|
||||
lastTraceId: traceId,
|
||||
updatedAt: "2026-06-20T13:05:00.000Z",
|
||||
session: { sessionStatus: "completed", lastTraceId: traceId, messages: [{ role: "user", text: "events gap", traceId }, { role: "agent", text: "OK", traceId }] }
|
||||
};
|
||||
const accessController = {
|
||||
store: {},
|
||||
async ensureBootstrap() {},
|
||||
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
|
||||
};
|
||||
const runtimeStore = createDurableFactsRuntimeStore({
|
||||
sessions: [{ session, events: [], status: "completed", finalText: "OK", runId: "run_workbench_trace_events_gap", commandId: "cmd_workbench_trace_events_gap", lastProjectedSeq: 2 }]
|
||||
});
|
||||
const server = createCloudApiServer({ accessController, traceStore, runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
|
||||
assert.equal(turn.status, 200);
|
||||
assert.equal(turn.body.turn.status, "completed");
|
||||
|
||||
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
|
||||
assert.equal(trace.status, 404);
|
||||
assert.equal(trace.body.error.code, "workbench_trace_events_missing");
|
||||
assert.equal(trace.body.error.message, "Workbench trace events are missing from the durable read model.");
|
||||
assert.equal(trace.body.error.sessionId, session.id);
|
||||
assert.equal(trace.body.error.projectionStatus, "blocked");
|
||||
assert.equal(trace.body.error.projectionHealth, "degraded");
|
||||
assert.equal(trace.body.error.lastProjectedSeq, 2);
|
||||
assert.equal(trace.body.error.sourceRunId, "run_workbench_trace_events_gap");
|
||||
assert.equal(trace.body.error.sourceCommandId, "cmd_workbench_trace_events_gap");
|
||||
assert.equal(trace.body.error.blocker.code, "workbench_trace_events_missing");
|
||||
assert.notEqual(trace.body.error.message, "Workbench trace is not visible to the current actor.");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench read model projects current turn running state from trace projection", async () => {
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
const traceId = "trc_workbench_active_state";
|
||||
@@ -1430,7 +1527,7 @@ function createDurableFactsRuntimeStore({ sessions = [], queryError = null, quer
|
||||
};
|
||||
}
|
||||
|
||||
function buildDurableFactsForSession({ session, events = [], status = null, finalText = null, runId = null, commandId = null, projectionStatus = null, projectionHealth = "healthy", lastProjectedSeq = null } = {}) {
|
||||
function buildDurableFactsForSession({ session, events = [], status = null, finalText = null, runId = null, commandId = null, projectionStatus = null, projectionHealth = "healthy", lastProjectedSeq = null, omitSessions = false } = {}) {
|
||||
const traceId = session.lastTraceId ?? session.session?.lastTraceId;
|
||||
const projectedStatus = normalizeTestStatus(status ?? session.status);
|
||||
const terminal = testTerminalStatuses.has(projectedStatus);
|
||||
@@ -1441,7 +1538,7 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina
|
||||
const projectedSeq = Number.isFinite(explicitLastProjectedSeq) ? explicitLastProjectedSeq : Math.max(normalizedEvents.length, normalizedMessages.length, 0);
|
||||
const checkpointStatus = projectionStatus ?? (terminal ? "caught_up" : "projecting");
|
||||
return {
|
||||
sessions: [{
|
||||
sessions: omitSessions ? [] : [{
|
||||
sessionId: session.id,
|
||||
ownerUserId: session.ownerUserId,
|
||||
ownerRole: session.ownerRole ?? null,
|
||||
|
||||
@@ -1034,14 +1034,29 @@ async function handleWorkbenchTraceEventPage(response, url, options, actor, rawT
|
||||
const pageOptions = tracePageOptions(url);
|
||||
const metadata = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_METADATA_FAMILIES, limit: 1 });
|
||||
if (metadata.error) return sendJson(response, 503, workbenchProjectionStoreError(metadata.error));
|
||||
const session = visibleFactSessions(metadata.facts, actor).find((item) => item.lastTraceId === traceId) ?? visibleFactSessions(metadata.facts, actor)[0] ?? null;
|
||||
const metadataSessions = visibleFactSessions(metadata.facts, actor);
|
||||
const session = metadataSessions.find((item) => item.lastTraceId === traceId) ?? metadataSessions[0] ?? null;
|
||||
if (!session) {
|
||||
const context = await readModel.queryFacts({ traceId, families: ["sessions", "turns", "checkpoints"], limit: MAX_PAGE_LIMIT });
|
||||
if (context.error) return sendJson(response, 503, workbenchProjectionStoreError(context.error));
|
||||
const contextSessions = visibleFactSessions(context.facts, actor);
|
||||
const contextSession = contextSessions.find((item) => item.lastTraceId === traceId) ?? contextSessions[0] ?? null;
|
||||
const contextTurn = factTurnForTrace(context.facts, traceId, traceId);
|
||||
if (contextSession || contextTurn) {
|
||||
const projection = factProjectionForTrace(context.facts, traceId);
|
||||
const blocker = traceEventsReadModelBlocker("workbench_trace_metadata_missing", "Workbench trace metadata is missing from the trace events read model.", { traceId, projection, session: contextSession, turn: contextTurn, route: url.pathname });
|
||||
return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session: contextSession, turn: contextTurn }));
|
||||
}
|
||||
return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId }));
|
||||
}
|
||||
const projection = factProjectionForTrace(metadata.facts, traceId);
|
||||
const pageResult = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_PAGE_FAMILIES, afterProjectedSeq: pageOptions.afterProjectedSeq, limit: pageOptions.limit + 1 });
|
||||
if (pageResult.error) return sendJson(response, 503, workbenchProjectionStoreError(pageResult.error));
|
||||
const page = traceEventPageFromFacts(pageResult.facts.traceEvents, pageOptions, { total: projection.lastProjectedSeq, traceStatus: session.status });
|
||||
if (traceEventPageMissing(page, projection, pageOptions)) {
|
||||
const blocker = traceEventsReadModelBlocker("workbench_trace_events_missing", "Workbench trace events are missing from the durable read model.", { traceId, projection, session, route: url.pathname });
|
||||
return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session }));
|
||||
}
|
||||
const responseProjection = page.blocker ? blockedTraceEventProjection(projection, page.blocker) : projection;
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
@@ -1153,6 +1168,65 @@ function blockedTraceEventProjection(projection = {}, blocker) {
|
||||
};
|
||||
}
|
||||
|
||||
function traceEventPageMissing(page = {}, projection = {}, options = {}) {
|
||||
if (page.blocker) return false;
|
||||
const expectedSeq = Number(projection?.lastProjectedSeq);
|
||||
if (!Number.isFinite(expectedSeq) || expectedSeq <= 0) return false;
|
||||
return factArray(page.events).length === 0 && expectedSeq > Number(options.afterProjectedSeq ?? 0);
|
||||
}
|
||||
|
||||
function traceEventsReadModelBlocker(code, message, { traceId, projection = {}, session = null, turn = null, route = null } = {}) {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
userMessage: message,
|
||||
layer: "workbench-read-model",
|
||||
category: "trace-events",
|
||||
retryable: true,
|
||||
route,
|
||||
traceId,
|
||||
sessionId: factSessionId(session) ?? turn?.sessionId ?? null,
|
||||
turnId: factTurnId(turn, traceId),
|
||||
projectionStatus: projection.projectionStatus ?? null,
|
||||
projectionHealth: "degraded",
|
||||
lastProjectedSeq: projection.lastProjectedSeq ?? null,
|
||||
sourceRunId: projection.sourceRunId ?? null,
|
||||
sourceCommandId: projection.sourceCommandId ?? null,
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function workbenchTraceEventsReadModelError(blocker, { traceId, projection = {}, session = null, turn = null } = {}) {
|
||||
const responseProjection = blockedTraceEventProjection(projection, blocker);
|
||||
return workbenchError(blocker.code, blocker.message, {
|
||||
traceId,
|
||||
sessionId: blocker.sessionId ?? factSessionId(session) ?? turn?.sessionId ?? null,
|
||||
turnId: blocker.turnId ?? factTurnId(turn, traceId),
|
||||
route: blocker.route ?? null,
|
||||
layer: blocker.layer,
|
||||
category: blocker.category,
|
||||
retryable: blocker.retryable,
|
||||
projection: responseProjection,
|
||||
projectionStatus: responseProjection.projectionStatus,
|
||||
projectionHealth: responseProjection.projectionHealth,
|
||||
lastProjectedSeq: responseProjection.lastProjectedSeq,
|
||||
sourceRunId: responseProjection.sourceRunId,
|
||||
sourceCommandId: responseProjection.sourceCommandId,
|
||||
blocker,
|
||||
diagnostic: {
|
||||
contractVersion: "hwlab-error-diagnostic-v1",
|
||||
route: blocker.route ?? null,
|
||||
layer: blocker.layer,
|
||||
category: blocker.category,
|
||||
code: blocker.code,
|
||||
httpStatus: 404,
|
||||
source: "server",
|
||||
retryable: blocker.retryable,
|
||||
valuesPrinted: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function visibleSessionById(store, sessionId, actor) {
|
||||
const safeId = safeSessionId(sessionId);
|
||||
if (!safeId) return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import type { ApiError, ErrorDiagnostic } from "@/types";
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const { copied, copy } = useClipboard();
|
||||
const expanded = ref(!props.compact);
|
||||
const resolvedDiagnostic = computed(() => props.diagnostic ?? props.apiError?.diagnostic ?? null);
|
||||
const message = computed(() => firstString(props.error, props.apiError?.userMessage, props.apiError?.message));
|
||||
const facts = computed(() => {
|
||||
@@ -33,6 +34,11 @@ const facts = computed(() => {
|
||||
fact("valuesPrinted", valuesPrinted)
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
});
|
||||
const primaryTraceFact = computed(() => facts.value.find((item) => item.label === "trace_id") ?? null);
|
||||
const summaryText = computed(() => {
|
||||
const parts = [props.showMessage && message.value ? message.value : null, primaryTraceFact.value ? `trace_id=${primaryTraceFact.value.value}` : null].filter(Boolean);
|
||||
return parts.join(" ") || (props.title ? "" : "诊断详情");
|
||||
});
|
||||
const hasContent = computed(() => Boolean(message.value || facts.value.length));
|
||||
|
||||
function fact(label: string, value: string | number | null | undefined): { label: string; value: string } | null {
|
||||
@@ -69,19 +75,22 @@ function copyDiagnostic(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="hasContent" class="api-error-diagnostic" :data-compact="compact ? 'true' : 'false'">
|
||||
<header v-if="title || (showMessage && message)">
|
||||
<div>
|
||||
<section v-if="hasContent" class="api-error-diagnostic" :data-compact="compact ? 'true' : 'false'" :data-expanded="expanded ? 'true' : 'false'">
|
||||
<header v-if="title || summaryText || facts.length" class="api-error-diagnostic-summary">
|
||||
<div class="api-error-diagnostic-summary-text">
|
||||
<strong v-if="title">{{ title }}</strong>
|
||||
<p v-if="showMessage && message">{{ message }}</p>
|
||||
<p v-if="summaryText">{{ summaryText }}</p>
|
||||
</div>
|
||||
<button v-if="facts.length" class="table-action" type="button" @click="copyDiagnostic">{{ copied ? "已复制" : "复制诊断" }}</button>
|
||||
<button v-if="facts.length" class="api-error-diagnostic-toggle" type="button" :aria-expanded="expanded ? 'true' : 'false'" aria-label="诊断详情" title="诊断详情" @click="expanded = !expanded">!</button>
|
||||
</header>
|
||||
<dl v-if="facts.length" class="api-error-diagnostic-grid">
|
||||
<div v-for="item in facts" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd><code>{{ item.value }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="facts.length && expanded" class="api-error-diagnostic-details">
|
||||
<button class="table-action" type="button" @click="copyDiagnostic">{{ copied ? "已复制" : "复制诊断" }}</button>
|
||||
<dl class="api-error-diagnostic-grid">
|
||||
<div v-for="item in facts" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd><code>{{ item.value }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -289,12 +289,13 @@
|
||||
}
|
||||
|
||||
.api-error-diagnostic {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
display: inline-grid;
|
||||
max-width: min(720px, 100%);
|
||||
width: fit-content;
|
||||
gap: 8px;
|
||||
border-left: 3px solid #b91c1c;
|
||||
background: #f8fafc;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -303,11 +304,20 @@
|
||||
margin: 4px auto 0;
|
||||
}
|
||||
|
||||
.api-error-diagnostic header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
.api-error-diagnostic-summary {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-error-diagnostic-summary-text {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.api-error-diagnostic strong {
|
||||
@@ -317,10 +327,41 @@
|
||||
}
|
||||
|
||||
.api-error-diagnostic p {
|
||||
margin: 2px 0 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.api-error-diagnostic-toggle {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #92400e;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.api-error-diagnostic-toggle[aria-expanded="true"] {
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.api-error-diagnostic-details {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.api-error-diagnostic-grid {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { expect, fakeServerState, test } from "../fixtures/test";
|
||||
|
||||
@@ -34,7 +34,7 @@ test("admin HWPOD groups renders node-ops as structured UI before raw JSON is re
|
||||
test("performance dashboard renders Chinese charts and keeps drill-down reachable", async ({ page }) => {
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".performance-contract-item")).toHaveCount(5);
|
||||
await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("最近 15 分钟");
|
||||
await expect(page.locator(".performance-contract-item").filter({ hasText: "生成时间" })).toContainText("fake-server");
|
||||
@@ -83,7 +83,7 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/performance");
|
||||
await expect(page.getByText("性能明细")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能明细" })).toBeVisible();
|
||||
await expect(page.locator("svg.performance-line-chart")).toHaveCount(3);
|
||||
await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-mobile.png", fullPage: true });
|
||||
|
||||
@@ -107,7 +107,7 @@ test.describe("performance dashboard empty collection", () => {
|
||||
test("renders the empty summary contract without invented rows", async ({ page }) => {
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".performance-contract-item")).toHaveCount(5);
|
||||
await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本");
|
||||
await expect(page.locator(".performance-collection-item")).toHaveCount(4);
|
||||
@@ -126,7 +126,7 @@ test.describe("performance dashboard stale current window", () => {
|
||||
test("keeps retained samples separate from the empty current window", async ({ page }) => {
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本");
|
||||
await expect(page.locator(".performance-collection-item").filter({ hasText: "最近样本" })).toContainText("当前窗口暂无样本");
|
||||
await expect(page.locator(".performance-collection-item").filter({ hasText: "当前窗口样本" })).toContainText("0 样本");
|
||||
@@ -147,8 +147,9 @@ test.describe("performance dashboard summary error", () => {
|
||||
await expect(page.getByText("性能监控加载失败")).toBeVisible();
|
||||
await expect(page.getByText("暂时无法连接上游。")).toBeVisible();
|
||||
const diagnostic = page.locator(".api-error-diagnostic");
|
||||
await expect(diagnostic).toContainText("trace_id");
|
||||
await expect(diagnostic).toContainText("11111111111111111111111111111111");
|
||||
await expectDiagnosticSummary(diagnostic, "11111111111111111111111111111111");
|
||||
await expect(diagnostic).not.toContainText("requestId");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("requestId");
|
||||
await expect(diagnostic).toContainText("req_e2e_api_error_diagnostic");
|
||||
await expect(diagnostic).toContainText("upstream_unavailable");
|
||||
@@ -168,11 +169,12 @@ for (const status of performanceErrorStatuses) {
|
||||
test(`surfaces HTTP ${status} trace diagnostics without stale dashboard data`, async ({ page }) => {
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("性能监控加载失败")).toBeVisible();
|
||||
const diagnostic = page.locator(".api-error-diagnostic");
|
||||
await expect(diagnostic).toContainText("trace_id");
|
||||
await expect(diagnostic).toContainText(statusTraceId(status));
|
||||
await expectDiagnosticSummary(diagnostic, statusTraceId(status));
|
||||
await expect(diagnostic).not.toContainText("requestId");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("requestId");
|
||||
await expect(diagnostic).toContainText(`req_e2e_api_status_${status}`);
|
||||
await expect(diagnostic).toContainText(`e2e_http_${status}`);
|
||||
@@ -192,10 +194,13 @@ test.describe("performance dashboard browser network diagnostic", () => {
|
||||
test("surfaces browser generated trace diagnostics when the request has no response", async ({ page }) => {
|
||||
await page.goto("/performance");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("性能监控加载失败")).toBeVisible();
|
||||
const diagnostic = page.locator(".api-error-diagnostic");
|
||||
await expect(diagnostic).toContainText("trace_id");
|
||||
await expect(diagnostic.getByRole("button", { name: "诊断详情" })).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(diagnostic).not.toContainText("requestId");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("requestId");
|
||||
await expect(diagnostic).toContainText("browser_network_error");
|
||||
await expect(diagnostic).toContainText("web / network");
|
||||
@@ -210,3 +215,16 @@ test.describe("performance dashboard browser network diagnostic", () => {
|
||||
function statusTraceId(status: number): string {
|
||||
return String(status).repeat(11).slice(0, 32);
|
||||
}
|
||||
|
||||
async function expectDiagnosticSummary(diagnostic: Locator, traceId: string): Promise<void> {
|
||||
await expect(diagnostic).toContainText(`trace_id=${traceId}`);
|
||||
const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(toggle).toBeVisible();
|
||||
}
|
||||
|
||||
async function expandDiagnostic(diagnostic: Locator): Promise<void> {
|
||||
const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response.
|
||||
// Responsibility: Workbench diagnostic visibility regression, including separation from sealed final response.
|
||||
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
|
||||
import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
@@ -13,11 +14,15 @@ test.describe("projection diagnostics visibility", () => {
|
||||
await expect(page.locator(sessionTab("ses_projection_degraded"))).toHaveAttribute("data-projection-health", "degraded");
|
||||
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
|
||||
await expect(card).toContainText("状态更新超时");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("agentrun_result_timeout");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("trace_id");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("trc_projection_degraded");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("valuesPrinted");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("false");
|
||||
const diagnostic = card.locator(".projection-diagnostic");
|
||||
await expectCompactDiagnostic(diagnostic, card, "trc_projection_degraded");
|
||||
await expect(diagnostic).not.toContainText("valuesPrinted");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("agentrun_result_timeout");
|
||||
await expect(diagnostic).toContainText("trace_id");
|
||||
await expect(diagnostic).toContainText("trc_projection_degraded");
|
||||
await expect(diagnostic).toContainText("valuesPrinted");
|
||||
await expect(diagnostic).toContainText("false");
|
||||
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
|
||||
await expect(card.locator(".trace-empty")).toContainText("状态更新超时");
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
|
||||
@@ -32,9 +37,13 @@ test.describe("SSE projection errors", () => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_projection_sse_error");
|
||||
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
|
||||
await expect(card).toContainText("SSE 投影事件异常");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("workbench_sse_projection_error");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_workbench_sse_projection_error");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/events");
|
||||
const diagnostic = card.locator(".projection-diagnostic");
|
||||
await expectCompactDiagnostic(diagnostic, card, "trc_projection_sse_error");
|
||||
await expect(diagnostic).not.toContainText("req_e2e_workbench_sse_projection_error");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("workbench_sse_projection_error");
|
||||
await expect(diagnostic).toContainText("req_e2e_workbench_sse_projection_error");
|
||||
await expect(diagnostic).toContainText("/v1/workbench/events");
|
||||
await expect(page.locator(sessionTab("ses_projection_sse_error"))).toHaveAttribute("data-projection-health", "degraded");
|
||||
await saveScreenshot(page, testInfo, "projection-sse-error-visible");
|
||||
});
|
||||
@@ -47,10 +56,15 @@ test.describe("REST gap hydration errors", () => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_trace_hydration_timeout");
|
||||
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
|
||||
await expect(card).toContainText("Trace 更新超时");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("trace_hydration_timeout");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("33333333333333333333333333333333");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_trace_hydration_timeout");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
|
||||
const diagnostic = card.locator(".projection-diagnostic");
|
||||
await expectCompactDiagnostic(diagnostic, card, "33333333333333333333333333333333");
|
||||
await expect(diagnostic).not.toContainText("req_e2e_trace_hydration_timeout");
|
||||
await expect(diagnostic).not.toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("trace_hydration_timeout");
|
||||
await expect(diagnostic).toContainText("33333333333333333333333333333333");
|
||||
await expect(diagnostic).toContainText("req_e2e_trace_hydration_timeout");
|
||||
await expect(diagnostic).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
|
||||
await expect(card).toHaveAttribute("data-status", "running");
|
||||
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
|
||||
@@ -64,9 +78,13 @@ test.describe("sealed final response diagnostics separation", () => {
|
||||
test("keeps completed final response visible while showing diagnostics separately", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_sealed_final_diag");
|
||||
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last();
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("Trace 更新超时");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_sealed_final_trace_timeout");
|
||||
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events");
|
||||
const diagnostic = card.locator(".projection-diagnostic");
|
||||
await expectCompactDiagnostic(diagnostic, card, "55555555555555555555555555555555");
|
||||
await expect(diagnostic).not.toContainText("req_e2e_sealed_final_trace_timeout");
|
||||
await expandDiagnostic(diagnostic);
|
||||
await expect(diagnostic).toContainText("Trace 更新超时");
|
||||
await expect(diagnostic).toContainText("req_e2e_sealed_final_trace_timeout");
|
||||
await expect(diagnostic).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events");
|
||||
const finalResponse = card.locator(".message-text").filter({ hasText: "已完成的 sealed final response 不应被诊断覆盖。" });
|
||||
await expect(finalResponse).toBeVisible();
|
||||
await expect(finalResponse).not.toContainText("Trace 更新超时");
|
||||
@@ -74,6 +92,23 @@ test.describe("sealed final response diagnostics separation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function expectCompactDiagnostic(diagnostic: Locator, container: Locator, traceId: string): Promise<void> {
|
||||
await expect(diagnostic).toContainText(`trace_id=${traceId}`);
|
||||
const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(toggle).toBeVisible();
|
||||
const diagnosticBox = await diagnostic.boundingBox();
|
||||
const containerBox = await container.boundingBox();
|
||||
expect(diagnosticBox?.width ?? 0).toBeGreaterThan(0);
|
||||
if (containerBox && diagnosticBox) expect(diagnosticBox.width).toBeLessThan(containerBox.width - 12);
|
||||
}
|
||||
|
||||
async function expandDiagnostic(diagnostic: Locator): Promise<void> {
|
||||
const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
test.describe("trace detail final response separation", () => {
|
||||
test.use({ scenarioId: "trace-final-response-not-message-authority" });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user