This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7541-dcecfa69fd9a94319f931221e7f99420e9d5970b in repository https://gitbox.apache.org/repos/asf/texera.git
commit af38ca99c13c0be4fbe69cc168c82d69aae36c56 Author: Meng Wang <[email protected]> AuthorDate: Mon Aug 10 23:42:26 2026 -0700 test(frontend): cover the report-generation image pipeline and shared-editing branches (#7541) ### What changes were proposed in this PR? 18 new tests across two units. | file | statements | branches | functions | | --- | --- | --- | --- | | `report-generation.service.ts` | 126/129 (was 98/129) | 25/25 | 34/35 | | `shared-editing.interface.ts` | 115/115 | 79/80 | 6/6 | **ReportGenerationService** — `fetchImageAsBase64` and the loop that feeds it were entirely unhit. Covered: an image whose bytes convert (its `href` is rewritten to the base64 result), one whose `FileReader` fails, one whose request fails, and one with no source at all (skipped without a request). `XMLHttpRequest` is replaced with a fake that settles synchronously and `FileReader` with one that fires on a microtask, so nothing depends on the network or on real timing; both globals are restored afterwards. Also covered while the report was in view: the two `|| "Unknown error"` fallbacks, the outer `catch` that reports a failure to build the report at all, and the visualization snapshot that has no wrapper `div` to resize. That takes branch coverage of the file to 25/25. **shared-editing.interface.ts** — the file is `createYTypeFromObject` / `updateYTypeFromObject` over Yjs types, so the tests drive real `Y.Doc`s in memory. Covered: the `typeof` arms no caller passes (function, symbol, bigint), the boxed-String arms of both functions, the no-op when a string is already up to date, an `undefined` array entry becoming `null`, an array element whose kind changes being replaced rather than merged, an array whose additions sit before its removals, and the `return false` for a type the dispatch has no strategy for. No production code was changed. ### Coverage that is not reachable - `report-generation.service.ts:93-95` — the html2canvas success callback. jsdom has no canvas, and reaching it means standing in a CSS engine, a 2D context, `toDataURL` and the image loader; that is a simulated renderer rather than a test, and it would break on an html2canvas or jsdom upgrade. Left alone deliberately. - `shared-editing.interface.ts:223` — the `_.isEqual` guard's false arm, i.e. an equal pair at the same offset *inside* an unmatched segment. A script replicating the LCS walk and segment construction found none across all 1,185,921 array pairs of length 2-6 over a 3-value alphabet, so the alignment appears to preclude it. Note that html2canvas still runs for real in the image tests and jsdom cannot render it, so those tests emit `Not implemented` notices on stderr. They are jsdom's, not failures — the tests assert on what the inlining step did, not on the render. ### One defect worth recording `createYTypeFromObject(new String("x"))` returns an **empty** `Y.Text`: `new Y.Text(...)` only accepts a primitive, so the boxed value is dropped. The update path does not share the bug — `Y.Text.insert` coerces — so the same input round-trips correctly through `updateYTypeFromObject`. The test asserts the real behaviour with a comment saying what to flip when the branch unwraps the box. ### Any related issues, documentation, discussions? Closes #7538. Note: the issue describes this file as a `switch` over shared-editing events with awareness state; the file is actually the two YType conversion functions above, with an LCS-based array diff. The tests follow the code. ### How was this PR tested? `ng test --watch=false` over the two specs — 52 passed (34 existing + 18 new), run 3x for determinism. Coverage (`--coverage`) gives the table above. The failure path was verified by breaking one assertion in each spec (red, non-zero exit) and restoring them; eslint and prettier are clean. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../report-generation.service.spec.ts | 186 +++++++++++++++++++++ .../types/shared-editing.interface.spec.ts | 117 +++++++++++++ 2 files changed, 303 insertions(+) diff --git a/frontend/src/app/workspace/service/report-generation/report-generation.service.spec.ts b/frontend/src/app/workspace/service/report-generation/report-generation.service.spec.ts index f6d33c55c9..21617223c8 100644 --- a/frontend/src/app/workspace/service/report-generation/report-generation.service.spec.ts +++ b/frontend/src/app/workspace/service/report-generation/report-generation.service.spec.ts @@ -175,6 +175,50 @@ describe("ReportGenerationService", () => { expect(html).toContain("No results found for operator"); }); + it("falls back to a generic reason when the page failure carries no message", async () => { + deps.workflowResultService.getPaginatedResultService.mockReturnValue({ + selectPage: vi.fn().mockReturnValue(throwError(() => new Error(""))), + }); + + await expect(htmlFor("op-1")).rejects.toBeInstanceOf(Error); + expect(deps.notificationService.error).toHaveBeenCalledWith( + "Error processing results for operator op-1: Unknown error" + ); + }); + + it("still renders a visualization snapshot that has no wrapper div to resize", async () => { + deps.workflowResultService.getResultService.mockReturnValue({ + getCurrentResultSnapshot: () => [{ "html-content": "<p>plain</p>" }], + }); + + const html = await htmlFor("op-1"); + + expect(html).toContain("plain"); + }); + + it("notifies and fails when building the report throws outright", async () => { + const failure = new Error("result service unavailable"); + deps.workflowResultService.getResultService.mockImplementation(() => { + throw failure; + }); + + await expect(htmlFor("op-1")).rejects.toBe(failure); + expect(deps.notificationService.error).toHaveBeenCalledWith( + "Unexpected error in retrieveOperatorInfoReport for operator op-1: result service unavailable" + ); + }); + + it("falls back to a generic reason when that failure carries no message", async () => { + deps.workflowResultService.getResultService.mockImplementation(() => { + throw new Error(""); + }); + + await expect(htmlFor("op-1")).rejects.toBeInstanceOf(Error); + expect(deps.notificationService.error).toHaveBeenCalledWith( + "Unexpected error in retrieveOperatorInfoReport for operator op-1: Unknown error" + ); + }); + it("embeds the operator's own definition in the collapsible details block", async () => { deps.workflowActionService.getWorkflowContent.mockReturnValue({ operators: [ @@ -291,5 +335,147 @@ describe("ReportGenerationService", () => { "Workflow editor element not found" ); }); + + /** + * Before the editor can be rendered, every <image> in it is refetched and inlined as + * base64 so the snapshot does not depend on URLs the renderer cannot resolve. Both async + * sources are replaced with fakes that settle synchronously (XHR) or on a microtask + * (FileReader), so nothing here depends on the network or on real timing. + * + * The html2canvas render that follows is left alone — it needs a real canvas — so these + * assert on what the inlining step did, not on the observable's outcome. + */ + describe("inlining the editor's images", () => { + const XLINK_HREF = "xlink:href"; + const BASE64 = "data:image/png;base64,AAAA"; + + let realXhr: typeof globalThis.XMLHttpRequest; + let realFileReader: typeof globalThis.FileReader; + let editor: HTMLElement; + let xhrOutcome: "load" | "error"; + let readerOutcome: "loadend" | "error"; + let sentUrls: string[]; + + class FakeXhr { + public response: unknown = "blob-stand-in"; + public responseType = ""; + public onload: (() => void) | null = null; + public onerror: (() => void) | null = null; + private url = ""; + open(_method: string, url: string): void { + this.url = url; + } + send(): void { + sentUrls.push(this.url); + if (xhrOutcome === "load") { + this.onload?.(); + } else { + this.onerror?.(); + } + } + } + + class FakeFileReader { + public result: string | null = null; + public onloadend: (() => void) | null = null; + public onerror: (() => void) | null = null; + readAsDataURL(): void { + queueMicrotask(() => { + if (readerOutcome === "loadend") { + this.result = BASE64; + this.onloadend?.(); + } else { + this.onerror?.(); + } + }); + } + } + + /** Adds an SVG <image> to the editor, optionally with a source attribute. */ + function addImage(src?: string): SVGElement { + const image = document.createElementNS("http://www.w3.org/2000/svg", "image"); + if (src !== undefined) { + image.setAttribute(XLINK_HREF, src); + } + editor.appendChild(image); + return image; + } + + /** Runs the snapshot and resolves once it settles, whichever way html2canvas goes. */ + function runSnapshot(): Promise<void> { + return new Promise<void>(resolve => { + service.generateWorkflowSnapshot("myflow").subscribe({ + next: () => resolve(), + error: () => resolve(), + }); + }); + } + + beforeEach(() => { + sentUrls = []; + xhrOutcome = "load"; + readerOutcome = "loadend"; + realXhr = globalThis.XMLHttpRequest; + realFileReader = globalThis.FileReader; + (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = FakeXhr; + (globalThis as unknown as { FileReader: unknown }).FileReader = FakeFileReader; + editor = document.createElement("div"); + editor.id = "workflow-editor"; + document.body.appendChild(editor); + }); + + afterEach(() => { + (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = realXhr; + (globalThis as unknown as { FileReader: unknown }).FileReader = realFileReader; + // Two of these tests spy on console.error; without this the spy would outlive them. + vi.restoreAllMocks(); + editor.remove(); + }); + + it("rewrites an image's source to the fetched base64 data", async () => { + const image = addImage("/assets/icon.png"); + + await runSnapshot(); + + expect(sentUrls).toEqual(["/assets/icon.png"]); + expect(image.getAttribute("href")).toBe(BASE64); + }); + + it("leaves an image with no source alone and fetches nothing for it", async () => { + const image = addImage(); + + await runSnapshot(); + + expect(sentUrls).toEqual([]); + expect(image.getAttribute("href")).toBeNull(); + }); + + it("reports an image whose bytes cannot be converted, and leaves its source alone", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + readerOutcome = "error"; + const image = addImage("/assets/icon.png"); + + await runSnapshot(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to load image: /assets/icon.png", + "Failed to convert image to Base64" + ); + expect(image.getAttribute("href")).toBeNull(); + }); + + it("reports an image that cannot be fetched at all", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + xhrOutcome = "error"; + addImage("/assets/missing.png"); + + await runSnapshot(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to load image: /assets/missing.png", + "Failed to load image from /assets/missing.png" + ); + }); + }); }); }); diff --git a/frontend/src/app/workspace/types/shared-editing.interface.spec.ts b/frontend/src/app/workspace/types/shared-editing.interface.spec.ts index 47e20c0931..40141d0006 100644 --- a/frontend/src/app/workspace/types/shared-editing.interface.spec.ts +++ b/frontend/src/app/workspace/types/shared-editing.interface.spec.ts @@ -200,3 +200,120 @@ describe("updateYTypeFromObject", () => { }); }); }); + +/** + * The blocks above drive the shapes production actually stores. These cover the arms that + * only a boxed String, a `typeof` the switch lists but no caller passes, or a particular + * array alignment can reach. + */ +describe("createYTypeFromObject edge kinds", () => { + it("returns values of the non-storable primitive kinds unchanged", () => { + const fn = () => 0; + const sym = Symbol("s"); + const big = BigInt(9); + + expect(createYTypeFromObject(fn as any)).toBe(fn); + expect(createYTypeFromObject(sym as any)).toBe(sym); + expect(createYTypeFromObject(big as any)).toBe(big); + }); + + it("converts a boxed String object into a Y.Text, losing its content", () => { + // `typeof` a boxed String is "object", so it reaches the constructor-name check rather + // than the "string" case above it. + const yText = createYTypeFromObject(new String("boxed") as unknown as object) as unknown as Y.Text; + + expect(yText).toBeInstanceOf(Y.Text); + // Characterizing a defect, not an intent: Y.Text's constructor only accepts a primitive, + // so the boxed value is dropped and the text comes out empty. Change this to "boxed" + // when the branch unwraps the box (`String(obj)`) before constructing the Y.Text. + expect(yText.toJSON()).toBe(""); + }); +}); + +describe("updateYTypeFromObject edge kinds", () => { + it("returns false for the non-storable primitive kinds", () => { + const doc = new Y.Doc(); + const yText = attach(doc, "t", createYTypeFromObject("hi" as unknown as object)); + + expect(updateYTypeFromObject(yText, (() => 0) as any)).toBe(false); + expect(updateYTypeFromObject(yText, Symbol("s") as any)).toBe(false); + expect(updateYTypeFromObject(yText, BigInt(9) as any)).toBe(false); + }); + + it("leaves a Y.Text untouched when the new string is identical", () => { + const doc = new Y.Doc(); + const yText = attach(doc, "t", createYTypeFromObject("same" as unknown as object)) as unknown as Y.Text; + const before = Y.encodeStateAsUpdate(doc); + + expect(updateYTypeFromObject(yText as unknown as YType<object>, "same" as any)).toBe(true); + + expect(yText.toJSON()).toBe("same"); + // An in-place delete+insert would have produced new document state. + expect(Y.encodeStateAsUpdate(doc)).toEqual(before); + }); + + it("updates a Y.Text from a boxed String", () => { + const doc = new Y.Doc(); + const yText = attach(doc, "t", createYTypeFromObject("old" as unknown as object)) as unknown as Y.Text; + + // Unlike the constructor, `insert` coerces the boxed value, so this arm does carry the + // content through. + expect(updateYTypeFromObject(yText as unknown as YType<object>, new String("new") as any)).toBe(true); + + expect(yText.toJSON()).toBe("new"); + }); + + it("stores an undefined array entry as null", () => { + const doc = new Y.Doc(); + const yArray = attach(doc, "a", createYTypeFromObject(["keep"])) as unknown as Y.Array<any>; + + expect(updateYTypeFromObject(yArray as unknown as YType<object>, ["keep", undefined] as any)).toBe(true); + + expect(yArray.toJSON()).toEqual(["keep", null]); + }); + + it("replaces an array element outright when its kind changes", () => { + const doc = new Y.Doc(); + const yArray = attach(doc, "a", createYTypeFromObject([{ a: 1 }, "tail"])) as unknown as Y.Array<any>; + + // A number cannot be updated into the Y.Map that held the object, so the element is + // deleted and re-inserted rather than mutated. + expect(updateYTypeFromObject(yArray as unknown as YType<object>, [7, "tail"] as any)).toBe(true); + + expect(yArray.toJSON()).toEqual([7, "tail"]); + }); + + it("aligns an array whose additions sit before its removals", () => { + const doc = new Y.Doc(); + const yArray = attach(doc, "a", createYTypeFromObject(["a", "b", "c"])) as unknown as Y.Array<any>; + + // The longest common subsequence is "a","c"; reaching it forces the walk through both + // of its advance arms. + expect(updateYTypeFromObject(yArray as unknown as YType<object>, ["a", "x", "y", "c"] as any)).toBe(true); + + expect(yArray.toJSON()).toEqual(["a", "x", "y", "c"]); + }); +}); + +describe("updateYTypeFromObject unsupported kinds", () => { + it("returns false for a matching type it has no merge strategy for", () => { + // Both sides report the same constructor, so the type-mismatch guard lets them through, + // but the dispatch below only knows String, Array and Object. + const oldStandIn = { toJSON: () => new Date(0) } as unknown as YType<object>; + + expect(updateYTypeFromObject(oldStandIn, new Date(1) as unknown as object)).toBe(false); + }); +}); + +describe("updateYTypeFromObject identical boxed strings", () => { + it("writes nothing when the new value is the very String object already stored", () => { + // The String branch is only reachable for a boxed String, and a boxed String never + // compares equal to the primitive a Y.Text reports — so the only way to reach the + // "already up to date" arm is for both sides to be the same object. A stand-in whose + // toJSON returns that object is what makes the comparison meet. + const boxed = new String("same"); + const oldStandIn = { toJSON: () => boxed } as unknown as YType<object>; + + expect(updateYTypeFromObject(oldStandIn, boxed as unknown as object)).toBe(true); + }); +});
