From 9e993dd1c06ebda52c47ad31a23e85e2994fbc1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:56:21 -0700 Subject: [PATCH] test: bridge happy-dom OffscreenCanvas canvas mocks Provide the existing HTML canvas test double when happy-dom exposes an adapter-less OffscreenCanvas 2D context. This keeps xterm tests working across supported happy-dom versions without changing production rendering. --- .../happy-dom-offscreen-canvas.test.ts | 67 +++++++++++++++++++ config/scripts/happy-dom-offscreen-canvas.ts | 59 ++++++++++++++++ config/vitest.config.ts | 1 + 3 files changed, 127 insertions(+) create mode 100644 config/scripts/happy-dom-offscreen-canvas.test.ts create mode 100644 config/scripts/happy-dom-offscreen-canvas.ts diff --git a/config/scripts/happy-dom-offscreen-canvas.test.ts b/config/scripts/happy-dom-offscreen-canvas.test.ts new file mode 100644 index 00000000000..a857136d563 --- /dev/null +++ b/config/scripts/happy-dom-offscreen-canvas.test.ts @@ -0,0 +1,67 @@ +/** @vitest-environment happy-dom */ +import { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installHappyDomOffscreenCanvasCompatibility } from './happy-dom-offscreen-canvas' + +type TestOffscreenCanvas = new ( + width: number, + height: number +) => { + getContext: (contextType: string, contextAttributes?: unknown) => unknown +} + +const openTerminals: Terminal[] = [] + +describe('happy-dom OffscreenCanvas compatibility', () => { + afterEach(() => { + while (openTerminals.length > 0) { + openTerminals.pop()?.dispose() + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('delegates adapter-less 2D contexts to the existing HTML canvas double', () => { + const offscreenCanvas = (globalThis as { OffscreenCanvas?: TestOffscreenCanvas }) + .OffscreenCanvas + if (!offscreenCanvas) { + expect(installHappyDomOffscreenCanvasCompatibility()).toBe(false) + return + } + + const context = { measureText: () => ({ width: 10 }) } + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(context as unknown as CanvasRenderingContext2D) + + expect(installHappyDomOffscreenCanvasCompatibility()).toBe(true) + expect(new offscreenCanvas(1, 1).getContext('2d')).toBe(context) + expect(getContext).toHaveBeenCalled() + }) + + it('does not turn unsupported non-2D contexts into test doubles', () => { + const offscreenCanvas = (globalThis as { OffscreenCanvas?: TestOffscreenCanvas }) + .OffscreenCanvas + if (!offscreenCanvas) { + return + } + + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + expect(new offscreenCanvas(1, 1).getContext('webgl')).toBeNull() + expect(getContext).not.toHaveBeenCalled() + }) + + it('keeps xterm DOM rendering open with the existing HTML canvas double', () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + const host = document.createElement('div') + document.body.append(host) + const terminal = new Terminal() + openTerminals.push(terminal) + + terminal.open(host) + + expect(terminal.element).not.toBeNull() + }) +}) diff --git a/config/scripts/happy-dom-offscreen-canvas.ts b/config/scripts/happy-dom-offscreen-canvas.ts new file mode 100644 index 00000000000..1b06e472025 --- /dev/null +++ b/config/scripts/happy-dom-offscreen-canvas.ts @@ -0,0 +1,59 @@ +// Why: happy-dom exposes OffscreenCanvas without a default canvas adapter. xterm prefers that API +// and then cannot open in tests that already provide the HTML canvas text-metric stand-in. + +const OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED = Symbol.for( + 'orca.happyDomOffscreenCanvasCompatibility' +) + +type PatchableCanvas = { + getContext?: (contextType: string, contextAttributes?: unknown) => unknown +} & Record + +type CanvasConstructor = { + prototype: PatchableCanvas + new (width: number, height: number): PatchableCanvas +} + +type HappyDomGlobals = { + OffscreenCanvas?: CanvasConstructor + HTMLCanvasElement?: { prototype: PatchableCanvas } + document?: Pick +} + +/** Make happy-dom's adapter-less OffscreenCanvas honor the existing HTML canvas test double. */ +export function installHappyDomOffscreenCanvasCompatibility(): boolean { + const globals = globalThis as unknown as HappyDomGlobals + const offscreenCanvas = globals.OffscreenCanvas + const htmlCanvas = globals.HTMLCanvasElement + const document = globals.document + if (!offscreenCanvas || !htmlCanvas || !document) { + return false + } + + const prototype = offscreenCanvas.prototype + if (prototype[OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED] === true) { + return true + } + const originalGetContext = prototype.getContext + if (!originalGetContext || !htmlCanvas.prototype.getContext) { + return false + } + + prototype.getContext = function patchedGetContext( + this: PatchableCanvas, + contextType: string, + contextAttributes?: unknown + ): unknown { + const context = originalGetContext.call(this, contextType, contextAttributes) + if (contextType !== '2d' || context) { + return context + } + + const canvas = document.createElement('canvas') as PatchableCanvas + return htmlCanvas.prototype.getContext!.call(canvas, contextType, contextAttributes) + } + prototype[OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED] = true + return true +} + +installHappyDomOffscreenCanvasCompatibility() diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 92e0cf146a5..55333230101 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [ + resolve('config/scripts/happy-dom-offscreen-canvas.ts'), resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), resolve('config/scripts/vitest-host-ports-setup.ts') ],