Files
remoteconn-gitea/scripts/terminal-perf-replay.mjs
2026-03-21 18:57:10 +08:00

542 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const ROOT = process.cwd();
const TERMINAL_PAGE_PATH = path.join(ROOT, "apps/miniprogram/pages/terminal/index.js");
const TERMINAL_FIXTURE_PATH = path.join(ROOT, "apps/miniprogram/pages/terminal/codexCaptureFixture.js");
const TERMINAL_BUFFER_STATE_PATH = path.join(
ROOT,
"apps/miniprogram/pages/terminal/terminalBufferState.js"
);
const DEFAULT_CHUNK_SIZE = 97;
const DEFAULT_CADENCE_MS = 8;
const DEFAULT_REPEAT = 4;
const DEFAULT_SETTLE_MS = 1600;
const DEFAULT_CAPTURE_SPEED = 1;
const DEFAULT_RECT = Object.freeze({
left: 0,
top: 0,
right: 375,
bottom: 520,
width: 375,
height: 520
});
function parsePositiveInteger(raw, fallback) {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.round(value);
}
function parseArgs(argv) {
const args = Array.isArray(argv) ? argv : [];
const parsed = {
chunkSize: DEFAULT_CHUNK_SIZE,
cadenceMs: DEFAULT_CADENCE_MS,
repeat: DEFAULT_REPEAT,
settleMs: DEFAULT_SETTLE_MS,
fixture: "20260311",
captureFile: "",
captureSpeed: DEFAULT_CAPTURE_SPEED,
quiet: false
};
args.forEach((item) => {
const source = String(item || "");
if (!source.startsWith("--")) {
return;
}
const [key, rawValue = ""] = source.slice(2).split("=");
if (key === "chunk-size") {
parsed.chunkSize = parsePositiveInteger(rawValue, DEFAULT_CHUNK_SIZE);
return;
}
if (key === "cadence-ms") {
parsed.cadenceMs = Math.max(0, parsePositiveInteger(rawValue, DEFAULT_CADENCE_MS));
return;
}
if (key === "repeat") {
parsed.repeat = parsePositiveInteger(rawValue, DEFAULT_REPEAT);
return;
}
if (key === "settle-ms") {
parsed.settleMs = Math.max(0, parsePositiveInteger(rawValue, DEFAULT_SETTLE_MS));
return;
}
if (key === "fixture" && rawValue) {
parsed.fixture = rawValue;
return;
}
if (key === "capture-file" && rawValue) {
parsed.captureFile = path.isAbsolute(rawValue) ? rawValue : path.join(ROOT, rawValue);
return;
}
if (key === "capture-speed") {
const value = Number(rawValue);
parsed.captureSpeed = Number.isFinite(value) && value > 0 ? value : DEFAULT_CAPTURE_SPEED;
return;
}
if (key === "quiet") {
parsed.quiet = rawValue !== "false";
}
});
return parsed;
}
function splitTextIntoChunks(text, chunkSize) {
const chunks = [];
const size = Math.max(1, parsePositiveInteger(chunkSize, DEFAULT_CHUNK_SIZE));
for (let index = 0; index < text.length; index += size) {
chunks.push(text.slice(index, index + size));
}
return chunks;
}
function wait(ms) {
const delay = Math.max(0, Number(ms) || 0);
return new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
function loadCaptureRecording(filePath) {
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(ROOT, filePath);
const lines = fs
.readFileSync(resolvedPath, "utf8")
.split(/\r?\n/)
.filter(Boolean);
const events = [];
let meta = null;
lines.forEach((line, index) => {
let parsed = null;
try {
parsed = JSON.parse(line);
} catch (error) {
throw new Error(`录制文件解析失败,第 ${index + 1} 行不是合法 JSON: ${(error && error.message) || error}`);
}
if (!parsed || typeof parsed !== "object") {
return;
}
if (parsed.kind === "meta") {
meta = parsed;
return;
}
if (parsed.kind === "frame" && typeof parsed.type === "string" && typeof parsed.data === "string") {
events.push({
offsetMs: Math.max(0, Number(parsed.offsetMs) || 0),
type: parsed.type,
data: parsed.data
});
}
});
return {
filePath: resolvedPath,
meta,
events
};
}
function cloneRect(rect) {
const source = rect && typeof rect === "object" ? rect : DEFAULT_RECT;
return {
left: Number(source.left) || 0,
top: Number(source.top) || 0,
right: Number(source.right) || 0,
bottom: Number(source.bottom) || 0,
width: Number(source.width) || DEFAULT_RECT.width,
height: Number(source.height) || DEFAULT_RECT.height
};
}
function createSelectorQueryStub() {
const queue = [];
const api = {
in() {
return api;
},
select() {
return {
boundingClientRect(callback) {
queue.push({
type: "rect",
callback: typeof callback === "function" ? callback : null
});
return api;
},
scrollOffset(callback) {
queue.push({
type: "scroll",
callback: typeof callback === "function" ? callback : null
});
return api;
}
};
},
exec(callback) {
const results = queue.map((item) =>
item.type === "rect"
? cloneRect(DEFAULT_RECT)
: {
scrollTop: 0,
scrollLeft: 0
}
);
queue.forEach((item, index) => {
if (item.callback) {
item.callback(results[index]);
}
});
if (typeof callback === "function") {
callback(results);
}
}
};
return api;
}
function installMiniprogramGlobals() {
const globalState = globalThis;
const previousPage = globalState.Page;
const previousWx = globalState.wx;
let capturedPageOptions = null;
const noop = () => {};
// 回放脚本只需要一个轻量的内存存储桩,避免触发真实小程序存储分支。
const storage = new Map();
globalState.Page = (options) => {
capturedPageOptions = options;
};
globalState.wx = {
env: {
USER_DATA_PATH: "/tmp"
},
getRecorderManager: () => ({
onStart: noop,
onStop: noop,
onError: noop,
onFrameRecorded: noop,
start: noop,
stop: noop
}),
createInnerAudioContext: () => ({
onCanplay: noop,
onPlay: noop,
onEnded: noop,
onStop: noop,
onError: noop,
stop: noop,
destroy: noop
}),
setInnerAudioOption: noop,
createSelectorQuery: () => createSelectorQueryStub(),
nextTick: (callback) => {
if (typeof callback === "function") {
callback();
}
},
getSystemInfoSync: () => ({
windowWidth: DEFAULT_RECT.width,
windowHeight: 667,
screenWidth: DEFAULT_RECT.width,
screenHeight: 667,
pixelRatio: 2
}),
canIUse: () => false,
getStorageSync: (key) => storage.get(String(key)),
setStorageSync: (key, value) => {
storage.set(String(key), value);
},
removeStorageSync: (key) => {
storage.delete(String(key));
},
clearStorageSync: () => {
storage.clear();
},
getStorageInfoSync: () => ({
keys: Array.from(storage.keys()),
currentSize: storage.size,
limitSize: 10240
}),
setNavigationBarTitle: noop,
showToast: noop,
showModal: noop,
hideKeyboard: noop
};
delete require.cache[require.resolve(TERMINAL_PAGE_PATH)];
require(TERMINAL_PAGE_PATH);
return {
capturedPageOptions,
restore() {
if (previousPage === undefined) {
delete globalState.Page;
} else {
globalState.Page = previousPage;
}
if (previousWx === undefined) {
delete globalState.wx;
} else {
globalState.wx = previousWx;
}
}
};
}
function createTerminalPageHarness() {
const { createEmptyTerminalBufferState, cloneAnsiState, ANSI_RESET_STATE } = require(
TERMINAL_BUFFER_STATE_PATH
);
const { capturedPageOptions, restore } = installMiniprogramGlobals();
if (!capturedPageOptions) {
restore();
throw new Error("terminal page not captured");
}
const captured = capturedPageOptions;
const page = {
...captured,
data: JSON.parse(JSON.stringify(captured.data || {})),
setData(patch, callback) {
Object.assign(this.data, patch || {});
if (typeof callback === "function") {
callback();
}
}
};
page.initTerminalPerfState();
page.initTerminalRenderScheduler();
page.connectionDiagnosticNetworkProbeTimer = null;
page.connectionDiagnosticNetworkProbePending = false;
page.connectionDiagnosticKeepSamplesOnNextConnect = false;
page.outputCursorRow = 0;
page.outputCursorCol = 0;
page.outputCells = [[]];
page.outputReplayText = "";
page.outputReplayBytes = 0;
page.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE);
page.outputRectWidth = DEFAULT_RECT.width;
page.outputRectHeight = DEFAULT_RECT.height;
page.stdoutReplayCarryText = "";
page.terminalSyncUpdateState = page.terminalSyncUpdateState || { depth: 0, carryText: "", bufferedText: "" };
page.terminalStdoutUserInputPending = false;
page.terminalCols = 80;
page.terminalRows = 24;
page.outputTerminalState = createEmptyTerminalBufferState({
bufferCols: page.terminalCols,
bufferRows: page.terminalRows
});
page.applyTerminalBufferState(page.outputTerminalState);
page.currentOutputScrollTop = 0;
page.outputRectSnapshot = cloneRect(DEFAULT_RECT);
page.outputViewportWindow = null;
page.outputViewportScrollRefreshPending = false;
page.terminalScrollOverlayTimer = null;
page.terminalScrollIdleTimer = null;
page.terminalScrollViewportPrefetchTimer = null;
page.terminalScrollLastOverlayAt = 0;
page.terminalScrollLastViewportRefreshAt = 0;
page.terminalScrollDirection = 0;
page.shellFontSizePx = 15;
page.shellLineHeightRatio = 1.4;
page.shellLineHeightPx = 21;
page.shellCharWidthPx = 9;
page.outputHorizontalPaddingPx = 8;
page.outputRightPaddingPx = 8;
page.windowWidth = DEFAULT_RECT.width;
page.windowHeight = 667;
page.keyboardVisibleHeightPx = 0;
page.keyboardRestoreScrollTop = null;
page.keyboardSessionActive = false;
page.sessionSuspended = false;
page.codexBootstrapGuard = null;
page.activeAiProvider = "codex";
page.activeCodexSandboxMode = "";
page.aiSessionShellReady = true;
page.aiRuntimeExitCarry = "";
page.pendingCodexResumeAfterReconnect = false;
page.client = null;
page.data.statusText = "connected";
page.data.statusClass = "connected";
page.data.serverId = "terminal-replay";
page.data.sessionId = "terminal-replay-session";
page.data.serverLabel = "terminal-replay";
page.queryOutputRect = function queryOutputRect(callback) {
this.currentOutputScrollTop = Math.max(0, Number(this.currentOutputScrollTop) || 0);
this.outputRectSnapshot = cloneRect(DEFAULT_RECT);
if (typeof callback === "function") {
callback(cloneRect(DEFAULT_RECT));
}
};
page.runAfterTerminalLayout = function runAfterTerminalLayout(callback) {
if (typeof callback === "function") {
callback();
}
};
page.measureShellMetrics = function measureShellMetrics(callback) {
if (typeof callback === "function") {
callback();
}
};
page.setStatus = function setStatus(status) {
const nextStatus = String(status || "");
this.data.statusText = nextStatus;
this.data.statusClass = nextStatus === "connected" ? "connected" : "idle";
};
page.syncConnectionAction = () => {};
page.persistTerminalSessionStatus = () => {};
page.persistConnectionDiagnosticSamples = () => {};
page.resetTtsRoundState = () => {};
page.appendTtsRoundOutput = () => {};
page.handleError = (error) => {
throw error instanceof Error ? error : new Error(String(error || "terminal replay error"));
};
return {
page,
restore
};
}
function loadFixture(name) {
const fixtureModule = require(TERMINAL_FIXTURE_PATH);
if (name === "20260311" && typeof fixtureModule.decodeCodexTtyCapture20260311 === "function") {
return fixtureModule.decodeCodexTtyCapture20260311();
}
throw new Error(`未知 fixture${name}`);
}
function parsePerfLine(line) {
const source = String(line || "");
const marker = "[terminal.perf] ";
const index = source.indexOf(marker);
if (index < 0) {
return null;
}
const jsonText = source.slice(index + marker.length).trim();
try {
return JSON.parse(jsonText);
} catch {
return null;
}
}
async function runReplay(options) {
const config = options && typeof options === "object" ? options : {};
const recording = config.captureFile ? loadCaptureRecording(config.captureFile) : null;
const capture = recording ? "" : loadFixture(config.fixture || "20260311");
const chunks = recording ? [] : splitTextIntoChunks(capture, config.chunkSize);
const { page, restore } = createTerminalPageHarness();
const originalConsoleInfo = console.info;
const perfRecords = [];
console.info = (...args) => {
const line = args.map((item) => String(item)).join(" ");
const parsed = parsePerfLine(line);
if (parsed) {
perfRecords.push(parsed);
}
if (!config.quiet) {
originalConsoleInfo(...args);
}
};
try {
for (let round = 0; round < config.repeat; round += 1) {
if (recording) {
let previousOffsetMs = 0;
for (let index = 0; index < recording.events.length; index += 1) {
const event = recording.events[index];
const deltaMs = Math.max(0, event.offsetMs - previousOffsetMs);
previousOffsetMs = event.offsetMs;
if (deltaMs > 0) {
await wait(deltaMs / config.captureSpeed);
}
if (event.type === "stdout" || event.type === "stderr") {
page.handleFrame({
type: event.type,
payload: {
data: event.data
}
});
}
}
} else {
for (let index = 0; index < chunks.length; index += 1) {
page.handleFrame({
type: "stdout",
payload: {
data: chunks[index]
}
});
if (config.cadenceMs > 0) {
await wait(config.cadenceMs);
}
}
}
}
if (config.settleMs > 0) {
await wait(config.settleMs);
}
const flushedSummary = page.flushTerminalPerfLogs("script_end");
const scheduler = page.getTerminalRenderSchedulerSnapshot();
const activeTask = page.getActiveStdoutTaskSnapshot();
const summaries = perfRecords.filter((item) => item && item.event === "perf.summary");
const snapshots = perfRecords.filter((item) => item && item.event === "perf.snapshot");
return {
captureFile: recording ? recording.filePath : "",
captureMeta: recording ? recording.meta : null,
fixture: config.fixture,
chunkSize: config.chunkSize,
cadenceMs: config.cadenceMs,
captureSpeed: config.captureSpeed,
repeat: config.repeat,
settleMs: config.settleMs,
chunkCount: recording ? recording.events.length : chunks.length,
captureChars: capture.length,
perfSummaryCount: summaries.length,
perfSnapshotCount: snapshots.length,
lastPerfSummary: flushedSummary || summaries.at(-1) || null,
lastPerfSnapshot: snapshots.at(-1) || null,
scheduler,
activeTask,
outputLineCount: Array.isArray(page.data.outputRenderLines) ? page.data.outputRenderLines.length : 0,
outputTopSpacerPx: Number(page.data.outputTopSpacerPx) || 0,
outputBottomSpacerPx: Number(page.data.outputBottomSpacerPx) || 0
};
} finally {
console.info = originalConsoleInfo;
restore();
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const result = await runReplay(options);
console.log(JSON.stringify(result, null, 2));
}
export {
createTerminalPageHarness,
parseArgs,
runReplay,
splitTextIntoChunks
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}