Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ const jestConfig = {
"<rootDir>/packages/uix-core/src/__helpers__/jest.messagechannel.cjs",
],
}),
sdkProject("host", {}),
sdkProject("host", {
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
}),
sdkProject("guest", {
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
}),
sdkProject("host-react", {
testMatch: [
"<rootDir>/packages/uix-host-react/src/**/*.test.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/uix-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export * from "./types";
export * from "./cross-realm-object";
export * from "./logging-formatters";
export * from "./promises";
export * from "./trace";
export * from "./tunnel";
export type { Asynced } from "./object-walker";
151 changes: 151 additions & 0 deletions packages/uix-core/src/trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

import { createTracer, __resetTraceStateForTests } from "./trace";

describe("createTracer", () => {
let logSpy: jest.SpyInstance;

beforeEach(() => {
window.localStorage.clear();
__resetTraceStateForTests();
logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
});

afterEach(() => {
logSpy.mockRestore();
window.__UIX_DEBUG__?.disable();
});

it("is disabled by default and logs nothing", () => {
const trace = createTracer("uix-host");

expect(trace.enabled).toBe(false);
trace("something happened");

expect(logSpy).not.toHaveBeenCalled();
});

it("never calls a details thunk while disabled", () => {
const trace = createTracer("uix-host");
const buildDetails = jest.fn(() => ({ expensive: "work" }));

trace("something happened", buildDetails);

expect(buildDetails).not.toHaveBeenCalled();
});

it("logs a single JSON-structured line once its exact namespace is enabled", () => {
const trace = createTracer("uix-host");

window.__UIX_DEBUG__?.enable("uix-host");
expect(trace.enabled).toBe(true);

trace("guest connected", { id: "msm" });

expect(logSpy).toHaveBeenCalledTimes(1);
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toMatch(/^\[uix-host\] guest connected \{.*\}$/);
const payload = JSON.parse(
line.replace(/^\[uix-host\] guest connected /, "")
) as Record<string, unknown>;
expect(payload).toMatchObject({ id: "msm" });
expect(payload.at).toEqual(expect.any(String));
});

it("calls a details thunk once enabled, and uses its return value", () => {
const trace = createTracer("uix-host");
window.__UIX_DEBUG__?.enable("uix-host");

trace("guest connected", () => ({ id: "msm" }));

const [line] = logSpy.mock.calls[0] as [string];
expect(
JSON.parse(line.replace(/^\[uix-host\] guest connected /, ""))
).toMatchObject({ id: "msm" });
});

it("does not enable other namespaces when enabling one by exact name", () => {
const hostTrace = createTracer("uix-host");
const guestTrace = createTracer("uix-guest");

window.__UIX_DEBUG__?.enable("uix-host");

expect(hostTrace.enabled).toBe(true);
expect(guestTrace.enabled).toBe(false);
});

it("supports trailing-wildcard patterns", () => {
const hostTrace = createTracer("uix-host");
const guestTrace = createTracer("uix-guest");
const otherTrace = createTracer("rte-before-load");

window.__UIX_DEBUG__?.enable("uix-*");

expect(hostTrace.enabled).toBe(true);
expect(guestTrace.enabled).toBe(true);
expect(otherTrace.enabled).toBe(false);
});

it("'*' enables every namespace, including ones registered afterward", () => {
const hostTrace = createTracer("uix-host");

window.__UIX_DEBUG__?.enable("*");
expect(hostTrace.enabled).toBe(true);

// A tracer created after enable("*") should also come up enabled -- the
// check is live against the current pattern set, not baked in at
// createTracer() time.
const laterTrace = createTracer("some-new-namespace");
expect(laterTrace.enabled).toBe(true);
});

it("enable() defaults to '*' when called with no pattern", () => {
const trace = createTracer("uix-host");

window.__UIX_DEBUG__?.enable();

expect(trace.enabled).toBe(true);
});

it("disable() turns every namespace back off and clears the persisted preference", () => {
const trace = createTracer("uix-host");
window.__UIX_DEBUG__?.enable("uix-host");
expect(trace.enabled).toBe(true);

window.__UIX_DEBUG__?.disable();

expect(trace.enabled).toBe(false);
expect(window.localStorage.getItem("uix:debug")).toBeNull();
});

it("list() returns every namespace a tracer has been created for, regardless of enabled state", () => {
createTracer("uix-host");
createTracer("uix-guest");

expect(window.__UIX_DEBUG__?.list()).toEqual(["uix-guest", "uix-host"]);
});

it("persists enabled patterns to localStorage and a fresh page load honours them", () => {
const trace = createTracer("uix-host");
window.__UIX_DEBUG__?.enable("uix-host");

expect(window.localStorage.getItem("uix:debug")).toBe("uix-host");

// Simulate a reload: reset in-memory state, but leave localStorage as-is.
__resetTraceStateForTests();
const traceAfterReload = createTracer("uix-host");

expect(traceAfterReload.enabled).toBe(true);
void trace; // keep referenced for clarity of the "before" state above
});
});
167 changes: 167 additions & 0 deletions packages/uix-core/src/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

/**
* Namespaced, runtime-toggleable tracing for diagnosing extension-loading and
* RPC timing issues, in the SDK itself and in apps that consume it.
*
* Unlike {@link debugEmitter}/{@link _customConsole} (which require a `debug`
* option at construction time and produce human-oriented colored console
* output), a trace channel can be enabled *after the fact*, at runtime, by
* anyone with devtools access -- `__UIX_DEBUG__.enable("uix-host")` -- and
* produces single-line, JSON-structured output that survives a plain-text
* devtools console export.
*
* @packageDocumentation
*/

/** A plain object of details to attach to a trace line. */
type TraceDetails = Record<string, unknown>;

/**
* A namespaced trace function. Calling it is a no-op unless its namespace is
* currently enabled, in which case it logs `[namespace] label {...details}`
* with an ISO timestamp.
*
* `details` may be a plain object or a function returning one. Prefer a
* function whenever building `details` costs more than a property read or
* two (mapping a list, serializing an error) -- it will not be called at all
* while the namespace is disabled.
*/
export interface Tracer {
(label: string, details?: TraceDetails | (() => TraceDetails)): void;
/** Whether this tracer's namespace is currently enabled. Safe to check before doing any work the tracer itself doesn't need. */
readonly enabled: boolean;
}

/** Methods for enabling/inspecting trace channels at runtime, exposed as `window.__UIX_DEBUG__`. */
export interface UixDebug {
/**
* Enable one or more namespace patterns. Patterns may be an exact
* namespace (`"uix-host"`), a trailing wildcard (`"uix-*"`), or `"*"` for
* everything. Persists across reloads via `localStorage`.
*/
enable(pattern?: string): void;
/** Disable all tracing and clear the persisted preference. */
disable(): void;
/** Every namespace any tracer has been created for so far, enabled or not. */
list(): string[];
}

const STORAGE_KEY = "uix:debug";

function readInitialPatterns(): string[] {
if (typeof window === "undefined") {
return [];
}
const fromQuery = new URLSearchParams(window.location?.search ?? "").get(
"uixDebug"
);
const fromStorage = (() => {
try {
return window.localStorage?.getItem(STORAGE_KEY);
} catch {
// localStorage can throw in sandboxed/private contexts; treat as unset.
return null;
}
})();
const raw = fromQuery ?? fromStorage ?? "";
return raw
.split(",")
.map((pattern) => pattern.trim())
.filter((pattern) => pattern.length > 0);
}

let enabledPatterns: string[] = readInitialPatterns();
const registry = new Set<string>();

function matches(pattern: string, namespace: string): boolean {
if (pattern === "*" || pattern === namespace) {
return true;
}
return pattern.endsWith("*") && namespace.startsWith(pattern.slice(0, -1));
}

function isNamespaceEnabled(namespace: string): boolean {
return enabledPatterns.some((pattern) => matches(pattern, namespace));
}

/**
* Create a trace function for the given namespace. Cheap to call at module
* load time from anywhere -- registration itself has no cost, and the
* returned function does no work beyond a property read while its namespace
* is disabled.
*/
export function createTracer(namespace: string): Tracer {
registry.add(namespace);

const tracer = ((
label: string,
details?: TraceDetails | (() => TraceDetails)
) => {
if (!isNamespaceEnabled(namespace)) {
return;
}
const resolved = typeof details === "function" ? details() : details;
// eslint-disable-next-line no-console
console.log(
`[${namespace}] ${label} ${JSON.stringify({
at: new Date().toISOString(),
...resolved,
})}`
);
}) as Tracer;

Object.defineProperty(tracer, "enabled", {
get: () => isNamespaceEnabled(namespace),
});

return tracer;
}

const uixDebug: UixDebug = {
disable(): void {
enabledPatterns = [];
try {
window.localStorage?.removeItem(STORAGE_KEY);
} catch {
// ignore -- localStorage unavailable
}
},
enable(pattern = "*"): void {
enabledPatterns = [...new Set([...enabledPatterns, pattern])];
try {
window.localStorage?.setItem(STORAGE_KEY, enabledPatterns.join(","));
} catch {
// ignore -- localStorage unavailable
}
},
list(): string[] {
return [...registry].sort();
},
};

declare global {
interface Window {
__UIX_DEBUG__?: UixDebug;
}
}

if (typeof window !== "undefined") {
window.__UIX_DEBUG__ = uixDebug;
}

/** @internal exported for tests only */
export const __resetTraceStateForTests = (): void => {
enabledPatterns = readInitialPatterns();
registry.clear();
};
Loading
Loading