Ctrl K
ComunidadGuías…

scripts/desktop-cdp.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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)),
);