diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts new file mode 100644 index 000000000..889a5dc19 --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -0,0 +1,760 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + encodeWireMessage, + TRUAPI_CODEC_VERSION, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostAccountGetRequest, +} from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; +import { Window } from "happy-dom"; + +import { createInAppDebugger, type InAppFrameIdentity } from "./in-app.js"; +import { WIRE_ENVELOPE_VERSION } from "./ingest.js"; +import { + INSPECTOR_LAYOUT_CSS, + INSPECTOR_SHELL_CSS, +} from "./inspector-styles.js"; +import { computeTraceStats, createDebugSession } from "./session.js"; +import { renderOperationRow } from "./trace-render.js"; +import { wireTraceToView } from "./trace-view.js"; + +function frameBytes( + id: number, + value: number[] = [0], + requestId = "p:1", +): Uint8Array { + const r = encodeWireMessage({ + requestId, + payload: { id, value: new Uint8Array(value) }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +/** A real, decodable account-get request wire message (non-sensitive). */ +function accountGetRequestBytes(requestId = "p:1"): Uint8Array { + const value = VersionedHostAccountGetRequest.enc({ + tag: "V1", + value: { + productAccountId: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + }, + }); + const r = encodeWireMessage({ + requestId, + payload: { id: W.ACCOUNT_GET_ACCOUNT.request, value }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +/** + * The wire identity a feeding host stamps when it was built against THIS + * debugger's wire table — the only state in which the panel decodes a payload. + */ +const ATTESTED: InAppFrameIdentity = { + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, +}; + +/** Selector lists of every rule in a stylesheet (no nested at-rules in ours). */ +function ruleSelectors(css: string): string[] { + return [...css.matchAll(/([^{}]+)\{[^{}]*\}/g)].map((m) => + (m[1] ?? "").trim(), + ); +} + +describe("createInAppDebugger", () => { + // The mount is a real interactive panel now (querySelector, dataset, event + // listeners), so it needs a real DOM rather than a stand-in. + /* eslint-disable @typescript-eslint/no-explicit-any -- install a DOM global */ + const g = globalThis as any; + const original = g.document; + let win: Window; + beforeAll(() => { + win = new Window(); + g.document = win.document; + }); + afterAll(() => { + g.document = original; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + /** A detached container to mount into. */ + const container = (): HTMLElement => + win.document.createElement("div") as unknown as HTMLElement; + + /** + * A container attached to the document, so `getComputedStyle` resolves the + * mount's injected stylesheet against it. + */ + const attached = (): HTMLElement => { + const el = container(); + win.document.body.append(el as never); + return el; + }; + + /** Click the first operation row of a mounted panel. */ + const openFirstOp = (el: HTMLElement): void => { + const row = el.querySelector(".td-op"); + expect(row).not.toBeNull(); + row?.click(); + }; + + test("feeds frames in-process and decodes by default", () => { + const dbg = createInAppDebugger(); // decode ON by default (dev-only tool) + + // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). + // The request leg carries a real, decodable account-get payload. + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED); + dbg.handleFrame( + "shop.dot", + "in", + frameBytes(W.ACCOUNT_GET_ACCOUNT.response), + ATTESTED, + ); + + expect(dbg.session.traceEngine.traces()).toHaveLength(1); + expect(dbg.session.decodeValues).toBe(true); // decodes by default + + // The drill-down surfaces the decoded value. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(detail?.kind).toBe("decoded"); + + const el = container(); + const dispose = dbg.mount(el); + // Rendered by the shared renderer — the method resolved via the wire table. + expect(el.querySelector(".ins-list")?.innerHTML).toContain( + "account.getAccount", + ); + dispose(); + expect(el.children).toHaveLength(0); + }); + + test("a formerly-sensitive op shows its payload like any other (no redaction)", () => { + const dbg = createInAppDebugger(); + dbg.handleFrame( + "shop.dot", + "out", + frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2]), + ATTESTED, + ); + dbg.handleFrame( + "shop.dot", + "in", + frameBytes(W.SIGNING_SIGN_RAW.response), + ATTESTED, + ); + + const el = attached(); + const dispose = dbg.mount(el); + openFirstOp(el); + const html = el.querySelector(".ins-detail")?.innerHTML ?? ""; + + // No denylist: the panel shows this op's payload — decoded, or the raw hex + // when the codec can't type it — exactly as it would any other method. + expect(html).toContain("signing.signRaw"); + expect(html).toContain(`
`);
+    expect(html).not.toContain("payload not shown");
+    expect(html.toLowerCase()).not.toContain("redact");
+
+    dispose();
+    el.remove();
+  });
+
+  test("decodeValues:false keeps the mount payload-blind (bytes only)", () => {
+    const dbg = createInAppDebugger({ decodeValues: false });
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    expect(dbg.session.decodeValues).toBe(false);
+    expect(dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("bytes");
+  });
+
+  test("the mount renders the full inspector chrome, not a bare list", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    // The pieces that made the standalone look like a Network tab and were
+    // absent here: a top bar with filter + sort, the aggregate strip, the
+    // list/detail split, and a status bar.
+    for (const selector of [
+      ".ins-top",
+      ".ins-filter",
+      ".ins-sort",
+      ".ins-summary",
+      ".ins-body",
+      ".ins-list",
+      ".ins-split",
+      ".ins-detail",
+      ".ins-status",
+    ]) {
+      expect(el.querySelector(selector)).not.toBeNull();
+    }
+    // The strip reports real aggregates rather than a placeholder.
+    expect(el.querySelector(".ins-summary")?.innerHTML).toContain("ops");
+    expect(el.querySelector(".ins-summary")?.className).not.toContain("empty");
+
+    dispose();
+  });
+
+  test("selecting an operation opens its frames in the detail pane", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    const detail = el.querySelector(".ins-detail");
+    expect(detail?.innerHTML).toContain("Select an operation");
+
+    openFirstOp(el);
+
+    // The drill-down replaces the placeholder, and the row reads as selected.
+    expect(detail?.innerHTML).not.toContain("Select an operation");
+    expect(detail?.innerHTML).toContain("account.getAccount");
+    expect(el.querySelector(".td-op.selected")).not.toBeNull();
+
+    dispose();
+  });
+
+  test("the filter narrows the operation list", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+    expect(el.querySelectorAll(".td-op")).toHaveLength(1);
+
+    const filter = el.querySelector(".ins-filter");
+    expect(filter).not.toBeNull();
+    if (filter !== null) {
+      filter.value = "signing.signRaw";
+      filter.dispatchEvent(new win.Event("input") as unknown as Event);
+    }
+    expect(el.querySelectorAll(".td-op")).toHaveLength(0);
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain("no operations");
+
+    dispose();
+  });
+
+  // --- wire identity ------------------------------------------------------
+
+  test("an unattested frame is grouped but never decoded", () => {
+    const dbg = createInAppDebugger();
+    // No identity: exactly the 3-arg call an embed makes today.
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes());
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+    expect(dbg.decodeTrusted()).toBe(false);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+
+    // Payload-blind grouping needs no wire contract, so the op still lists.
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "account.getAccount",
+    );
+    // ...and the panel says the names may be wrong, as the standalone does.
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "declared no wire contract",
+    );
+    expect(el.querySelector(".ins-status")?.innerHTML).toContain(
+      "wire identity unconfirmed",
+    );
+
+    openFirstOp(el);
+    const html = el.querySelector(".ins-detail")?.innerHTML ?? "";
+    // The value is NOT surfaced: the feeder's frame ids are not attested to mean
+    // what this debugger's table says they mean.
+    expect(html).not.toContain("alice.dot");
+    expect(html).toContain("payload not shown");
+
+    dispose();
+    el.remove();
+  });
+
+  test("an attested frame decodes in the panel", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+    openFirstOp(el);
+    expect(el.querySelector(".ins-detail")?.innerHTML).toContain("alice.dot");
+    expect(el.querySelector(".ins-list")?.innerHTML).not.toContain("⚠");
+
+    dispose();
+    el.remove();
+  });
+
+  test("a mismatched wire schema refuses decode and banners the drift", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), {
+      ...ATTESTED,
+      schema: "0000000000000000",
+    });
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+    openFirstOp(el);
+    expect(el.querySelector(".ins-detail")?.innerHTML).not.toContain(
+      "alice.dot",
+    );
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "differs from this debugger's",
+    );
+    expect(el.querySelector(".ins-status")?.innerHTML).toContain(
+      "codec mismatch",
+    );
+
+    dispose();
+    el.remove();
+  });
+
+  test("one mismatching frame marks the channel untrusted for good", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+    // A later frame declaring a different codec version: sticky refusal.
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      { ...ATTESTED, codec: TRUAPI_CODEC_VERSION + 1 },
+    );
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+  });
+
+  test("a payload-blind mount needs no attestation (nothing decodes anyway)", () => {
+    const dbg = createInAppDebugger({ decodeValues: false });
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes());
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+  });
+
+  // --- one shared aggregate ----------------------------------------------
+
+  test("the summary strip reports the whole shared aggregate", () => {
+    // maxFramesPerTrace: 1 forces a `truncated` op; the malformed frame and the
+    // unanswered subscription supply the other health tallies.
+    const dbg = createInAppDebugger({ maxFramesPerTrace: 1 });
+    dbg.handleFrame(
+      "shop.dot",
+      "out",
+      frameBytes(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, [0], "p:9"),
+      { ...ATTESTED, dropped: 3 },
+    );
+    dbg.handleFrame("shop.dot", "in", new Uint8Array([0xff, 0xff, 0xff]), ATTESTED);
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    // The shared roll-up over the same views is the reference: the strip must
+    // agree with it rather than compute its own subset.
+    const stats = computeTraceStats(
+      dbg.session.traceEngine
+        .traces()
+        .map((t) => wireTraceToView(t, dbg.session.methodNames)),
+    );
+    expect(stats.malformed).toBe(1);
+    expect(stats.truncated).toBe(1);
+    expect(stats.subscriptions).toBe(1);
+    expect(stats.liveSubscriptions).toBe(1);
+    expect(stats.out).toBeGreaterThan(0);
+    expect(stats.in).toBeGreaterThan(0);
+
+    const el = container();
+    const dispose = dbg.mount(el);
+    const html = el.querySelector(".ins-summary")?.innerHTML ?? "";
+
+    for (const label of [
+      "ops",
+      "frames",
+      "data",
+      "subs",
+      "avg op",
+      "malformed",
+      "orphaned",
+      "retry storms",
+      "truncated",
+      "evicted",
+      "dropped",
+    ]) {
+      expect(html).toContain(`class="k">${label}`);
+    }
+    // The tallies the bespoke strip omitted entirely.
+    expect(html).toContain(
+      `${String(stats.malformed)}malformed`,
+    );
+    expect(html).toContain(
+      `${String(stats.truncated)}truncated`,
+    );
+    expect(html).toContain(
+      `3dropped`,
+    );
+    // The in/out split and the observed maximum.
+    expect(html).toContain(`${String(stats.out)}▶ ${String(stats.in)}◀`);
+    expect(html).toContain("max ");
+
+    dispose();
+  });
+
+  // --- retention ---------------------------------------------------------
+
+  test("session retention caps reach the trace engine", () => {
+    const session = createDebugSession({ maxTraces: 2 });
+    for (const requestId of ["p:1", "p:2", "p:3"]) {
+      session.handleEnvelope({
+        channelId: "shop.dot",
+        dir: "out",
+        frame: frameBytes(W.ACCOUNT_GET_ACCOUNT.request, [0], requestId),
+      });
+    }
+    expect(session.traceEngine.traces()).toHaveLength(2);
+    expect(session.traceEngine.evictedTraces()).toBe(1);
+  });
+
+  test("the embed retains less than the standalone engine's default", () => {
+    const dbg = createInAppDebugger();
+    for (let i = 0; i < 200; i++) {
+      dbg.handleFrame(
+        "shop.dot",
+        "out",
+        frameBytes(W.ACCOUNT_GET_ACCOUNT.request, [0], `p:${String(i)}`),
+        ATTESTED,
+      );
+    }
+    // The engine default is 256 ops × 1 MiB of retained payload each; inside the
+    // observed app's own tab that ceiling is the product's crash.
+    expect(dbg.session.traceEngine.traces().length).toBeLessThanOrEqual(128);
+    expect(dbg.session.traceEngine.evictedTraces()).toBeGreaterThan(0);
+  });
+
+  // --- containment -------------------------------------------------------
+
+  test("a throwing session cannot break the product's frame path", () => {
+    const dbg = createInAppDebugger();
+    // The embed's tap runs in the host's own send/receive path, so a throw here
+    // would surface to the product as a protocol failure.
+    dbg.session.handleEnvelope = () => {
+      throw new Error("boom");
+    };
+    expect(() => {
+      dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    }).not.toThrow();
+  });
+
+  test("every injected rule is confined to the mount root", () => {
+    const dbg = createInAppDebugger();
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    const css = el.querySelector("style")?.textContent ?? "";
+    expect(css).not.toBe("");
+    const leaked = ruleSelectors(css)
+      .flatMap((list) => list.split(","))
+      .map((s) => s.trim())
+      .filter((s) => s !== "" && !s.startsWith(".td-inapp"));
+    expect(leaked).toEqual([]);
+
+    dispose();
+  });
+
+  test("the panel does not restyle the host application's own markup", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+
+    // The host application's own debug panel uses the same `td-*` class names
+    // (they are lifted from it), so a global rule restyles it.
+    const hostPanel = win.document.createElement("div");
+    hostPanel.className = "td-op";
+    hostPanel.innerHTML = `host panel`;
+    win.document.body.append(hostPanel);
+
+    const inside = el.querySelector(".td-op-meta");
+    const outside = hostPanel.querySelector(".td-op-meta");
+    expect(inside).not.toBeNull();
+    expect(outside).not.toBeNull();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+    const colorOf = (node: any): string =>
+      win.getComputedStyle(node).color ?? "";
+    // The panel's own rows are styled; the host's identically-classed markup is
+    // untouched by anything the panel injected.
+    expect(colorOf(inside)).not.toBe("");
+    expect(colorOf(outside)).toBe("");
+    expect(
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+      win.getComputedStyle(hostPanel as any).cursor ?? "",
+    ).not.toBe("pointer");
+
+    hostPanel.remove();
+    dispose();
+    el.remove();
+  });
+
+  // --- refresh cost ------------------------------------------------------
+
+  test("an unchanged open op is not re-decoded on every refresh", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    let decodes = 0;
+    const real = dbg.session.decodedFrames.bind(dbg.session);
+    dbg.session.decodedFrames = ((...args: Parameters) => {
+      decodes += 1;
+      return real(...args);
+    }) as typeof dbg.session.decodedFrames;
+
+    openFirstOp(el);
+    expect(decodes).toBe(1);
+    const rendered = el.querySelector(".ins-detail")?.innerHTML ?? "";
+
+    // Three more render passes with nothing about the op changed: re-decoding and
+    // re-hexing every frame here is ~50ms of blocked main thread per second, in
+    // the product's own tab, for an identical result.
+    const filter = el.querySelector(".ins-filter");
+    for (let i = 0; i < 3; i++) {
+      filter?.dispatchEvent(new win.Event("input") as unknown as Event);
+    }
+    expect(decodes).toBe(1);
+    expect(el.querySelector(".ins-detail")?.innerHTML).toBe(rendered);
+
+    // A new frame on the open op DOES refresh it.
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+    filter?.dispatchEvent(new win.Event("input") as unknown as Event);
+    expect(decodes).toBe(2);
+
+    dispose();
+  });
+
+  // --- style precedence --------------------------------------------------
+
+  test("a live-and-waiting op's meta reads waiting-amber, not live-green", () => {
+    const dbg = createInAppDebugger();
+    // A subscription start with nothing back: live (no stop) AND waiting
+    // (orphaned opener). Both classes land on the same row.
+    dbg.handleFrame(
+      "shop.dot",
+      "out",
+      frameBytes(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start),
+      ATTESTED,
+    );
+    const trace = dbg.session.traceEngine.traces()[0];
+    expect(trace).toBeDefined();
+    const view = wireTraceToView(trace!, dbg.session.methodNames);
+    const rowHtml = renderOperationRow(view, { now: Date.now() + 5000 });
+    expect(rowHtml).toContain("td-op-live");
+    expect(rowHtml).toContain("td-op-waiting");
+
+    const style = win.document.createElement("style");
+    style.textContent = INSPECTOR_SHELL_CSS;
+    const holder = win.document.createElement("div");
+    holder.innerHTML = rowHtml;
+    win.document.body.append(style, holder);
+
+    // A stalled op must read as a problem: the waiting colour has to beat the
+    // live one, whatever order the two rules are declared in.
+    const meta = holder.querySelector(".td-op-meta");
+    expect(meta).not.toBeNull();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+    expect(win.getComputedStyle(meta as any).color).toBe("#fbbf24");
+
+    style.remove();
+    holder.remove();
+  });
+
+  test("two ops whose ids would collide under a bare separator stay distinct", () => {
+    // `opKey` joins (channel, request, generation) with a SPACE, and all three
+    // components are sender-controlled and only length-clamped - `normalizeId`
+    // filters no characters. Without the length prefixes, channel "a" + request
+    // "b 0" and channel "a b" + request "0" both flatten to `a b 0 0`: one
+    // selection key for two different ops, so clicking either highlighted BOTH
+    // rows and the drill-down rendered whichever happened to sort first.
+    //
+    // Pins the render key and the click-path key TOGETHER. Mutating only one of
+    // the two desynchronises them and is caught by any selection test; this is
+    // the case that survives mutating both, which is the collision itself.
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("a", "out", accountGetRequestBytes("b 0"), ATTESTED);
+    dbg.handleFrame("a b", "out", accountGetRequestBytes("0"), ATTESTED);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+    const rows = el.querySelectorAll(".td-op");
+    expect(rows.length).toBe(2);
+
+    rows[0]?.click();
+    expect(el.querySelectorAll(".td-op.selected").length).toBe(1);
+
+    dispose();
+    el.remove();
+  });
+});
+
+describe("waiting beats live in the cascade", () => {
+  /**
+   * An unanswered subscription carries BOTH `td-op-live` and `td-op-waiting`, so
+   * the two rules collide on the same element. The amber wait has to win: green
+   * says "healthy and streaming", which is the opposite of what an unanswered
+   * opener means. Asserting the COMPUTED colour rather than source order is the
+   * point - the previous rule pair was ordered wrongly at equal specificity, so
+   * the amber was dead and no assertion on the stylesheet text would have caught
+   * it.
+   */
+  test("a row that is both live and waiting computes amber, not green", () => {
+    const win = new Window();
+    const doc = win.document;
+    const style = doc.createElement("style");
+    style.textContent = `${INSPECTOR_SHELL_CSS}\n${INSPECTOR_LAYOUT_CSS}`;
+    doc.head.appendChild(style);
+
+    const row = doc.createElement("div");
+    row.className = "td-op td-op-sub td-op-live td-op-waiting";
+    const meta = doc.createElement("span");
+    meta.className = "td-op-meta";
+    row.appendChild(meta);
+    doc.body.appendChild(row);
+
+    // Amber, not the green a healthy live subscription gets.
+    expect(win.getComputedStyle(meta as never).color).toBe("#fbbf24");
+  });
+
+  /**
+   * The informational tiles (`infoStat` / `infoTile`) emit `ins-stat zero` with no
+   * `warn`. While the dimming rule was written `.ins-stat.warn.zero`, that class
+   * was inert for them: 0 and N computed the SAME colour, so the strip carried no
+   * signal at all for the count it was added to surface - and at 0 it rendered as
+   * bright as the headline metrics. Asserting the computed colour rather than the
+   * stylesheet text is the point; the rule existed, it just never applied.
+   */
+  test("a neutral tile dims at zero and brightens at non-zero", () => {
+    const win = new Window();
+    const doc = win.document;
+    const style = doc.createElement("style");
+    style.textContent = `${INSPECTOR_SHELL_CSS}\n${INSPECTOR_LAYOUT_CSS}`;
+    doc.head.appendChild(style);
+
+    const colourOf = (cls: string): string => {
+      const tile = doc.createElement("div");
+      tile.className = cls;
+      const n = doc.createElement("span");
+      n.className = "n";
+      tile.appendChild(n);
+      doc.body.appendChild(tile);
+      return win.getComputedStyle(n as never).color;
+    };
+
+    const neutralZero = colourOf("ins-stat zero");
+    const neutralSome = colourOf("ins-stat");
+    expect(neutralZero).not.toBe(neutralSome);
+    // Dimmed to the same grey a zeroed warning uses.
+    expect(neutralZero).toBe(colourOf("ins-stat warn zero"));
+    // And a neutral count is never the alarm colour a real warning gets.
+    expect(neutralSome).not.toBe(colourOf("ins-stat warn"));
+  });
+
+  test("a live row that is NOT waiting still computes green", () => {
+    const win = new Window();
+    const doc = win.document;
+    const style = doc.createElement("style");
+    style.textContent = `${INSPECTOR_SHELL_CSS}\n${INSPECTOR_LAYOUT_CSS}`;
+    doc.head.appendChild(style);
+
+    const row = doc.createElement("div");
+    row.className = "td-op td-op-sub td-op-live";
+    const meta = doc.createElement("span");
+    meta.className = "td-op-meta";
+    row.appendChild(meta);
+    doc.body.appendChild(row);
+
+    expect(win.getComputedStyle(meta as never).color).toBe("#4ade80");
+  });
+});
+
+test("recordIdentity's per-frame verdict refuses a mismatched envelope version", () => {
+  // The stamping is covered elsewhere; this pins the VERDICT. A matching schema
+  // with a wrong `v` is "confirmed AND mismatched" and must not decode.
+  //
+  // Asserted through `session.frameDetail`, which is the STANDALONE mount's decode
+  // entry - the in-app panel renders via `decodeTraceFrames` instead. So this pins
+  // `recordIdentity`'s return value, not what this panel puts on screen; the two
+  // agree today because both ultimately gate on the same verdict. The channel here
+  // is mismatched, so `decodeTrusted` would refuse it as well - what isolates the
+  // per-frame arm is the mutation that flips only that return.
+  const d = createInAppDebugger();
+  const bytes = accountGetRequestBytes();
+  d.handleFrame("shop.dot", "out", bytes, {
+    ...ATTESTED,
+    v: (ATTESTED.v ?? 0) + 99,
+  });
+  const detail = d.session.frameDetail("p:1", 0, "shop.dot");
+  expect(detail?.kind).toBe("bytes");
+});
+
+test("the per-frame verdict refuses a mismatch on a channel's SECOND frame", () => {
+  // The existing-channel arm of `recordIdentity`, i.e. every frame after the first
+  // on a channel - the common case, and a different return site from the one the
+  // test above exercises. Same caveat as above: read through the standalone's
+  // `frameDetail`, so it pins the verdict rather than this panel's render.
+  const d = createInAppDebugger();
+  const bytes = accountGetRequestBytes();
+  // Frame 1 registers the channel, correctly attested.
+  d.handleFrame("shop.dot", "out", bytes, ATTESTED);
+  expect(d.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("decoded");
+  // Frame 2 on the SAME channel declares a wrong codec version.
+  d.handleFrame("shop.dot", "out", bytes, {
+    ...ATTESTED,
+    codec: (ATTESTED.codec ?? 0) + 7,
+  });
+  // Both frames are openers for the same requestId, so the second rotates to a
+  // new generation rather than joining the first trace.
+  const second = d.session.frameDetail("p:1", 0, "shop.dot", 1);
+  expect(second?.kind).toBe("bytes");
+});
diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts
new file mode 100644
index 000000000..6607c992c
--- /dev/null
+++ b/js/packages/truapi-debugger/src/in-app.ts
@@ -0,0 +1,672 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: MIT
+/**
+ * In-app mount: render the inspector from a {@link DebugSession} that lives in
+ * the SAME app as the host — no server, no dial-out, no relay. A host running in
+ * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame};
+ * {@link InAppDebugger.mount} renders them with the same engine, the same
+ * renderers, and the same stylesheet the standalone app uses.
+ *
+ * This is the "host and debugger in the same bits" transport: the frames never
+ * leave the app, so each browser tab is its own tenant — nothing to host or
+ * scope. Browser-only (uses `document`).
+ *
+ * Three consequences of sharing the app follow from that, and they are the
+ * invariants this module holds:
+ *
+ *  - **The tap is in the product's frame path.** Feeding a frame can never throw
+ *    into the caller, so {@link InAppDebugger.handleFrame} is contained.
+ *  - **The feeding host is not this debugger's build.** dotli pins its own truapi
+ *    dependencies, so a frame id may mean a different method here than it did
+ *    there. Decode is therefore gated on an {@link InAppFrameIdentity} that
+ *    affirmatively matches this build's wire schema — exactly the gate the
+ *    standalone server applies to a dialing host. An unattested frame still
+ *    groups (payload-blind grouping needs no contract) but never decodes.
+ *  - **The document belongs to the host application.** Every shared rule is
+ *    scoped to the mount root before injection (`scopeCss`), so the panel cannot
+ *    restyle the host's own UI.
+ *
+ * The standalone app is a thin client over server-rendered fragments; this mount
+ * has the session in-process, so it renders the same fragments directly and needs
+ * no polling. Everything visible — the summary strip, the operation list, the
+ * drill-down — comes from the shared renderers, the shared aggregate
+ * ({@link computeTraceStats}), and the shared stylesheet, so the two mounts cannot
+ * drift apart.
+ *
+ * @module
+ */
+
+import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi";
+import {
+  computeTraceStats,
+  createDebugSession,
+  decodeTraceFrames,
+  formatStatBytes,
+  formatStatMs,
+  type TraceStats,
+} from "./session.js";
+import type { DebugSession, DebugSessionOptions } from "./session.js";
+import { normalizeId, WIRE_ENVELOPE_VERSION } from "./ingest.js";
+import {
+  operationMethod,
+  wireTraceToView,
+  type TraceView,
+} from "./trace-view.js";
+import { renderOperationRow, renderTraceDetail } from "./trace-render.js";
+import { detectRetryStorms } from "./retry-storm.js";
+import { TRACE_DETAIL_CSS } from "./trace-styles.js";
+import {
+  INSPECTOR_LAYOUT_CSS,
+  INSPECTOR_SHELL_CSS,
+  scopeCss,
+} from "./inspector-styles.js";
+
+/** How an operation list is ordered. */
+type SortMode = "arrival" | "slowest" | "frames";
+
+/** The mount root's class; also the CSS scope every injected rule is confined to. */
+const MOUNT_CLASS = "td-inapp";
+
+/**
+ * Retention caps for an embed, deliberately below the engine defaults the
+ * standalone runs with. The standalone is its own process — if it retains a few
+ * hundred MiB of traces, only the debugger pays. An embed retains inside the
+ * observed application's own tab, where the same ceiling is the product's crash,
+ * so the panel keeps a shorter, byte-bounded history.
+ */
+const EMBED_MAX_TRACES = 128;
+/** @see EMBED_MAX_TRACES */
+const EMBED_MAX_FRAMES_PER_TRACE = 256;
+/** @see EMBED_MAX_TRACES */
+const EMBED_MAX_BYTES_PER_TRACE = 256 * 1024;
+
+/**
+ * Cap on tracked channels, matching the standalone's registry: a host feeding
+ * frames under many distinct channelIds must not grow the identity map without
+ * bound.
+ */
+const MAX_CHANNELS = 256;
+
+/**
+ * The wire identity a feeding host stamps on a tapped frame — the same
+ * `v`/`codec`/`schema` triple a dialing host puts on the standalone's envelope.
+ *
+ * A frame id is a `u8` discriminant that gets reassigned as the API evolves, so a
+ * frame from a host built against a different wire table decodes to the WRONG
+ * method and the wrong value off this debugger's table. The embed is the mount
+ * most exposed to that: dotli pins its truapi dependencies independently of the
+ * debugger's. Without an identity a frame is grouped but not decoded.
+ */
+export interface InAppFrameIdentity {
+  /** Envelope version the feeder speaks; see {@link WIRE_ENVELOPE_VERSION}. */
+  v?: number;
+  /** The feeding host's wire codec version (`TRUAPI_CODEC_VERSION`). */
+  codec?: number;
+  /**
+   * The feeding host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a
+   * hash of every frame id and its method leg. This is the field decode is gated
+   * on — unlike `codec` (the coarse handshake number, bumped ~never), it changes
+   * whenever a frame id is reassigned.
+   */
+  schema?: string;
+  /** Frames this tap dropped before this one; surfaced in the summary strip. */
+  dropped?: number;
+}
+
+/** What one channel has declared about its wire contract, across all its frames. */
+interface ChannelIdentity {
+  /** `false` once a frame declared a `v`/`codec`/`schema` that differs. Sticky. */
+  codecOk: boolean;
+  /** Monotonic counter of the last frame seen, so eviction can pick the LRU. */
+  lastSeen: number;
+  /** `true` once a frame affirmatively declared a matching `schema`. */
+  schemaOk: boolean;
+  /** Frames the feeding tap reported dropping. */
+  dropped: number;
+}
+
+/** A same-app debugger: feed it frames, mount its panel. */
+export interface InAppDebugger {
+  /** The underlying session — grouped traces, inline value decode. */
+  readonly session: DebugSession;
+  /**
+   * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir`
+   * is product-vantage (`out` = left the product), matching the standalone tap.
+   *
+   * `identity` is the feeder's wire contract. Pass it — a frame fed WITHOUT an
+   * identity that matches this build's `TRUAPI_WIRE_SCHEMA_HASH` is grouped and
+   * listed but never decoded, because its ids cannot be trusted to mean what this
+   * debugger's table says they mean. Never throws: this runs inside the product's
+   * own frame path.
+   */
+  handleFrame(
+    channelId: string,
+    dir: "in" | "out",
+    frame: Uint8Array,
+    identity?: InAppFrameIdentity,
+  ): void;
+  /**
+   * Whether a decoded value may be surfaced for a channel's frames: it declared a
+   * matching wire schema and never declared a mismatching identity. The panel
+   * gates itself on this; an embedding host can read it to gate its own views.
+   * Always `true` when the session has decode off (nothing decodes anyway).
+   */
+  decodeTrusted(channelId?: string): boolean;
+  /**
+   * Render a live, self-contained panel into `el` and keep it refreshed; returns
+   * a disposer that tears the panel down. Decodes the open op's frames when the
+   * session has `decodeValues` on AND the op's channel is
+   * {@link InAppDebugger.decodeTrusted}.
+   */
+  mount(el: HTMLElement, options?: { refreshMs?: number }): () => void;
+}
+
+/** HTML-escape a string for interpolation into the markup this module builds. */
+function esc(value: string): string {
+  return value
+    .replace(/&/g, "&")
+    .replace(//g, ">")
+    .replace(/"/g, """);
+}
+
+/** One metric tile in the aggregate strip. */
+function stat(n: string, k: string, sub = "", cls = ""): string {
+  return (
+    `
` + + `${esc(n)}` + + (sub === "" ? "" : ` ${esc(sub)}`) + + `${esc(k)}
` + ); +} + +/** A tile that reads red when non-zero and muted at zero. */ +function warnStat(n: number, k: string): string { + return stat(String(n), k, "", n === 0 ? "warn zero" : "warn"); +} + +/** Neutral sibling of {@link warnStat}: a count worth showing that is not a fault. */ +function infoStat(n: number, k: string): string { + return stat(String(n), k, "", n === 0 ? "zero" : ""); +} + +/** + * The aggregate summary strip: the "at a glance" row above the list. + * + * Every number comes from the shared {@link computeTraceStats}, and the tiles + * mirror the standalone's strip one-for-one — same set, same labels, same + * formatting. That is the whole point: a bespoke second roll-up here is how the + * standalone came to report `malformed 1 / truncated 1` on a stream this mount + * showed as clean. + */ +function renderSummary(stats: TraceStats): string { + if (stats.ops === 0) return "waiting for frames…"; + + const pills = stats.topMethods + .map( + ({ method, count }) => + `` + + `${esc(method)} ${String(count)}`, + ) + .join(""); + + return ( + stat(String(stats.ops), "ops") + + stat( + String(stats.frames), + "frames", + `${String(stats.out)}▶ ${String(stats.in)}◀`, + ) + + stat(formatStatBytes(stats.bytes), "data") + + stat( + String(stats.subscriptions), + "subs", + stats.liveSubscriptions > 0 + ? `${String(stats.liveSubscriptions)} live` + : "", + ) + + stat( + formatStatMs(stats.avgDurationMs), + "avg op", + `max ${formatStatMs(stats.maxDurationMs)}, observed`, + ) + + warnStat(stats.malformed, "malformed") + + warnStat(stats.orphaned, "orphaned") + + infoStat(stats.unpaired, "unpaired") + + warnStat(stats.retryStorms, "retry storms") + + warnStat(stats.truncated, "truncated") + + warnStat(stats.evictedTraces, "evicted") + + warnStat(stats.droppedByHost, "dropped") + + (pills === "" ? "" : `${pills}`) + ); +} + +/** Stable identity for an op across refreshes: channel + id + generation. */ +function opKey(view: TraceView): string { + // Length-prefixed so a separator cannot be forged from inside a component. The + // components are sender-controlled and only length-clamped (`normalizeId` filters + // no characters), so a bare separator - a SPACE here, far likelier than NUL - let + // channel "a" + request "b 0" collide with channel "a b" + request "0". Two ops + // on different channels then shared one selection key: both rows highlighted and + // the drill-down rendered whichever sorted first. + const part = (v: string): string => `${String(v.length)}:${v}`; + return `${part(view.channelId ?? "")} ${part(view.requestId)} ${part(String(view.generation ?? 0))}`; +} + +/** + * Create an in-app debugger. Decode is ON by default (dev-only tool) for frames + * whose feeder attests a matching wire schema; pass `decodeValues: false` to keep + * a bundled mount payload-blind regardless. Retention defaults to the + * embed-appropriate caps ({@link EMBED_MAX_TRACES}); override any of them through + * {@link DebugSessionOptions}. + */ +export function createInAppDebugger( + options: DebugSessionOptions = {}, +): InAppDebugger { + const session = createDebugSession({ + ...options, + maxTraces: options.maxTraces ?? EMBED_MAX_TRACES, + maxFramesPerTrace: options.maxFramesPerTrace ?? EMBED_MAX_FRAMES_PER_TRACE, + maxBytesPerTrace: options.maxBytesPerTrace ?? EMBED_MAX_BYTES_PER_TRACE, + }); + + const channels = new Map(); + /** + * Channels evicted while carrying a mismatch verdict: a channel that declared + * a foreign contract must never buy back trust simply by being forgotten. + * + * Add-only, never pruned, so it GROWS WITHOUT BOUND for the life of the tab - + * one normalized channelId (<= 256 chars) per distinct channel that ever + * mismatched. Measured 40 MiB at 200k such channels. Accepted rather than + * capped because evicting from here is exactly the laundering it exists to + * prevent; only mismatching channels feed it, so a well-behaved host + * contributes nothing. + */ + const distrusted = new Set(); + /** Monotonic sequence for LRU ordering. */ + let seq = 0; + // Sticky: some frame arrived unattested (or mismatched) this session. The + // no-channel decode query keys on this rather than scanning the registry, whose + // records can be evicted while the frames they described survive. + let sawUnconfirmed = false; + + /** + * Fold one frame's declared identity into its channel's record, and return + * THIS frame's own verdict so the caller can stamp it on the envelope. The + * channel record still drives the header chips and the "wire contract + * differs" notice, but it must not be what decode keys on: as a latch, one + * attested frame retroactively unlocked every unattested frame already + * retained under that `channelId`. + */ + const recordIdentity = ( + channelId: string, + identity: InAppFrameIdentity | undefined, + ): boolean => { + const mismatch = + (typeof identity?.v === "number" && + identity.v !== WIRE_ENVELOPE_VERSION) || + (typeof identity?.codec === "number" && + identity.codec !== TRUAPI_CODEC_VERSION) || + (typeof identity?.schema === "string" && + identity.schema !== TRUAPI_WIRE_SCHEMA_HASH); + // Confirmed only by an affirmative match. An absent schema is NOT trusted: + // "omit the identity and decode anyway" is the hole this closes. + const confirmed = identity?.schema === TRUAPI_WIRE_SCHEMA_HASH; + if (!confirmed || mismatch) sawUnconfirmed = true; + // Same validation the standalone applies: a non-finite or fractional count + // would otherwise render as "Infinity" or a rounded lie in the strip, and the + // two mounts would disagree about the same feeder. + const droppedRaw = identity?.dropped; + const dropped = + typeof droppedRaw === "number" && + Number.isSafeInteger(droppedRaw) && + droppedRaw > 0 + ? droppedRaw + : 0; + const key = normalizeId(channelId); + const existing = channels.get(key); + if (existing) { + if (mismatch) existing.codecOk = false; + if (confirmed) existing.schemaOk = true; + existing.dropped += dropped; + existing.lastSeen = seq++; + // Re-insert so map order tracks recency: without this the map stays in + // insertion order and the busiest, longest-lived channel is the FIRST + // evicted under pressure. + channels.delete(key); + channels.set(key, existing); + return confirmed && !mismatch; + } + if (channels.size >= MAX_CHANNELS) { + // Evict the least recently seen, matching the standalone's registry. + let oldestKey: string | undefined; + let oldestSeen = Infinity; + for (const [candidate, entry] of channels) { + if (entry.lastSeen < oldestSeen) { + oldestSeen = entry.lastSeen; + oldestKey = candidate; + } + } + if (oldestKey !== undefined) { + const evicted = channels.get(oldestKey); + channels.delete(oldestKey); + // A mismatch verdict is sticky FOR THE SESSION, not for as long as the + // entry survives. Forgetting it let a flood of distinct channelIds + // launder a channel that had already declared a foreign wire contract: + // it re-registered clean on its next frame and the panel decoded its + // frames — wrong methods and wrong values, presented as truth. + if (evicted !== undefined && !evicted.codecOk) { + distrusted.add(oldestKey); + } + } + } + channels.set(key, { + codecOk: !mismatch && !distrusted.has(key), + schemaOk: confirmed, + dropped, + lastSeen: seq++, + }); + return confirmed && !mismatch; + }; + + const decodeTrusted = (channelId?: string): boolean => { + // Payload-blind mode never decodes, so the gate has nothing to guard. + if (!session.decodeValues) return true; + if (channelId !== undefined) { + const c = channels.get(normalizeId(channelId)); + return c !== undefined && c.codecOk && c.schemaOk; + } + // No channel to key on: refuse once anything unattested has been seen. + return !sawUnconfirmed; + }; + + /** Channels whose method names (and values) can't be trusted to this table. */ + const untrustedChannels = (): { any: boolean; mismatch: boolean } => { + let any = false; + let mismatch = false; + for (const c of channels.values()) { + if (!c.codecOk) { + mismatch = true; + any = true; + } else if (!c.schemaOk) any = true; + } + return { any, mismatch }; + }; + + return { + session, + decodeTrusted, + handleFrame(channelId, dir, frame, identity) { + // A debug tap must never disturb the frame path. In this mount that is not + // a slogan: `handleFrame` is called from the host's own send/receive path in + // the same call stack, so a throw here surfaces to the product as a + // protocol failure. The standalone's socket callback carries the same guard + // for the same reason; here the blast radius is larger. + try { + const frameConfirmed = recordIdentity(channelId, identity); + // Stamp this frame with its own producer's verdict, so decode is gated + // per frame rather than latched per channel. The verdict is + // computed from the identity that arrived WITH this frame. + session.handleEnvelope({ + channelId, + dir, + frame, + identityConfirmed: frameConfirmed, + }); + } catch { + // Drop the frame; the observed session is worth more than one trace. + } + }, + mount(el, mountOptions = {}) { + const style = document.createElement("style"); + // The shared rules are FLAT (`.ins-*`, `.td-*`) because that is right for + // the standalone, which owns its page. Here they share a document with the + // host application — an unscoped rule would restyle the host's own debug + // panel, which `INSPECTOR_LAYOUT_CSS` is written to override — so every one + // of them is rewritten to `.td-inapp ` before injection. Only the + // root rules below are written already-scoped. + style.textContent = ` +.${MOUNT_CLASS} { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; + height: 100%; min-height: 0; overflow: hidden; background: #0a0a0a; color: #e0e0e0; + font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.${MOUNT_CLASS} * { box-sizing: border-box; } +${scopeCss( + `${INSPECTOR_SHELL_CSS}\n${TRACE_DETAIL_CSS}\n${INSPECTOR_LAYOUT_CSS}`, + `.${MOUNT_CLASS}`, +)}`; + + const root = document.createElement("div"); + root.className = MOUNT_CLASS; + root.innerHTML = ` +
+ TrUAPI Wire Inspector + + + +
+
+
+
waiting for frames…
+
+
Select an operation to inspect its frames.
+
+
`; + el.append(style, root); + + const pick = (selector: string): T => { + const node = root.querySelector(selector); + if (node === null) throw new Error(`in-app mount: missing ${selector}`); + return node; + }; + const filterEl = pick(".ins-filter"); + const sortEl = pick(".ins-sort"); + const channelsEl = pick(".ins-channels"); + const summaryEl = pick(".ins-summary"); + const listEl = pick(".ins-list"); + const detailEl = pick(".ins-detail"); + const statusEl = pick(".ins-status"); + + let selected: string | null = null; + let channel: string | null = null; + let disposed = false; + // Fingerprint of what the detail pane is currently showing. The refresh + // ticks once a second, but re-rendering the open op means re-decoding and + // re-hexing every one of its frames — tens of ms of blocked main thread, in + // the product's own tab, for an op that has not changed. Skip unless the + // fingerprint moves. + let detailKey: string | null = null; + + const render = (): void => { + if (disposed) return; + const traces = session.traceEngine.traces(); + const storms = detectRetryStorms(traces); + // One clock per render so every waiting op in this pass agrees, and the + // 1s refresh makes a hung call visibly count up. + const now = Date.now(); + const all = traces.map((trace) => + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []), + ); + + // Channel chips only earn their row once a second host has dialed in. + const channelIds = [ + ...new Set( + all + .map((v) => v.channelId) + .filter((c): c is string => c !== undefined), + ), + ]; + channelsEl.innerHTML = + channelIds.length < 2 + ? "" + : [null, ...channelIds] + .map((c) => { + const active = c === channel ? " active" : ""; + return ( + `` + ); + }) + .join(""); + + const scoped = + channel === null ? all : all.filter((v) => v.channelId === channel); + const untrusted = untrustedChannels(); + summaryEl.className = `ins-summary${scoped.length === 0 ? " empty" : ""}`; + summaryEl.innerHTML = renderSummary( + computeTraceStats(scoped, { + evictedTraces: session.traceEngine.evictedTraces(), + droppedByHost: [...channels.values()].reduce( + (n, c) => n + c.dropped, + 0, + ), + codecMismatch: untrusted.mismatch, + }), + ); + + const needle = filterEl.value.trim().toLowerCase(); + const filtered = + needle === "" + ? scoped + : scoped.filter((v) => + // `operationMethod` is THE definition of an op's method and its + // docstring requires every consumer to call it rather than + // re-derive one, so the filter can never disagree with the label + // the row renders. Re-deriving it off `frames[0]` hid rows whose + // first observed frame was a closer. + (operationMethod(v) ?? "").toLowerCase().includes(needle), + ); + + const sort = sortEl.value as SortMode; + const ordered = [...filtered].sort((a, b) => { + if (sort === "slowest") return b.durationMs - a.durationMs; + if (sort === "frames") return b.frames.length - a.frames.length; + return a.startedAt - b.startedAt; + }); + + // A row whose channel never attested a matching wire contract carries + // method names resolved off THIS debugger's table, which may be wrong for + // it. Say so above the rows, as the standalone does, rather than only in + // the status bar. + const listedUntrusted = ordered.some( + (v) => !decodeTrusted(v.channelId), + ); + const notice = + !listedUntrusted || !session.decodeValues + ? "" + : `
⚠ ${ + untrusted.mismatch + ? "a feeding host's wire contract differs from this debugger's — method names below may be wrong and values are not decoded" + : "a feeding host declared no wire contract — method names below may be wrong and values are not decoded" + }
`; + listEl.innerHTML = + ordered.length === 0 + ? `
${scoped.length === 0 ? "waiting for frames…" : "no operations match the filter"}
` + : notice + + ordered + .map((view) => { + const row = renderOperationRow(view, { now }); + return opKey(view) === selected + ? row.replace('class="td-op ', 'class="td-op selected ') + : row; + }) + .join(""); + + const open = ordered.find((v) => opKey(v) === selected); + const trusted = open !== undefined && decodeTrusted(open.channelId); + // Everything the detail render depends on, and nothing that ticks: frame + // count and `lastAt` move whenever a frame lands, badges move when the op + // changes shape, and the trust flag moves when an identity arrives. + const nextDetailKey = + open === undefined + ? "" + : [ + opKey(open), + String(open.frames.length), + String(open.lastAt), + open.badges.join("|"), + trusted ? "t" : "u", + ].join(" "); + if (nextDetailKey !== detailKey) { + detailKey = nextDetailKey; + detailEl.innerHTML = + open === undefined + ? `
Select an operation to inspect its frames.
` + : renderTraceDetail(open, { + offerDecode: session.decodeValues, + // Same wire-identity gate the standalone applies to a dialing + // host: an unattested channel groups but surfaces no value. + decoded: trusted + ? decodeTraceFrames(session, open) + : undefined, + }); + } + + const evicted = session.traceEngine.evictedTraces(); + const identityWarning = untrusted.mismatch + ? `⚠ codec mismatch` + : untrusted.any || (session.decodeValues && sawUnconfirmed) + ? `⚠ wire identity unconfirmed` + : ""; + statusEl.innerHTML = + `${String(all.length)} ops` + + `in-app · decode ${session.decodeValues ? "on" : "off"}` + + (evicted > 0 + ? `${String(evicted)} evicted` + : "") + + identityWarning; + }; + + // Selecting a row is the only interaction that changes what the detail + // pane shows, so re-render at once rather than waiting for the next tick. + listEl.addEventListener("click", (event) => { + const row = (event.target as HTMLElement).closest( + ".td-op", + ); + if (row === null) return; + // Must match `opKey` exactly, INCLUDING its length prefixes: this rebuilds + // the same key from the DOM, and a bare join would undo the collision fix + // on the click path. + const keyPart = (v: string): string => `${String(v.length)}:${v}`; + const key = [ + keyPart(row.dataset["channelId"] ?? ""), + keyPart(row.dataset["requestId"] ?? ""), + keyPart(row.dataset["generation"] ?? "0"), + ].join(" "); + selected = selected === key ? null : key; + render(); + }); + channelsEl.addEventListener("click", (event) => { + const chip = (event.target as HTMLElement).closest( + ".ins-chan", + ); + if (chip === null) return; + const value = chip.dataset["channel"] ?? ""; + channel = value === "" ? null : value; + render(); + }); + summaryEl.addEventListener("click", (event) => { + const pill = (event.target as HTMLElement).closest( + ".ins-method", + ); + if (pill === null) return; + filterEl.value = pill.dataset["method"] ?? ""; + render(); + }); + filterEl.addEventListener("input", render); + sortEl.addEventListener("change", render); + + render(); + const timer = setInterval(render, mountOptions.refreshMs ?? 1000); + return () => { + disposed = true; + clearInterval(timer); + style.remove(); + root.remove(); + }; + }, + }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 812db4dfc..57ac2aeec 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -45,6 +45,8 @@ export { INSPECTOR_LAYOUT_CSS, INSPECTOR_SHELL_CSS, } from "./inspector-styles.js"; +export { createInAppDebugger } from "./in-app.js"; +export type { InAppDebugger } from "./in-app.js"; export { operationMethod, isSubscription, @@ -53,3 +55,4 @@ export { export type { TraceDropCounts } from "./wire-debugger.js"; export { computeTraceStats } from "./session.js"; export type { TraceStats } from "./session.js"; +export type { InAppFrameIdentity } from "./in-app.js";