-
Notifications
You must be signed in to change notification settings - Fork 1
fix(visual-builder): handle rejection when discussion highlights has no listener #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop_v4
Are you sure you want to change the base?
Changes from all commits
eb3a86d
19f80f9
b78d266
dc4d8bf
de91b09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ); | ||
| 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 |
|---|---|---|
|
|
@@ -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>; | ||
|
|
@@ -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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit Setting the implementation inside the A beforeEach(() => {
(mockVisualBuilderPostMessage.send as any).mockResolvedValue(undefined);
});
Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken. Both I left the factory default in place as well. It is redundant with the Good catch on |
||
| }, | ||
| }; | ||
| }); | ||
|
|
@@ -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); | ||
| }); | ||
|
|
@@ -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", () => { | ||
|
|
@@ -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(() => {}); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit This spy is never restored. The SSR block's Nothing breaks today: these are the last two tests in the file and Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken in Worth noting for anyone reading later: this is safe here only because both |
||
| (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 |
|---|---|---|
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -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(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit The regression test covers the Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| 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 = { | ||
|
|
||
| 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"; | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 export function sendOptional(event: VisualBuilderPostMessageEvents): void {
visualBuilderPostMessage?.send(event).catch(ignoreMissingListener(event));
}Worth weighing against the import cycle it would add, since Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving it as is, for the reason you raised yourself. 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( | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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
The last case in the file ( Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and taken almost verbatim in I kept one coded case, 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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 this.postMessage.sendResponse({ type, hash, payload: undefined,
error: { code: ERROR_CODES.receiveEvent.noRequestListenerFound, message: ... } })The two timeout paths in 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)
Either match the no-ack case as well (the message is stable: Generated by Claude Code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| return; | ||
| } | ||
| PublicLogger.warn( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
if (typeof process !== "undefined" && process?.env?.NODE_ENV !== "test") {
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checked, and it holds. 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 It does reach the consumers this change is about: Next.js provides a I am not changing it here. That guard sits in |
||
| `Failed to send "${event}" to the visual builder`, | ||
| error | ||
| ); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
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, readswarn.mock.calls[0][0], which is the message the helper builds from the event name. Delete theerrorargument from thePublicLogger.warncall inpostMessageErrors.tsand 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:
The same line applies to the twin at
useVariantsPostMessageEvent.spec.ts:797. If you only take it in one place, take it in theit.eachcase inpostMessageErrors.test.ts, since that is the helper's own unit test and the contract belongs there.Generated by Claude Code
There was a problem hiding this comment.
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 viatoBe(error), and both call-site specs pin the concrete value. Verified by deleting the argument: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.