all repos — sz-chrome-remote-cdp @ master

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

test/cdp/session-call-test.ts (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { decodeResult } from "../../src/cdp/commands";
import { createCdpSession } from "../../src/cdp/session";
import type { CdpTransport } from "../../src/cdp/types";
import type { CdpEvent } from "../../src/cdp/types";
import { type Result, ok, err } from "../../src/util/result";
import { type CdpError, cdpError } from "../../src/cdp/errors";

type RequestOpts = { readonly sessionId?: string | null; readonly timeoutMs?: number };
type RecordedCall = {
  readonly method: string;
  readonly params: Record<string, unknown>;
  readonly opts: RequestOpts | undefined;
};

const makeStubTransport = (
  responses: Record<string, Result<unknown, CdpError>> = {},
): { transport: CdpTransport; calls: RecordedCall[] } => {
  const calls: RecordedCall[] = [];
  const transport: CdpTransport = {
    connect: () => Promise.resolve(ok(undefined)),
    close: () => Promise.resolve(),
    request: (method, params, opts) => {
      calls.push({ method, params, opts });
      return Promise.resolve(responses[method] ?? ok({}));
    },
    // No real events ever arrive, so the session's background consumer awaits forever — with no timers involved, it does not keep the process alive.
    events: (): AsyncIterable<CdpEvent> => (async function* (): AsyncGenerator<CdpEvent> {
      await new Promise<never>(() => {});
    })(),
    state: () => "open",
    onClose: () => () => {},
  };
  return { transport, calls };
};

describe("typed call decoding", () => {
  test("extra Chrome fields on a result are tolerated", () => {
    const r = decodeResult("Target.getTargetInfo", {
      targetInfo: { targetId: "t", type: "page", title: "T", url: "u", futureField: 1 },
    });
    assert.equal(r.success, true);
  });
});

describe("CdpSession.call / callOnTarget / callBrowser", () => {
  test("(a) a valid stub payload decodes to typed data", async () => {
    const { transport } = makeStubTransport({
      "Target.getTargetInfo": ok({ targetInfo: { targetId: "t1", type: "page", title: "T", url: "u" } }),
    });
    const session = createCdpSession(transport);
    const r = await session.call("Target.getTargetInfo", {});
    assert.equal(r.success, true);
    if (r.success) {
      const targetId: string = r.data.targetInfo.targetId;
      assert.equal(targetId, "t1");
    }
  });

  test("(b) a garbage stub payload becomes invalid_response, without throwing", async () => {
    const { transport } = makeStubTransport({
      "Target.getTargetInfo": ok({ targetInfo: { targetId: 42 } }),
    });
    const session = createCdpSession(transport);
    const r = await session.call("Target.getTargetInfo", {});
    assert.equal(r.success, false);
    if (!r.success) assert.equal(r.error.kind, "invalid_response");
  });

  test("(c) a transport-level error passes through with its original kind", async () => {
    const { transport } = makeStubTransport({
      "Target.getTargetInfo": err(cdpError("timeout", "socket dead", "Target.getTargetInfo")),
    });
    const session = createCdpSession(transport);
    const r = await session.call("Target.getTargetInfo", {});
    assert.equal(r.success, false);
    if (!r.success) {
      assert.equal(r.error.kind, "timeout");
      assert.equal(r.error.message, "socket dead");
    }
  });

  test("(d) call/callOnTarget/callBrowser route the session id correctly", async () => {
    const { transport, calls } = makeStubTransport({
      "Target.attachToTarget": ok({ sessionId: "session-xyz" }),
    });
    const session = createCdpSession(transport);

    const switched = await session.switchTo("target-1");
    assert.equal(switched.success, true);

    calls.length = 0;

    await session.call("Page.navigate", { url: "http://example.com" });
    await session.callOnTarget("Page.navigate", { url: "http://example.com" }, "explicit-sid");
    await session.callBrowser("Target.getTargets");

    const navigateCalls = calls.filter((c) => c.method === "Page.navigate");
    assert.equal(navigateCalls[0]?.opts?.sessionId, "session-xyz");
    assert.equal(navigateCalls[1]?.opts?.sessionId, "explicit-sid");

    const browserCall = calls.find((c) => c.method === "Target.getTargets");
    assert.equal(browserCall?.opts?.sessionId, null);
  });

  test("opts.timeoutMs reaches the transport only when provided (exactOptionalPropertyTypes)", async () => {
    const { transport, calls } = makeStubTransport();
    const session = createCdpSession(transport);

    await session.callBrowser("Target.getTargets", {}, { timeoutMs: 5_000 });
    const withTimeout = calls.find((c) => c.method === "Target.getTargets");
    assert.equal(withTimeout?.opts?.timeoutMs, 5_000);

    await session.callBrowser("Target.getTargets");
    const withoutTimeout = calls.filter((c) => c.method === "Target.getTargets")[1];
    // The key must be absent, not present-with-undefined — the distinction exactOptionalPropertyTypes enforces at the call site.
    assert.equal(withoutTimeout?.opts && "timeoutMs" in withoutTimeout.opts, false);
  });
});