@jsenv/terminal-recorder
Version:
Record terminal output as .svg, .gif, .webm, .mp4
217 lines (211 loc) • 6.45 kB
JavaScript
/*
- start dev server (must be able to find xterm.html)
- start chrome
- visit xterm.html
- call page.evaluate of initTerminal()
- call page.evaluate startRecording
- do some page.evaluate window.writeIntoTerminal()
- call page.evaluate stopRecording
- we should have a webm video
*/
// import prettier from "prettier";
import { renderTerminalSvg } from "./svg/render_terminal_svg.js";
const isDev = process.execArgv.some(
(arg) =>
arg.includes("--conditions=development") ||
arg.includes("--conditions=dev:"),
);
const startLocalServer = async () => {
if (isDev) {
const serverDirectoryUrl = import.meta.resolve("./client/");
const { startDevServer } = await import("@jsenv/core");
const devServer = await startDevServer({
logLevel: "warn",
port: 0,
sourceDirectoryUrl: serverDirectoryUrl,
keepProcessAlive: false,
clientAutoreload: false,
ribbon: false,
handleSIGINT: false,
});
return devServer;
}
const serverDirectoryUrl = import.meta.resolve("../dist/");
const { startServer, createFileSystemFetch } = await import("@jsenv/server");
const server = await startServer({
logLevel: "warn",
port: 0,
routes: [
{
endpoint: "GET *",
fetch: createFileSystemFetch(serverDirectoryUrl),
},
],
});
return server;
};
export const startTerminalRecording = async ({
logs,
cols,
rows,
svg,
gif,
video,
debug,
} = {}) => {
const writeCallbackSet = new Set();
const stopCallbackSet = new Set();
const terminalRecords = {
svg: () => {
throw new Error("svg not recorded");
},
gif: () => {
throw new Error("gif not recorded");
},
webm: () => {
throw new Error("video not recorded");
},
mp4: () => {
throw new Error("video not recorded");
},
};
if (!svg && !gif && !video) {
throw new Error("svg, video or gif must be enabled ");
}
const { chromium } = await import("playwright");
const server = await startLocalServer();
const browser = await chromium.launch({
// channel: "chrome", // https://github.com/microsoft/playwright/issues/7716#issuecomment-882634893
headless: !debug,
// needed because https-localhost fails to trust cert on chrome + linux (ubuntu 20.04)
args: ["--ignore-certificate-errors"],
});
const page = await browser.newPage({
ignoreHTTPSErrors: true,
});
page.on("pageerror", (error) => {
throw error;
});
const onConsole = (consoleMessage) => {
console.log(`browser> ${consoleMessage.text()}`);
};
stopCallbackSet.add(() => {
page.off("console", onConsole);
});
page.on("console", onConsole);
await page.goto(`${server.origin}/xterm.html`);
await page.evaluate(
/* eslint-disable no-undef */
async ({ cols, rows, convertEol, textInViewport, gif, video, logs }) => {
await window.xtreamReadyPromise;
const __term__ = await window.initTerminal({
cols,
rows,
convertEol,
textInViewport,
gif,
video,
logs,
});
window.__term__ = __term__;
},
/* eslint-enable no-undef */
{
cols,
rows,
convertEol: process.platform !== "win32",
textInViewport: Boolean(svg),
gif,
video,
logs,
},
);
await page.evaluate(
/* eslint-disable no-undef */
async () => {
const { writeIntoTerminal, stopRecording } =
await window.__term__.startRecording();
window.terminalRecording = { writeIntoTerminal, stopRecording };
},
/* eslint-enable no-undef */
);
writeCallbackSet.add(async (data, options) => {
await page.evaluate(
/* eslint-disable no-undef */
async ({ data, options }) => {
await window.terminalRecording.writeIntoTerminal(data, options);
},
/* eslint-enable no-undef */
{ data, options },
);
});
stopCallbackSet.add(async () => {
const recordedFormats = await page.evaluate(
/* eslint-disable no-undef */
() => {
return window.terminalRecording.stopRecording();
},
/* eslint-enable no-undef */
);
if (!debug) {
server.stop();
browser.close();
}
terminalRecords.svg = async () => {
const ansi = recordedFormats.textInViewport;
const terminalSvg = renderTerminalSvg(ansi, svg);
// prettier is not able to format svg with <text> elements correctly
// see https://github.com/prettier/prettier/issues/14816
// const terminalSvgFormatted = await prettier.format(terminalSvg, {
// parser: "html",
// // https://prettier.io/docs/en/options.html#html-whitespace-sensitivity
// // there is some <text style="white-space:pre">
// // and prettier don't respect this by default
// // we enforce it to be strict here
// // ideally prettier would detect the inline style and respect white-space (opening an issue one day would be great)
// // for now le'ts just force it globally
// htmlWhitespaceSensitivity: "strict",
// });
// return terminalSvgFormatted;
return terminalSvg;
};
terminalRecords.gif = () => {
const terminalGifBuffer = Buffer.from(recordedFormats.gif, "binary");
return terminalGifBuffer;
};
terminalRecords.webm = () => {
const terminalWebmBuffer = Buffer.from(recordedFormats.video, "binary");
return terminalWebmBuffer;
};
terminalRecords.mp4 = async () => {
const terminalWebmBuffer = Buffer.from(terminalRecords.video, "binary");
const webmToMp4Namespace = await import("webm-to-mp4");
const webmToMp4 = webmToMp4Namespace.default;
const terminalMp4Buffer = Buffer.from(webmToMp4(terminalWebmBuffer));
return terminalMp4Buffer;
};
});
let stopped = false;
return {
write: async (data, options) => {
if (stopped) {
throw new Error("write after stop()");
}
const promises = [];
for (const writeCallback of writeCallbackSet) {
promises.push(writeCallback(data, options));
}
await Promise.all(promises);
},
stop: async () => {
const promises = [];
for (const stopCallback of stopCallbackSet) {
promises.push(stopCallback());
}
writeCallbackSet.clear();
stopCallbackSet.clear();
await Promise.all(promises);
return terminalRecords;
},
};
};