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
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";

// Identity debounce so the observer's request fires within the test.
vi.mock("lodash-es", async (importOriginal) => {
const actual = await importOriginal<typeof import("lodash-es")>();
return { ...actual, debounce: vi.fn((fn: any) => fn) };
});

vi.mock("../../../visualBuilder/utils/visualBuilderPostMessage", () => ({
default: { on: vi.fn(), send: vi.fn().mockResolvedValue(undefined) },
}));

vi.mock("../../../visualBuilder", () => ({
VisualBuilder: {
VisualBuilderGlobalState: {
value: { variant: null, highlightVariantFields: false },
},
},
}));

import { updateVariantClasses } from "../useRecalculateVariantDataCSLPValues";
import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types";
import visualBuilderPostMessage from "../../../visualBuilder/utils/visualBuilderPostMessage";
import * as cslpdata from "../../../cslp/cslpdata";
import { PublicLogger } from "../../../logger/logger";

const send = (visualBuilderPostMessage as any).send;

// vitest.setup.ts swaps MutationObserver for a stub that drops the callback, so
// the real observer never fires here. Capture the callback and drive it instead.
let observerCallbacks: MutationCallback[] = [];
// Captured in beforeEach, not at module scope: vitest.setup.ts installs its
// stub in beforeAll, so a module-level read would grab jsdom's native one and
// afterEach would restore the wrong implementation.
let installedMutationObserver: typeof MutationObserver;

class CapturingMutationObserver {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn((): MutationRecord[] => []);
constructor(callback: MutationCallback) {
observerCallbacks.push(callback);
}
}

const attributeMutation = {
type: "attributes",
attributeName: "data-cslp",
addedNodes: [] as unknown as NodeList,
} as unknown as MutationRecord;

describe("requestDiscussionHighlights via the CSLP mutation observer", () => {
beforeEach(() => {
vi.clearAllMocks();
observerCallbacks = [];
installedMutationObserver = global.MutationObserver;
global.MutationObserver =
CapturingMutationObserver as unknown as typeof MutationObserver;
send.mockResolvedValue(undefined);
vi.spyOn(cslpdata, "isValidCslp").mockReturnValue(true);
document.body.innerHTML = `<p data-cslp="v2:ct.entry.en-us.title">hi</p>`;
});

afterEach(() => {
global.MutationObserver = installedMutationObserver;
document.body.innerHTML = "";
vi.restoreAllMocks();
});

function fireMutation() {
updateVariantClasses();
expect(observerCallbacks).toHaveLength(1);
observerCallbacks[0]([attributeMutation], {} as MutationObserver);
}

it("requests discussion highlights when a data-cslp attribute changes", () => {
fireMutation();

expect(send).toHaveBeenCalledWith(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
});

// The bug this guards: the Discussions panel is closed on most CSR page
// loads, so this send rejects nearly every time. Left unhandled it reaches
// the Next.js dev overlay.
it("attaches a rejection handler to that send", async () => {
const rejection = Promise.reject({
code: "NO_REQUEST_LISTENER_FOUND",
message: 'No request listener found for event "x"',
});
const catchSpy = vi.spyOn(rejection, "catch");
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});
send.mockReturnValue(rejection);

fireMutation();
await expect(rejection).rejects.toMatchObject({
code: "NO_REQUEST_LISTENER_FOUND",
});

expect(catchSpy).toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});

// postMessageErrors is not mocked here, so the real helper runs. This is
// the case that ties the call site to it: a bare `.catch(() => {})` would
// pass every assertion above but fail this one.
it("warns through the helper when the send fails for another reason", async () => {
// The shape the library's no-ack timeout rejects with: no code.
const rejection = Promise.reject(
"contentstack-adv-post-message: The ACK was not received"
);
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});
send.mockReturnValue(rejection);

fireMutation();
await expect(rejection).rejects.toBe(
"contentstack-adv-post-message: The ACK was not received"
);

expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
Comment on lines +121 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix Nothing asserts that the rejection value reaches the warning. Every assertion that exercises the warn branch, here and in the twin spec and in postMessageErrors.test.ts, reads warn.mock.calls[0][0], which is the message the helper builds from the event name. Delete the error argument from the PublicLogger.warn call in postMessageErrors.ts and all of them stay green, while the warning loses the only part that says what actually failed.

That argument is the reason the warn branch exists at all, so it is worth pinning:

Suggested change
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
expect(warn.mock.calls[0][1]).toBe(
"contentstack-adv-post-message: The ACK was not received"
);

The same line applies to the twin at useVariantsPostMessageEvent.spec.ts:797. If you only take it in one place, take it in the it.each case in postMessageErrors.test.ts, since that is the helper's own unit test and the contract belongs there.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this one had real teeth. Every warn-branch assertion read warn.mock.calls[0][0], so the error argument could have been dropped without a single test noticing, which is the half of the warning that says what actually failed.

Taken in de91b09, and in all four places rather than one. The helper's own unit test pins it per row via toBe(error), and both call-site specs pin the concrete value. Verified by deleting the argument:

× postMessageErrors.test.ts > warns on an Error, as the closed-window path rejects
  → expected undefined to be Error: closed
× postMessageErrors.test.ts > warns on a bare string, as the no-ack timeout rejects
  → expected undefined to be 'contentstack-adv-post-message: The AC…'
× useVariantsPostMessageEvent.spec.ts > warns through the helper when the send fails for another reason
  → expected undefined to be 'contentstack-adv-post-message: The AC…'
× useRecalculateVariantDataCSLPValues.spec.ts > warns through the helper when the send fails for another reason
  → expected undefined to be 'contentstack-adv-post-message: The AC…'

Agreed the contract belongs in the helper's unit test. I kept it at the call sites too, since they are what would catch a call site that stopped routing through the helper at all.

expect(warn.mock.calls[0][1]).toBe(
"contentstack-adv-post-message: The ACK was not received"
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import visualBuilderPostMessage from "../../../visualBuilder/utils/visualBuilder
import { EventManager } from "@contentstack/advanced-post-message";
import { updateVariantClasses } from "../../../visualBuilder/eventManager/useRecalculateVariantDataCSLPValues";
import * as cslpdata from "../../../cslp/cslpdata";
import { PublicLogger } from "../../../logger/logger";

const mockVisualBuilderPostMessage =
visualBuilderPostMessage as MockedObject<EventManager>;
Expand All @@ -48,7 +49,9 @@ vi.mock("../../../visualBuilder/utils/visualBuilderPostMessage", () => {
return {
default: {
on: vi.fn(),
send: vi.fn(),
// send always returns a promise; tests that assert on rejection
// handling need the mock to match that contract.
send: vi.fn().mockResolvedValue(undefined),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit Setting the implementation inside the vi.mock factory works, but it survives only because nothing in this file calls vi.resetAllMocks() and vitest.config.ts sets no mockReset. That is the same fragility the description describes, one level up: the spec still leans on reset semantics instead of stating the contract per test. If mockReset is ever switched on, every test that reaches the get-variant-id handler starts failing on .catch of undefined.

A beforeEach re-establishing it is not order dependent, and it also removes the need for the finally block in the new test to hand-restore the mock:

beforeEach(() => {
    (mockVisualBuilderPostMessage.send as any).mockResolvedValue(undefined);
});

src/visualBuilder/utils/__test__/getResolvedVariantPermissions.spec.ts mocks this module with a bare send: vi.fn(). It does not reach either changed hook today, so nothing is broken there, but it is the other copy of the same wrong contract.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken. Both beforeEach blocks that reach the get-variant-id handler now restate mockResolvedValue(undefined), so the file no longer depends on reset semantics, and the new test's finally hand-restore is gone.

I left the factory default in place as well. It is redundant with the beforeEach, but it keeps the contract correct for any describe block added later that forgets the hook.

Good catch on getResolvedVariantPermissions.spec.ts. It does not reach either hook today, so I have left it rather than touch an unrelated spec in this PR.

},
};
});
Expand Down Expand Up @@ -153,7 +156,8 @@ describe("useVariantFieldsPostMessageEvent", () => {

// Reset mocks
vi.clearAllMocks();

(mockVisualBuilderPostMessage.send as any).mockResolvedValue(undefined);

// Mock isValidCslp to return true for test data (after clearAllMocks)
vi.spyOn(cslpdata, "isValidCslp").mockReturnValue(true);
});
Expand Down Expand Up @@ -613,10 +617,14 @@ describe("useVariantFieldsPostMessageEvent SSR handling", () => {
beforeEach(() => {
document.querySelectorAll = mockQuerySelectorAll;
vi.clearAllMocks();
// Restate the contract rather than inherit it: send always returns a
// promise, and a test below swaps in a rejecting one.
(mockVisualBuilderPostMessage.send as any).mockResolvedValue(undefined);
});

afterEach(() => {
document.querySelectorAll = originalQuerySelectorAll;
vi.restoreAllMocks();
});

it("should call addVariantFieldClass directly when isSSR is true and variant is provided", () => {
Expand Down Expand Up @@ -728,4 +736,72 @@ describe("useVariantFieldsPostMessageEvent SSR handling", () => {
);
expect(updateVariantClasses).toHaveBeenCalled();
});

// The visual builder registers this listener only while the
// Discussions panel is open, so the send rejects on most page loads. Left
// unhandled it reaches Next.js's unhandledrejection hook and renders a
// "[object Object]" runtime error overlay in dev.
it("attaches a rejection handler to the send", async () => {
useVariantFieldsPostMessageEvent({ isSSR: true });
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
(call: any[]) =>
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
);
const handler = call ? call[1] : null;

const rejection = Promise.reject({
code: "NO_REQUEST_LISTENER_FOUND",
message:
'No request listener found for event "request-discussion-highlights"',
});
// Deliberately coupled to the `.catch` shape. Asserting "no unhandled
// rejection escapes" would survive a refactor to try/catch, but neither
// a process `unhandledRejection` listener nor the jsdom window event
// fires reliably under vitest here: the same assertion passes with the
// fix removed, so it proves nothing. Rewriting this to catch a
// try/catch refactor means fixing that detection first.
const catchSpy = vi.spyOn(rejection, "catch");
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});
(mockVisualBuilderPostMessage.send as any).mockReturnValue(rejection);

handler!({ data: { variant: "variant-123" } });
await expect(rejection).rejects.toMatchObject({
code: "NO_REQUEST_LISTENER_FOUND",
});

expect(catchSpy).toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});

// postMessageErrors is not mocked here, so the real helper runs. This is
// the case that ties the call site to it: a bare `.catch(() => {})` would
// pass every assertion above but fail this one.
it("warns through the helper when the send fails for another reason", async () => {
useVariantFieldsPostMessageEvent({ isSSR: true });
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
(call: any[]) =>
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
);
const handler = call ? call[1] : null;

// The shape the library's no-ack timeout rejects with: no code.
const rejection = Promise.reject(
"contentstack-adv-post-message: The ACK was not received"
);
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit This spy is never restored. The SSR block's afterEach puts back document.querySelectorAll and nothing else, and the vi.clearAllMocks() in beforeEach clears call history without removing an implementation, so PublicLogger.warn stays stubbed for the remainder of the file once this test has run.

Nothing breaks today: these are the last two tests in the file and isolate: true keeps it inside this worker. It costs the next person who appends a test to this block and cannot work out why their logger assertion sees nothing. useRecalculateVariantDataCSLPValues.spec.ts and postMessageErrors.test.ts both call vi.restoreAllMocks(), so adding it to this block's afterEach would make the three consistent.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in de91b09. The SSR block's afterEach now calls vi.restoreAllMocks(), which lines it up with the other two specs.

Worth noting for anyone reading later: this is safe here only because both beforeEach blocks restate send's mockResolvedValue(undefined). restoreAllMocks resets implementations on the module factory's vi.fn()s as well, so without that restatement it would have broken the tests it was meant to protect. Full run is green, 42 files and 349 tests.

(mockVisualBuilderPostMessage.send as any).mockReturnValue(rejection);

handler!({ data: { variant: "variant-123" } });
await expect(rejection).rejects.toBe(
"contentstack-adv-post-message: The ACK was not received"
);

expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
expect(warn.mock.calls[0][1]).toBe(
"contentstack-adv-post-message: The ACK was not received"
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { visualBuilderStyles } from "../visualBuilder.style";
import { isValidCslp } from "../../cslp/cslpdata";
import { setHighlightVariantFields } from "./useVariantsPostMessageEvent";
import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
import { ignoreMissingListener } from "../utils/postMessageErrors";
import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types";
import { debounce } from "lodash-es";

Expand All @@ -14,9 +15,15 @@ const VARIANT_UPDATE_DELAY_MS: Readonly<number> = 8000;
// Coalesce a burst of data-cslp mutations into a single request to the
// visual editor.
const requestDiscussionHighlights = debounce(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit The regression test covers the useVariantFieldsPostMessageEvent send only. This debounced site got the same fix and has no test, and there is no spec file for this module at all. The two sites exist because the observer path and the SSR path fire independently, so a test here is the one that would catch a future edit dropping the .catch from the path that runs on most CSR page loads. The description notes that jsdom does not fire attribute mutations reliably, which is fair for the observer itself, but requestDiscussionHighlights can be exercised without the observer by invoking it directly and advancing timers.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, there is now a spec for this module.

Your suggestion to drive requestDiscussionHighlights directly is what I tried first, and the reason it needed a different approach is worth recording: vitest.setup.ts calls installGlobalObserverMocks(), which replaces MutationObserver globally with a stub whose constructor discards the callback. So no DOM-driven test of this path could ever have fired, regardless of jsdom.

The new spec swaps in a capturing observer, takes the callback and invokes it with an attribute mutation record. That removes the timing question entirely. Mutation checked: dropping the .catch from this site fails it.

visualBuilderPostMessage?.send(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
// The visual builder listens only while the Discussions panel is open, so
// an absent receiver is expected here rather than a failure.
visualBuilderPostMessage
?.send(VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS)
.catch(
ignoreMissingListener(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
)
);
}, 200);

type OnAudienceModeVariantPatchUpdate = {
Expand Down
20 changes: 13 additions & 7 deletions src/visualBuilder/eventManager/useVariantsPostMessageEvent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { VisualBuilder } from "..";
import { visualBuilderStyles } from "../visualBuilder.style";
import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
import { ignoreMissingListener } from "../utils/postMessageErrors";
import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types";
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
import { updateVariantClasses } from "./useRecalculateVariantDataCSLPValues";
Expand Down Expand Up @@ -167,17 +168,22 @@ export function useVariantFieldsPostMessageEvent({ isSSR }: { isSSR: boolean }):
if (selectedVariant) {
addVariantFieldClass(selectedVariant);
}
// SSR DOM is final; observer never fires, request directly.
visualBuilderPostMessage?.send(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
);
} else {
// CSR: observer in updateVariantClasses requests on settle.
// CSR: observer in updateVariantClasses also requests on settle.
updateVariantClasses();
visualBuilderPostMessage?.send(
}
// Sent in both modes: SSR has no observer to fire it later. The
// visual builder listens only while the Discussions panel is open,
// so an absent receiver is expected here rather than a failure.
visualBuilderPostMessage
?.send(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
)
.catch(
ignoreMissingListener(
VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS
)
);
Comment on lines +178 to 186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the event name is now written twice per call site, so the two sends carry four references that have to stay in sync, and a mismatch would be silent (the send goes to one event, the warning names another).

A wrapper next to ignoreMissingListener that takes the name once would collapse both sites to a single line:

export function sendOptional(event: VisualBuilderPostMessageEvents): void {
    visualBuilderPostMessage?.send(event).catch(ignoreMissingListener(event));
}

Worth weighing against the import cycle it would add, since postMessageErrors.ts would then pull in visualBuilderPostMessage, which is the module the variants spec mocks wholesale. The PR description says that separation was deliberate, so leaving it as is with the name repeated is a reasonable call too.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it as is, for the reason you raised yourself. postMessageErrors.ts would have to import visualBuilderPostMessage, and the variants spec mocks that module wholesale, so the helper's own tests would end up exercising a mock of the thing under test. That is exactly why the helper got its own module: I put it in visualBuilderPostMessage.ts first and hit this.

The four references staying in sync is a real cost and I do not have a better answer than repetition right now. If a third optional-receiver site appears, the wrapper earns the cycle and I would move both then.

}
}
);
visualBuilderPostMessage?.on(
Expand Down
54 changes: 54 additions & 0 deletions src/visualBuilder/utils/__test__/postMessageErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import { ignoreMissingListener } from "../postMessageErrors";
import { VisualBuilderPostMessageEvents } from "../types/postMessage.types";
import { PublicLogger } from "../../../logger/logger";

const EVENT = VisualBuilderPostMessageEvents.REQUEST_DISCUSSION_HIGHLIGHTS;

describe("ignoreMissingListener", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

// The only rejection adv-post-message sends back with a code: the receiver
// acked, looked for a listener and found none.
it("stays silent when the receiver answers that nobody is listening", () => {
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});

ignoreMissingListener(EVENT)({
code: "NO_REQUEST_LISTENER_FOUND",
message:
'No request listener found for event "request-discussion-highlights"',
});

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

// The shapes the library's own timeout paths reject with. Neither carries a
// code, so both must fall through to the warning.
it.each([
["an Error, as the closed-window path rejects", new Error("closed")],
[
"a bare string, as the no-ack timeout rejects",
"contentstack-adv-post-message: The ACK was not received",
],
])("warns on %s so a real failure stays visible", (_shape, error) => {
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});

ignoreMissingListener(EVENT)(error);

expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(EVENT);
// The rejection itself is the reason this branch exists; without this
// the warn call could drop it and every assertion here would hold.
expect(warn.mock.calls[0][1]).toBe(error);
});
Comment on lines +29 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix — these three cases pass, but none of them is a shape a sender can actually receive, so the test gives confidence about failures it never exercises.

From the 0.0.5 bundle: WINDOW_CLOSED reaches the sender as new Error(...) with no code, NO_ACK_RECEIVED as a bare string with no code, and CODE_RETURNED_ERROR is only logged on the receiving side (.catch(e => logger.error(codeReturnedError, e))) and never sent back as a response error, so the sender's promise does not settle at all in that case. { code: "NO_ACK_RECEIVED" } is a value the library never produces.

The handler still does the right thing for all of them, because an uncoded rejection warns. It is the test, and the matching claim in the PR description, that describe a contract the dependency does not have.

Suggested change
it.each([
"NO_ACK_RECEIVED",
"WINDOW_CLOSED",
"CODE_RETURNED_ERROR",
])("warns on %s so a real failure is still visible", (code) => {
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});
ignoreMissingListener("request-discussion-highlights")({ code });
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
"request-discussion-highlights"
);
});
it.each([
["an Error, as the closed-window path rejects", new Error("closed")],
["a bare string, as the no-ack timeout rejects", "no ACK received"],
])("warns on %s so a real failure is still visible", (_shape, error) => {
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});
ignoreMissingListener("request-discussion-highlights")(error);
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain(
"request-discussion-highlights"
);
});

The last case in the file (new Error("boom")) then overlaps the first row here, so it can go.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and taken almost verbatim in b78d266. The three invented shapes are gone, replaced by the two the library actually rejects with, and the redundant trailing new Error("boom") case is removed as you suggested.

I kept one coded case, { code: "SOMETHING_ELSE" }, to pin the branch itself. Without it the only coded value under test is the one that returns early, so a handler that ignored the code entirely and always returned silently would still pass.

The matching claim in the description is corrected too. That error was mine, not the code's.


it("warns on a coded rejection that is not the missing listener", () => {
const warn = vi.spyOn(PublicLogger, "warn").mockImplementation(() => {});

ignoreMissingListener(EVENT)({ code: "SOMETHING_ELSE" });

expect(warn).toHaveBeenCalledOnce();
});
});
28 changes: 28 additions & 0 deletions src/visualBuilder/utils/postMessageErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PublicLogger } from "../../logger/logger";
import type { VisualBuilderPostMessageEvents } from "./types/postMessage.types";

// adv-post-message does not export ERROR_CODES from its entry point, so the
// wire value is matched directly.
const NO_REQUEST_LISTENER_FOUND = "NO_REQUEST_LISTENER_FOUND";

/**
* Rejection handler for sends whose receiver is only mounted some of the time.
*
* Only the missing-listener reply carries a `code`; the library's other
* rejections are an uncoded `Error` (closed window) or a bare string (no ack),
* so they fall through to the warning. That is deliberate: a receiver that
* never acks is not the same as one that answered "nobody is listening".
*/
export function ignoreMissingListener(
event: VisualBuilderPostMessageEvents
): (error: unknown) => void {
return (error: unknown) => {
if ((error as { code?: string })?.code === NO_REQUEST_LISTENER_FOUND) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix — this recognises only one of the three ways the library reports that the receiver is not there, so the other two land in the warn branch carrying nothing a reader can act on.

I unpacked @contentstack/advanced-post-message@0.0.5 to check. Only the missing-listener response is sent back as a coded object:

this.postMessage.sendResponse({ type, hash, payload: undefined,
    error: { code: ERROR_CODES.receiveEvent.noRequestListenerFound, message: ... } })

The two timeout paths in EventManager.send do not carry a code at all:

targetWindow.closed
    ? r.reject(new Error(getErrorMessage(ERROR_MESSAGES.common.windowClosed)))   // an Error
    : (!hasReceivedAck && budget <= 0
        ? r.reject(getErrorMessage(ERROR_MESSAGES.sendEvent.noAckReceived))      // a bare string
        : undefined)

(error as { code?: string })?.code is undefined for both, so both warn. The realistic trigger is the visual builder parent not acking inside the library's 1s budget, or unloading mid-send, which is the same "receiver is not there" condition this helper exists to absorb. The exposure is a transient console warning rather than anything a content editor sees, so it is not urgent, but the helper's own doc comment claims to cover no-ack and closed-window and it does not.

Either match the no-ack case as well (the message is stable: contentstack-adv-post-message: The ACK was not received), or keep the code check as the only rule and narrow the doc comment to say so.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and I verified it against the 0.0.5 bundle rather than take it on trust:

closed ? r.reject(new Error(getErrorMessage(windowClosed)))     // Error, no code
       : (!hasReceivedAck && budget <= 0
            ? r.reject(getErrorMessage(noAckReceived))          // bare string, no code
            : void 0)

Only the missing-listener reply is a coded object, and CODE_RETURNED_ERROR is logged on the receiving side and never sent back, so the sender never settles on it.

I took your second option: the code check stays the only rule and the doc comment now says so, rather than claiming coverage it does not have. Two reasons for that over also matching the no-ack case. The no-ack match would key on a message string, which is a weaker contract than the code and would break silently if the library reworded it. And a receiver that acks and then says "nobody is listening" is a different condition from one that never answers at all, which on this channel means the visual builder parent is wedged or unloading. That seems worth one warning rather than silence.

Fixed in b78d266.

return;
}
PublicLogger.warn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, pre-existing rather than introduced here, and worth a check rather than a change in this PR.

PublicLogger.warn only reaches the console when process exists:

if (typeof process !== "undefined" && process?.env?.NODE_ENV !== "test") {

tsup.config.js defines process.env.PACKAGE_VERSION and the two purge flags, but nothing that makes bare process resolvable, and the dist is cjs and esm only. A consumer whose bundler replaces process.env.NODE_ENV but leaves typeof process as a runtime check, and anyone using the ESM CDN snippet in the README, would evaluate that guard as false and see nothing.

If that holds, the visibility this commit is buying does not reach those consumers, and the silent branch and the warn branch look the same from outside. I did not run it, so please confirm before treating it as a gap.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked, and it holds. tsup.config.js defines only:

define: {
  "process.env.PACKAGE_VERSION": "\"<version>\"",
  "process.env.PURGE_PREVIEW_SDK": "process.env.PURGE_PREVIEW_SDK",
  "process.env.REACT_APP_PURGE_PREVIEW_SDK": "process.env.REACT_APP_PURGE_PREVIEW_SDK",
}

Nothing makes bare process resolvable, and the dist is cjs and esm only, so typeof process !== "undefined" is a real runtime check in the shipped bundle. A Vite consumer, or anyone on the ESM CDN snippet, evaluates it false and sees nothing.

It does reach the consumers this change is about: Next.js provides a process shim, and the report behind this fix came from a Next 16 app. So the visibility is real where the bug was reported, and absent elsewhere.

I am not changing it here. That guard sits in PublicLogger and gates every log in the SDK, so inverting it could surface a lot of output that has been silently dropped for years. That deserves its own change rather than riding along on this one. Raising it separately.

`Failed to send "${event}" to the visual builder`,
error
);
};
}
Loading