test: Preview3D e2e hardening and review follow-ups

- Add ComfyPage.waitForReady; reload uses domcontentloaded then ready wait
- NodeReference helpers for widget/properties; object_info skip before workflow load
- preview3dRestoreCameraStatesMatch + Vitest; poll messages; drop @node tag
- waitForWorkflowIdle optional diagnostic message; vite Vitest include and @e2e alias
This commit is contained in:
Kelly Yang
2026-04-10 22:24:55 -07:00
parent 84413b2b73
commit 71d96a411c
8 changed files with 397 additions and 173 deletions

View File

@@ -315,13 +315,28 @@ export class ComfyPage {
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForReady()
}
/**
* Wait until the graph view is interactive (extension manager + loading mask).
* Use after reload or any navigation that skips {@link setup}.
*/
async waitForReady(options: { timeout?: number } = {}): Promise<void> {
const waitOpts =
options.timeout !== undefined ? { timeout: options.timeout } : undefined
await this.page.waitForFunction(
() =>
// window.app => GraphCanvas ready
// window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager
Boolean(window.app?.extensionManager),
undefined,
waitOpts
)
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.page.locator('.p-blockui-mask').waitFor({
state: 'hidden',
...(options.timeout !== undefined ? { timeout: options.timeout } : {})
})
await this.nextFrame()
}

View File

@@ -1,65 +1,31 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { Preview3dE2eCameraState as CameraState } from '@e2e/fixtures/utils/preview3dCameraState'
import {
isPreview3dE2eCameraState as isCameraState,
preview3dCameraStatesDiffer as cameraStatesDiffer
} from '@e2e/fixtures/utils/preview3dCameraState'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
export async function waitForAppBootstrapped(
export type { CameraState }
export { isCameraState }
export async function backendHasPreview3DNodes(
comfyPage: ComfyPage
): Promise<void> {
await comfyPage.page.waitForFunction(
() => Boolean(window.app?.extensionManager),
undefined,
{ timeout: 30_000 }
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden',
timeout: 30_000
): Promise<boolean> {
const resp = await comfyPage.request.get(`${comfyPage.url}/object_info`, {
failOnStatusCode: false
})
await comfyPage.nextFrame()
}
export function vecClose(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number },
eps: number
): boolean {
if (!resp.ok()) return false
const data: unknown = await resp.json()
return (
Math.abs(a.x - b.x) < eps &&
Math.abs(a.y - b.y) < eps &&
Math.abs(a.z - b.z) < eps
)
}
export function cameraStatesClose(
a: unknown,
b: unknown,
eps: number
): boolean {
if (
a === null ||
b === null ||
typeof a !== 'object' ||
typeof b !== 'object'
)
return false
const ca = a as {
position?: { x: number; y: number; z: number }
target?: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
const cb = b as typeof ca
if (
!ca.position ||
!ca.target ||
!cb.position ||
!cb.target ||
ca.cameraType !== cb.cameraType
)
return false
if (Math.abs((ca.zoom ?? 0) - (cb.zoom ?? 0)) > eps) return false
return (
vecClose(ca.position, cb.position, eps) &&
vecClose(ca.target, cb.target, eps)
typeof data === 'object' &&
data !== null &&
'Load3D' in data &&
'Preview3D' in data
)
}
@@ -80,41 +46,133 @@ export class Preview3DPipelineContext {
}
async getModelFileWidgetValue(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const w = n?.widgets?.find((x) => x.name === 'model_file')
return typeof w?.value === 'string' ? w.value : ''
}, nodeId)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const v = await ref.getWidgetValueByName('model_file')
return typeof v === 'string' ? v : ''
}
async getLastTimeModelFile(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const v = n?.properties?.['Last Time Model File']
return typeof v === 'string' ? v : ''
}, nodeId)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const v = await ref.getStoredPropertyValue('Last Time Model File')
return typeof v === 'string' ? v : ''
}
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const cfg = n?.properties?.['Camera Config'] as
| { state?: unknown }
| undefined
return cfg?.state ?? null
}, nodeId)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const cfg = await ref.getStoredPropertyValue('Camera Config')
if (cfg === null || typeof cfg !== 'object') return null
const state = Reflect.get(cfg, 'state')
return state ?? null
}
}
export async function seedLoad3dWithCubeObj(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await expect
.poll(() =>
pipeline.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId)
)
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
}
export async function setNonDefaultLoad3dCameraState(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const initialCamera = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
const box = await load3d.canvas.boundingBox()
expect(box, 'Load3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await load3d.canvas.dragTo(load3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 + 80, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
await expect
.poll(
async () => {
const current = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
if (current === null) return false
if (initialCamera === null) return true
return cameraStatesDiffer(current, initialCamera, 1e-4)
},
{
timeout: 10_000,
message:
'Load3D camera state should change after orbit drag (see cameraStatesDiffer)'
}
)
.toBe(true)
}
export async function nudgePreview3dCameraIntoProperties(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, preview3d } = pipeline
const box = await preview3d.canvas.boundingBox()
expect(box, 'Preview3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await preview3d.canvas.dragTo(preview3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 - 60, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
}
export async function alignPreview3dWorkflowUiSettings(
pipeline: Preview3DPipelineContext
): Promise<void> {
await pipeline.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await pipeline.comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
}
export const preview3dPipelineTest = comfyPageFixture.extend<{
preview3dPipeline: Preview3DPipelineContext
}>({
preview3dPipeline: async ({ comfyPage }, use) => {
preview3dPipeline: async ({ comfyPage }, use, testInfo) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
const hasPreview3dNodes = await backendHasPreview3DNodes(comfyPage)
if (!hasPreview3dNodes) {
testInfo.skip(
true,
'Requires ComfyUI backend with Load3D and Preview3D nodes (object_info)'
)
await use(new Preview3DPipelineContext(comfyPage))
await comfyPage.workflow.setupWorkflowsDirectory({})
return
}
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
await comfyPage.vueNodes.waitForNodes()

View File

@@ -165,14 +165,27 @@ export class WorkflowHelper {
})
}
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout }
)
async waitForWorkflowIdle(
timeout = 5000,
options?: { message?: string }
): Promise<void> {
try {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)
?.workflow?.isBusy,
undefined,
{ timeout }
)
} catch (err) {
if (options?.message) {
throw new Error(
`${options.message} (timed out after ${timeout}ms waiting for workflow idle)`,
{ cause: err }
)
}
throw err
}
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>

View File

@@ -345,6 +345,32 @@ export class NodeReference {
[this.id, prop] as const
)
}
async getWidgetValueByName(name: string): Promise<unknown> {
return await this.comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets?.find((w) => w.name === name)
return widget?.value
},
[this.id, name] as const
)
}
async getStoredPropertyValue(key: string): Promise<unknown> {
return await this.comfyPage.page.evaluate(
([id, key]) => {
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const bag = node.properties
if (bag === null || typeof bag !== 'object') return undefined
return Reflect.get(bag, key)
},
[this.id, key] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
preview3dCameraStatesDiffer,
preview3dRestoreCameraStatesMatch
} from '@e2e/fixtures/utils/preview3dCameraState'
const base = {
cameraType: 'perspective',
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1
}
describe('preview3dRestoreCameraStatesMatch', () => {
it('returns true for identical states', () => {
expect(preview3dRestoreCameraStatesMatch(base, { ...base })).toBe(true)
})
it('returns false for invalid payloads', () => {
expect(preview3dRestoreCameraStatesMatch(null, base)).toBe(false)
expect(preview3dRestoreCameraStatesMatch(base, { position: 'nope' })).toBe(
false
)
})
it('returns false when cameraType differs', () => {
expect(
preview3dRestoreCameraStatesMatch(base, { ...base, cameraType: 'ortho' })
).toBe(false)
})
it('accepts axis drift within PREVIEW3D_CAMERA_AXIS_RESTORE_EPS', () => {
const drifted = {
...base,
position: {
x: base.position.x + PREVIEW3D_CAMERA_AXIS_RESTORE_EPS * 0.9,
y: base.position.y,
z: base.position.z
}
}
expect(preview3dRestoreCameraStatesMatch(base, drifted)).toBe(true)
})
it('rejects axis drift beyond PREVIEW3D_CAMERA_AXIS_RESTORE_EPS', () => {
const drifted = {
...base,
position: {
x: base.position.x + PREVIEW3D_CAMERA_AXIS_RESTORE_EPS * 1.1,
y: base.position.y,
z: base.position.z
}
}
expect(preview3dRestoreCameraStatesMatch(base, drifted)).toBe(false)
})
})
describe('preview3dCameraStatesDiffer', () => {
it('treats missing typed state as different', () => {
expect(preview3dCameraStatesDiffer(null, base, 1e-4)).toBe(true)
})
it('returns false when states are equal within eps', () => {
expect(preview3dCameraStatesDiffer(base, { ...base }, 1e-4)).toBe(false)
})
})

View File

@@ -0,0 +1,91 @@
export interface Preview3dE2eCameraState {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
function isVec3(v: unknown): v is { x: number; y: number; z: number } {
if (v === null || typeof v !== 'object') return false
const x = Reflect.get(v, 'x')
const y = Reflect.get(v, 'y')
const z = Reflect.get(v, 'z')
return typeof x === 'number' && typeof y === 'number' && typeof z === 'number'
}
export function isPreview3dE2eCameraState(
v: unknown
): v is Preview3dE2eCameraState {
if (v === null || typeof v !== 'object') return false
const position = Reflect.get(v, 'position')
const target = Reflect.get(v, 'target')
return isVec3(position) && isVec3(target)
}
function vecMaxAbsDelta(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number }
): number {
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z))
}
/**
* Max abs error per position/target axis when comparing restored Preview3D
* camera state (same order of magnitude as the former 2e-2 poll tolerance).
*/
export const PREVIEW3D_CAMERA_AXIS_RESTORE_EPS = 0.02
/**
* Max abs zoom error when comparing restored Preview3D state (aligned with
* Playwright `toBeCloseTo(..., 5)`-style checks on typical zoom magnitudes).
*/
export const PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS = 1e-4
export function preview3dRestoreCameraStatesMatch(
a: unknown,
b: unknown
): boolean {
if (!isPreview3dE2eCameraState(a) || !isPreview3dE2eCameraState(b)) {
return false
}
if (a.cameraType !== b.cameraType) return false
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS) {
return false
}
return (
vecMaxAbsDelta(a.position, b.position) <=
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS &&
vecMaxAbsDelta(a.target, b.target) <= PREVIEW3D_CAMERA_AXIS_RESTORE_EPS
)
}
function vecClose(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number },
eps: number
): boolean {
return (
Math.abs(a.x - b.x) < eps &&
Math.abs(a.y - b.y) < eps &&
Math.abs(a.z - b.z) < eps
)
}
export function preview3dCameraStatesDiffer(
a: unknown,
b: unknown,
eps: number
): boolean {
if (!isPreview3dE2eCameraState(a) || !isPreview3dE2eCameraState(b)) {
return true
}
if (a.cameraType !== b.cameraType) return true
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > eps) return true
return !(
vecClose(a.position, b.position, eps) && vecClose(a.target, b.target, eps)
)
}

View File

@@ -1,85 +1,20 @@
import { expect } from '@playwright/test'
import {
cameraStatesClose,
alignPreview3dWorkflowUiSettings,
nudgePreview3dCameraIntoProperties,
preview3dPipelineTest as test,
Preview3DPipelineContext,
waitForAppBootstrapped
seedLoad3dWithCubeObj,
setNonDefaultLoad3dCameraState
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
import { assetPath } from '@e2e/fixtures/utils/paths'
import {
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS,
preview3dRestoreCameraStatesMatch
} from '@e2e/fixtures/utils/preview3dCameraState'
async function seedLoad3dWithCubeObj(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await expect
.poll(() =>
pipeline.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId)
)
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
}
async function setNonDefaultLoad3dCameraState(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const box = await load3d.canvas.boundingBox()
expect(box, 'Load3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await load3d.canvas.dragTo(load3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 + 80, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
await expect
.poll(() =>
pipeline.getCameraStateFromProperties(Preview3DPipelineContext.loadNodeId)
)
.not.toBeNull()
}
async function nudgePreview3dCameraIntoProperties(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, preview3d } = pipeline
const box = await preview3d.canvas.boundingBox()
expect(box, 'Preview3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await preview3d.canvas.dragTo(preview3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 - 60, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
}
async function alignPreview3dWorkflowUiSettings(
pipeline: Preview3DPipelineContext
): Promise<void> {
await pipeline.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await pipeline.comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
}
test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
test.describe('Preview3D execution flow', { tag: ['@slow'] }, () => {
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {
@@ -88,7 +23,10 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
await seedLoad3dWithCubeObj(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000, {
message:
'QueuePrompt did not finish: backend needs Load3D and Preview3D nodes and a working execution environment'
})
await expect
.poll(() =>
@@ -99,7 +37,7 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
const modelPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
await expect(
expect(
modelPath.length,
'Preview3D model path populated'
).toBeGreaterThan(4)
@@ -129,7 +67,10 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
await setNonDefaultLoad3dCameraState(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000, {
message:
'QueuePrompt did not finish: backend needs Load3D and Preview3D nodes and a working execution environment'
})
await expect
.poll(() =>
@@ -164,15 +105,18 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
await pipeline.comfyPage.menu.workflowsTab.open()
await pipeline.comfyPage.menu.topbar.saveWorkflow(workflowName)
await pipeline.comfyPage.page.reload({ waitUntil: 'networkidle' })
await waitForAppBootstrapped(pipeline.comfyPage)
await pipeline.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await pipeline.comfyPage.waitForReady({ timeout: 30_000 })
await alignPreview3dWorkflowUiSettings(pipeline)
const tab = pipeline.comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(workflowName).click()
await pipeline.comfyPage.workflow.waitForWorkflowIdle(30_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(30_000, {
message:
'Workflow did not settle after opening saved workflow from sidebar (Preview3D restore test)'
})
await pipeline.comfyPage.vueNodes.waitForNodes()
await expect
@@ -197,14 +141,18 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
.toBe(true)
await expect
.poll(async () =>
cameraStatesClose(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
.poll(
async () =>
preview3dRestoreCameraStatesMatch(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
savedCamera
),
savedCamera,
2e-2
)
{
timeout: 15_000,
message: `Preview3D camera after reload should match saved state (axis max delta ≤ ${PREVIEW3D_CAMERA_AXIS_RESTORE_EPS}, zoom delta ≤ ${PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS}; see preview3dCameraState.test.ts)`
}
)
.toBe(true)
})

View File

@@ -2,6 +2,8 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { execSync } from 'child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as dotenvConfig } from 'dotenv'
import type { IncomingMessage, ServerResponse } from 'http'
import { Readable } from 'stream'
@@ -19,6 +21,8 @@ import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
const projectRoot = path.dirname(fileURLToPath(import.meta.url))
dotenvConfig()
const IS_DEV = process.env.NODE_ENV === 'development'
@@ -634,7 +638,8 @@ export default defineConfig({
'@/utils/formatUtil': '/packages/shared-frontend-utils/src/formatUtil.ts',
'@/utils/networkUtil':
'/packages/shared-frontend-utils/src/networkUtil.ts',
'@': '/src'
'@': '/src',
'@e2e': path.join(projectRoot, 'browser_tests')
}
},
@@ -652,7 +657,8 @@ export default defineConfig({
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'packages/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'browser_tests/fixtures/utils/**/*.test.ts'
],
coverage: {
provider: 'v8',