aboutsummaryrefslogtreecommitdiff
path: root/test/cdp/session-call-test.ts
blob: 7b1386b16de227c870a0ace79722c2741d7b641a (plain)
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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/transport";
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;
};

/**
 * A stub transport: no socket, no browser. `request()` looks up a canned
 * Result by method name (defaulting to `ok({})` for methods the test doesn't
 * care about — e.g. the six domain-enable calls switchTo fires as a side
 * effect). Every call is recorded so tests can assert on exactly what the
 * session sent, including the sessionId routing and the timeoutMs option.
 */
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; the session's background consumer just
    // awaits forever. No timers involved, so it doesn't 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) {
      // Must stay "timeout" — decodeResult must never run on a failed
      // transport call and relabel it "invalid_response".
      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);

    // Establish an internal sessionId distinct from both null and the
    // explicit sid used below, via the public switchTo() — no attachFirstPage
    // bookkeeping required.
    const switched = await session.switchTo("target-1");
    assert.equal(switched.success, true);

    calls.length = 0; // discard switchTo's own requests (activate/attach/enable*6)

    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"); // call() uses the active session
    assert.equal(navigateCalls[1]?.opts?.sessionId, "explicit-sid"); // callOnTarget() uses the given sid, not the active one

    const browserCall = calls.find((c) => c.method === "Target.getTargets");
    assert.equal(browserCall?.opts?.sessionId, null); // callBrowser() forces null even though a session is active
  });

  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 itself must be absent, not present-with-undefined — that's the
    // distinction exactOptionalPropertyTypes exists to enforce at the call site.
    assert.equal(withoutTimeout?.opts && "timeoutMs" in withoutTimeout.opts, false);
  });
});