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-7383-54a78939ed71c4bc515a215e0e5eee149dc68c28 in repository https://gitbox.apache.org/repos/asf/texera.git
commit e6b93a7d637861ea94658f9e7e6de4e7f115fec9 Author: Xinyuan Lin <[email protected]> AuthorDate: Fri Aug 7 04:14:46 2026 -0700 test(frontend): cover ReportGenerationService's report assembly (#7383) ### What changes were proposed in this PR? `ReportGenerationService` had no spec. It assembles the downloadable workflow report, and every branch deciding what a given operator contributes to it was unexercised. Adds 16 tests. The core of it is that `retrieveOperatorInfoReport` picks one of five renderings per operator: | Operator has | Report shows | |---|---| | paginated service, page non-empty | a table of the first 10 rows | | paginated service, page empty | "No results found for operator" | | paginated service, page fetch fails | a notification naming the operator, and the observable errors | | plain result service with a snapshot | the **last** snapshot, resized to fit | | plain result service, no snapshot | "No data found for operator" | | neither service | "No results found for operator" | The failure mode here is a silently wrong report rather than an exception, which is what makes the assertions worth having: showing the first visualization snapshot instead of the latest is invisible until someone compares the report against the canvas. Also covered: both prompt builders, the assembly and download of the final document, and the missing-editor path of `generateWorkflowSnapshot`. **Verified by mutation**, all reverted (production diff empty): | Mutation | Result | |---|---| | render the first snapshot instead of the last | red | | drop the empty-page branch | red | | drop the workflow name from the filename | red | | make the summary prompt ask for 80 words like the per-operator one | red | | request page 0 instead of page 1 | red | | stop resizing the embedded chart | red | | drop the error notification | red | Two jsdom details are commented in the spec: `Blob` has no `text()` so the document is read back through a `FileReader`, and the anchor is built before `document.createElement` is stubbed so the stub does not intercept its own creation. One thing found but deliberately **not** changed, to avoid cementing it: the service injects `HttpClient` and declares an `isAIAssistantEnabled` field, and neither is ever used. `retrieveOperatorInfoReport` also subscribes to `isOpenAIEnabled()` and ignores the emitted value, so the flag gates nothing while still being able to stall the report if it never emits — there is a test pinning that stall. No production file is touched. ### Any related issues, documentation, discussions? Closes #7380 ### How was this PR tested? ``` npx ng test --watch=false --include="**/report-generation.service.spec.ts" ``` ``` Test Files 1 passed (1) Tests 16 passed (16) ``` `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../report-generation.service.spec.ts | 295 +++++++++++++++++++++ 1 file changed, 295 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 new file mode 100644 index 0000000000..f6d33c55c9 --- /dev/null +++ b/frontend/src/app/workspace/service/report-generation/report-generation.service.spec.ts @@ -0,0 +1,295 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClient } from "@angular/common/http"; +import { firstValueFrom, of, Subject, throwError } from "rxjs"; +import { ReportGenerationService } from "./report-generation.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { WorkflowResultService } from "../workflow-result/workflow-result.service"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { AiAnalystService } from "../ai-analyst/ai-analyst.service"; +import { commonTestProviders } from "../../../common/testing/test-utils"; + +/** + * The service reaches for five collaborators but only ever calls a handful of their methods, so the + * suite injects narrow stubs rather than the real service graph. `http` is injected by the service + * and never used, so an empty object is enough to satisfy the constructor. + */ +function stubs() { + return { + workflowActionService: { getWorkflowContent: vi.fn().mockReturnValue({ operators: [] }) }, + workflowResultService: { + getResultService: vi.fn().mockReturnValue(undefined), + getPaginatedResultService: vi.fn().mockReturnValue(undefined), + }, + notificationService: { error: vi.fn() }, + aiAnalystService: { + isOpenAIEnabled: vi.fn().mockReturnValue(of(true)), + sendPromptToOpenAI: vi.fn().mockReturnValue(of("GENERATED COMMENT")), + }, + }; +} + +/** jsdom's Blob has no `text()`, so the report body is read back through a FileReader. */ +function readBlob(blob: Blob): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); +} + +describe("ReportGenerationService", () => { + let service: ReportGenerationService; + let deps: ReturnType<typeof stubs>; + + beforeEach(() => { + deps = stubs(); + TestBed.configureTestingModule({ + providers: [ + ReportGenerationService, + { provide: HttpClient, useValue: {} }, + { provide: WorkflowActionService, useValue: deps.workflowActionService }, + { provide: WorkflowResultService, useValue: deps.workflowResultService }, + { provide: NotificationService, useValue: deps.notificationService }, + { provide: AiAnalystService, useValue: deps.aiAnalystService }, + ...commonTestProviders, + ], + }); + service = TestBed.inject(ReportGenerationService); + }); + + /** Runs the report for one operator and hands back the HTML it pushed into the accumulator. */ + async function htmlFor(operatorId: string): Promise<string> { + const collected: { operatorId: string; html: string }[] = []; + await firstValueFrom(service.retrieveOperatorInfoReport(operatorId, collected)); + return collected[0].html; + } + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("retrieveOperatorInfoReport", () => { + it("renders a paginated result as a table of its first page", async () => { + // A null cell renders as the text "null" rather than an empty cell, so the column stays + // aligned with its header; pinned here because "tidying" it to a blank would shift the row. + deps.workflowResultService.getPaginatedResultService.mockReturnValue({ + selectPage: vi.fn().mockReturnValue(of({ table: [{ colA: 1, colB: null }] })), + }); + deps.workflowActionService.getWorkflowContent.mockReturnValue({ + operators: [{ operatorID: "op-1", operatorType: "CSVFileScan" }], + }); + + const html = await htmlFor("op-1"); + + expect(html).toContain("<h3>Operator ID: op-1</h3>"); + expect(html).toContain(">colA</th>"); + expect(html).toContain(">colB</th>"); + expect(html).toContain(">1</td>"); + expect(html).toContain(">null</td>"); + expect(html).toContain("GENERATED COMMENT"); + }); + + it("asks for the first page of ten rows", async () => { + const selectPage = vi.fn().mockReturnValue(of({ table: [{ a: 1 }] })); + deps.workflowResultService.getPaginatedResultService.mockReturnValue({ selectPage }); + + await htmlFor("op-1"); + + expect(selectPage).toHaveBeenCalledWith(1, 10); + }); + + it("reports an empty page as no results rather than an empty table", async () => { + deps.workflowResultService.getPaginatedResultService.mockReturnValue({ + selectPage: vi.fn().mockReturnValue(of({ table: [] })), + }); + + const html = await htmlFor("op-1"); + + expect(html).toContain("No results found for operator"); + expect(html).not.toContain("<table"); + }); + + it("notifies and fails when the page cannot be fetched", async () => { + const failure = new Error("page 1 unavailable"); + deps.workflowResultService.getPaginatedResultService.mockReturnValue({ + selectPage: vi.fn().mockReturnValue(throwError(() => failure)), + }); + + await expect(htmlFor("op-1")).rejects.toBe(failure); + expect(deps.notificationService.error).toHaveBeenCalledWith( + expect.stringContaining("Error processing results for operator op-1") + ); + expect(deps.notificationService.error).toHaveBeenCalledWith(expect.stringContaining("page 1 unavailable")); + }); + + it("renders the most recent snapshot of a visualization operator", async () => { + // Visualizations accumulate snapshots; the report must show the latest, not the first. + deps.workflowResultService.getResultService.mockReturnValue({ + getCurrentResultSnapshot: () => [ + { "html-content": '<div id="v1">FIRST</div>' }, + { "html-content": '<div id="v2">LAST</div>' }, + ], + }); + + const html = await htmlFor("op-1"); + + expect(html).toContain("LAST"); + expect(html).not.toContain("FIRST"); + // The embedded document is resized so the chart fits the report rather than overflowing it. + expect(html).toContain("height: 100%"); + }); + + it("reports a visualization operator with no snapshot as having no data", async () => { + deps.workflowResultService.getResultService.mockReturnValue({ + getCurrentResultSnapshot: () => undefined, + }); + + const html = await htmlFor("op-1"); + + expect(html).toContain("No data found for operator"); + }); + + it("reports an operator with neither result service as having no results", async () => { + const html = await htmlFor("op-1"); + + expect(html).toContain("No results found for operator"); + }); + + it("embeds the operator's own definition in the collapsible details block", async () => { + deps.workflowActionService.getWorkflowContent.mockReturnValue({ + operators: [ + { operatorID: "op-1", operatorType: "CSVFileScan" }, + { operatorID: "op-2", operatorType: "PythonUDFV2" }, + ], + }); + + const html = await htmlFor("op-2"); + + expect(html).toContain('id="details-op-2"'); + expect(html).toContain("PythonUDFV2"); + expect(html).not.toContain("CSVFileScan"); + }); + + it("produces nothing until the AI-enabled check emits", async () => { + // The whole body is nested inside isOpenAIEnabled().subscribe, so a check that never settles + // leaves the report silently unfinished rather than failing. + deps.aiAnalystService.isOpenAIEnabled.mockReturnValue(new Subject<boolean>()); + const collected: { operatorId: string; html: string }[] = []; + + service.retrieveOperatorInfoReport("op-1", collected).subscribe(); + + expect(collected).toEqual([]); + }); + }); + + describe("getAllOperatorResults", () => { + it("returns one entry per operator, in the order asked for", async () => { + const results = await firstValueFrom(service.getAllOperatorResults(["op-a", "op-b"])); + + expect(results.map(r => r.operatorId)).toEqual(["op-a", "op-b"]); + }); + }); + + describe("prompt construction", () => { + it("asks for a per-operator comment carrying that operator's JSON", () => { + service.generateComment({ operatorID: "op-1", operatorType: "CSVFileScan" }).subscribe(); + + const prompt = deps.aiAnalystService.sendPromptToOpenAI.mock.calls[0][0] as string; + expect(prompt).toContain('"operatorType": "CSVFileScan"'); + expect(prompt).toContain("at least 80 words"); + }); + + it("asks the summary for a longer answer than the per-operator comment", () => { + // The two methods are near-identical; only the length and the workflow-level framing differ, + // so a copy-paste between them would otherwise go unnoticed. + service.generateSummaryComment({ operators: [] }).subscribe(); + + const prompt = deps.aiAnalystService.sendPromptToOpenAI.mock.calls[0][0] as string; + expect(prompt).toContain("at least 150 words"); + expect(prompt).toContain("UDFs"); + }); + }); + + describe("generateReportAsHtml", () => { + let anchor: HTMLAnchorElement; + let clickSpy: ReturnType<typeof vi.spyOn>; + let createdBlob: Blob | undefined; + let revoked: string[]; + let originalCreate: unknown; + let originalRevoke: unknown; + + beforeEach(() => { + // Build the anchor before stubbing createElement, or the stub would intercept its own creation. + anchor = document.createElement("a"); + clickSpy = vi.spyOn(anchor, "click").mockImplementation(() => {}); + vi.spyOn(document, "createElement").mockReturnValue(anchor as unknown as HTMLElement); + + createdBlob = undefined; + revoked = []; + originalCreate = (URL as any).createObjectURL; + originalRevoke = (URL as any).revokeObjectURL; + (URL as any).createObjectURL = (blob: Blob) => { + createdBlob = blob; + return "blob:report-url"; + }; + (URL as any).revokeObjectURL = (url: string) => revoked.push(url); + }); + + afterEach(() => { + vi.restoreAllMocks(); + (URL as any).createObjectURL = originalCreate; + (URL as any).revokeObjectURL = originalRevoke; + }); + + it("downloads a report named after the workflow", () => { + service.generateReportAsHtml("data:image/png;base64,SNAP", ["<p>R1</p>"], "myflow"); + + expect(anchor.download).toBe("myflow-report.html"); + expect(anchor.href).toContain("blob:report-url"); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(revoked).toEqual(["blob:report-url"]); + }); + + it("writes the snapshot, every operator result, and the summary into the document", async () => { + deps.aiAnalystService.sendPromptToOpenAI.mockReturnValue(of("OVERALL SUMMARY")); + + service.generateReportAsHtml("data:image/png;base64,SNAP", ["<p>R1</p>", "<p>R2</p>"], "myflow"); + + const text = await readBlob(createdBlob!); + expect(text).toContain("data:image/png;base64,SNAP"); + expect(text).toContain("<p>R1</p>"); + expect(text).toContain("<p>R2</p>"); + expect(text).toContain("OVERALL SUMMARY"); + // The in-report download button names the file after the workflow too. + expect(text).toContain("myflow-workflow.json"); + }); + }); + + describe("generateWorkflowSnapshot", () => { + it("fails when the editor is not on the page", async () => { + await expect(firstValueFrom(service.generateWorkflowSnapshot("myflow"))).rejects.toBe( + "Workflow editor element not found" + ); + }); + }); +});
