aboutsummaryrefslogtreecommitdiff
path: root/test/cdp/endpoint-test.ts
blob: ee8f6926d3de3403d5aa5dee64e2a3f460fce2a2 (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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
/**
 * Unit tests for CDP target resolution — no browser and no network required.
 *
 * This is fork-specific logic (see FORK.md): upstream only ever drives a
 * browser on localhost. The precedence here is deliberately asymmetric — an
 * explicitly configured endpoint must never silently fall back to a different
 * browser, while the built-in default must, so a machine off the wire network
 * still gets the local browser the extension was originally written for.
 *
 * Run: npx tsx test/cdp/endpoint-test.ts
 */
import {
  DEFAULT_REMOTE_CDP,
  describeCdpTarget,
  isValidTargetValue,
  redactWsUrl,
  resolveCdpTarget,
} from "../../src/cdp/endpoint";
import { getBrowserSystemPrompt } from "../../src/prompt";

let passed = 0;
let failed = 0;
const check = (cond: boolean, label: string): void => {
  if (cond) {
    passed++;
    console.log(`  ✓ ${label}`);
  } else {
    failed++;
    console.error(`  ✗ ${label}`);
  }
};

const env = (value?: string): NodeJS.ProcessEnv => (value === undefined ? {} : { BU_CDP_HTTP: value });

function main(): void {
  // E1: the built-in default, used when nothing is configured.
  {
    const t = resolveCdpTarget(env());
    check(t.kind === "remote", "E1: unset BU_CDP_HTTP resolves to a remote target");
    if (t.kind === "remote") {
      check(t.source === "default", "E1: it is marked as the built-in default, not an instruction");
      check(`${t.host}:${t.port}` === DEFAULT_REMOTE_CDP, "E1: it matches DEFAULT_REMOTE_CDP");
    }
  }

  // E2: DEFAULT_REMOTE_CDP must itself be parseable, or the default silently
  // degrades to local for everyone.
  {
    const t = resolveCdpTarget(env());
    check(t.kind !== "local", "E2: the built-in default constant is well-formed");
  }

  // E3: an explicit endpoint is an instruction — source must reflect that, so
  // discovery fails loudly rather than falling back.
  {
    const t = resolveCdpTarget(env("10.0.0.9:9333"));
    check(t.kind === "remote", "E3: an explicit host:port resolves to a remote target");
    if (t.kind === "remote") {
      check(t.host === "10.0.0.9" && t.port === 9333, "E3: host and port are parsed");
      check(t.source === "env", "E3: it is marked as explicitly configured");
    }
  }

  // E4: opting back into upstream behaviour.
  for (const alias of ["local", "LOCAL", "off", "none", "0", "no"]) {
    const t = resolveCdpTarget(env(alias));
    check(t.kind === "local", `E4: "${alias}" selects this machine's own browser`);
  }

  // E5: empty or whitespace is treated as unset rather than as an error — a
  // shell exporting an empty var should not break the browser.
  for (const blank of ["", "   "]) {
    const t = resolveCdpTarget(env(blank));
    check(t.kind === "remote" && t.source === "default", `E5: ${JSON.stringify(blank)} falls back to the default`);
  }

  // E6: malformed values are rejected, not coerced. Silently reinterpreting
  // these would drive an unexpected browser.
  for (const bad of ["nonsense", "host:", ":9222", "host:0", "host:70000", "host:abc"]) {
    const t = resolveCdpTarget(env(bad));
    check(t.kind === "invalid", `E6: "${bad}" is rejected as malformed`);
  }

  // E7: IPv6 and hostnames still work — the port is taken from the LAST colon.
  {
    const t = resolveCdpTarget(env("castle.local:9223"));
    check(t.kind === "remote" && t.host === "castle.local", "E7: hostnames are accepted");
    const v6 = resolveCdpTarget(env("[::1]:9223"));
    check(v6.kind === "remote" && v6.host === "[::1]" && v6.port === 9223, "E7: bracketed IPv6 keeps its host");
  }

  // E8: descriptions are for humans in /browser-status; they must distinguish
  // a default from an instruction, since only one of them falls back.
  {
    const d = describeCdpTarget(resolveCdpTarget(env()));
    const e = describeCdpTarget(resolveCdpTarget(env("10.0.0.9:9333")));
    check(d.includes("default"), "E8: the default is labelled as such");
    check(e.includes("BU_CDP_HTTP"), "E8: an explicit endpoint names the variable that set it");
    check(describeCdpTarget(resolveCdpTarget(env("local"))).includes("own browser"), "E8: local is described plainly");
  }

  // E9: the stored choice (/browser-target) sits between env and the default.
  {
    const t = resolveCdpTarget({}, "local");
    check(t.kind === "local" && t.source === "stored", "E9: a stored value is used when the env is unset");

    const r = resolveCdpTarget({}, "10.2.3.4:9224");
    check(r.kind === "remote" && r.source === "stored", "E9: a stored host:port resolves as a remote target");
  }

  // E10: the environment wins, so a one-off `BU_CDP_HTTP=… pi` is never
  // silently overridden by a setting made days ago.
  {
    const t = resolveCdpTarget(env("local"), "10.2.3.4:9224");
    check(t.kind === "local" && t.source === "env", "E10: env beats the stored value");

    const r = resolveCdpTarget(env("10.9.9.9:1234"), "local");
    check(r.kind === "remote" && r.host === "10.9.9.9" && r.source === "env", "E10: an env endpoint beats stored local");
  }

  // E11: clearing the stored value falls back to the built-in default rather
  // than to local — clearing is "use the usual browser", not "go local".
  {
    const t = resolveCdpTarget({}, null);
    check(t.kind === "remote" && t.source === "default", "E11: no stored value falls back to the default");
    const blank = resolveCdpTarget({}, "   ");
    check(blank.kind === "remote" && blank.source === "default", "E11: a blank stored value is treated as cleared");
  }

  // E12: a corrupt stored value must be reported against the right source, or
  // the operator goes hunting through their shell config for an env var that
  // was never set.
  {
    const t = resolveCdpTarget({}, "garbage");
    check(t.kind === "invalid" && t.source === "stored", "E12: a bad stored value is blamed on /browser-target");
    if (t.kind === "invalid") {
      check(describeCdpTarget(t).includes("/browser-target"), "E12: the description names the stored source");
    }
    const e = resolveCdpTarget(env("garbage"), null);
    check(e.kind === "invalid" && e.source === "env", "E12: a bad env value is blamed on BU_CDP_HTTP");
  }

  // E13: the values /browser-target accepts must match what resolution can
  // actually use, or the command would happily store something unusable.
  {
    for (const good of ["local", "off", "1.2.3.4:9222", "castle.local:9223"]) {
      check(isValidTargetValue(good), `E13: "${good}" is accepted by the command`);
    }
    for (const bad of ["", "  ", "garbage", "host:", "host:99999"]) {
      check(!isValidTargetValue(bad), `E13: "${bad}" is rejected by the command`);
    }
    check(!isValidTargetValue("default"), "E13: \"default\" is handled as clear, not stored verbatim");
  }

  // E14: upstream's BU_CDP_WS is read by discoverEndpoint() before anything the
  // fork resolves, so it must outrank BU_CDP_HTTP and the stored choice here
  // too — otherwise status, /browser-target and the prompt name a browser the
  // daemon is not driving.
  {
    const pinned = "ws://10.0.0.7:9229/devtools/browser/0b5c-uuid";
    const t = resolveCdpTarget({ BU_CDP_WS: pinned, BU_CDP_HTTP: "10.0.0.9:9333" }, "10.0.0.8:9444");
    check(t.kind === "remote", "E14: BU_CDP_WS resolves to a remote target");
    if (t.kind === "remote") {
      check(t.host === "10.0.0.7" && t.port === 9229, "E14: it wins over BU_CDP_HTTP and the stored choice");
      check(t.source === "ws", "E14: it is marked as the pinned WebSocket");
    }
    const blank = resolveCdpTarget({ BU_CDP_WS: "  ", BU_CDP_HTTP: "10.0.0.9:9333" });
    check(blank.kind === "remote" && blank.source === "env", "E14: a blank BU_CDP_WS is ignored, as upstream does");
  }

  // E15: only host:port is ever shown. The path carries the browser id and a
  // proxied endpoint may carry a token or credentials.
  {
    const secret = "wss://user:hunter2@cdp.example.com/devtools/browser/abc?token=s3cr3t";
    const t = resolveCdpTarget({ BU_CDP_WS: secret });
    const shown = describeCdpTarget(t);
    check(t.kind === "remote" && t.port === 443, "E15: wss without a port means 443");
    check(shown.startsWith("cdp.example.com:443 (BU_CDP_WS"), `E15: described as host:port — got ${shown}`);
    check(!/hunter2|user|abc|token|s3cr3t|devtools/.test(shown), "E15: no path, query or credentials leak");
    check(redactWsUrl(secret) === "cdp.example.com:443", "E15: redactWsUrl keeps host:port only");
    check(redactWsUrl("ws://[::1]:9222/x") === "::1:9222", "E15: IPv6 hosts lose their brackets like elsewhere");
  }

  // E16: an unusable BU_CDP_WS is reported, still without echoing it.
  for (const bad of ["not a url", "http://10.0.0.7:9229/devtools/browser/x"]) {
    const t = resolveCdpTarget({ BU_CDP_WS: bad });
    check(t.kind === "invalid" && t.source === "ws", `E16: "${bad}" is reported as an invalid BU_CDP_WS`);
    check(!describeCdpTarget(t).includes("devtools"), "E16: the invalid value is not echoed");
  }

  // E17: the system prompt names the pinned endpoint, redacted.
  {
    const t = resolveCdpTarget({ BU_CDP_WS: "ws://10.0.0.7:9229/devtools/browser/0b5c-uuid" });
    const prompt = getBrowserSystemPrompt(true, t);
    check(prompt.includes("10.0.0.7:9229"), "E17: the prompt names the pinned host:port");
    check(!prompt.includes("0b5c-uuid"), "E17: the prompt does not carry the browser id");
  }

  console.log(`\n${passed} passed, ${failed} failed`);
  if (failed > 0) process.exit(1);
}

main();