import { expect, test, type Page } from "@playwright/test"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; 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[]; }; // Historical Python activity replay, opt-in only. Current native acceptance uses // tests/manual/browser_native_simulation.mjs with the corrected eight-branch JSON. const PROJECT_PATH = fileURLToPath( new URL("../../../tests/baselines/simulation/test_mql_8/sources/test-mql-8.json", import.meta.url), ); const EVIDENCE_PATH = fileURLToPath( new URL( "../../../test/live-browser-mql8/latest-evidence.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 { 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.skip( ({ baseURL }) => baseURL !== "http://127.0.0.1:14173", "Historical activity replay requires --config playwright.live.config.ts and its separate backend; current native acceptance has a separate entry.", ); 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 | 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) => { 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; 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 | undefined; const diagnostics = result?.diagnostics as | Record | undefined; const series = result?.series as Record | 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({ 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/baselines/simulation/test_mql_8/sources/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`; mkdirSync(dirname(EVIDENCE_PATH), { recursive: true }); 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", }); } } });