ccqa 1.40.2 → 1.40.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -18,10 +18,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
18
18
  import { promisify } from "node:util";
19
19
  import { createInterface } from "node:readline/promises";
20
20
  import { createServer } from "node:http";
21
+ import { connect, createServer as createServer$1 } from "node:net";
22
+ import { connect as connect$1 } from "node:tls";
21
23
  import { gunzipSync, gzipSync } from "node:zlib";
22
24
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
23
25
  import { createInterface as createInterface$1 } from "node:readline";
24
- import { createServer as createServer$1 } from "node:net";
25
26
  //#region src/run/report-constants.ts
26
27
  /**
27
28
  * Pure report/run constants with no runtime dependencies. Kept separate from
@@ -6559,16 +6560,370 @@ function message$2(error) {
6559
6560
  return error instanceof Error ? error.message : String(error);
6560
6561
  }
6561
6562
  //#endregion
6563
+ //#region src/coverage/browser/ws.ts
6564
+ /**
6565
+ * Minimal RFC 6455 client for the CDP transport, dependency-free on purpose.
6566
+ *
6567
+ * Not a general WebSocket: it exists because the runtime's global `WebSocket`
6568
+ * (undici) unconditionally offers `permessage-deflate`, Chromium's DevTools
6569
+ * server accepts it with context takeover, and a single hiccup in that
6570
+ * stateful inflate stream kills the connection from the client side — the
6571
+ * browser and the spec keep running while the measurement silently dies.
6572
+ * Every CDP client that ships (Playwright, puppeteer) disables the extension;
6573
+ * this transport never offers it, so there is no compressed state to corrupt.
6574
+ *
6575
+ * Deliberately lenient where the payload is concerned — text arrives through
6576
+ * `Buffer.toString("utf8")`, so an invalid byte becomes U+FFFD instead of a
6577
+ * dead transport — and strict only about frame structure, where a violation
6578
+ * means the stream can no longer be trusted at all.
6579
+ */
6580
+ const HANDSHAKE_TIMEOUT_MS = 1e4;
6581
+ const HANDSHAKE_HEADER_CAP = 64 * 1024;
6582
+ /** One assembled message; CDP takes and `IO.read` chunks reach megabytes, not this. */
6583
+ const MESSAGE_CAP = 256 * 1024 * 1024;
6584
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
6585
+ const OP_CONTINUATION = 0;
6586
+ const OP_TEXT = 1;
6587
+ const OP_BINARY = 2;
6588
+ const OP_CLOSE = 8;
6589
+ const OP_PING = 9;
6590
+ const OP_PONG = 10;
6591
+ var WsError = class extends Error {};
6592
+ /** Why a 101 response head is unacceptable, or `undefined` when it is fine. */
6593
+ function handshakeFailure(head, expectedAccept, host) {
6594
+ const [statusLine = "", ...headerLines] = head.split("\r\n");
6595
+ if (!/^HTTP\/1\.1 101 /i.test(statusLine)) return `handshake with ${host} answered "${statusLine.slice(0, 80)}"`;
6596
+ const headers = /* @__PURE__ */ new Map();
6597
+ for (const line of headerLines) {
6598
+ const colon = line.indexOf(":");
6599
+ if (colon === -1) continue;
6600
+ headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
6601
+ }
6602
+ if (headers.get("sec-websocket-accept") !== expectedAccept) return `handshake with ${host} returned a wrong Sec-WebSocket-Accept`;
6603
+ const extensions = headers.get("sec-websocket-extensions");
6604
+ if (extensions !== void 0 && extensions !== "") return `server negotiated unrequested extension "${extensions}"`;
6605
+ }
6606
+ var RawWebSocket = class RawWebSocket {
6607
+ socket;
6608
+ handlers;
6609
+ buffer = Buffer.alloc(0);
6610
+ /**
6611
+ * Arrived-but-unparsed chunks. Left as a list until a parse attempt can
6612
+ * make progress: a multi-megabyte take answered in one frame would
6613
+ * otherwise re-copy everything buffered so far on every TCP chunk.
6614
+ */
6615
+ pendingChunks = [];
6616
+ pendingBytes = 0;
6617
+ /** Bytes `buffer` must reach before another parse attempt can complete a frame. */
6618
+ needed = 0;
6619
+ fragments = [];
6620
+ fragmentedBytes = 0;
6621
+ fragmentedOpcode;
6622
+ closeSent = false;
6623
+ finished = false;
6624
+ finishDetail;
6625
+ peerCloseDetail;
6626
+ /** Bytes that arrived with the 101, held until attach() wires the handlers. */
6627
+ pendingLeftover;
6628
+ isOpen = true;
6629
+ constructor(socket) {
6630
+ this.socket = socket;
6631
+ }
6632
+ get open() {
6633
+ return this.isOpen;
6634
+ }
6635
+ /**
6636
+ * Opens the socket and completes the upgrade. No `Sec-WebSocket-Extensions`
6637
+ * is offered — that omission is this class's reason to exist — and a server
6638
+ * that answers with one anyway is refused: it would speak a framing this
6639
+ * side does not.
6640
+ */
6641
+ static async connect(wsUrl) {
6642
+ let url;
6643
+ try {
6644
+ url = new URL(wsUrl);
6645
+ } catch {
6646
+ throw new WsError(`not a ws URL: "${wsUrl}"`);
6647
+ }
6648
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new WsError(`not a ws URL: "${wsUrl}"`);
6649
+ const secure = url.protocol === "wss:";
6650
+ const port = url.port !== "" ? Number(url.port) : secure ? 443 : 80;
6651
+ const host = url.hostname;
6652
+ const socket = secure ? connect$1({
6653
+ host,
6654
+ port,
6655
+ servername: host
6656
+ }) : connect({
6657
+ host,
6658
+ port
6659
+ });
6660
+ socket.setNoDelay(true);
6661
+ const key = randomBytes(16).toString("base64");
6662
+ const expectedAccept = createHash("sha1").update(`${key}${WS_GUID}`).digest("base64");
6663
+ const request = `GET ${`${url.pathname || "/"}${url.search}`} HTTP/1.1\r\nHost: ${url.host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`;
6664
+ const leftover = await new Promise((resolve, reject) => {
6665
+ let response = Buffer.alloc(0);
6666
+ const timer = setTimeout(() => {
6667
+ fail(new WsError(`handshake with ${url.host} timed out`));
6668
+ }, HANDSHAKE_TIMEOUT_MS);
6669
+ timer.unref?.();
6670
+ const fail = (error) => {
6671
+ cleanup();
6672
+ socket.destroy();
6673
+ reject(error);
6674
+ };
6675
+ const onData = (chunk) => {
6676
+ response = Buffer.concat([response, chunk]);
6677
+ const headerEnd = response.indexOf("\r\n\r\n");
6678
+ if (headerEnd === -1) {
6679
+ if (response.length > HANDSHAKE_HEADER_CAP) fail(new WsError(`handshake response from ${url.host} exceeded ${HANDSHAKE_HEADER_CAP} bytes`));
6680
+ return;
6681
+ }
6682
+ const failure = handshakeFailure(response.subarray(0, headerEnd).toString("latin1"), expectedAccept, url.host);
6683
+ if (failure !== void 0) {
6684
+ fail(new WsError(failure));
6685
+ return;
6686
+ }
6687
+ cleanup();
6688
+ resolve(response.subarray(headerEnd + 4));
6689
+ };
6690
+ const onError = (error) => {
6691
+ fail(new WsError(`could not connect to ${url.host} (${error.message})`));
6692
+ };
6693
+ const onEnd = () => {
6694
+ fail(new WsError(`${url.host} closed the connection during the handshake`));
6695
+ };
6696
+ const cleanup = () => {
6697
+ clearTimeout(timer);
6698
+ socket.off("data", onData);
6699
+ socket.off("error", onError);
6700
+ socket.off("close", onEnd);
6701
+ };
6702
+ socket.on("data", onData);
6703
+ socket.on("error", onError);
6704
+ socket.on("close", onEnd);
6705
+ socket.on("connect", () => socket.write(request));
6706
+ });
6707
+ const ws = new RawWebSocket(socket);
6708
+ socket.on("data", (chunk) => ws.ingest(chunk));
6709
+ socket.on("error", (error) => ws.finish(`socket error: ${error.message}`));
6710
+ socket.on("close", () => ws.finish(ws.peerCloseDetail ?? "connection closed abruptly"));
6711
+ socket.pause();
6712
+ if (leftover.length > 0) ws.pendingLeftover = leftover;
6713
+ return ws;
6714
+ }
6715
+ attach(handlers) {
6716
+ this.handlers = handlers;
6717
+ if (this.finished) {
6718
+ handlers.onClose(this.finishDetail ?? "connection closed");
6719
+ return;
6720
+ }
6721
+ const leftover = this.pendingLeftover;
6722
+ this.pendingLeftover = void 0;
6723
+ if (leftover !== void 0) this.ingest(leftover);
6724
+ if (!this.finished) this.socket.resume();
6725
+ }
6726
+ /** Sends one text message. Throws `WsError` when the connection is gone. */
6727
+ send(text) {
6728
+ if (!this.isOpen) throw new WsError("connection closed");
6729
+ this.sendFrame(OP_TEXT, Buffer.from(text, "utf8"));
6730
+ }
6731
+ /** Starts an orderly close; `onClose` fires when the socket is down. */
6732
+ close() {
6733
+ if (!this.isOpen) return;
6734
+ this.isOpen = false;
6735
+ this.peerCloseDetail ??= "connection closed";
6736
+ try {
6737
+ if (!this.closeSent) {
6738
+ this.closeSent = true;
6739
+ this.sendFrameRaw(OP_CLOSE, Buffer.from([3, 232]));
6740
+ }
6741
+ this.socket.end();
6742
+ } catch {
6743
+ this.socket.destroy();
6744
+ }
6745
+ }
6746
+ ingest(chunk) {
6747
+ if (this.finished) return;
6748
+ this.pendingChunks.push(chunk);
6749
+ this.pendingBytes += chunk.length;
6750
+ if (this.buffer.length + this.pendingBytes < this.needed) return;
6751
+ this.buffer = Buffer.concat([this.buffer, ...this.pendingChunks]);
6752
+ this.pendingChunks = [];
6753
+ this.pendingBytes = 0;
6754
+ this.needed = 0;
6755
+ for (;;) {
6756
+ const frame = this.parseFrame();
6757
+ if (frame === void 0 || this.finished) return;
6758
+ this.handleFrame(frame.fin, frame.opcode, frame.payload);
6759
+ }
6760
+ }
6761
+ parseFrame() {
6762
+ const buf = this.buffer;
6763
+ if (buf.length < 2) return void 0;
6764
+ const first = buf[0] ?? 0;
6765
+ const second = buf[1] ?? 0;
6766
+ if ((first & 112) !== 0) {
6767
+ this.fail(`frame with RSV bits set (0x${first.toString(16)}) though no extension was negotiated`);
6768
+ return;
6769
+ }
6770
+ const fin = (first & 128) !== 0;
6771
+ const opcode = first & 15;
6772
+ const masked = (second & 128) !== 0;
6773
+ let length = second & 127;
6774
+ let offset = 2;
6775
+ if (length === 126) {
6776
+ if (buf.length < offset + 2) return void 0;
6777
+ length = buf.readUInt16BE(offset);
6778
+ offset += 2;
6779
+ } else if (length === 127) {
6780
+ if (buf.length < offset + 8) return void 0;
6781
+ const big = buf.readBigUInt64BE(offset);
6782
+ if (big > BigInt(MESSAGE_CAP)) {
6783
+ this.fail(`frame of ${big} bytes exceeds the ${MESSAGE_CAP}-byte cap`);
6784
+ return;
6785
+ }
6786
+ length = Number(big);
6787
+ offset += 8;
6788
+ }
6789
+ let mask;
6790
+ if (masked) {
6791
+ if (buf.length < offset + 4) return void 0;
6792
+ mask = buf.subarray(offset, offset + 4);
6793
+ offset += 4;
6794
+ }
6795
+ if (buf.length < offset + length) {
6796
+ this.needed = offset + length;
6797
+ return;
6798
+ }
6799
+ const payload = Buffer.from(buf.subarray(offset, offset + length));
6800
+ if (mask !== void 0) for (let i = 0; i < payload.length; i++) payload[i] = (payload[i] ?? 0) ^ (mask[i % 4] ?? 0);
6801
+ this.buffer = buf.subarray(offset + length);
6802
+ return {
6803
+ fin,
6804
+ opcode,
6805
+ payload
6806
+ };
6807
+ }
6808
+ handleFrame(fin, opcode, payload) {
6809
+ switch (opcode) {
6810
+ case OP_TEXT:
6811
+ case OP_BINARY:
6812
+ if (this.fragmentedOpcode !== void 0) {
6813
+ this.fail("new data frame started inside a fragmented message");
6814
+ return;
6815
+ }
6816
+ if (fin) {
6817
+ this.deliver(payload);
6818
+ return;
6819
+ }
6820
+ this.fragmentedOpcode = opcode;
6821
+ this.fragments = [payload];
6822
+ this.fragmentedBytes = payload.length;
6823
+ return;
6824
+ case OP_CONTINUATION:
6825
+ if (this.fragmentedOpcode === void 0) {
6826
+ this.fail("continuation frame outside a fragmented message");
6827
+ return;
6828
+ }
6829
+ this.fragments.push(payload);
6830
+ this.fragmentedBytes += payload.length;
6831
+ if (this.fragmentedBytes > MESSAGE_CAP) {
6832
+ this.fail(`fragmented message exceeds the ${MESSAGE_CAP}-byte cap`);
6833
+ return;
6834
+ }
6835
+ if (fin) {
6836
+ const whole = Buffer.concat(this.fragments);
6837
+ this.fragments = [];
6838
+ this.fragmentedBytes = 0;
6839
+ this.fragmentedOpcode = void 0;
6840
+ this.deliver(whole);
6841
+ }
6842
+ return;
6843
+ case OP_PING:
6844
+ try {
6845
+ this.sendFrameRaw(OP_PONG, payload);
6846
+ } catch {}
6847
+ return;
6848
+ case OP_PONG: return;
6849
+ case OP_CLOSE: {
6850
+ const code = payload.length >= 2 ? payload.readUInt16BE(0) : void 0;
6851
+ const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : "";
6852
+ this.peerCloseDetail = code === void 0 ? "connection closed" : `connection closed (code ${code}${reason ? `: ${reason}` : ""})`;
6853
+ if (!this.closeSent) {
6854
+ this.closeSent = true;
6855
+ try {
6856
+ this.sendFrameRaw(OP_CLOSE, payload.subarray(0, 2));
6857
+ } catch {}
6858
+ }
6859
+ this.socket.end();
6860
+ return;
6861
+ }
6862
+ default: this.fail(`unknown frame opcode 0x${opcode.toString(16)}`);
6863
+ }
6864
+ }
6865
+ deliver(payload) {
6866
+ this.handlers?.onMessage(payload.toString("utf8"));
6867
+ }
6868
+ sendFrame(opcode, payload) {
6869
+ try {
6870
+ this.sendFrameRaw(opcode, payload);
6871
+ } catch (error) {
6872
+ throw new WsError(`send failed: ${error instanceof Error ? error.message : String(error)}`);
6873
+ }
6874
+ }
6875
+ sendFrameRaw(opcode, payload) {
6876
+ const mask = randomBytes(4);
6877
+ let header;
6878
+ if (payload.length < 126) header = Buffer.from([128 | opcode, 128 | payload.length]);
6879
+ else if (payload.length < 65536) {
6880
+ header = Buffer.alloc(4);
6881
+ header[0] = 128 | opcode;
6882
+ header[1] = 254;
6883
+ header.writeUInt16BE(payload.length, 2);
6884
+ } else {
6885
+ header = Buffer.alloc(10);
6886
+ header[0] = 128 | opcode;
6887
+ header[1] = 255;
6888
+ header.writeBigUInt64BE(BigInt(payload.length), 2);
6889
+ }
6890
+ const masked = Buffer.from(payload);
6891
+ for (let i = 0; i < masked.length; i++) masked[i] = (masked[i] ?? 0) ^ (mask[i % 4] ?? 0);
6892
+ this.socket.write(Buffer.concat([
6893
+ header,
6894
+ mask,
6895
+ masked
6896
+ ]));
6897
+ }
6898
+ fail(reason) {
6899
+ this.socket.destroy();
6900
+ this.finish(reason);
6901
+ }
6902
+ finish(detail) {
6903
+ if (this.finished) return;
6904
+ this.finished = true;
6905
+ this.finishDetail = detail;
6906
+ this.isOpen = false;
6907
+ this.buffer = Buffer.alloc(0);
6908
+ this.pendingChunks = [];
6909
+ this.pendingBytes = 0;
6910
+ this.fragments = [];
6911
+ this.fragmentedBytes = 0;
6912
+ this.handlers?.onClose(detail);
6913
+ }
6914
+ };
6915
+ //#endregion
6562
6916
  //#region src/coverage/browser/cdp.ts
6563
6917
  /**
6564
6918
  * Minimal Chrome DevTools Protocol client, dependency-free on purpose.
6565
6919
  *
6566
6920
  * Coverage acquisition speaks a handful of domains over one transport, which
6567
6921
  * is not enough to justify a protocol library in a published CLI. The
6568
- * transport is the `WebSocket` global stable since Node 22 so availability
6569
- * is gated with an explicit error instead of a package.json engines bump:
6570
- * everything else in ccqa still runs on 20, and only `--coverage`'s browser
6571
- * half needs more.
6922
+ * transport is `./ws.ts`'s own RFC 6455 client, not the `WebSocket` global:
6923
+ * the global (undici) unconditionally negotiates `permessage-deflate` with
6924
+ * Chromium's DevTools server, and one hiccup in that stateful inflate stream
6925
+ * kills the connection from the client side while the browser and the spec
6926
+ * keep running (observed in CI as every measurement dying mid-spec).
6572
6927
  */
6573
6928
  var CdpError = class extends Error {};
6574
6929
  /**
@@ -6590,10 +6945,6 @@ function trace(direction, text) {
6590
6945
  appendFileSync(TRACE_FILE, line);
6591
6946
  } catch {}
6592
6947
  }
6593
- /** Throws with the actual requirement when the runtime cannot open the socket. */
6594
- function requireWebSocket() {
6595
- if (typeof WebSocket === "undefined") throw new CdpError(`browser coverage needs the WebSocket global (node 22+); this is node ${process.version}`);
6596
- }
6597
6948
  /**
6598
6949
  * Resolves whatever a target hands us — `host:port`, an `http://` endpoint, or
6599
6950
  * a ws URL — to the **browser-level** ws endpoint. A page-level ws URL is not
@@ -6626,21 +6977,18 @@ var CdpClient = class CdpClient {
6626
6977
  closeHandlers = /* @__PURE__ */ new Set();
6627
6978
  constructor(ws) {
6628
6979
  this.ws = ws;
6629
- ws.addEventListener("message", (event) => this.receive(String(event.data)));
6630
- ws.addEventListener("close", () => this.drop("connection closed"));
6631
- ws.addEventListener("error", () => this.drop("connection error"));
6980
+ ws.attach({
6981
+ onMessage: (text) => this.receive(text),
6982
+ onClose: (detail) => this.drop(detail)
6983
+ });
6632
6984
  }
6633
6985
  static async connect(wsUrl) {
6634
- requireWebSocket();
6635
- const ws = new WebSocket(wsUrl);
6636
- await new Promise((resolve, reject) => {
6637
- ws.addEventListener("open", () => resolve(), { once: true });
6638
- ws.addEventListener("error", () => reject(new CdpError(`could not connect to ${wsUrl}`)), { once: true });
6639
- });
6640
- return new CdpClient(ws);
6986
+ return new CdpClient(await RawWebSocket.connect(wsUrl).catch((error) => {
6987
+ throw new CdpError(`could not connect to ${wsUrl} (${message$1(error)})`);
6988
+ }));
6641
6989
  }
6642
6990
  send(method, params, sessionId) {
6643
- if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(new CdpError(`${method}: connection closed`));
6991
+ if (!this.ws.open) return Promise.reject(new CdpError(`${method}: connection closed`));
6644
6992
  const id = this.nextId++;
6645
6993
  const promise = new Promise((resolve, reject) => {
6646
6994
  this.pending.set(id, {
@@ -6650,12 +6998,17 @@ var CdpClient = class CdpClient {
6650
6998
  });
6651
6999
  });
6652
7000
  trace("->", `#${id} ${method} sid:${shortId(sessionId)}`);
6653
- this.ws.send(JSON.stringify({
6654
- id,
6655
- method,
6656
- params: params ?? {},
6657
- sessionId
6658
- }));
7001
+ try {
7002
+ this.ws.send(JSON.stringify({
7003
+ id,
7004
+ method,
7005
+ params: params ?? {},
7006
+ sessionId
7007
+ }));
7008
+ } catch (error) {
7009
+ this.pending.delete(id);
7010
+ return Promise.reject(new CdpError(`${method}: ${message$1(error)}`));
7011
+ }
6659
7012
  return promise;
6660
7013
  }
6661
7014
  on(method, handler) {
@@ -6705,10 +7058,11 @@ var CdpClient = class CdpClient {
6705
7058
  }
6706
7059
  }
6707
7060
  drop(reason) {
7061
+ trace("!!", `dropped: ${reason}`);
6708
7062
  for (const waiting of this.pending.values()) waiting.reject(new CdpError(`${waiting.method}: ${reason}`));
6709
7063
  this.pending.clear();
6710
7064
  for (const handler of this.closeHandlers) try {
6711
- handler();
7065
+ handler(reason);
6712
7066
  } catch {}
6713
7067
  this.closeHandlers.clear();
6714
7068
  }
@@ -6733,14 +7087,25 @@ const TAKE_INTERVAL_MS = 400;
6733
7087
  const NAVIGATION_GUARD_MS = 5e3;
6734
7088
  /** How long `stop()` waits for the final take before declaring the tail lost. */
6735
7089
  const STOP_TAKE_TIMEOUT_MS = 2e3;
7090
+ /**
7091
+ * Dropped-transport reconnects per engine (= per spec). Bounded because a
7092
+ * deterministic failure — a proxy mangling frames, a peer that keeps
7093
+ * violating the framing — would otherwise loop for the whole spec; four
7094
+ * doubling delays cover a browser that was merely busy.
7095
+ */
7096
+ const MAX_RECONNECTS = 4;
7097
+ const RECONNECT_BASE_MS = 200;
6736
7098
  /** The browser's own chrome. Nothing there is the application under test. */
6737
7099
  const INTERNAL_URL = /^(chrome|chrome-untrusted|chrome-extension|devtools):/;
6738
7100
  async function startBrowserCoverage(opts) {
6739
- const client = await (opts.connect ?? ((wsUrl) => CdpClient.connect(wsUrl)))(await browserWebSocketUrl(opts.cdpUrl));
6740
- const engine = new Engine(client, opts);
7101
+ const connect = opts.connect ?? ((wsUrl) => CdpClient.connect(wsUrl));
7102
+ const wsUrl = await browserWebSocketUrl(opts.cdpUrl);
7103
+ const client = await connect(wsUrl);
7104
+ const engine = new Engine(client, opts, connect, wsUrl);
6741
7105
  try {
6742
7106
  await engine.arm();
6743
7107
  } catch (error) {
7108
+ engine.abandon();
6744
7109
  client.close();
6745
7110
  throw error;
6746
7111
  }
@@ -6748,6 +7113,10 @@ async function startBrowserCoverage(opts) {
6748
7113
  }
6749
7114
  var Engine = class {
6750
7115
  client;
7116
+ connectFn;
7117
+ wsUrl;
7118
+ reconnects = 0;
7119
+ reconnectInFlight = false;
6751
7120
  opts;
6752
7121
  resolution;
6753
7122
  pages = /* @__PURE__ */ new Map();
@@ -6760,11 +7129,15 @@ var Engine = class {
6760
7129
  armedTargets = /* @__PURE__ */ new Set();
6761
7130
  /** Sessions whose take failure was already said; see enqueueTake. */
6762
7131
  warnedTakeSessions = /* @__PURE__ */ new Set();
7132
+ /** Same, for the cookie warning: one per session, not one per 400ms tick. */
7133
+ warnedCookieSessions = /* @__PURE__ */ new Set();
6763
7134
  cookies;
6764
7135
  timer;
6765
7136
  stopped = false;
6766
- constructor(client, opts) {
7137
+ constructor(client, opts, connectFn, wsUrl) {
6767
7138
  this.client = client;
7139
+ this.connectFn = connectFn;
7140
+ this.wsUrl = wsUrl;
6768
7141
  this.opts = opts;
6769
7142
  this.cookies = opts.origins.map((url) => ({
6770
7143
  name: COVERAGE_COOKIE,
@@ -6812,10 +7185,14 @@ var Engine = class {
6812
7185
  if (page === void 0 || !this.isMainFrame(page, params.frameId)) return;
6813
7186
  page.navigatingSince = void 0;
6814
7187
  });
6815
- this.client.onClose(() => {
7188
+ this.client.onClose((reason) => {
6816
7189
  if (!this.stopped && this.pages.size > 0) this.resolution.markStopped();
6817
7190
  this.pages.clear();
6818
- if (this.timer !== void 0) clearInterval(this.timer);
7191
+ this.armedTargets.clear();
7192
+ this.warnedTakeSessions.clear();
7193
+ this.warnedCookieSessions.clear();
7194
+ this.clearTimer();
7195
+ if (!this.stopped) this.reconnect(reason);
6819
7196
  });
6820
7197
  await this.client.send("Target.setAutoAttach", {
6821
7198
  autoAttach: true,
@@ -6824,13 +7201,10 @@ var Engine = class {
6824
7201
  filter: [{ type: "tab" }, { exclude: true }]
6825
7202
  });
6826
7203
  const existing = await this.client.send("Target.getTargets", { filter: [{ type: "tab" }] });
6827
- for (const info of existing.targetInfos) {
6828
- if (this.armedTargets.has(info.targetId)) continue;
6829
- await this.client.send("Target.attachToTarget", {
6830
- targetId: info.targetId,
6831
- flatten: true
6832
- }).catch(() => void 0);
6833
- }
7204
+ await Promise.all(existing.targetInfos.filter((info) => !this.armedTargets.has(info.targetId)).map((info) => this.client.send("Target.attachToTarget", {
7205
+ targetId: info.targetId,
7206
+ flatten: true
7207
+ }).catch(() => void 0)));
6834
7208
  this.timer = setInterval(() => {
6835
7209
  for (const page of this.pages.values()) {
6836
7210
  this.enqueueTake(page.sessionId);
@@ -6839,9 +7213,71 @@ var Engine = class {
6839
7213
  }, TAKE_INTERVAL_MS);
6840
7214
  this.timer.unref?.();
6841
7215
  }
7216
+ /**
7217
+ * A dropped transport is re-opened and re-armed, bounded by
7218
+ * `MAX_RECONNECTS`. Sound to retry: `startPreciseCoverage` restarts the
7219
+ * counters from zero and `absorbEntries` takes the union of covered
7220
+ * ranges, so a re-measured page adds to the result instead of doubling it.
7221
+ * What ran while disconnected is gone either way — `markStopped` was
7222
+ * already recorded when the transport dropped, so the hole stays visible
7223
+ * in the result even after a successful reconnect.
7224
+ */
7225
+ clearTimer() {
7226
+ if (this.timer !== void 0) clearInterval(this.timer);
7227
+ this.timer = void 0;
7228
+ }
7229
+ /** Stops a never-armed engine: no takes ran, so there is nothing to flush. */
7230
+ abandon() {
7231
+ this.stopped = true;
7232
+ this.clearTimer();
7233
+ }
7234
+ async reconnect(initialReason) {
7235
+ if (this.reconnectInFlight) return;
7236
+ this.reconnectInFlight = true;
7237
+ try {
7238
+ let reason = initialReason;
7239
+ while (!this.stopped) {
7240
+ if (this.reconnects >= MAX_RECONNECTS) {
7241
+ this.opts.warn(`browser coverage transport dropped (${reason}); giving up after ${MAX_RECONNECTS} reconnects — the rest of the spec goes unmeasured`);
7242
+ return;
7243
+ }
7244
+ this.reconnects += 1;
7245
+ const delay = RECONNECT_BASE_MS * 2 ** (this.reconnects - 1);
7246
+ this.opts.warn(`browser coverage transport dropped (${reason}); reconnecting in ${delay}ms (${this.reconnects}/${MAX_RECONNECTS})`);
7247
+ await new Promise((resolve) => setTimeout(resolve, delay));
7248
+ if (this.stopped) return;
7249
+ let fresh;
7250
+ try {
7251
+ fresh = await this.connectFn(this.wsUrl);
7252
+ } catch (error) {
7253
+ reason = `reconnect failed: ${message(error)}`;
7254
+ continue;
7255
+ }
7256
+ if (this.stopped) {
7257
+ fresh.close();
7258
+ return;
7259
+ }
7260
+ this.client = fresh;
7261
+ try {
7262
+ await this.arm();
7263
+ } catch (error) {
7264
+ fresh.close();
7265
+ reason = `reconnect failed: ${message(error)}`;
7266
+ continue;
7267
+ }
7268
+ if (this.stopped) {
7269
+ this.clearTimer();
7270
+ fresh.close();
7271
+ }
7272
+ return;
7273
+ }
7274
+ } finally {
7275
+ this.reconnectInFlight = false;
7276
+ }
7277
+ }
6842
7278
  async stop() {
6843
7279
  this.stopped = true;
6844
- if (this.timer !== void 0) clearInterval(this.timer);
7280
+ this.clearTimer();
6845
7281
  let sawEverything = ![...this.pages.values()].some((page) => !page.armed);
6846
7282
  for (const page of this.pages.values()) page.navigatingSince = void 0;
6847
7283
  const takes = Promise.all([...this.pages.keys()].map((sessionId) => this.enqueueTake(sessionId)));
@@ -6906,6 +7342,9 @@ var Engine = class {
6906
7342
  setCookies(page) {
6907
7343
  if (this.cookies.length === 0) return Promise.resolve();
6908
7344
  return this.client.send("Network.setCookies", { cookies: this.cookies }, page.sessionId).catch((error) => {
7345
+ if (this.stopped || !this.pages.has(page.sessionId)) return;
7346
+ if (this.warnedCookieSessions.has(page.sessionId)) return;
7347
+ this.warnedCookieSessions.add(page.sessionId);
6909
7348
  this.opts.warn(`could not attach the spec cookie (${message(error)})`);
6910
7349
  });
6911
7350
  }
@@ -7140,6 +7579,10 @@ var CoverageSession = class CoverageSession {
7140
7579
  get sinkUrl() {
7141
7580
  return this.sink?.url ?? "";
7142
7581
  }
7582
+ /** The id this run's events carry in the stream — what a hub resolve is asked for. */
7583
+ get streamRunId() {
7584
+ return this.runId;
7585
+ }
7143
7586
  /**
7144
7587
  * True in hub mode: the facts leave as a stream, rows carry no coverage,
7145
7588
  * and the run-side health read-outs below answer empty. The one mode flag
@@ -11443,6 +11886,189 @@ function foldTouchIndex(current, entry, selection) {
11443
11886
  }
11444
11887
  return out;
11445
11888
  }
11889
+ z.object({
11890
+ runId: z.string(),
11891
+ hubRunId: z.string().optional(),
11892
+ asOf: z.number(),
11893
+ lastSeq: z.number(),
11894
+ universe: z.object({
11895
+ include: z.array(z.string()),
11896
+ files: z.array(z.string())
11897
+ }).optional(),
11898
+ specs: z.array(z.object({
11899
+ specId: z.string(),
11900
+ files: z.array(z.string()),
11901
+ actorEvents: z.record(z.string(), z.number())
11902
+ })),
11903
+ boot: z.array(z.string()),
11904
+ health: z.object({
11905
+ heardFromApplication: z.boolean(),
11906
+ pushesDuringRun: z.number(),
11907
+ attributedSpecs: z.number(),
11908
+ rejectedPushes: z.number(),
11909
+ uninstrumentedFiles: z.number(),
11910
+ uninstrumentedProcesses: z.number(),
11911
+ droppedPushes: z.number(),
11912
+ unmappedActorEvents: z.number(),
11913
+ outsideWindowEvents: z.record(z.string(), z.number()),
11914
+ specsMeasured: z.number()
11915
+ })
11916
+ });
11917
+ /**
11918
+ * One read-out line for a resolved spec — shared by the run-end summary and
11919
+ * `ccqa hub coverage`, so the two never drift on how a measurement reads.
11920
+ */
11921
+ function formatResolvedSpec(spec) {
11922
+ const actors = Object.entries(spec.actorEvents).map(([key, count]) => `${key}: ${count} event(s)`).join(", ");
11923
+ return `${spec.specId}: ${spec.files.length} file(s)${actors ? ` (${actors})` : ""}`;
11924
+ }
11925
+ /**
11926
+ * Interprets `runId`'s view of the stream.
11927
+ *
11928
+ * Two passes, because the resolver needs its context up front: the first
11929
+ * collects what the run's own markers establish — which ids it issued, which
11930
+ * identity tags were its to hand out, its universe, and when its first and
11931
+ * last marker arrived. The second replays the stream through the shared
11932
+ * resolver: this run's window markers as they came, and every application
11933
+ * push — as-is when its stamp falls inside the span the run's sink would
11934
+ * have been listening (first marker to last marker plus `GRACE_MS`),
11935
+ * stripped of its spec and actor attribution when it does not. A push
11936
+ * outside the span was another run's audience, so its attribution is not
11937
+ * this run's to claim — but the collector never re-sends what an earlier
11938
+ * run acked, so on an always-on hub the boot set and each process's health
11939
+ * figures arrived long before this run began, and only survive here.
11940
+ */
11941
+ function resolveStream(events, runId) {
11942
+ const issued = /* @__PURE__ */ new Set();
11943
+ const specOrder = [];
11944
+ const tagToKey = /* @__PURE__ */ new Map();
11945
+ const browserFiles = /* @__PURE__ */ new Map();
11946
+ let universe;
11947
+ let hubRunId;
11948
+ let firstMarkerAt;
11949
+ let lastMarkerAt = 0;
11950
+ for (const event of events) {
11951
+ const body = event.body;
11952
+ if (!("kind" in body) || body.runId !== runId) continue;
11953
+ if (firstMarkerAt === void 0) firstMarkerAt = event.at;
11954
+ lastMarkerAt = event.at;
11955
+ switch (body.kind) {
11956
+ case "spec-open":
11957
+ if (!issued.has(body.specId)) {
11958
+ issued.add(body.specId);
11959
+ specOrder.push(body.specId);
11960
+ }
11961
+ break;
11962
+ case "window-open":
11963
+ tagToKey.set(body.tag, body.key);
11964
+ break;
11965
+ case "universe":
11966
+ universe = {
11967
+ include: body.include,
11968
+ files: body.files
11969
+ };
11970
+ break;
11971
+ case "run-link":
11972
+ hubRunId = body.hubRunId;
11973
+ break;
11974
+ case "browser": {
11975
+ const files = browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
11976
+ for (const file of body.files) files.add(file);
11977
+ browserFiles.set(body.specId, files);
11978
+ break;
11979
+ }
11980
+ }
11981
+ }
11982
+ const resolver = new CoverageResolver(issued, tagToKey);
11983
+ let asOf = 0;
11984
+ let lastSeq = 0;
11985
+ let pushesDuringRun = 0;
11986
+ for (const event of events) {
11987
+ if (event.seq > lastSeq) lastSeq = event.seq;
11988
+ const body = event.body;
11989
+ if ("kind" in body) {
11990
+ if (body.runId !== runId) continue;
11991
+ asOf = event.at;
11992
+ if (body.kind === "window-open") resolver.apply({
11993
+ kind: "window-open",
11994
+ at: event.at,
11995
+ tag: body.tag,
11996
+ key: body.key,
11997
+ specId: body.specId
11998
+ });
11999
+ else if (body.kind === "window-close") resolver.apply({
12000
+ kind: "window-close",
12001
+ at: event.at,
12002
+ tag: body.tag
12003
+ });
12004
+ continue;
12005
+ }
12006
+ if (firstMarkerAt === void 0 || event.at < firstMarkerAt || event.at > lastMarkerAt + 3e4) {
12007
+ resolver.apply({
12008
+ kind: "push",
12009
+ at: event.at,
12010
+ push: {
12011
+ ...body,
12012
+ specs: {},
12013
+ actors: []
12014
+ }
12015
+ });
12016
+ continue;
12017
+ }
12018
+ asOf = event.at;
12019
+ pushesDuringRun++;
12020
+ resolver.apply({
12021
+ kind: "push",
12022
+ at: event.at,
12023
+ push: body
12024
+ });
12025
+ }
12026
+ const specs = specOrder.map((specId) => {
12027
+ const actorEvents = {};
12028
+ for (const [key, count] of resolver.actorEventsFor(specId)) actorEvents[key] = count;
12029
+ return {
12030
+ specId,
12031
+ files: [...new Set([...resolver.filesFor(specId) ?? [], ...browserFiles.get(specId) ?? []])].sort(),
12032
+ actorEvents
12033
+ };
12034
+ });
12035
+ const outsideWindowEvents = {};
12036
+ for (const [key, count] of resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
12037
+ return {
12038
+ runId,
12039
+ ...hubRunId !== void 0 ? { hubRunId } : {},
12040
+ asOf,
12041
+ lastSeq,
12042
+ ...universe !== void 0 ? { universe } : {},
12043
+ specs,
12044
+ boot: [...resolver.boot()].sort(),
12045
+ health: {
12046
+ heardFromApplication: resolver.heardFromApplication(),
12047
+ pushesDuringRun,
12048
+ attributedSpecs: resolver.attributedSpecs(),
12049
+ rejectedPushes: resolver.rejectedPushes(),
12050
+ uninstrumentedFiles: resolver.uninstrumentedFiles(),
12051
+ uninstrumentedProcesses: resolver.uninstrumentedProcesses(),
12052
+ droppedPushes: resolver.droppedPushes(),
12053
+ unmappedActorEvents: resolver.unmappedActorEvents(),
12054
+ outsideWindowEvents,
12055
+ specsMeasured: specs.length
12056
+ }
12057
+ };
12058
+ }
12059
+ /**
12060
+ * Every run that opened a spec in this stream, most recently heard-from
12061
+ * first — recency by the arrival position of each run's latest spec-open,
12062
+ * the one order the hub's stamps establish.
12063
+ */
12064
+ function listRunIds(events) {
12065
+ const lastOpenIndex = /* @__PURE__ */ new Map();
12066
+ events.forEach((event, index) => {
12067
+ const body = event.body;
12068
+ if ("kind" in body && body.kind === "spec-open") lastOpenIndex.set(body.runId, index);
12069
+ });
12070
+ return [...lastOpenIndex.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
12071
+ }
11446
12072
  //#endregion
11447
12073
  //#region src/cli/session.ts
11448
12074
  const AB = resolveAgentBrowserBin$1();
@@ -11566,7 +12192,7 @@ function resolveBaseUrl(opts) {
11566
12192
  return baseUrl.replace(/\/+$/, "");
11567
12193
  }
11568
12194
  /** Resolve the hub client from flags / env, or exit 2 with a clear message. */
11569
- function connect(opts) {
12195
+ function connect$2(opts) {
11570
12196
  const client = resolveHubClient(opts);
11571
12197
  if (client) return client;
11572
12198
  resolveBaseUrl(opts);
@@ -11615,7 +12241,7 @@ const sessionPush = new Command("push").description("Upload a locally-saved brow
11615
12241
  hint(`create it first with: ccqa hub session capture ${name}${opts.profile ? ` --profile ${opts.profile}` : ""}`);
11616
12242
  process.exit(2);
11617
12243
  }
11618
- await connect(opts).putSession(project, profile, name, state);
12244
+ await connect$2(opts).putSession(project, profile, name, state);
11619
12245
  header("hub session push", name);
11620
12246
  meta("project", project);
11621
12247
  meta("profile", profile);
@@ -11624,7 +12250,7 @@ const sessionPush = new Command("push").description("Upload a locally-saved brow
11624
12250
  const sessionLs = new Command("ls").description("List sessions stored on the hub for a project/profile (names + last-updated times). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (opts) => {
11625
12251
  const project = resolveProject(opts);
11626
12252
  const profile = opts.profile ?? "default";
11627
- const sessions = await connect(opts).listSessions(project, profile);
12253
+ const sessions = await connect$2(opts).listSessions(project, profile);
11628
12254
  header("hub sessions", `${project}/${profile}`);
11629
12255
  if (sessions.length === 0) {
11630
12256
  info("no sessions stored on the hub for this project/profile");
@@ -11636,7 +12262,7 @@ const sessionRm = new Command("rm").description("Delete a session from the hub."
11636
12262
  const name = validateSessionName(rawName);
11637
12263
  const project = resolveProject(opts);
11638
12264
  const profile = opts.profile ?? "default";
11639
- await connect(opts).deleteSession(project, profile, name);
12265
+ await connect$2(opts).deleteSession(project, profile, name);
11640
12266
  header("hub session rm", name);
11641
12267
  info(`deleted session "${name}" from the hub`);
11642
12268
  }));
@@ -11649,7 +12275,7 @@ const varSet = new Command("set").description("Store an environment variable on
11649
12275
  error("no value provided (pass --value <value> or pipe it on stdin)");
11650
12276
  process.exit(2);
11651
12277
  }
11652
- await connect(opts).putVariable(project, profile, name, {
12278
+ await connect$2(opts).putVariable(project, profile, name, {
11653
12279
  value,
11654
12280
  sensitive: opts.sensitive ?? false
11655
12281
  });
@@ -11662,7 +12288,7 @@ const varSet = new Command("set").description("Store an environment variable on
11662
12288
  const varLs = new Command("ls").description("List variables stored on the hub for a project/profile. Non-sensitive values are shown inline; sensitive ones are hidden here but still fetched at run time by `ccqa run` / `ccqa record`.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (opts) => {
11663
12289
  const project = resolveProject(opts);
11664
12290
  const profile = opts.profile ?? "default";
11665
- const variables = await connect(opts).listVariables(project, profile);
12291
+ const variables = await connect$2(opts).listVariables(project, profile);
11666
12292
  header("hub variables", `${project}/${profile}`);
11667
12293
  if (variables.length === 0) {
11668
12294
  info("no variables stored on the hub for this project/profile");
@@ -11676,7 +12302,7 @@ const varLs = new Command("ls").description("List variables stored on the hub fo
11676
12302
  const varRm = new Command("rm").description("Delete a variable from the hub.").argument("<name>", "Variable name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (name, opts) => {
11677
12303
  const project = resolveProject(opts);
11678
12304
  const profile = opts.profile ?? "default";
11679
- await connect(opts).deleteVariable(project, profile, name);
12305
+ await connect$2(opts).deleteVariable(project, profile, name);
11680
12306
  header("hub var rm", name);
11681
12307
  info(`deleted variable "${name}" from the hub`);
11682
12308
  }));
@@ -11707,14 +12333,14 @@ const promptPush = new Command("push").description("Upload a locally-generated p
11707
12333
  hint("nothing to push; generate it first (e.g. ccqa run --learn-hub-live-prompt)");
11708
12334
  process.exit(2);
11709
12335
  }
11710
- await connect(opts).putPrompt(project, name, body);
12336
+ await connect$2(opts).putPrompt(project, name, body);
11711
12337
  header("hub prompt push", name);
11712
12338
  meta("project", project);
11713
12339
  info(`uploaded prompt "${name}" to the hub`);
11714
12340
  }));
11715
12341
  const promptLs = new Command("ls").description("List prompts stored on the hub for a project (name, kind, last-updated). Prompts are project-wide (not per-profile). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (opts) => {
11716
12342
  const project = resolveProject(opts);
11717
- const prompts = await connect(opts).listPrompts(project);
12343
+ const prompts = await connect$2(opts).listPrompts(project);
11718
12344
  header("hub prompts", project);
11719
12345
  if (prompts.length === 0) {
11720
12346
  info("no prompts stored on the hub for this project");
@@ -11725,7 +12351,7 @@ const promptLs = new Command("ls").description("List prompts stored on the hub f
11725
12351
  const promptRm = new Command("rm").description("Delete a prompt from the hub.").argument("<name>", "Prompt name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
11726
12352
  const name = validatePromptName(rawName);
11727
12353
  const project = resolveProject(opts);
11728
- await connect(opts).deletePrompt(project, name);
12354
+ await connect$2(opts).deletePrompt(project, name);
11729
12355
  header("hub prompt rm", name);
11730
12356
  info(`deleted prompt "${name}" from the hub`);
11731
12357
  }));
@@ -11736,7 +12362,7 @@ const deployRecord = new Command("record").description("Tell the hub what a depl
11736
12362
  async function runDeployRecord(opts) {
11737
12363
  const cwd = resolveCwd(opts.cwd);
11738
12364
  const project = resolveProject(opts);
11739
- const hub = connect(opts);
12365
+ const hub = connect$2(opts);
11740
12366
  const previous = opts.previous ?? await deployHeadSha(hub, project, opts.profile);
11741
12367
  const runUrl = githubRunUrl();
11742
12368
  const diff = previous === null ? null : await diffOrNull(previous, opts.sha, cwd);
@@ -11830,7 +12456,7 @@ async function runCostPush(opts) {
11830
12456
  process.exit(2);
11831
12457
  }
11832
12458
  const project = resolveProject(opts);
11833
- const hub = connect(opts);
12459
+ const hub = connect$2(opts);
11834
12460
  header("hub cost push", opts.label);
11835
12461
  meta("project", project);
11836
12462
  const total = await readCostFileTotal(path);
@@ -11872,7 +12498,7 @@ const pushCommand = new Command("push").description("Upload the report directory
11872
12498
  const deployedSha = parsed.data.deployedSha;
11873
12499
  const branch = opts.branch ?? await detectBranch(cwd);
11874
12500
  const archive = await packDirToTarGz(reportDir);
11875
- const run = await connect(opts).pushRun(archive, {
12501
+ const run = await connect$2(opts).pushRun(archive, {
11876
12502
  project,
11877
12503
  ...branch ? { branch } : {},
11878
12504
  ...opts.profile ? { profile: opts.profile } : {},
@@ -11888,7 +12514,7 @@ const pushCommand = new Command("push").description("Upload the report directory
11888
12514
  }));
11889
12515
  const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person checked a spec's behaviour by hand against the deployed environment. The verdict answers manuallyVerified instead of asking a person for what a person already did — the drift ledger is untouched, so the repair loop keeps its reason to fix the test. The attestation lapses on its own when a deploy reaches the spec or the spec is edited.").requiredOption("--profile <name>", "Environment that was checked (e.g. 'stg'). The attestation is anchored to its current deploy head.").option("--by <name>", "Who checked. Required unless --revoke.").option("--note <text>", "What was checked and how — the reader deciding whether to trust it sees this.").option("--revoke", "Withdraw the spec's attestation instead of recording one.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
11890
12516
  const project = resolveProject(opts);
11891
- const hub = connect(opts);
12517
+ const hub = connect$2(opts);
11892
12518
  const specId = requireSpecId(rawSpecId);
11893
12519
  if (opts.revoke) {
11894
12520
  await hub.deleteAttestation(project, { profile: opts.profile }, specId);
@@ -11911,7 +12537,7 @@ const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id,
11911
12537
  }));
11912
12538
  const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person judged the spec's current audit finding wrong: the spec describes the code fine. This settles the audit axis rather than the verdict — the spec goes back to being run like any other, and the next run says whether the person was right. The dismissal is pinned to the audit run that raised the finding, so a later audit can raise it again. No --profile: a finding is about the repository, not an environment.").option("--by <name>", "Who judged it wrong. Required unless --revoke.").option("--reason <text>", "Why the finding is wrong. Required unless --revoke — this is what a mis-firing audit learns from.").option("--revoke", "Withdraw the dismissal, putting the finding back in force.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
11913
12539
  const project = resolveProject(opts);
11914
- const hub = connect(opts);
12540
+ const hub = connect$2(opts);
11915
12541
  const specId = requireSpecId(rawSpecId);
11916
12542
  if (opts.revoke) {
11917
12543
  await hub.deleteAuditDismissal(project, specId);
@@ -11932,7 +12558,44 @@ const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec i
11932
12558
  meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
11933
12559
  info("this finding no longer holds the spec back; a later audit can raise one of its own");
11934
12560
  }));
11935
- const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
12561
+ const coverageCommand = new Command("coverage").description("Print what the hub's coverage stream resolved for a run: per-spec measured file counts and the stream's health counters. This is the read-out measured spec selection consumes — use it to see why a spec's reach is empty before blaming the selection.").option("--run-id <id>", "Stream run id to resolve. Defaults to the most recently measured run.").option("--files", "List each spec's measured files, not just their count.").option("--json", "Print the raw resolved answer as JSON (everything, including file lists).").option("--project <name>", "Project whose stream is read. Defaults to the current directory's name.").option(...cwdOption).option(...hubUrlOption).option(...hubTokenOption).action(withHubErrors(runCoverageInspect));
12562
+ async function runCoverageInspect(opts) {
12563
+ const project = resolveProject(opts);
12564
+ const answer = await connect$2(opts).getCoverage(project, opts.runId ? { runId: opts.runId } : {});
12565
+ if (opts.json === true) {
12566
+ process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
12567
+ return;
12568
+ }
12569
+ header("hub coverage", project);
12570
+ if (answer.runIds.length === 0) {
12571
+ warn("the stream holds no measured runs for this project");
12572
+ return;
12573
+ }
12574
+ meta("measured runs", `${answer.runIds.length} (newest first): ${answer.runIds.join(", ")}`);
12575
+ const resolved = answer.resolved;
12576
+ if (resolved == null) {
12577
+ warn("nothing resolved — pass --run-id with one of the runs above");
12578
+ return;
12579
+ }
12580
+ if (resolved.asOf === 0 && resolved.specs.length === 0) {
12581
+ warn(`the stream holds no events for run ${resolved.runId} — check it against the runs above`);
12582
+ return;
12583
+ }
12584
+ meta("run", resolved.runId + (resolved.hubRunId ? ` (hub run ${resolved.hubRunId})` : ""));
12585
+ meta("as of", new Date(resolved.asOf).toISOString());
12586
+ if (resolved.universe) meta("universe", `${resolved.universe.files.length} file(s) (include: ${resolved.universe.include.join(", ")})`);
12587
+ meta("boot", `${resolved.boot.length} file(s) reached only at module load`);
12588
+ meta("specs", `${resolved.specs.filter((spec) => spec.files.length > 0).length}/${resolved.specs.length} measured files`);
12589
+ for (const spec of resolved.specs) {
12590
+ info(` ${formatResolvedSpec(spec)}`);
12591
+ if (opts.files === true) for (const file of spec.files) info(` ${file}`);
12592
+ }
12593
+ const h = resolved.health;
12594
+ meta("health", `heard-from-application=${h.heardFromApplication} pushes-during-run=${h.pushesDuringRun} attributed-specs=${h.attributedSpecs} specs-measured=${h.specsMeasured} rejected=${h.rejectedPushes} dropped=${h.droppedPushes} uninstrumented-files=${h.uninstrumentedFiles} uninstrumented-processes=${h.uninstrumentedProcesses} unmapped-actor-events=${h.unmappedActorEvents}`);
12595
+ const outside = Object.entries(h.outsideWindowEvents);
12596
+ if (outside.length > 0) warn(`outside-window events (identity was driven while unclaimed): ${outside.map(([key, count]) => `${key}: ${count}`).join(", ")}`);
12597
+ }
12598
+ const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(coverageCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
11936
12599
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
11937
12600
  function isStorageStateShape(state) {
11938
12601
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -16113,6 +16776,7 @@ async function executeRun(targets, opts) {
16113
16776
  };
16114
16777
  const live = await runLiveSpecs(liveSpecs, liveOpts);
16115
16778
  if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
16779
+ else if (coverage && hubCtx != null) await reportStreamedCoverageHealth(coverage, hubCtx);
16116
16780
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
16117
16781
  if (live.failedCount > 0) overallExitCode = 1;
16118
16782
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -16242,6 +16906,44 @@ function explainMissingCoverage(row) {
16242
16906
  coverageUnavailable: row.status === "skipped" ? "the spec did not execute" : "this target is not measured by --coverage yet"
16243
16907
  };
16244
16908
  }
16909
+ /**
16910
+ * The hub-inbox counterpart of `reportCoverageHealth`: ask the hub to resolve
16911
+ * this run's slice of the stream and read the answer out into the run log.
16912
+ * Best-effort — the measurement already left as events, so a failed read-out
16913
+ * loses visibility, never data. Application pushes may still land for
16914
+ * `GRACE_MS` after the last window closed, so the counts here are a floor.
16915
+ */
16916
+ async function reportStreamedCoverageHealth(coverage, hubCtx) {
16917
+ await new Promise((resolve) => setTimeout(resolve, 3e3));
16918
+ let resolved;
16919
+ try {
16920
+ resolved = (await hubCtx.hub.getCoverage(hubCtx.project, { runId: coverage.streamRunId })).resolved;
16921
+ } catch (error) {
16922
+ warn(`coverage: could not read this run's resolve from the hub (${errMessage(error)})`);
16923
+ return;
16924
+ }
16925
+ if (resolved == null) {
16926
+ warn("coverage: the hub resolved nothing for this run — its events never reached the stream, so every spec's measured reach is absent");
16927
+ return;
16928
+ }
16929
+ const measured = resolved.specs.filter((spec) => spec.files.length > 0);
16930
+ const empty = resolved.specs.filter((spec) => spec.files.length === 0);
16931
+ meta("coverage", `stream resolve: ${measured.length}/${resolved.specs.length} spec(s) measured files` + (resolved.boot.length > 0 ? `; ${resolved.boot.length} file(s) reached only at boot` : ""));
16932
+ for (const spec of measured) meta("coverage", ` ${formatResolvedSpec(spec)}`);
16933
+ if (empty.length > 0) warn(`coverage: ${empty.length} spec(s) had measured no files at read-out time — unless a late push still lands (\`ccqa hub coverage\` shows the settled answer), their reach stays unknown to selection: ${empty.map((spec) => spec.specId).join(", ")}`);
16934
+ const h = resolved.health;
16935
+ if (!h.heardFromApplication) warn("coverage: no instrumented application process pushed to the stream — only the browser half was measured. The server needs ccqa-tools and CCQA_COVERAGE_ENDPOINT pointed at the hub's inbox");
16936
+ else if (h.pushesDuringRun === 0) warn("coverage: the stream holds application pushes, but none arrived while this run was measuring — check CCQA_COVERAGE_ENDPOINT on the application");
16937
+ else if (h.attributedSpecs === 0) warn(`coverage: ${h.pushesDuringRun} application push(es) arrived during the run, but none was attributed to a spec — the spec cookie is not reaching the application, so every spec's server-side reach is zero for that reason rather than because the server ran nothing`);
16938
+ const trouble = [];
16939
+ if (h.rejectedPushes > 0) trouble.push(`rejected=${h.rejectedPushes}`);
16940
+ if (h.droppedPushes > 0) trouble.push(`dropped=${h.droppedPushes}`);
16941
+ if (h.unmappedActorEvents > 0) trouble.push(`unmapped-actor-events=${h.unmappedActorEvents}`);
16942
+ if (h.uninstrumentedProcesses > 0) trouble.push(`uninstrumented-processes=${h.uninstrumentedProcesses}`);
16943
+ const outside = Object.entries(h.outsideWindowEvents);
16944
+ if (outside.length > 0) trouble.push(`outside-window=${outside.map(([key, count]) => `${key}:${count}`).join(",")}`);
16945
+ if (trouble.length > 0) warn(`coverage: stream health flags — ${trouble.join(" ")}`);
16946
+ }
16245
16947
  /** Everything the measurement could not place; silence here reads as "never reached". */
16246
16948
  function reportCoverageHealth(coverage, rows) {
16247
16949
  if (!coverage.heardFromApplication()) warn("no instrumented application process reported — only the browser half was measured. The server needs ccqa-tools and CCQA_COVERAGE_ENDPOINT pointed at this run's sink");
@@ -22140,181 +22842,6 @@ z.object({
22140
22842
  at: z.number(),
22141
22843
  body: InboxBodySchema
22142
22844
  });
22143
- z.object({
22144
- runId: z.string(),
22145
- hubRunId: z.string().optional(),
22146
- asOf: z.number(),
22147
- lastSeq: z.number(),
22148
- universe: z.object({
22149
- include: z.array(z.string()),
22150
- files: z.array(z.string())
22151
- }).optional(),
22152
- specs: z.array(z.object({
22153
- specId: z.string(),
22154
- files: z.array(z.string()),
22155
- actorEvents: z.record(z.string(), z.number())
22156
- })),
22157
- boot: z.array(z.string()),
22158
- health: z.object({
22159
- heardFromApplication: z.boolean(),
22160
- pushesDuringRun: z.number(),
22161
- attributedSpecs: z.number(),
22162
- rejectedPushes: z.number(),
22163
- uninstrumentedFiles: z.number(),
22164
- uninstrumentedProcesses: z.number(),
22165
- droppedPushes: z.number(),
22166
- unmappedActorEvents: z.number(),
22167
- outsideWindowEvents: z.record(z.string(), z.number()),
22168
- specsMeasured: z.number()
22169
- })
22170
- });
22171
- /**
22172
- * Interprets `runId`'s view of the stream.
22173
- *
22174
- * Two passes, because the resolver needs its context up front: the first
22175
- * collects what the run's own markers establish — which ids it issued, which
22176
- * identity tags were its to hand out, its universe, and when its first and
22177
- * last marker arrived. The second replays the stream through the shared
22178
- * resolver: this run's window markers as they came, and every application
22179
- * push — as-is when its stamp falls inside the span the run's sink would
22180
- * have been listening (first marker to last marker plus `GRACE_MS`),
22181
- * stripped of its spec and actor attribution when it does not. A push
22182
- * outside the span was another run's audience, so its attribution is not
22183
- * this run's to claim — but the collector never re-sends what an earlier
22184
- * run acked, so on an always-on hub the boot set and each process's health
22185
- * figures arrived long before this run began, and only survive here.
22186
- */
22187
- function resolveStream(events, runId) {
22188
- const issued = /* @__PURE__ */ new Set();
22189
- const specOrder = [];
22190
- const tagToKey = /* @__PURE__ */ new Map();
22191
- const browserFiles = /* @__PURE__ */ new Map();
22192
- let universe;
22193
- let hubRunId;
22194
- let firstMarkerAt;
22195
- let lastMarkerAt = 0;
22196
- for (const event of events) {
22197
- const body = event.body;
22198
- if (!("kind" in body) || body.runId !== runId) continue;
22199
- if (firstMarkerAt === void 0) firstMarkerAt = event.at;
22200
- lastMarkerAt = event.at;
22201
- switch (body.kind) {
22202
- case "spec-open":
22203
- if (!issued.has(body.specId)) {
22204
- issued.add(body.specId);
22205
- specOrder.push(body.specId);
22206
- }
22207
- break;
22208
- case "window-open":
22209
- tagToKey.set(body.tag, body.key);
22210
- break;
22211
- case "universe":
22212
- universe = {
22213
- include: body.include,
22214
- files: body.files
22215
- };
22216
- break;
22217
- case "run-link":
22218
- hubRunId = body.hubRunId;
22219
- break;
22220
- case "browser": {
22221
- const files = browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
22222
- for (const file of body.files) files.add(file);
22223
- browserFiles.set(body.specId, files);
22224
- break;
22225
- }
22226
- }
22227
- }
22228
- const resolver = new CoverageResolver(issued, tagToKey);
22229
- let asOf = 0;
22230
- let lastSeq = 0;
22231
- let pushesDuringRun = 0;
22232
- for (const event of events) {
22233
- if (event.seq > lastSeq) lastSeq = event.seq;
22234
- const body = event.body;
22235
- if ("kind" in body) {
22236
- if (body.runId !== runId) continue;
22237
- asOf = event.at;
22238
- if (body.kind === "window-open") resolver.apply({
22239
- kind: "window-open",
22240
- at: event.at,
22241
- tag: body.tag,
22242
- key: body.key,
22243
- specId: body.specId
22244
- });
22245
- else if (body.kind === "window-close") resolver.apply({
22246
- kind: "window-close",
22247
- at: event.at,
22248
- tag: body.tag
22249
- });
22250
- continue;
22251
- }
22252
- if (firstMarkerAt === void 0 || event.at < firstMarkerAt || event.at > lastMarkerAt + 3e4) {
22253
- resolver.apply({
22254
- kind: "push",
22255
- at: event.at,
22256
- push: {
22257
- ...body,
22258
- specs: {},
22259
- actors: []
22260
- }
22261
- });
22262
- continue;
22263
- }
22264
- asOf = event.at;
22265
- pushesDuringRun++;
22266
- resolver.apply({
22267
- kind: "push",
22268
- at: event.at,
22269
- push: body
22270
- });
22271
- }
22272
- const specs = specOrder.map((specId) => {
22273
- const actorEvents = {};
22274
- for (const [key, count] of resolver.actorEventsFor(specId)) actorEvents[key] = count;
22275
- return {
22276
- specId,
22277
- files: [...new Set([...resolver.filesFor(specId) ?? [], ...browserFiles.get(specId) ?? []])].sort(),
22278
- actorEvents
22279
- };
22280
- });
22281
- const outsideWindowEvents = {};
22282
- for (const [key, count] of resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
22283
- return {
22284
- runId,
22285
- ...hubRunId !== void 0 ? { hubRunId } : {},
22286
- asOf,
22287
- lastSeq,
22288
- ...universe !== void 0 ? { universe } : {},
22289
- specs,
22290
- boot: [...resolver.boot()].sort(),
22291
- health: {
22292
- heardFromApplication: resolver.heardFromApplication(),
22293
- pushesDuringRun,
22294
- attributedSpecs: resolver.attributedSpecs(),
22295
- rejectedPushes: resolver.rejectedPushes(),
22296
- uninstrumentedFiles: resolver.uninstrumentedFiles(),
22297
- uninstrumentedProcesses: resolver.uninstrumentedProcesses(),
22298
- droppedPushes: resolver.droppedPushes(),
22299
- unmappedActorEvents: resolver.unmappedActorEvents(),
22300
- outsideWindowEvents,
22301
- specsMeasured: specs.length
22302
- }
22303
- };
22304
- }
22305
- /**
22306
- * Every run that opened a spec in this stream, most recently heard-from
22307
- * first — recency by the arrival position of each run's latest spec-open,
22308
- * the one order the hub's stamps establish.
22309
- */
22310
- function listRunIds(events) {
22311
- const lastOpenIndex = /* @__PURE__ */ new Map();
22312
- events.forEach((event, index) => {
22313
- const body = event.body;
22314
- if ("kind" in body && body.kind === "spec-open") lastOpenIndex.set(body.runId, index);
22315
- });
22316
- return [...lastOpenIndex.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
22317
- }
22318
22845
  //#endregion
22319
22846
  //#region src/hub/api/auth.ts
22320
22847
  /**
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.2",
3
+ "version": "1.40.4",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -3,14 +3,14 @@ import * as fs from "node:fs";
3
3
  import { URL as URL$1 } from "node:url";
4
4
  import * as http from "node:http";
5
5
  import { Agent, ClientRequest, ClientRequestArgs, OutgoingHttpHeaders } from "node:http";
6
- import { ZlibOptions } from "node:zlib";
7
6
  import * as net from "node:net";
7
+ import { SecureContextOptions } from "node:tls";
8
+ import { ZlibOptions } from "node:zlib";
8
9
  import { Http2SecureServer } from "node:http2";
9
10
  import { EventEmitter } from "node:events";
10
11
  import { Server as Server$1, ServerOptions as ServerOptions$1 } from "node:https";
11
12
  import { Duplex, DuplexOptions, Stream } from "node:stream";
12
13
  import esbuild from "esbuild";
13
- import { SecureContextOptions } from "node:tls";
14
14
  import DartSass from "sass";
15
15
  import SassEmbedded from "sass-embedded";
16
16
  import Less from "less";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.2",
3
+ "version": "1.40.4",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {