AkurAI Build
Menu

popagent

public

Latest change 2c7138e7ddf8cb6b5ac0dc1abbe6d76743366c19 - Checkpoint workspace and secret management work by AkurAI Build

import { afterAll, describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import type { ManagedBrowser } from "./browser-sessions";
import { browserRuntime } from "./browser-settings";
import { startServer } from "./server";

const server = startServer(0);
const base = `http://127.0.0.1:${server.port}`;
const threadId = `browser-api-${crypto.randomUUID()}`;
const sessionId = `interactive:${threadId}`;
const mouseEvents: unknown[] = [];
const keyboardEvents: unknown[] = [];
const closedThreads: string[] = [];

const browser: ManagedBrowser = {
  getState: async () => ({ currentUrl: "https://example.com", tabs: [{ id: "0", url: "https://example.com", title: "Example" }], activeTabIndex: 0 }),
  closeThreadSession: async (id) => { closedThreads.push(id); },
  startScreencast: async () => {
    const emitter = new EventEmitter() as EventEmitter & { stop: () => Promise<void>; isActive: () => boolean; reconnect: () => Promise<void>; emitUrl: (url: string) => void };
    emitter.stop = async () => { emitter.emit("stop", "manual"); };
    emitter.isActive = () => true;
    emitter.reconnect = async () => undefined;
    emitter.emitUrl = (url) => { emitter.emit("url", url); };
    setTimeout(() => {
      emitter.emit("frame", { data: "ZmFrZQ==", viewport: { width: 100, height: 50 } });
      emitter.emit("stop", "manual");
    }, 0);
    return emitter;
  },
  injectMouseEvent: async (event) => { mouseEvents.push(event); },
  injectKeyboardEvent: async (event) => { keyboardEvents.push(event); },
};

afterAll(async () => {
  await browserRuntime.sessions.close(sessionId);
  server.stop(true);
});

describe("browser session API", () => {
  test("reports local provider health without exposing secrets", async () => {
    const response = await fetch(`${base}/api/browser/health`);
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual(expect.objectContaining({ provider: "AgentBrowser", headless: true, healthy: true }));
  });

  test("runs an end-to-end provider diagnostic", async () => {
    const response = await fetch(`${base}/api/browser/test`, { method: "POST" });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual(expect.objectContaining({ url: "https://example.com/", elementCount: expect.any(Number) }));
  }, 30_000);

  test("lists browser sessions for a chat thread", async () => {
    await browserRuntime.sessions.touch(threadId, "interactive", browser);
    const response = await fetch(`${base}/api/browser/sessions?threadId=${encodeURIComponent(threadId)}`);
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ sessions: [expect.objectContaining({ id: sessionId, currentUrl: "https://example.com" })] });
  });

  test("requires takeover before accepting bounded input events", async () => {
    const encoded = encodeURIComponent(sessionId);
    expect((await fetch(`${base}/api/browser/sessions/${encoded}/input`, {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "mouse", event: { type: "mouseMoved", x: 10, y: 20 } }),
    })).status).toBe(409);
    expect((await fetch(`${base}/api/browser/sessions/${encoded}/takeover`, {
      method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: true }),
    })).status).toBe(200);
    expect((await fetch(`${base}/api/browser/sessions/${encoded}/input`, {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "mouse", event: { type: "mouseMoved", x: 10, y: 20 } }),
    })).status).toBe(204);
    expect(mouseEvents).toHaveLength(1);
    expect((await fetch(`${base}/api/browser/sessions/${encoded}/input`, {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "keyboard", event: { type: "keyDown", key: "Enter", code: "Enter" } }),
    })).status).toBe(204);
    expect(keyboardEvents).toHaveLength(1);
  });

  test("streams actual screencast frames as server-sent events", async () => {
    const response = await fetch(`${base}/api/browser/sessions/${encodeURIComponent(sessionId)}/stream`);
    expect(response.status).toBe(200);
    expect(response.headers.get("content-type")).toContain("text/event-stream");
    expect(await response.text()).toContain('"data":"ZmFrZQ=="');
  });

  test("closes a browser session", async () => {
    const response = await fetch(`${base}/api/browser/sessions/${encodeURIComponent(sessionId)}/close`, { method: "POST" });
    expect(response.status).toBe(204);
    expect(await browserRuntime.sessions.list(threadId)).toEqual([]);
  });

  test("manages encrypted browser authentication profiles without returning state", async () => {
    const name = `Browser profile ${crypto.randomUUID()}`;
    const create = await fetch(`${base}/api/browser/profiles`, {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ name, enabled: false, state: { cookies: [], origins: [] } }),
    });
    expect(create.status).toBe(201);
    const profile = await create.json() as { id: string; name: string };
    try {
      const listed = await (await fetch(`${base}/api/browser/profiles`)).json() as { profiles: unknown[] };
      expect(listed.profiles).toContainEqual(expect.objectContaining({ id: profile.id, name }));
      expect(JSON.stringify(listed)).not.toContain("cookies");
      expect((await fetch(`${base}/api/browser/profiles`, {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ name: "bad", enabled: false, state: { cookies: "invalid" } }),
      })).status).toBe(400);
    } finally {
      expect((await fetch(`${base}/api/browser/profiles/${profile.id}`, { method: "DELETE" })).status).toBe(204);
    }
  });

  test("exposes the contained recording catalog and rejects invalid names", async () => {
    const list = await fetch(`${base}/api/browser/recordings`);
    expect(list.status).toBe(200);
    expect(await list.json()).toEqual({ recordings: expect.any(Array) });
    expect((await fetch(`${base}/api/browser/recordings/${encodeURIComponent("../secret.avi")}`)).status).toBe(404);
  });

  test("manages origin-bound secrets without returning values", async () => {
    const name = `browser/password-${crypto.randomUUID()}`;
    const value = `credential-${crypto.randomUUID()}`;
    const encoded = encodeURIComponent(name);
    const create = await fetch(`${base}/api/secrets`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name, value, allowedOrigin: "https://example.com" }),
    });
    expect(create.status).toBe(201);
    try {
      expect(await create.json()).toEqual(expect.objectContaining({
        name,
        allowedOrigin: "https://example.com",
      }));
      const listed = await (await fetch(`${base}/api/secrets`)).json() as { secrets: unknown[] };
      expect(listed.secrets).toContainEqual(expect.objectContaining({ name, allowedOrigin: "https://example.com" }));
      expect(JSON.stringify(listed)).not.toContain(value);

      const update = await fetch(`${base}/api/secrets/${encoded}`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ allowedOrigin: "https://example.org:443" }),
      });
      expect(update.status).toBe(200);
      expect(await update.json()).toEqual(expect.objectContaining({ allowedOrigin: "https://example.org" }));

      expect((await fetch(`${base}/api/secrets`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: `${name}-invalid`,
          value,
          allowedOrigin: "https://example.com/login",
        }),
      })).status).toBe(400);
    } finally {
      expect((await fetch(`${base}/api/secrets/${encoded}`, { method: "DELETE" })).status).toBe(204);
    }
  });

  test("closes thread-scoped browsers when a chat is permanently deleted", async () => {
    const create = await fetch(`${base}/api/sessions`, {
      method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspaceId: "default" }),
    });
    expect(create.status).toBe(201);
    const session = await create.json() as { id: string };
    await browserRuntime.sessions.touch(session.id, "interactive", browser);
    expect((await browserRuntime.sessions.list(session.id))).toHaveLength(1);
    expect((await fetch(`${base}/api/sessions/${session.id}`, { method: "DELETE" })).status).toBe(204);
    expect(await browserRuntime.sessions.list(session.id)).toEqual([]);
    expect(closedThreads).toContain(session.id);
  });
});