Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() => {
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/jsdom-svg-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
Neilk1021 marked this conversation as resolved.
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
Expand Down
Loading