同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。 纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
type CompactProgressEvent = {
|
|
receivedAtMs: number;
|
|
phase?: string;
|
|
heartbeat: boolean;
|
|
progress?: number;
|
|
simulatedTime?: number;
|
|
totalTime?: number;
|
|
activitySequence?: number;
|
|
activityKind?: string;
|
|
currentTrialTime?: number | null;
|
|
rhsCallCount?: number;
|
|
acceptedStepSequence?: number;
|
|
acceptedTime?: number | null;
|
|
};
|
|
|
|
type CompactResult = {
|
|
success?: boolean;
|
|
status?: string;
|
|
partial?: boolean;
|
|
simulatedUntil?: number;
|
|
requestedStopTime?: number;
|
|
sampleCount?: number;
|
|
timePointCount?: number;
|
|
finalTime?: number;
|
|
};
|
|
|
|
type BrowserStreamEvidence = {
|
|
lineCount: number;
|
|
progressEvents: CompactProgressEvent[];
|
|
result: CompactResult | null;
|
|
streamErrors: Array<{
|
|
receivedAtMs: number;
|
|
status?: number;
|
|
message?: string;
|
|
detail?: unknown;
|
|
}>;
|
|
parseErrors: string[];
|
|
};
|
|
|
|
const PROJECT_PATH = fileURLToPath(
|
|
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
|
);
|
|
const EVIDENCE_PATH = fileURLToPath(
|
|
new URL(
|
|
"../../../tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-browser-live-activity-v1-0.2.json",
|
|
import.meta.url,
|
|
),
|
|
);
|
|
|
|
const EXPECTED_RUNTIME_ENVIRONMENT = {
|
|
SIMULATION_CAUSAL_EXECUTOR_V2: "1",
|
|
SIMULATION_CAUSAL_COORDINATE_KERNEL: "1",
|
|
SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS: "1",
|
|
SIMULATION_CAUSAL_DIRECT_EQUATION_READERS: "1",
|
|
SIMULATION_CAUSAL_FAST_PATH: "1",
|
|
SIMULATION_MECHANICAL_ATOL_MODE: "legacy",
|
|
SIMULATION_ODE_JACOBIAN_MODE: "scipy",
|
|
SIMULATIONAPP_PROPERTY_CACHE: "on",
|
|
} as const;
|
|
|
|
function extractSimulationAttribute(xml: string, name: string) {
|
|
const match = xml.match(new RegExp(`\\b${name}="([^"]+)"`));
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function buildActivitySummary(events: CompactProgressEvent[]) {
|
|
const heartbeats = events.filter((event) => event.heartbeat);
|
|
const activityHeartbeats = heartbeats.filter(
|
|
(event) => typeof event.activitySequence === "number",
|
|
);
|
|
const activitySequences = activityHeartbeats.map(
|
|
(event) => event.activitySequence as number,
|
|
);
|
|
|
|
const acceptedTransitions: CompactProgressEvent[] = [];
|
|
let lastAcceptedStepSequence: number | undefined;
|
|
for (const event of events) {
|
|
if (
|
|
event.phase !== "integrating" ||
|
|
typeof event.acceptedStepSequence !== "number"
|
|
) {
|
|
continue;
|
|
}
|
|
if (event.acceptedStepSequence === lastAcceptedStepSequence) {
|
|
continue;
|
|
}
|
|
lastAcceptedStepSequence = event.acceptedStepSequence;
|
|
acceptedTransitions.push(event);
|
|
}
|
|
|
|
let maximumAcceptedPlateau:
|
|
| {
|
|
durationMs: number;
|
|
fromAcceptedStepSequence?: number;
|
|
toAcceptedStepSequence?: number;
|
|
fromAcceptedTime?: number | null;
|
|
toAcceptedTime?: number | null;
|
|
heartbeatCount: number;
|
|
activitySequenceStart: number | null;
|
|
activitySequenceEnd: number | null;
|
|
activitySequenceAdvanced: boolean;
|
|
}
|
|
| undefined;
|
|
for (let index = 1; index < acceptedTransitions.length; index += 1) {
|
|
const previous = acceptedTransitions[index - 1];
|
|
const current = acceptedTransitions[index];
|
|
const plateauHeartbeats = activityHeartbeats.filter(
|
|
(event) =>
|
|
event.receivedAtMs >= previous.receivedAtMs &&
|
|
event.receivedAtMs <= current.receivedAtMs,
|
|
);
|
|
const plateauActivitySequences = plateauHeartbeats.map(
|
|
(event) => event.activitySequence as number,
|
|
);
|
|
const candidate = {
|
|
durationMs: current.receivedAtMs - previous.receivedAtMs,
|
|
fromAcceptedStepSequence: previous.acceptedStepSequence,
|
|
toAcceptedStepSequence: current.acceptedStepSequence,
|
|
fromAcceptedTime: previous.acceptedTime,
|
|
toAcceptedTime: current.acceptedTime,
|
|
heartbeatCount: plateauHeartbeats.length,
|
|
activitySequenceStart: plateauActivitySequences.at(0) ?? null,
|
|
activitySequenceEnd: plateauActivitySequences.at(-1) ?? null,
|
|
activitySequenceAdvanced:
|
|
plateauActivitySequences.length >= 2 &&
|
|
(plateauActivitySequences.at(-1) as number) >
|
|
(plateauActivitySequences.at(0) as number),
|
|
};
|
|
if (
|
|
!maximumAcceptedPlateau ||
|
|
candidate.durationMs > maximumAcceptedPlateau.durationMs
|
|
) {
|
|
maximumAcceptedPlateau = candidate;
|
|
}
|
|
}
|
|
|
|
const slowRegionEvents = events.filter((event) => {
|
|
const reportedTimes = [
|
|
event.acceptedTime,
|
|
event.currentTrialTime,
|
|
event.simulatedTime,
|
|
].filter((value): value is number => typeof value === "number");
|
|
return reportedTimes.some((value) => value >= 0.0475 && value <= 0.0505);
|
|
});
|
|
|
|
return {
|
|
progressEventCount: events.length,
|
|
heartbeatCount: heartbeats.length,
|
|
activityTelemetryHeartbeatCount: activityHeartbeats.length,
|
|
firstActivitySequence: activitySequences.at(0) ?? null,
|
|
lastActivitySequence: activitySequences.at(-1) ?? null,
|
|
activitySequenceAdvanced:
|
|
activitySequences.length >= 2 &&
|
|
(activitySequences.at(-1) as number) >
|
|
(activitySequences.at(0) as number),
|
|
acceptedTransitionCount: acceptedTransitions.length,
|
|
maximumAcceptedPlateau: maximumAcceptedPlateau ?? null,
|
|
slowRegionEvents,
|
|
};
|
|
}
|
|
|
|
async function readBrowserStreamEvidence(page: Page): Promise<BrowserStreamEvidence> {
|
|
return page.evaluate(() => {
|
|
const state = (
|
|
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
|
|
).__mql8LiveStreamEvidence;
|
|
return (
|
|
state ?? {
|
|
lineCount: 0,
|
|
progressEvents: [],
|
|
result: null,
|
|
streamErrors: [],
|
|
parseErrors: ["Browser stream evidence was not initialized."],
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function emptyBrowserStreamEvidence(): BrowserStreamEvidence {
|
|
return {
|
|
lineCount: 0,
|
|
progressEvents: [],
|
|
result: null,
|
|
streamErrors: [],
|
|
parseErrors: [],
|
|
};
|
|
}
|
|
|
|
function retainMoreCompleteEvidence(
|
|
retained: BrowserStreamEvidence,
|
|
candidate: BrowserStreamEvidence,
|
|
) {
|
|
if (
|
|
candidate.lineCount > retained.lineCount ||
|
|
(candidate.lineCount === retained.lineCount &&
|
|
candidate.result !== null &&
|
|
retained.result === null)
|
|
) {
|
|
return candidate;
|
|
}
|
|
return retained;
|
|
}
|
|
|
|
test("test-mql-8 在真实浏览器流式链路完成 0.2 s BDF 仿真", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const startedAt = Date.now();
|
|
const startedAtIso = new Date(startedAt).toISOString();
|
|
const cancellationRequests: string[] = [];
|
|
const browserConsoleErrors: string[] = [];
|
|
const pageErrors: string[] = [];
|
|
let simulationXml = "";
|
|
let simulationId: string | undefined;
|
|
let workflowCompleted = false;
|
|
let retainedStreamEvidence = emptyBrowserStreamEvidence();
|
|
let evidenceRetentionTimer: ReturnType<typeof setInterval> | undefined;
|
|
|
|
page.on("request", (request) => {
|
|
const url = request.url();
|
|
if (url.includes("/api/system-xml/simulate-stream")) {
|
|
simulationXml = request.postData() ?? "";
|
|
simulationId = request.headers()["x-simulation-id"];
|
|
}
|
|
if (/\/api\/system-xml\/simulations\/[^/]+\/cancel(?:\?|$)/.test(url)) {
|
|
cancellationRequests.push(url);
|
|
}
|
|
});
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") {
|
|
browserConsoleErrors.push(message.text());
|
|
}
|
|
});
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
|
|
await page.addInitScript(() => {
|
|
window.localStorage.clear();
|
|
window.sessionStorage.clear();
|
|
window.location.hash = "#/modeling";
|
|
|
|
const evidence: BrowserStreamEvidence = {
|
|
lineCount: 0,
|
|
progressEvents: [],
|
|
result: null,
|
|
streamErrors: [],
|
|
parseErrors: [],
|
|
};
|
|
(
|
|
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
|
|
).__mql8LiveStreamEvidence = evidence;
|
|
|
|
const nativeFetch = window.fetch.bind(window);
|
|
window.fetch = async (...args: Parameters<typeof window.fetch>) => {
|
|
const response = await nativeFetch(...args);
|
|
const input = args[0];
|
|
const requestUrl =
|
|
typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.href
|
|
: input.url;
|
|
if (!requestUrl.includes("/api/system-xml/simulate-stream") || !response.body) {
|
|
return response;
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
const recordLine = (line: string) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
evidence.lineCount += 1;
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
const receivedAtMs = performance.now();
|
|
if (parsed.event === "progress") {
|
|
evidence.progressEvents.push({
|
|
receivedAtMs,
|
|
phase: typeof parsed.phase === "string" ? parsed.phase : undefined,
|
|
heartbeat: parsed.heartbeat === true,
|
|
progress:
|
|
typeof parsed.progress === "number" ? parsed.progress : undefined,
|
|
simulatedTime:
|
|
typeof parsed.simulatedTime === "number"
|
|
? parsed.simulatedTime
|
|
: undefined,
|
|
totalTime:
|
|
typeof parsed.totalTime === "number" ? parsed.totalTime : undefined,
|
|
activitySequence:
|
|
typeof parsed.activitySequence === "number"
|
|
? parsed.activitySequence
|
|
: undefined,
|
|
activityKind:
|
|
typeof parsed.activityKind === "string"
|
|
? parsed.activityKind
|
|
: undefined,
|
|
currentTrialTime:
|
|
typeof parsed.currentTrialTime === "number" ||
|
|
parsed.currentTrialTime === null
|
|
? parsed.currentTrialTime
|
|
: undefined,
|
|
rhsCallCount:
|
|
typeof parsed.rhsCallCount === "number"
|
|
? parsed.rhsCallCount
|
|
: undefined,
|
|
acceptedStepSequence:
|
|
typeof parsed.acceptedStepSequence === "number"
|
|
? parsed.acceptedStepSequence
|
|
: undefined,
|
|
acceptedTime:
|
|
typeof parsed.acceptedTime === "number" || parsed.acceptedTime === null
|
|
? parsed.acceptedTime
|
|
: undefined,
|
|
});
|
|
} else if (parsed.event === "result") {
|
|
const result = parsed.result as Record<string, unknown> | undefined;
|
|
const diagnostics = result?.diagnostics as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const series = result?.series as Record<string, unknown> | undefined;
|
|
const times = Array.isArray(series?.time)
|
|
? (series.time as unknown[]).filter(
|
|
(value): value is number => typeof value === "number",
|
|
)
|
|
: [];
|
|
evidence.result = {
|
|
success:
|
|
typeof result?.success === "boolean" ? result.success : undefined,
|
|
status: typeof result?.status === "string" ? result.status : undefined,
|
|
partial:
|
|
typeof result?.partial === "boolean" ? result.partial : undefined,
|
|
simulatedUntil:
|
|
typeof result?.simulatedUntil === "number"
|
|
? result.simulatedUntil
|
|
: undefined,
|
|
requestedStopTime:
|
|
typeof result?.requestedStopTime === "number"
|
|
? result.requestedStopTime
|
|
: undefined,
|
|
sampleCount:
|
|
typeof diagnostics?.sampleCount === "number"
|
|
? diagnostics.sampleCount
|
|
: undefined,
|
|
timePointCount: times.length,
|
|
finalTime: times.at(-1),
|
|
};
|
|
} else if (parsed.event === "error") {
|
|
evidence.streamErrors.push({
|
|
receivedAtMs,
|
|
status: typeof parsed.status === "number" ? parsed.status : undefined,
|
|
message:
|
|
typeof parsed.message === "string" ? parsed.message : undefined,
|
|
detail: parsed.detail,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
evidence.parseErrors.push(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
};
|
|
const flushLines = () => {
|
|
let newlineIndex = buffer.indexOf("\n");
|
|
while (newlineIndex >= 0) {
|
|
recordLine(buffer.slice(0, newlineIndex));
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
newlineIndex = buffer.indexOf("\n");
|
|
}
|
|
};
|
|
const monitoredBody = response.body.pipeThrough(
|
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
buffer += decoder.decode(chunk, { stream: true });
|
|
flushLines();
|
|
controller.enqueue(chunk);
|
|
},
|
|
flush() {
|
|
buffer += decoder.decode();
|
|
flushLines();
|
|
recordLine(buffer);
|
|
buffer = "";
|
|
},
|
|
}),
|
|
);
|
|
return new Response(monitoredBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
};
|
|
});
|
|
|
|
const retainCurrentStreamEvidence = async () => {
|
|
const candidate = await readBrowserStreamEvidence(page);
|
|
retainedStreamEvidence = retainMoreCompleteEvidence(
|
|
retainedStreamEvidence,
|
|
candidate,
|
|
);
|
|
};
|
|
evidenceRetentionTimer = setInterval(() => {
|
|
void retainCurrentStreamEvidence().catch(() => undefined);
|
|
}, 1_000);
|
|
|
|
try {
|
|
await page.goto("/");
|
|
await expect(page.locator(".catalog-source.ready")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await page.locator('input[type="file"]').setInputFiles(PROJECT_PATH);
|
|
const consolePanel = page.getByRole("complementary", {
|
|
name: "仿真控制台",
|
|
exact: true,
|
|
});
|
|
const consoleSummary = consolePanel.locator(
|
|
".simulation-console-dock-summary",
|
|
);
|
|
await expect(consoleSummary).toContainText(
|
|
"已导入工程:test-mql-8.json",
|
|
{ timeout: 30_000 },
|
|
);
|
|
|
|
const stopTimeInput = page.getByLabel("结束时间", { exact: true });
|
|
await stopTimeInput.fill("0.2");
|
|
await stopTimeInput.press("Enter");
|
|
await expect(stopTimeInput).toHaveValue("0.2");
|
|
await expect(page.getByLabel("采样步长", { exact: true })).toHaveValue(
|
|
"0.01",
|
|
);
|
|
await expect(
|
|
page.getByLabel("最大积分步长", { exact: true }),
|
|
).toHaveValue("0.001");
|
|
await expect(page.getByLabel("求解器", { exact: true })).toHaveValue("BDF");
|
|
|
|
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
|
|
await expect.poll(() => simulationXml.length, { timeout: 60_000 }).toBeGreaterThan(0);
|
|
expect(extractSimulationAttribute(simulationXml, "tStop")).toBe("0.2");
|
|
expect(extractSimulationAttribute(simulationXml, "sampleStep")).toBe("0.01");
|
|
expect(extractSimulationAttribute(simulationXml, "maxStep")).toBe("0.001");
|
|
expect(extractSimulationAttribute(simulationXml, "method")).toBe("BDF");
|
|
|
|
await expect(consoleSummary).toContainText(
|
|
"仿真完成,已生成新的结果,共 21 个采样点",
|
|
{ timeout: 480_000 },
|
|
);
|
|
await expect
|
|
.poll(async () => (await readBrowserStreamEvidence(page)).result?.status, {
|
|
timeout: 30_000,
|
|
})
|
|
.toBe("completed");
|
|
|
|
const streamEvidence = await readBrowserStreamEvidence(page);
|
|
expect(streamEvidence.parseErrors).toEqual([]);
|
|
expect(streamEvidence.streamErrors).toEqual([]);
|
|
expect(streamEvidence.result).toMatchObject({
|
|
success: true,
|
|
status: "completed",
|
|
partial: false,
|
|
simulatedUntil: 0.2,
|
|
requestedStopTime: 0.2,
|
|
sampleCount: 21,
|
|
timePointCount: 21,
|
|
finalTime: 0.2,
|
|
});
|
|
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
|
|
expect(activitySummary.heartbeatCount).toBeGreaterThan(0);
|
|
expect(activitySummary.activityTelemetryHeartbeatCount).toBeGreaterThan(1);
|
|
expect(activitySummary.activitySequenceAdvanced).toBe(true);
|
|
expect(cancellationRequests).toEqual([]);
|
|
expect(pageErrors).toEqual([]);
|
|
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
const consoleLog = consolePanel.getByRole("region", { name: "控制台日志" });
|
|
await expect(consoleLog).toContainText(
|
|
"仿真完成,已生成新的结果,共 21 个采样点",
|
|
);
|
|
await expect(consoleLog).not.toContainText("仿真失败");
|
|
await expect(consoleLog).not.toContainText("仿真因进度异常而终止");
|
|
|
|
if (
|
|
activitySummary.maximumAcceptedPlateau &&
|
|
activitySummary.maximumAcceptedPlateau.durationMs >= 60_000
|
|
) {
|
|
expect(
|
|
activitySummary.maximumAcceptedPlateau.activitySequenceAdvanced,
|
|
).toBe(true);
|
|
await expect(consoleLog).toContainText("后端内部活动仍在推进");
|
|
}
|
|
workflowCompleted = true;
|
|
} finally {
|
|
if (evidenceRetentionTimer !== undefined) {
|
|
clearInterval(evidenceRetentionTimer);
|
|
}
|
|
await retainCurrentStreamEvidence().catch(() => undefined);
|
|
const streamEvidence = retainedStreamEvidence;
|
|
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
|
|
const finishedAt = Date.now();
|
|
const projectBytes = readFileSync(PROJECT_PATH);
|
|
const evidence = {
|
|
schemaVersion: 1,
|
|
kind: "live-browser-stream-verification",
|
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
startedAt: startedAtIso,
|
|
wallSeconds: (finishedAt - startedAt) / 1_000,
|
|
workflowCompleted,
|
|
topology: {
|
|
browserOrigin: "http://127.0.0.1:14173",
|
|
backendOrigin: "http://127.0.0.1:18181",
|
|
servingMode: "isolated-vite-dev-proxy",
|
|
},
|
|
source: {
|
|
path: "tests/data/test-mql-8.json",
|
|
bytes: statSync(PROJECT_PATH).size,
|
|
sha256: createHash("sha256").update(projectBytes).digest("hex"),
|
|
},
|
|
target: {
|
|
tStart: 0,
|
|
tStop: 0.2,
|
|
sampleStep: 0.01,
|
|
maxStep: 0.001,
|
|
method: "BDF",
|
|
},
|
|
runtimeEnvironment: EXPECTED_RUNTIME_ENVIRONMENT,
|
|
request: {
|
|
simulationId: simulationId ?? null,
|
|
xmlBytes: Buffer.byteLength(simulationXml),
|
|
simulationAttributes: {
|
|
tStart: extractSimulationAttribute(simulationXml, "tStart"),
|
|
tStop: extractSimulationAttribute(simulationXml, "tStop"),
|
|
sampleStep: extractSimulationAttribute(simulationXml, "sampleStep"),
|
|
maxStep: extractSimulationAttribute(simulationXml, "maxStep"),
|
|
method: extractSimulationAttribute(simulationXml, "method"),
|
|
},
|
|
},
|
|
ndjson: {
|
|
lineCount: streamEvidence.lineCount,
|
|
parseErrors: streamEvidence.parseErrors,
|
|
streamErrors: streamEvidence.streamErrors,
|
|
result: streamEvidence.result,
|
|
activity: activitySummary,
|
|
},
|
|
browser: {
|
|
cancellationRequests,
|
|
pageErrors,
|
|
consoleErrors: browserConsoleErrors,
|
|
},
|
|
acceptance: {
|
|
completedWith21Samples:
|
|
workflowCompleted &&
|
|
streamEvidence.result?.status === "completed" &&
|
|
streamEvidence.result?.sampleCount === 21 &&
|
|
streamEvidence.result?.timePointCount === 21,
|
|
noCancellationRequest: cancellationRequests.length === 0,
|
|
noStreamError: streamEvidence.streamErrors.length === 0,
|
|
noPageError: pageErrors.length === 0,
|
|
activityTelemetryAdvanced: activitySummary.activitySequenceAdvanced,
|
|
},
|
|
};
|
|
const serializedEvidence = `${JSON.stringify(evidence, null, 2)}\n`;
|
|
writeFileSync(EVIDENCE_PATH, serializedEvidence, "utf8");
|
|
await testInfo.attach("mql8-live-browser-evidence", {
|
|
body: Buffer.from(serializedEvidence),
|
|
contentType: "application/json",
|
|
});
|
|
const screenshot = await page.screenshot({ fullPage: true }).catch(() => null);
|
|
if (screenshot) {
|
|
await testInfo.attach("mql8-live-browser-final-page", {
|
|
body: screenshot,
|
|
contentType: "image/png",
|
|
});
|
|
}
|
|
}
|
|
});
|