scripts/desktop-cdp.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Attach Playwright to the managed Desktop app over the Chrome DevTools
// Protocol, so an agent can drive the real Electron renderer with real input.
//
// make up C=desktop ARGS="--ephemeral --cdp"
// make env-exec ARGS="-- node scripts/desktop-cdp.mjs --screenshot desktop.png"
// make env-exec ARGS="-- node scripts/desktop-cdp.mjs --record run.mp4 ./my-steps.mjs"
//
// Run it through `make env-exec`: that is where the CDP endpoint, renderer
// port, backend port and dev verification code come from. A script argument is
// an ES module whose default export receives { browser, context, page, env,
// login, screencast, args }. The managed Desktop starts signed in; --login (or
// the script's `login()`) restores that session after a step signed out.
//
// The helper only ever disconnects. The app belongs to the environment, and
// `make down` / `make destroy` are what stop it.
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { chromium } from "@playwright/test";
const USAGE = `Usage: node scripts/desktop-cdp.mjs [options] [script.mjs [script args...]]
--login make sure the dev user is signed in and onboarded, then wait for the shell
--screenshot FILE save a PNG of the main window when done
--record FILE.mp4 record a CDP screencast while the script runs
--cdp URL CDP endpoint (default: $MULTICA_DEV_DESKTOP_CDP_URL)
--timeout SECONDS how long to wait for the app and the shell (default: 60)
Run through \`make env-exec ARGS="-- node scripts/desktop-cdp.mjs ..."\`.`;
function fail(message) {
console.error(`[desktop-cdp] ${message}`);
process.exit(1);
}
function log(message) {
console.log(`[desktop-cdp] ${message}`);
}
function parseCli(argv) {
const options = { login: false, screenshot: "", record: "", cdp: "", timeout: 60, script: "", args: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const value = () => {
if (i + 1 >= argv.length) fail(`${arg} needs a value.\n\n${USAGE}`);
return argv[++i];
};
if (arg === "-h" || arg === "--help") {
console.log(USAGE);
process.exit(0);
} else if (arg === "--login") options.login = true;
else if (arg === "--screenshot") options.screenshot = value();
else if (arg === "--record") options.record = value();
else if (arg === "--cdp") options.cdp = value();
else if (arg === "--timeout") options.timeout = Number(value());
else if (arg.startsWith("-")) fail(`unknown option ${arg}\n\n${USAGE}`);
else {
options.script = arg;
options.args = argv.slice(i + 1);
break;
}
}
if (!Number.isFinite(options.timeout) || options.timeout <= 0) fail("--timeout must be a positive number of seconds.");
return options;
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
async function connect(cdpUrl, deadline) {
for (;;) {
try {
return await chromium.connectOverCDP(cdpUrl);
} catch (error) {
if (Date.now() > deadline) {
fail(`could not attach to ${cdpUrl}: ${error.message}\nIs the desktop component running with a debug port? Check \`make status\`.`);
}
await sleep(1000);
}
}
}
// The main window and issue windows load the same renderer URL; only the
// preload's window context tells them apart.
async function mainRendererPage(browser, rendererUrl, deadline) {
const port = new URL(rendererUrl).port;
for (;;) {
const pages = browser
.contexts()
.flatMap((context) => context.pages())
.filter((page) => {
try {
const url = new URL(page.url());
return url.protocol === "http:" && url.port === port;
} catch {
return false;
}
});
for (const page of pages) {
const kind = await page
.evaluate(() => window.desktopAPI?.windowContext?.kind ?? "main")
.catch(() => "");
if (kind === "main") return page;
}
if (Date.now() > deadline) fail(`no main renderer window on port ${port} appeared in time.`);
await sleep(500);
}
}
async function request(apiUrl, method, path, { token, workspaceId, body } = {}) {
const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
if (workspaceId) headers["X-Workspace-ID"] = workspaceId;
const response = await fetch(`${apiUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) {
const error = new Error(`${method} ${path} → ${response.status} ${text.slice(0, 300)}`);
error.status = response.status;
throw error;
}
return text ? JSON.parse(text) : null;
}
async function tokenIsValid(apiUrl, token) {
if (!token) return false;
try {
await request(apiUrl, "GET", "/api/me", { token });
return true;
} catch {
return false;
}
}
// `make up` signs the environment's CLI profile in with a personal access
// token, the same one Desktop starts with. Reusing it avoids requesting a new
// login code, which the backend rate-limits.
function profileToken(env) {
if (!env.MULTICA_DEV_PROFILE) return null;
const home = env.MULTICA_DEV_PROFILES_HOME || join(homedir(), ".multica", "profiles");
try {
return JSON.parse(readFileSync(join(home, env.MULTICA_DEV_PROFILE, "config.json"), "utf8")).token || null;
} catch {
return null;
}
}
async function ensureWorkspace(apiUrl, token, env) {
const slug = env.MULTICA_DEV_WORKSPACE_SLUG || "dev";
const name = env.MULTICA_DEV_WORKSPACE_NAME || "Dev";
const workspaces = (await request(apiUrl, "GET", "/api/workspaces", { token })) ?? [];
const existing = workspaces.find((ws) => ws.slug === slug) ?? workspaces[0];
if (existing) return existing;
return request(apiUrl, "POST", "/api/workspaces", { token, body: { name, slug } });
}
// Signs in through the API rather than the form: Desktop keeps its session in
// localStorage, so writing the token and reloading lands in the same state a
// real login does, without depending on login-page markup.
async function login(page, env, { timeoutMs }) {
if (!env.PORT) fail("PORT is not set; run this through `make env-exec`.");
const apiUrl = `http://localhost:${env.PORT}`;
const email = env.MULTICA_DEV_EMAIL || "dev@localhost";
const code = env.MULTICA_DEV_VERIFICATION_CODE || "888888";
const stored = await page.evaluate(() => localStorage.getItem("multica_token"));
let token = null;
for (const candidate of [stored, profileToken(env)]) {
if (await tokenIsValid(apiUrl, candidate)) {
token = candidate;
break;
}
}
if (!token) {
try {
await request(apiUrl, "POST", "/auth/send-code", { body: { email } });
} catch (error) {
if (error.status !== 429) throw error;
fail(`${error.message}\nA login code was requested moments ago; wait a minute and retry.`);
}
try {
({ token } = await request(apiUrl, "POST", "/auth/verify-code", { body: { email, code } }));
} catch (error) {
fail(`${error.message}\nDo not retry immediately: repeated verify attempts lock the code. Wait ~40s.`);
}
}
const workspace = await ensureWorkspace(apiUrl, token, env);
const me = await request(apiUrl, "GET", "/api/me", { token });
if (!me.onboarded_at) {
await request(apiUrl, "POST", "/api/me/onboarding/complete", {
token,
workspaceId: workspace.id,
body: { completion_path: "skip_existing", workspace_id: workspace.id },
});
}
// The desktop shell's canvas; it shows a role=status loader until the
// workspace has resolved.
const shell = page.locator('[data-slot="main-canvas"]').first();
const changed = token !== stored || !me.onboarded_at;
if (changed || !(await shell.isVisible())) {
await page.evaluate((value) => localStorage.setItem("multica_token", value), token);
await page.reload();
}
await shell.waitFor({ state: "visible", timeout: timeoutMs });
await shell.locator('> [role="status"]').waitFor({ state: "detached", timeout: timeoutMs });
log(`signed in as ${email} in workspace ${workspace.slug}`);
return { token, workspace };
}
// CDP screencasts emit a frame only when the page repaints, so each frame's
// display time comes from its timestamp; the ffconcat file keeps that timing.
async function screencast(page, dir, { quality = 80 } = {}) {
mkdirSync(dir, { recursive: true });
const session = await page.context().newCDPSession(page);
const frames = [];
session.on("Page.screencastFrame", ({ data, metadata, sessionId }) => {
const file = `frame-${String(frames.length).padStart(5, "0")}.jpg`;
frames.push({ file, timestamp: metadata.timestamp });
writeFileSync(join(dir, file), Buffer.from(data, "base64"));
session.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
});
await session.send("Page.startScreencast", { format: "jpeg", quality, everyNthFrame: 1 });
return async function stop() {
const stoppedAt = Date.now() / 1000;
await session.send("Page.stopScreencast").catch(() => {});
await session.detach().catch(() => {});
const lines = ["ffconcat version 1.0"];
frames.forEach((frame, index) => {
const next = frames[index + 1]?.timestamp ?? Math.max(stoppedAt, frame.timestamp + 0.5);
lines.push(`file ${frame.file}`, `duration ${Math.max(next - frame.timestamp, 0.01).toFixed(3)}`);
});
if (frames.length) lines.push(`file ${frames.at(-1).file}`);
const concat = join(dir, "frames.ffconcat");
writeFileSync(concat, `${lines.join("\n")}\n`);
return { dir, concat, frames: frames.length };
};
}
function encodeVideo(concat, output) {
const args = [
"-y", "-loglevel", "error", "-f", "concat", "-i", concat,
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-pix_fmt", "yuv420p", "-vsync", "vfr", output,
];
const result = spawnSync("ffmpeg", args, { stdio: "inherit" });
if (result.error || result.status !== 0) {
log(`ffmpeg could not encode the video; frames are kept. Encode with: ffmpeg ${args.join(" ")}`);
return false;
}
return true;
}
async function main() {
const options = parseCli(process.argv.slice(2));
const env = process.env;
const cdpUrl = options.cdp || env.MULTICA_DEV_DESKTOP_CDP_URL;
if (!cdpUrl) {
fail('no CDP endpoint. Start Desktop with one (make up C=desktop ARGS="--cdp") and run this through `make env-exec`.');
}
const rendererUrl = env.MULTICA_DEV_DESKTOP_RENDERER_URL;
if (!rendererUrl) fail("MULTICA_DEV_DESKTOP_RENDERER_URL is not set; run this through `make env-exec`.");
const timeoutMs = options.timeout * 1000;
const deadline = Date.now() + timeoutMs;
const browser = await connect(cdpUrl, deadline);
const page = await mainRendererPage(browser, rendererUrl, deadline);
const context = page.context();
log(`attached to ${cdpUrl} → ${page.url()}`);
const loginHere = () => login(page, env, { timeoutMs });
if (options.login) await loginHere();
let stopRecording;
const recordTo = options.record ? resolve(options.record) : "";
if (recordTo) stopRecording = await screencast(page, `${recordTo}.frames`);
try {
if (options.script) {
const module = await import(pathToFileURL(resolve(options.script)).href);
if (typeof module.default !== "function") fail(`${options.script} must export a default async function.`);
await module.default({ browser, context, page, env, login: loginHere, screencast, args: options.args });
}
} finally {
if (stopRecording) {
const { concat, frames } = await stopRecording();
if (frames === 0) log("the screencast captured no frames (nothing repainted).");
else if (encodeVideo(concat, recordTo)) log(`recorded ${frames} frames → ${recordTo}`);
}
}
if (options.screenshot) {
const file = resolve(options.screenshot);
mkdirSync(dirname(file), { recursive: true });
await page.screenshot({ path: file });
log(`screenshot → ${file}`);
}
}
main().then(
// Exiting drops the CDP connection without asking the app to close.
() => process.exit(0),
(error) => fail(error?.stack || String(error)),
);