test(media): validate codec and recovery boundaries
This commit is contained in:
1 parent
e23f367313
commit
2d3e82e1f1
6 files changed
+548
-33
No files matched your search
@@ -0,0 +1,384 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { AccessToken, TrackSource } from "livekit-server-sdk";
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
cleanupRun,
|
||||
createPage,
|
||||
reservePort,
|
||||
waitForVersion,
|
||||
type PageHandle,
|
||||
} from "./native-one-viewer-gate";
|
||||
|
||||
const WIDTH = 1_920;
|
||||
const HEIGHT = 1_080;
|
||||
const FRAME_RATE = 30;
|
||||
const MAX_BITRATE = 5_000_000;
|
||||
const SETTLE_MS = 5_000;
|
||||
const SAMPLE_MS = 10_000;
|
||||
|
||||
interface RuntimeResult<T> {
|
||||
result: { value?: T };
|
||||
exceptionDetails?: {
|
||||
text?: string;
|
||||
exception?: { description?: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
type: string;
|
||||
id: number;
|
||||
cpuTime: number;
|
||||
}
|
||||
|
||||
interface ProcessSnapshot {
|
||||
processInfo: ProcessInfo[];
|
||||
}
|
||||
|
||||
async function evaluate<T>(
|
||||
cdp: CdpConnection,
|
||||
page: PageHandle,
|
||||
expression: string,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const value = await cdp.call<RuntimeResult<T>>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: true,
|
||||
},
|
||||
page.sessionId,
|
||||
Date.now() + timeoutMs,
|
||||
);
|
||||
if (value.exceptionDetails) {
|
||||
throw new Error(
|
||||
value.exceptionDetails.exception?.description ??
|
||||
value.exceptionDetails.text ??
|
||||
"Browser evaluation failed",
|
||||
);
|
||||
}
|
||||
return value.result.value as T;
|
||||
}
|
||||
|
||||
function sourceHtml(): string {
|
||||
return `<!doctype html><title>Codec Preflight Source</title>
|
||||
<style>html,body{margin:0;overflow:hidden;background:#000}canvas{display:block;width:100vw;height:100vh}</style>
|
||||
<canvas width="${WIDTH}" height="${HEIGHT}"></canvas><script>
|
||||
const c=document.querySelector('canvas'),x=c.getContext('2d',{alpha:false});let f=0;
|
||||
function draw(){f+=1;for(let r=0;r<14;r+=1)for(let q=0;q<24;q+=1){
|
||||
x.fillStyle='hsl('+((f*29+r*71+q*43)%360)+' 80% '+(35+(f+r+q)%30)+'%)';
|
||||
x.fillRect(q*80,r*78,80,78)}x.fillStyle='#fff';x.fillRect(f*13%1920,0,12,1080);
|
||||
x.fillStyle='#000';x.font='60px monospace';x.fillText(String(f).padStart(8,'0'),24,72)}
|
||||
draw();setInterval(draw,1000/${FRAME_RATE});</script>`;
|
||||
}
|
||||
|
||||
async function startServer(port: number): Promise<Server> {
|
||||
const livekitBundle = await readFile(
|
||||
resolve("node_modules/livekit-client/dist/livekit-client.umd.js"),
|
||||
);
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", `http://127.0.0.1:${port}`).pathname;
|
||||
if (path === "/livekit.js") {
|
||||
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
||||
response.end(livekitBundle);
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(path === "/source"
|
||||
? sourceHtml()
|
||||
: "<!doctype html><title>Codec Preflight</title><script src='/livekit.js'></script><video autoplay muted playsinline style='width:960px'></video>");
|
||||
});
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(port, "127.0.0.1", resolveListen);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
function browserProbe(): string {
|
||||
return `(() => {
|
||||
const WIDTH=${WIDTH},HEIGHT=${HEIGHT},FRAME_RATE=${FRAME_RATE},MAX_BITRATE=${MAX_BITRATE};
|
||||
const SETTLE_MS=${SETTLE_MS},SAMPLE_MS=${SAMPLE_MS};let stream=null,track=null;
|
||||
const delay=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));
|
||||
async function waitFor(predicate,timeout,label){const deadline=performance.now()+timeout;
|
||||
while(performance.now()<deadline){if(await predicate())return;await delay(50)}
|
||||
throw new Error('Timed out waiting for '+label)}
|
||||
function preferences(codec){const all=RTCRtpSender.getCapabilities('video')?.codecs||[];
|
||||
const mime=codec==='h264'?'video/h264':'video/vp8';const primary=all.filter((item)=>item.mimeType.toLowerCase()===mime);
|
||||
if(!primary.length)throw new Error(mime+' unavailable');
|
||||
const repair=new Set(['video/rtx','video/red','video/ulpfec','video/flexfec-03']);
|
||||
return [...primary,...all.filter((item)=>repair.has(item.mimeType.toLowerCase()))]}
|
||||
async function gathered(pc){if(pc.iceGatheringState==='complete')return;
|
||||
await new Promise((resolve)=>{const changed=()=>{if(pc.iceGatheringState!=='complete')return;
|
||||
pc.removeEventListener('icegatheringstatechange',changed);resolve()};pc.addEventListener('icegatheringstatechange',changed)})}
|
||||
async function negotiate(a,b){await a.setLocalDescription(await a.createOffer());await gathered(a);
|
||||
await b.setRemoteDescription(a.localDescription);await b.setLocalDescription(await b.createAnswer());
|
||||
await gathered(b);await a.setRemoteDescription(b.localDescription)}
|
||||
function statsValue(item){if(!item)return null;const keys=['id','timestamp','bytesSent','bytesReceived',
|
||||
'framesEncoded','framesDecoded','framesDropped','framesPerSecond','frameWidth','frameHeight','totalEncodeTime',
|
||||
'totalDecodeTime','qualityLimitationReason','qualityLimitationDurations','encoderImplementation',
|
||||
'decoderImplementation','powerEfficientEncoder','powerEfficientDecoder','frames'];const value={};
|
||||
for(const key of keys)value[key]=item[key]??null;return value}
|
||||
async function snapshot(sender,receiver,rendered){const [sendReport,receiveReport]=await Promise.all([sender.getStats(),receiver.getStats()]);
|
||||
let outbound=null,inbound=null;sendReport.forEach((item)=>{if(item.type==='outbound-rtp'&&item.kind==='video'&&!item.isRemote)outbound=item});
|
||||
receiveReport.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video'&&!item.isRemote)inbound=item});
|
||||
const sendCodec=outbound?.codecId?sendReport.get(outbound.codecId):null;
|
||||
const receiveCodec=inbound?.codecId?receiveReport.get(inbound.codecId):null;
|
||||
const mediaSource=outbound?.mediaSourceId?sendReport.get(outbound.mediaSourceId):null;
|
||||
const codec=(item)=>item?{mimeType:item.mimeType||null,sdpFmtpLine:item.sdpFmtpLine||null}:null;
|
||||
return {rendered:rendered(),sender:statsValue(outbound),receiver:statsValue(inbound),
|
||||
mediaSource:statsValue(mediaSource),senderCodec:codec(sendCodec),receiverCodec:codec(receiveCodec)}}
|
||||
function delta(after,before,key){const a=after?.[key],b=before?.[key];return Number.isFinite(a)&&Number.isFinite(b)&&a>=b?a-b:null}
|
||||
function summarize(before,after,sampleMs){const encoded=delta(after.sender,before.sender,'framesEncoded');
|
||||
const decoded=delta(after.receiver,before.receiver,'framesDecoded');const bytes=delta(after.sender,before.sender,'bytesSent');
|
||||
const sourceFrames=delta(after.mediaSource,before.mediaSource,'frames');
|
||||
const encodeTime=delta(after.sender,before.sender,'totalEncodeTime');const decodeTime=delta(after.receiver,before.receiver,'totalDecodeTime');
|
||||
return {senderCodec:after.senderCodec,receiverCodec:after.receiverCodec,sender:after.sender,receiver:after.receiver,
|
||||
measured:{sampleMs,encodedFps:encoded===null?null:encoded*1000/sampleMs,
|
||||
decodedFps:decoded===null?null:decoded*1000/sampleMs,bitrateKbps:bytes===null?null:bytes*8/sampleMs,
|
||||
encodeMsPerFrame:encodeTime!==null&&encoded>0?encodeTime*1000/encoded:null,
|
||||
decodeMsPerFrame:decodeTime!==null&&decoded>0?decodeTime*1000/decoded:null,
|
||||
sourceFps:sourceFrames===null?after.mediaSource?.framesPerSecond:sourceFrames*1000/sampleMs,
|
||||
encodedSourceRatio:sourceFrames>0&&encoded!==null?encoded/sourceFrames:null,
|
||||
renderedFrames:delta(after,before,'rendered')}}}
|
||||
async function setup(){stream=await navigator.mediaDevices.getDisplayMedia({video:{
|
||||
width:{ideal:WIDTH,max:WIDTH},height:{ideal:HEIGHT,max:HEIGHT},frameRate:{ideal:FRAME_RATE,max:FRAME_RATE}},audio:false});
|
||||
track=stream.getVideoTracks()[0];if(!track)throw new Error('No display video track');track.contentHint='motion';
|
||||
return {settings:track.getSettings(),constraints:track.getConstraints(),contentHint:track.contentHint,label:track.label}}
|
||||
function capabilities(){const simple=(codec)=>({mimeType:codec.mimeType,sdpFmtpLine:codec.sdpFmtpLine||null});
|
||||
return {sender:(RTCRtpSender.getCapabilities('video')?.codecs||[]).map(simple),
|
||||
receiver:(RTCRtpReceiver.getCapabilities('video')?.codecs||[]).map(simple)}}
|
||||
async function edge(codec,sourceTrack){
|
||||
const sendPc=new RTCPeerConnection({iceServers:[]}),receivePc=new RTCPeerConnection({iceServers:[]});
|
||||
const transceiver=sendPc.addTransceiver(sourceTrack,{direction:'sendonly'});transceiver.setCodecPreferences(preferences(codec));
|
||||
let receiver=null,remoteTrack=null;receivePc.addEventListener('track',(event)=>{receiver=event.receiver;remoteTrack=event.track});
|
||||
await negotiate(sendPc,receivePc);await waitFor(()=>sendPc.connectionState==='connected'&&receivePc.connectionState==='connected',10000,'connection');
|
||||
await waitFor(()=>receiver&&remoteTrack,5000,'remote track');const video=document.createElement('video');let rendered=0;
|
||||
video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video);
|
||||
video.srcObject=new MediaStream([remoteTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)}
|
||||
await video.play();const parameters=transceiver.sender.getParameters();if(!parameters.encodings.length)parameters.encodings=[{}];
|
||||
parameters.encodings[0].maxBitrate=MAX_BITRATE;parameters.encodings[0].maxFramerate=FRAME_RATE;
|
||||
parameters.encodings[0].scaleResolutionDownBy=1;parameters.degradationPreference='balanced';await transceiver.sender.setParameters(parameters);
|
||||
await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},10000,'decoded frame');
|
||||
return {sendPc,receivePc,sender:transceiver.sender,receiver,remoteTrack,rendered:()=>rendered}}
|
||||
async function run(codec,count,topology){if(!track)throw new Error('Capture not initialized');const edges=[];
|
||||
try{edges.push(await edge(codec,track));
|
||||
if(topology==='relay')edges.push(await edge(codec,edges[0].remoteTrack));
|
||||
else for(let index=1;index<count;index+=1)edges.push(await edge(codec,track));await delay(SETTLE_MS);
|
||||
const before=await Promise.all(edges.map((item)=>snapshot(item.sender,item.receiver,item.rendered))),started=performance.now();
|
||||
await delay(SAMPLE_MS);const sampleMs=performance.now()-started;
|
||||
const after=await Promise.all(edges.map((item)=>snapshot(item.sender,item.receiver,item.rendered)));
|
||||
return edges.map((_,index)=>summarize(before[index],after[index],sampleMs))}
|
||||
finally{for(const item of edges){item.sendPc.close();item.receivePc.close()}await delay(300)}}
|
||||
async function runLivekit(codec,url,publisherToken,subscriberToken){if(!track)throw new Error('Capture not initialized');
|
||||
const sdk=globalThis.LivekitClient,publisher=new sdk.Room({dynacast:true}),subscriber=new sdk.Room();let remoteTrack=null,rendered=0;
|
||||
subscriber.on(sdk.RoomEvent.TrackSubscribed,(nextTrack)=>{if(nextTrack.kind==='video')remoteTrack=nextTrack});
|
||||
try{await publisher.connect(url,publisherToken,{autoSubscribe:false,rtcConfig:{iceServers:[]}});
|
||||
await subscriber.connect(url,subscriberToken,{rtcConfig:{iceServers:[]}});
|
||||
const publication=await publisher.localParticipant.publishTrack(track,{source:sdk.Track.Source.ScreenShare,
|
||||
videoCodec:codec,backupCodec:false,screenShareEncoding:{maxBitrate:MAX_BITRATE,maxFramerate:FRAME_RATE},
|
||||
degradationPreference:'balanced'});
|
||||
await waitFor(()=>publication.videoTrack?.sender&&remoteTrack,15000,'LiveKit publication');
|
||||
const video=document.createElement('video');video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video);
|
||||
video.srcObject=new MediaStream([remoteTrack.mediaStreamTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)}
|
||||
await video.play();const receiver={getStats:async()=>{const report=await remoteTrack.getRTCStatsReport();if(!report)throw new Error('LiveKit receiver stats unavailable');return report}};
|
||||
await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report?.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},15000,'LiveKit decoded frame');
|
||||
await delay(SETTLE_MS);const sender=publication.videoTrack.sender;
|
||||
const before=await snapshot(sender,receiver,()=>rendered),started=performance.now();await delay(SAMPLE_MS);
|
||||
const sampleMs=performance.now()-started,after=await snapshot(sender,receiver,()=>rendered);
|
||||
return {...summarize(before,after,sampleMs),senderParameters:sender.getParameters()}}
|
||||
finally{await Promise.allSettled([publisher.disconnect(false),subscriber.disconnect(false)]);await delay(300)}}
|
||||
function stop(){stream?.getTracks().forEach((item)=>item.stop())}
|
||||
Object.defineProperty(globalThis,'__CODEC_PREFLIGHT__',{value:{setup,capabilities,run,runLivekit,stop}});
|
||||
})()`;
|
||||
}
|
||||
|
||||
async function processSnapshot(cdp: CdpConnection): Promise<ProcessSnapshot> {
|
||||
return cdp.call<ProcessSnapshot>(
|
||||
"SystemInfo.getProcessInfo",
|
||||
{},
|
||||
undefined,
|
||||
Date.now() + 5_000,
|
||||
);
|
||||
}
|
||||
|
||||
function processCpu(
|
||||
before: ProcessSnapshot,
|
||||
after: ProcessSnapshot,
|
||||
wallMs: number,
|
||||
): Record<string, unknown> {
|
||||
const baseline = new Map(
|
||||
before.processInfo.map((item) => [`${item.type}:${item.id}`, item.cpuTime]),
|
||||
);
|
||||
let cpuSeconds = 0;
|
||||
let stableProcesses = 0;
|
||||
for (const item of after.processInfo) {
|
||||
const previous = baseline.get(`${item.type}:${item.id}`);
|
||||
if (previous === undefined || item.cpuTime < previous) continue;
|
||||
cpuSeconds += item.cpuTime - previous;
|
||||
stableProcesses += 1;
|
||||
}
|
||||
const wallSeconds = wallMs / 1_000;
|
||||
return {
|
||||
wallSeconds,
|
||||
cpuSeconds,
|
||||
logicalCoreEquivalent: cpuSeconds / wallSeconds,
|
||||
stableProcesses,
|
||||
processIdentityChanged:
|
||||
stableProcesses !== before.processInfo.length ||
|
||||
stableProcesses !== after.processInfo.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
if (!chromePath) throw new Error("CHROME_PATH must point to Chrome");
|
||||
const codec = process.env.CODEC_PREFLIGHT_CODEC?.trim().toLowerCase();
|
||||
if (codec !== "vp8" && codec !== "h264") {
|
||||
throw new Error("CODEC_PREFLIGHT_CODEC must be vp8 or h264");
|
||||
}
|
||||
const edges = Number(process.env.CODEC_PREFLIGHT_EDGES ?? "1");
|
||||
if (edges !== 1 && edges !== 2) {
|
||||
throw new Error("CODEC_PREFLIGHT_EDGES must be 1 or 2");
|
||||
}
|
||||
const topology = process.env.CODEC_PREFLIGHT_TOPOLOGY?.trim() || "direct";
|
||||
if (topology !== "direct" && topology !== "relay" && topology !== "livekit") {
|
||||
throw new Error("CODEC_PREFLIGHT_TOPOLOGY must be direct, relay, or livekit");
|
||||
}
|
||||
const [debugPort, pagePort] = await Promise.all([reservePort(), reservePort()]);
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-codec-preflight-"));
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let server: Server | null = null;
|
||||
try {
|
||||
server = await startServer(pagePort);
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--auto-select-tab-capture-source-by-title=Codec Preflight Source",
|
||||
"--window-size=1920,1080",
|
||||
"about:blank",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000);
|
||||
const gpuInfo = await cdp.call<unknown>("SystemInfo.getInfo", {}, undefined, Date.now() + 5_000);
|
||||
await createPage(cdp, `http://127.0.0.1:${pagePort}/source`, undefined, true);
|
||||
const page = await createPage(cdp, `http://127.0.0.1:${pagePort}/test`, undefined, true);
|
||||
await evaluate<void>(cdp, page, browserProbe(), 5_000);
|
||||
const capabilities = await evaluate<unknown>(
|
||||
cdp,
|
||||
page,
|
||||
"globalThis.__CODEC_PREFLIGHT__.capabilities()",
|
||||
5_000,
|
||||
);
|
||||
const capture = await evaluate<unknown>(
|
||||
cdp,
|
||||
page,
|
||||
"globalThis.__CODEC_PREFLIGHT__.setup()",
|
||||
15_000,
|
||||
);
|
||||
const before = await processSnapshot(cdp);
|
||||
const started = performance.now();
|
||||
let media: unknown;
|
||||
if (topology === "livekit") {
|
||||
const livekitUrl = process.env.CODEC_PREFLIGHT_LIVEKIT_URL?.trim();
|
||||
const apiKey = process.env.CODEC_PREFLIGHT_LIVEKIT_KEY?.trim();
|
||||
const apiSecret = process.env.CODEC_PREFLIGHT_LIVEKIT_SECRET?.trim();
|
||||
if (!livekitUrl || !apiKey || !apiSecret) {
|
||||
throw new Error("LiveKit preflight credentials are missing");
|
||||
}
|
||||
const room = `codec-preflight-${Date.now()}`;
|
||||
const token = async (identity: string, publish: boolean) => {
|
||||
const access = new AccessToken(apiKey, apiSecret, { identity, ttl: 60 });
|
||||
access.addGrant({
|
||||
roomJoin: true,
|
||||
room,
|
||||
canPublish: publish,
|
||||
canPublishSources: publish ? [TrackSource.SCREEN_SHARE] : undefined,
|
||||
canSubscribe: !publish,
|
||||
canPublishData: false,
|
||||
});
|
||||
return access.toJwt();
|
||||
};
|
||||
const [publisherToken, subscriberToken] = await Promise.all([
|
||||
token("host", true),
|
||||
token("viewer", false),
|
||||
]);
|
||||
media = await evaluate<unknown>(
|
||||
cdp,
|
||||
page,
|
||||
`globalThis.__CODEC_PREFLIGHT__.runLivekit(${JSON.stringify(codec)}, ${JSON.stringify(livekitUrl)}, ${JSON.stringify(publisherToken)}, ${JSON.stringify(subscriberToken)})`,
|
||||
60_000,
|
||||
);
|
||||
} else {
|
||||
media = await evaluate<unknown>(
|
||||
cdp,
|
||||
page,
|
||||
`globalThis.__CODEC_PREFLIGHT__.run(${JSON.stringify(codec)}, ${edges}, ${JSON.stringify(topology)})`,
|
||||
45_000,
|
||||
);
|
||||
}
|
||||
const wallMs = performance.now() - started;
|
||||
const after = await processSnapshot(cdp);
|
||||
await evaluate<void>(cdp, page, "globalThis.__CODEC_PREFLIGHT__.stop()", 5_000);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
browser: version.Browser,
|
||||
configuration: {
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
frameRate: FRAME_RATE,
|
||||
maxBitrate: MAX_BITRATE,
|
||||
contentHint: "motion",
|
||||
settleMs: SETTLE_MS,
|
||||
sampleMs: SAMPLE_MS,
|
||||
edges,
|
||||
topology,
|
||||
},
|
||||
gpuInfo,
|
||||
capabilities,
|
||||
capture,
|
||||
codec,
|
||||
media,
|
||||
cpu: processCpu(before, after, wallMs),
|
||||
};
|
||||
const outputDirectory = resolve("benchmark-results");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const output = join(
|
||||
outputDirectory,
|
||||
`browser-codec-preflight-${codec}-${topology}-${edges}.json`,
|
||||
);
|
||||
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(output);
|
||||
} finally {
|
||||
await cleanupRun({
|
||||
cdp,
|
||||
native: null,
|
||||
chrome,
|
||||
server: null,
|
||||
profile,
|
||||
ports: [debugPort],
|
||||
});
|
||||
if (server) {
|
||||
await new Promise<void>((resolveClose) => server!.close(() => resolveClose()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user