This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 646e460838 fix(test, frontend): bound the workflow-snapshot render's 
cost under jsdom (#7999)
646e460838 is described below

commit 646e460838c568dc421042ac8bcd95aa534aa118
Author: Neil Ketteringham <[email protected]>
AuthorDate: Thu Aug 27 20:04:39 2026 +0000

    fix(test, frontend): bound the workflow-snapshot render's cost under jsdom 
(#7999)
    
    ### What changes were proposed in this PR?
    
    `report-generation.service.spec.ts` drives the real html2canvas, which
    clones the whole document
    rather than the element it is pointed at. The unit-test builder runs
    spec files with
    `isolate: false`, so one jsdom document is shared by every spec file in
    a worker and the clone
    drags in whatever DOM the files before it left behind.
    
    On the macOS runner that clone was measured at 12–37s against a 20s test
    timeout, failing
    `fails when the editor cannot be rendered` while ubuntu and windows
    passed on the same commit.
    
    Two changes, both test-only:
    
    - **`report-generation.service.spec.ts`** — the snapshot suite parks the
    document's existing body
    nodes for the duration of the file and restores them after, so the
    render's cost depends only on
    the DOM these tests build. It spans the file rather than each test
    because the renders outlive
    the tests that start them (visible in the failing log: clone #1 finished
    during test #3).
    
    - **`jsdom-svg-polyfill.ts`** — stops jsdom announcing
    `getComputedStyle(elt, pseudoElt)` and
    `scrollTo` on the virtual console, which vitest forwards to
    `console.error` as a full stack trace
    per call. The failing job carried 5,691 of them for six renders.
    Dropping `pseudoElt` changes no
    behaviour: jsdom complains and then ignores the argument, returning the
    element's own declaration
    either way. `scrollTo` has nothing to move — jsdom has no layout, and
    the one html2canvas reaches
    for belongs to the throwaway clone iframe, so it is neutered as each
    `contentWindow` is handed out.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8001.
    
    This continues #7886 (closing #7887), which stopped the four
    image-inlining tests in this file
    waiting on the render. The flake moved to the two tests that still await
    one.
    
    Scope note for reviewers: this bounds the render's cost, it does not
    remove the dependency on a
    real render. The two remaining tests still await html2canvas, so a
    sufficiently loaded runner
    could in principle still be slow — just not by the two orders of
    magnitude measured here. Taking
    #7886's logic to its conclusion (no test in this file waiting on a real
    render) would need a
    stub html2canvas that survives `isolate: false`, which is a larger
    change to what those two tests
    cover; happy to do that instead if reviewers prefer it.
    
    Unrelated pre-existing noise left alone: a full run still emits ~827
    jsdom
    `HTMLCanvasElement.prototype.getContext` traces from
    `JointUIService.getMeasureContext`
    (`joint-ui.service.ts:219`). Silencing it would change what
    `measureText` returns for the
    joint-ui specs, so it is out of scope here.
    
    ### How was this PR tested?
    
    `ng test --watch=false` on this branch (base `444fc58284`), four full
    runs: 201 files,
    5131 passed / 1 skipped each time, no `Not implemented:
    window.getComputedStyle` traces.
    Clone time across the file's six renders was 79–353 ms on three warm
    runs; the test that was
    timing out renders in 85–114 ms. The first run on a cold checkout was
    slower — up to 3.9s for a
    clone — which is a reminder that machine load, not document size, is
    what is left.
    
    The effect of the console noise was measured directly by seeding the
    document with 500 nodes:
    clone time across the six renders went 18.8s → 13.1s with the polyfill
    change alone (~30%; the
    share is larger where console forwarding is slower, as on CI).
    
    `yarn --cwd frontend format:ci` is clean.
    
    I cannot demonstrate the macOS leg green from here — that needs CI on
    this branch.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5 [1M context])
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../report-generation.service.spec.ts              | 32 +++++++++++++
 frontend/src/jsdom-svg-polyfill.ts                 | 55 ++++++++++++++++++++++
 2 files changed, 87 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 90926b7431..abcfbb24ba 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
@@ -330,6 +330,34 @@ describe("ReportGenerationService", () => {
   });
 
   describe("generateWorkflowSnapshot", () => {
+    /**
+     * html2canvas clones the whole document — from `documentElement`, not 
from the element it is
+     * pointed at — and the unit-test builder runs spec files with `isolate: 
false`, so one jsdom
+     * document is shared by every spec file in a worker and the renders below 
drag in whatever DOM
+     * the files before this one left behind, in `<head>` as much as in the 
body. That is what
+     * failed the macOS leg: a clone that costs ~60ms against this suite's own 
DOM was measured at
+     * 12–37s there, past the 20s test timeout, while ubuntu and windows 
passed. Park the foreign
+     * nodes of both for the duration of the suite and put them back after, so 
the render's cost
+     * depends only on what these tests build. The renders started here 
outlive the tests that
+     * start them, so this has to span the suite rather than each test.
+     */
+    let parkedNodes: [ParentNode, ChildNode][];
+
+    beforeAll(() => {
+      parkedNodes = [document.head, document.body].flatMap(parent =>
+        Array.from(parent.childNodes).map((node): [ParentNode, ChildNode] => 
[parent, node])
+      );
+      parkedNodes.forEach(([, node]) => node.remove());
+    });
+
+    afterAll(() => {
+      // html2canvas only detaches the iframe it clones into on the render's 
success path, so each
+      // render these tests leave failing strands one in the body. Drop them 
before the parked
+      // nodes go back, otherwise the next spec file's renders clone them.
+      
document.body.querySelectorAll("iframe.html2canvas-container").forEach(node => 
node.remove());
+      parkedNodes.forEach(([parent, node]) => parent.appendChild(node));
+    });
+
     it("fails when the editor is not on the page", async () => {
       await 
expect(firstValueFrom(service.generateWorkflowSnapshot("myflow"))).rejects.toBe(
         "Workflow editor element not found"
@@ -431,6 +459,10 @@ describe("ReportGenerationService", () => {
         editor = document.createElement("div");
         editor.id = "workflow-editor";
         document.body.appendChild(editor);
+        // The render these tests start is left to fail on its own, but jsdom 
announces its
+        // missing 2D context on the virtual console, so an otherwise clean 
run carries a stack
+        // trace per test. Hand back what jsdom hands back after complaining, 
minus the complaint.
+        vi.spyOn(HTMLCanvasElement.prototype, 
"getContext").mockReturnValue(null as never);
       });
 
       afterEach(() => {
diff --git a/frontend/src/jsdom-svg-polyfill.ts 
b/frontend/src/jsdom-svg-polyfill.ts
index 1fe62e2147..7aef063d7d 100644
--- a/frontend/src/jsdom-svg-polyfill.ts
+++ b/frontend/src/jsdom-svg-polyfill.ts
@@ -192,6 +192,61 @@ G.ResizeObserver ??= class {
   disconnect(): void {}
 };
 
+// `window.getComputedStyle(elt, pseudoElt)` and `window.scrollTo` — jsdom
+// implements neither. Both route through its `notImplemented` helper, which
+// emits a `jsdomError` on the virtual console; vitest's jsdom environment
+// forwards that to `console.error`, one full stack trace per call. html2canvas
+// calls both on every render — a `:before` and an `:after` lookup for each
+// cloned node, plus one `scrollTo` per document clone — so the
+// report-generation spec, which drives the real renderer on purpose (`vi.mock`
+// can't reach it under the builder's `isolate: false`), buries the run in
+// traces while all of its tests pass.
+// Dropping `pseudoElt` changes no behaviour for the arguments jsdom only
+// complains about: it ignores them and returns the element's own declaration
+// either way. The exception is a shadow-DOM pseudo-element (`::part(…)` /
+// `::slotted(…)`), which jsdom rejects with a `TypeError` before it reaches
+// `notImplemented` — those are forwarded so the rejection still happens.
+// `scrollTo` has nothing to move — jsdom has no layout.
+// Both patches wrap a global that this setup file is re-evaluated against once
+// per spec file, so each marks what it installed and does nothing when it 
finds
+// its own mark — otherwise the wrappers nest one layer deeper per spec file.
+// The mark rides on the installed function rather than on a `globalThis` flag
+// so that a jsdom window replaced mid-run still gets patched.
+const NOISE_PATCH_MARK = Symbol.for("texera.jsdomNotImplementedNoisePatched");
+const alreadyPatched = (fn: unknown): boolean => typeof fn === "function" && 
NOISE_PATCH_MARK in (fn as object);
+const markPatched = (fn: AnyFn): AnyFn => Object.assign(fn, { 
[NOISE_PATCH_MARK]: true });
+
+const SHADOW_DOM_PSEUDO = /^::(?:part|slotted)\(/i;
+const jsdomGetComputedStyle = G.getComputedStyle as ((elt: Element, 
pseudoElt?: string | null) => unknown) | undefined;
+if (typeof jsdomGetComputedStyle === "function" && 
!alreadyPatched(jsdomGetComputedStyle)) {
+  const withoutPseudoElement = markPatched(((elt: Element, pseudoElt?: string 
| null) =>
+    pseudoElt !== undefined && pseudoElt !== null && 
SHADOW_DOM_PSEUDO.test(String(pseudoElt))
+      ? jsdomGetComputedStyle(elt, pseudoElt)
+      : jsdomGetComputedStyle(elt)) as AnyFn);
+  G.getComputedStyle = withoutPseudoElement;
+  if (G.window) G.window.getComputedStyle = withoutPseudoElement;
+}
+// The only `scrollTo` html2canvas aims at this window is guarded by a
+// scroll-offset check that cannot fire under jsdom — there is no layout, so
+// both offsets are 0 and the call is skipped. The one it does make belongs to
+// the throwaway iframe it clones the page into, and jsdom installs the method
+// on each window instance rather than on a shared prototype, so it has to be
+// neutered as each `contentWindow` is handed out.
+const inertScrollTo: AnyFn = () => undefined;
+const iframeProto = G.HTMLIFrameElement?.prototype;
+const contentWindow = iframeProto && 
Object.getOwnPropertyDescriptor(iframeProto, "contentWindow");
+if (contentWindow?.get && !alreadyPatched(contentWindow.get)) {
+  const getContentWindow = contentWindow.get;
+  Object.defineProperty(iframeProto, "contentWindow", {
+    ...contentWindow,
+    get: markPatched(function (this: unknown): unknown {
+      const frameWindow = getContentWindow.call(this) as Record<string, 
unknown> | null;
+      if (frameWindow) frameWindow.scrollTo = inertScrollTo;
+      return frameWindow;
+    } as AnyFn),
+  });
+}
+
 // `WebSocket` — y-websocket schedules a reconnect timer the moment a
 // collaborative-editing service is constructed. When that timer fires AFTER
 // vitest has begun tearing down the jsdom window, jsdom's WebSocket

Reply via email to