Skip to content

Commit 1825237

Browse files
committed
Improvements to model test rendering pipeline to work around happy-dom limitations
1 parent 0710e73 commit 1825237

4 files changed

Lines changed: 181 additions & 43 deletions

File tree

‎src/components/Test/__tests__/webComponent.spec.ts‎

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ import { beforeEach, describe, expect, test } from 'vitest'
33
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
44
import TestWebComponentAstro from '@components/Test/webComponent.astro'
55
import type { TestWebComponent as TestWebComponentInstance } from '@components/Test/webComponent'
6-
import {
7-
renderInHappyDom,
8-
type RenderResult,
9-
} from '@test/unit/helpers/litRuntime'
6+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
7+
import { executeRender } from '@test/unit/helpers/litRuntime'
108

11-
type TestComponentModule = typeof import('@components/Test/webComponent')
9+
type TestComponentModule = WebComponentModule<TestWebComponentInstance>
1210

1311
describe('TestWebComponent class behavior', () => {
1412
let container: AstroContainer
@@ -17,49 +15,46 @@ describe('TestWebComponent class behavior', () => {
1715
container = await AstroContainer.create()
1816
})
1917

20-
const componentSelector = 'test-web-component'
2118
const renderArgs = {
2219
props: {
2320
heading: 'Integration test',
2421
},
2522
}
2623

27-
const executeRender = async (
24+
const runComponentRender = async (
2825
assertion: (_context: { element: TestWebComponentInstance }) => Promise<void> | void,
2926
): Promise<void> => {
30-
await renderInHappyDom<TestWebComponentInstance, TestComponentModule>({
27+
await executeRender<TestComponentModule>({
3128
container,
3229
component: TestWebComponentAstro,
30+
moduleSpecifier: '@components/Test/webComponent',
3331
args: renderArgs,
34-
selector: componentSelector,
35-
hydrate: async () => {
36-
const module = await import('@components/Test/webComponent')
37-
module.registerTestWebComponent()
38-
return module
39-
},
40-
waitForReady: async (element) => {
32+
waitForReady: async (element: TestWebComponentInstance) => {
4133
await element.updateComplete
4234
},
43-
assert: async ({ element, window, hydrateResult, renderResult }) => {
44-
expect(window.customElements.get(componentSelector)).toBe(
45-
hydrateResult.TestWebComponent,
46-
)
47-
const hydratedMarkup: RenderResult = renderResult
48-
expect(hydratedMarkup).toContain(`<${componentSelector}`)
49-
35+
assert: async ({
36+
element,
37+
module,
38+
renderResult,
39+
}: {
40+
element: TestWebComponentInstance
41+
module: TestComponentModule
42+
renderResult: string
43+
}) => {
44+
expect(renderResult).toContain(`<${module.registeredName}`)
5045
await assertion({ element })
5146
},
5247
})
5348
}
5449

5550
test('renders default message in light DOM', async () => {
56-
await executeRender(async ({ element }) => {
51+
await runComponentRender(async ({ element }) => {
5752
expect(element.querySelector('#message')?.textContent).toBe('Hello from Lit')
5853
})
5954
})
6055

6156
test('updates DOM when message changes', async () => {
62-
await executeRender(async ({ element }) => {
57+
await runComponentRender(async ({ element }) => {
6358
element.message = 'Updated message'
6459
await element.updateComplete
6560

‎src/components/Test/webComponent.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { LitElement, html, css } from 'lit'
22
import { defineCustomElement } from '@components/scripts/utils'
3+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
34

45
export class TestWebComponent extends LitElement {
6+
static registeredName = 'test-web-component'
7+
58
static override properties = {
69
message: { type: String },
710
}
@@ -31,5 +34,11 @@ export class TestWebComponent extends LitElement {
3134
}
3235
}
3336

34-
export const registerTestWebComponent = (tagName = 'test-web-component') =>
37+
export const registerWebComponent = (tagName = TestWebComponent.registeredName) =>
3538
defineCustomElement(tagName, TestWebComponent)
39+
40+
export const webComponentModule: WebComponentModule<TestWebComponent> = {
41+
registeredName: TestWebComponent.registeredName,
42+
componentCtor: TestWebComponent,
43+
registerWebComponent,
44+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { LitElement } from 'lit'
2+
3+
export interface WebComponentModule<TElement extends LitElement = LitElement> {
4+
registeredName: string
5+
componentCtor: CustomElementConstructor & { prototype: TElement }
6+
registerWebComponent: (tagName?: string) => Promise<void> | void
7+
}

‎test/unit/helpers/litRuntime.ts‎

Lines changed: 145 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Window } from 'happy-dom'
2+
import type HappyDomNode from 'happy-dom/lib/nodes/node/Node'
3+
import type HappyDomDocumentFragment from 'happy-dom/lib/nodes/document-fragment/DocumentFragment'
24
import type { experimental_AstroContainer as AstroContainer } from 'astro/container'
35
import { BuildError } from '@lib/errors/BuildError'
6+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
47

58
type HappyDomGlobalKey = 'window' | 'document' | 'customElements' | 'HTMLElement'
69
type HappyDomGlobals = Partial<Record<HappyDomGlobalKey, unknown>>
@@ -70,44 +73,168 @@ export const withHappyDomEnvironment = async <TReturn>(
7073
}
7174
}
7275

73-
interface RenderInHappyDomOptions<TElement, THydrateResult> {
76+
export type ModuleElement<TModule extends WebComponentModule> = TModule extends WebComponentModule<infer TElement>
77+
? TElement
78+
: HTMLElement
79+
80+
export interface RenderInHappyDomOptions<TModule extends WebComponentModule> {
7481
container: AstroContainer
7582
component: AstroComponentInstance
7683
args?: AstroRenderArgs
77-
selector: string
78-
hydrate?: () => Promise<THydrateResult> | THydrateResult
79-
waitForReady?: (_element: TElement) => Promise<void> | void
84+
moduleLoader: () => Promise<TModule> | TModule
85+
selector?: string
86+
waitForReady?: (_element: ModuleElement<TModule>) => Promise<void> | void
8087
assert: (_context: {
81-
element: TElement
88+
element: ModuleElement<TModule>
8289
window: Window
83-
hydrateResult: THydrateResult
90+
module: TModule
8491
renderResult: RenderResult
8592
}) => Promise<void> | void
8693
}
8794

88-
const defaultWaitForReady = async () => {}
95+
const defaultWaitForReady = async (_element?: unknown) => {}
96+
97+
const componentModuleImporters = import.meta.glob<ModuleImport<WebComponentModule>>(
98+
'/src/components/**/*.ts',
99+
)
100+
101+
const sanitizeRenderedHtml = (html: string): string => {
102+
const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/gi, '')
103+
return withoutScripts.replace(/\sdata-astro-cid-[^=\s>]+="[^"]*"/gi, '')
104+
}
105+
106+
const hasFileExtension = (specifier: string): boolean => /\.[a-zA-Z0-9]+$/.test(specifier)
107+
108+
const normalizeModuleSpecifier = (moduleSpecifier: string): string => {
109+
const ensureExtension = (specifier: string) => (hasFileExtension(specifier) ? specifier : `${specifier}.ts`)
110+
111+
if (moduleSpecifier.startsWith('@components/')) {
112+
const relativePath = moduleSpecifier.slice('@components/'.length)
113+
return `/src/components/${ensureExtension(relativePath)}`
114+
}
115+
116+
return ensureExtension(moduleSpecifier)
117+
}
118+
119+
export type HappyDomRenderer<TModule extends WebComponentModule> = (
120+
_options: RenderInHappyDomOptions<TModule>,
121+
) => Promise<void>
89122

90-
export const renderInHappyDom = async <TElement, THydrateResult = void>(
91-
options: RenderInHappyDomOptions<TElement, THydrateResult>,
123+
export const renderInHappyDom = async <TModule extends WebComponentModule>(
124+
_options: RenderInHappyDomOptions<TModule>,
92125
): Promise<void> => {
93-
const { container, component, args, selector, hydrate, waitForReady = defaultWaitForReady, assert } = options
126+
const { container, component, args, moduleLoader, selector, waitForReady = defaultWaitForReady, assert } = _options
94127

95128
await withHappyDomEnvironment(async ({ window }) => {
96-
const renderedHtml: RenderResult = await container.renderToString(component, args)
97-
98-
window.document.body.innerHTML = renderedHtml
99-
window.document.querySelectorAll('script').forEach((script) => script.remove())
129+
const module = await moduleLoader()
100130

101-
const hydrateResult = (await hydrate?.()) as THydrateResult
131+
const renderedHtml: RenderResult = await container.renderToString(component, args)
102132

103-
const element = window.document.querySelector(selector) as TElement | null
133+
const sanitizedHtml = sanitizeRenderedHtml(renderedHtml)
134+
const template = window.document.createElement('template')
135+
template.innerHTML = sanitizedHtml
136+
await module.registerWebComponent(module.registeredName)
137+
const fragment = template.content.cloneNode(true) as HappyDomDocumentFragment
138+
const placeholders = Array.from(fragment.querySelectorAll(module.registeredName))
139+
placeholders.forEach((placeholder) => {
140+
const upgradedElement = window.document.createElement(module.registeredName)
141+
Array.from(placeholder.attributes).forEach((attribute) => {
142+
upgradedElement.setAttribute(attribute.name, attribute.value ?? '')
143+
})
144+
while (placeholder.firstChild) {
145+
upgradedElement.appendChild(placeholder.firstChild as HappyDomNode)
146+
}
147+
placeholder.replaceWith(upgradedElement as HappyDomNode)
148+
})
149+
window.document.body.innerHTML = ''
150+
window.document.body.appendChild(fragment as HappyDomNode)
151+
152+
const querySelector = selector ?? module.registeredName
153+
const element = window.document.querySelector(querySelector) as ModuleElement<TModule> | null
104154
if (!element) {
105155
throw BuildError.fileOperation(
106-
`Unable to locate rendered element for selector "${selector}" during Happy DOM rendering`,
156+
`Unable to locate rendered element for selector "${querySelector}" during Happy DOM rendering`,
157+
)
158+
}
159+
160+
const registeredCtor = window.customElements.get(module.registeredName) as
161+
| CustomElementConstructor
162+
| undefined
163+
const expectedCtor: CustomElementConstructor = module.componentCtor
164+
if (!registeredCtor || registeredCtor !== expectedCtor) {
165+
throw BuildError.fileOperation(
166+
`Custom element "${module.registeredName}" is not registered with the expected constructor`,
107167
)
108168
}
109169

110170
await waitForReady(element)
111-
await assert({ element, window, hydrateResult, renderResult: renderedHtml })
171+
await assert({ element, window, module, renderResult: renderedHtml })
112172
})
113173
}
174+
175+
interface ModuleImport<TModule extends WebComponentModule> {
176+
webComponentModule?: TModule
177+
}
178+
179+
export const loadWebComponentModule = async <TModule extends WebComponentModule>(
180+
moduleSpecifier: string,
181+
): Promise<TModule> => {
182+
const normalizedSpecifier = normalizeModuleSpecifier(moduleSpecifier)
183+
const importer = componentModuleImporters[normalizedSpecifier]
184+
185+
if (!importer) {
186+
throw BuildError.fileOperation(
187+
`Unable to locate a component module for specifier "${moduleSpecifier}" (resolved to "${normalizedSpecifier}")`,
188+
)
189+
}
190+
191+
const imported = (await importer()) as ModuleImport<TModule>
192+
if (!imported.webComponentModule) {
193+
throw BuildError.fileOperation(
194+
`Module "${moduleSpecifier}" does not export a webComponentModule contract`,
195+
)
196+
}
197+
198+
return imported.webComponentModule
199+
}
200+
201+
export interface ExecuteRenderOptions<TModule extends WebComponentModule> {
202+
container: AstroContainer
203+
component: AstroComponentInstance
204+
moduleSpecifier: string
205+
args?: AstroRenderArgs
206+
selector?: string
207+
waitForReady?: (_element: ModuleElement<TModule>) => Promise<void> | void
208+
renderer?: HappyDomRenderer<TModule>
209+
assert: (_context: { element: ModuleElement<TModule>; module: TModule; renderResult: RenderResult }) => Promise<void> | void
210+
}
211+
212+
export const executeRender = async <TModule extends WebComponentModule>(
213+
options: ExecuteRenderOptions<TModule>,
214+
): Promise<void> => {
215+
const { container, component, moduleSpecifier, args, selector, waitForReady, renderer, assert } = options
216+
217+
const rendererToUse: HappyDomRenderer<TModule> = renderer
218+
? renderer
219+
: (rendererOptions) => renderInHappyDom<TModule>(rendererOptions)
220+
221+
const rendererOptions: RenderInHappyDomOptions<TModule> = {
222+
container,
223+
component,
224+
args,
225+
moduleLoader: () => loadWebComponentModule<TModule>(moduleSpecifier),
226+
assert: async ({ element, module, renderResult }) => {
227+
await assert({ element, module, renderResult })
228+
},
229+
}
230+
231+
if (typeof selector === 'string') {
232+
rendererOptions.selector = selector
233+
}
234+
235+
if (waitForReady) {
236+
rendererOptions.waitForReady = waitForReady
237+
}
238+
239+
await rendererToUse(rendererOptions)
240+
}

0 commit comments

Comments
 (0)