Compare commits

...

7 Commits

Author SHA1 Message Date
Kelly Yang
b084fc8cff fix: remove unused Enclosure type export 2026-04-15 22:16:33 -07:00
Kelly Yang
21e7163e9e test: add unit tests for editAttention parsing logic
Extract incrementWeight, findNearestEnclosure, and addWeightToParentheses
as exported module-level functions and cover them with unit tests.

Closes #11107
2026-04-15 22:16:12 -07:00
Dante
e5c81488e4 fix: include focusMode in splitter refresh key to prevent panel resize (#11295)
## Summary

When the properties panel is open, toggling focus mode on then off
causes the panel to resize unexpectedly. The root cause is that
`splitterRefreshKey` in `LiteGraphCanvasSplitterOverlay.vue` does not
include `focusMode`, so the PrimeVue Splitter component instance is
reused across focus mode transitions and restores stale panel sizes from
localStorage.

Fix: add `focusMode` to `splitterRefreshKey` so the Splitter is
recreated when focus mode toggles.

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for focus mode toggle resizing properties
panel` | 🔴 Red | Proves the test catches the bug |
| `fix: include focusMode in splitter refresh key to prevent panel
resize` | 🟢 Green | Proves the fix resolves the bug |

## demo

### AS IS


https://github.com/user-attachments/assets/95f6a9e3-e4c7-4aba-8e17-0eee11f70491


### TO BE


https://github.com/user-attachments/assets/595eafcd-6a80-443d-a6f3-bb7605ed0758



## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] E2E regression test added in
`browser_tests/tests/focusMode.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11295-fix-include-focusMode-in-splitter-refresh-key-to-prevent-panel-resize-3446d73d365081b7bc3ac65338e17a8f)
by [Unito](https://www.unito.io)
2026-04-16 13:43:02 +09:00
Christian Byrne
5c07198acb fix: add validation to E2E coverage shard merge (#11290)
## Summary

Add a validation step after merging E2E coverage shards to detect data
loss and improve observability.

## Changes

- **What**: After `lcov -a` merges shard LCOVs, a new step parses merged
+ per-shard stats (source files, lines hit) and writes them to the
**GitHub Actions job summary** as a markdown table. If merged `LH`
(lines hit) is less than any single shard's `LH`, an error annotation is
emitted — this invariant should never be violated since merging should
only add coverage.
- Helps diagnose the 68% → 42% E2E coverage drop after sharding was
introduced.

## Review Focus

The step is informational — it emits `::error::` annotations but does
not `exit 1`, so it won't block the workflow. We can make it a hard
failure once we're confident the merge is stable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11290-fix-add-validation-to-E2E-coverage-shard-merge-3446d73d365081c8a942e92deba92006)
by [Unito](https://www.unito.io)
2026-04-15 21:39:51 -07:00
Terry Jia
6fb90b224d fix(load3d): restore missed hover state when viewer init is async (#11265)
## Summary
followup https://github.com/Comfy-Org/ComfyUI_frontend/pull/9520
mouseenter fires before load3d is created during async init
(getLoad3dAsync), so the STATUS_MOUSE_ON_VIEWER flag is never set.
This causes isActive() to return false after INITIAL_RENDER_DONE,
stopping the animation loop from calling controlsManager.update() and
making OrbitControls unresponsive on first open.

Track hover state in the composable and sync it to load3d after
creation.
2026-04-15 22:34:57 -04:00
pythongosssss
a8e1fa8bef test: add regression test for WEBP RIFF padding (#8527) (#11267)
## Summary

Add a regression test for #8527 (handle RIFF padding for odd-sized WEBP
chunks). The fix added + (chunk_length % 2) to the chunk-stride
calculation in getWebpMetadata so EXIF chunks following an odd-sized
chunk are still located correctly. There was no existing unit test
covering getWebpMetadata, so without a regression test the fix could
silently break in a future
  refactor. 

## Changes

- **What**: 
- New unit test file src/scripts/pnginfo.test.ts covering
getWebpMetadata's RIFF chunk traversal.
- Helpers build a minimal in-memory WEBP with one VP8 chunk of
configurable length followed by an EXIF chunk encoding workflow:<json>.
- Odd-length case (regression for #8527): without the % 2 padding
adjustment, the parser walks one byte short and returns {}.
- Even-length case: guards against an over-correction that always adds
1.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11267-test-add-regression-test-for-WEBP-RIFF-padding-8527-3436d73d36508117a66edf3cb108ded0)
by [Unito](https://www.unito.io)
2026-04-15 18:14:49 +00:00
pythongosssss
83ceef8cb3 test: add regression test for non-string serverLogs (#8460) (#11268)
## Summary

Add a regression test for #8460 (handle non-string `serverLogs` in error
report). The fix added `typeof error.serverLogs === 'string' ? ... :
JSON.stringify(...)` in `errorReportUtil.ts` so object-shaped logs no
longer render as `[object Object]`. There was no existing unit test for
`generateErrorReport`, so this regression could silently return.

## Changes

- **What**: New unit test file `src/utils/errorReportUtil.test.ts`
covering `generateErrorReport`'s `serverLogs` rendering.
- String case: verifies plain-string logs still appear verbatim and
`[object Object]` is absent.
- Object case (regression for #8460): verifies object logs are
JSON-stringified instead of coerced to `[object Object]`.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11268-test-add-regression-test-for-non-string-serverLogs-8460-3436d73d36508195a32fc559ab7ce5bb)
by [Unito](https://www.unito.io)
2026-04-15 18:14:17 +00:00
9 changed files with 364 additions and 76 deletions

View File

@@ -54,6 +54,33 @@ jobs:
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6

View File

@@ -55,4 +55,30 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).toBeHidden()
})
test('Focus mode toggle preserves properties panel width', async ({
comfyPage
}) => {
// Open the properties panel
await comfyPage.actionbar.propertiesButton.click()
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
// Record the initial panel width
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
expect(initialBox).not.toBeNull()
const initialWidth = initialBox!.width
// Toggle focus mode on then off
await comfyPage.setFocusMode(true)
await comfyPage.setFocusMode(false)
// Properties panel should be visible again with the same width
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
await expect
.poll(async () => {
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
return box ? Math.abs(box.width - initialWidth) : Infinity
})
.toBeLessThan(2)
})
})

View File

@@ -171,14 +171,10 @@ const sidebarPanelVisible = computed(
)
const firstPanelVisible = computed(
() =>
!focusMode.value &&
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
)
const lastPanelVisible = computed(
() =>
!focusMode.value &&
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
)
/**
@@ -268,6 +264,7 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -275,6 +272,7 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -293,9 +291,13 @@ const lastPanelStyle = computed(() => {
background-color: var(--p-primary-color);
}
/* Hide sidebar gutter when sidebar is not visible */
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
/* Hide gutter when adjacent panel is not visible */
:deep(
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
) {
display: none;
}

View File

@@ -430,6 +430,17 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
it('should sync hover state when mouseenter fires before init', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
viewer.handleMouseEnter()
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
})
describe('restoreInitialState', () => {

View File

@@ -86,6 +86,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
let mouseOnViewer = false
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -304,6 +305,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: hasTargetDimensions
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraState = source.getCameraState()
@@ -416,6 +421,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: true
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
@@ -522,6 +531,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
mouseOnViewer = true
load3d?.updateStatusMouseOnViewer(true)
}
@@ -529,6 +539,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
mouseOnViewer = false
load3d?.updateStatusMouseOnViewer(false)
}
@@ -727,6 +738,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
mouseOnViewer = false
load3d?.remove()
load3d = null
sourceLoad3d = null

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: vi.fn(),
ui: { settings: { addSetting: vi.fn() } }
}
}))
import {
addWeightToParentheses,
findNearestEnclosure,
incrementWeight
} from './editAttention'
describe('incrementWeight', () => {
it('increments a weight by the given delta', () => {
expect(incrementWeight('1.0', 0.05)).toBe('1.05')
})
it('decrements a weight by the given delta', () => {
expect(incrementWeight('1.05', -0.05)).toBe('1')
})
it('returns the original string when weight is not a number', () => {
expect(incrementWeight('abc', 0.05)).toBe('abc')
})
it('rounds correctly and avoids floating point accumulation', () => {
expect(incrementWeight('1.1', 0.1)).toBe('1.2')
})
it('can produce a weight of zero', () => {
expect(incrementWeight('0.05', -0.05)).toBe('0')
})
it('produces negative weights', () => {
expect(incrementWeight('0.0', -0.05)).toBe('-0.05')
})
})
describe('findNearestEnclosure', () => {
it('returns start and end of a simple parenthesized expression', () => {
expect(findNearestEnclosure('(cat)', 2)).toEqual({ start: 1, end: 4 })
})
it('returns null when there are no parentheses', () => {
expect(findNearestEnclosure('cat dog', 3)).toBeNull()
})
it('returns null when cursor is outside any enclosure', () => {
expect(findNearestEnclosure('(cat) dog', 7)).toBeNull()
})
it('finds the inner enclosure when cursor is on nested content', () => {
expect(findNearestEnclosure('(outer (inner) end)', 9)).toEqual({
start: 8,
end: 13
})
})
it('finds the outer enclosure when cursor is on outer content', () => {
expect(findNearestEnclosure('(outer (inner) end)', 2)).toEqual({
start: 1,
end: 18
})
})
it('returns null for empty string', () => {
expect(findNearestEnclosure('', 0)).toBeNull()
})
it('returns null when opening paren has no matching closing paren', () => {
expect(findNearestEnclosure('(cat', 2)).toBeNull()
})
})
describe('addWeightToParentheses', () => {
it('adds weight 1.0 to a bare parenthesized token', () => {
expect(addWeightToParentheses('(cat)')).toBe('(cat:1.0)')
})
it('leaves a token that already has a weight unchanged', () => {
expect(addWeightToParentheses('(cat:1.5)')).toBe('(cat:1.5)')
})
it('leaves a token without parentheses unchanged', () => {
expect(addWeightToParentheses('cat')).toBe('cat')
})
it('leaves a token with scientific notation weight unchanged', () => {
expect(addWeightToParentheses('(cat:1e-3)')).toBe('(cat:1e-3)')
})
it('leaves a token with a negative weight unchanged', () => {
expect(addWeightToParentheses('(cat:-0.5)')).toBe('(cat:-0.5)')
})
it('adds weight to a multi-word parenthesized token', () => {
expect(addWeightToParentheses('(cat dog)')).toBe('(cat dog:1.0)')
})
})

View File

@@ -1,6 +1,61 @@
import { app } from '../../scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
type Enclosure = {
start: number
end: number
}
export function incrementWeight(weight: string, delta: number): string {
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
return String(Number(newWeight.toFixed(10)))
}
export function findNearestEnclosure(
text: string,
cursorPos: number
): Enclosure | null {
let start = cursorPos,
end = cursorPos
let openCount = 0,
closeCount = 0
while (start >= 0) {
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return null
openCount = 0
closeCount = 0
while (end < text.length) {
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return null
return { start: start + 1, end: end }
}
export function addWeightToParentheses(text: string): string {
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
const floatMatch = text.match(floatRegex)
if (parenMatch && !floatMatch) {
return `(${parenMatch[1]}:1.0)`
} else {
return text
}
}
app.registerExtension({
name: 'Comfy.EditAttention',
@@ -18,65 +73,6 @@ app.registerExtension({
defaultValue: 0.05
})
function incrementWeight(weight: string, delta: number): string {
const floatWeight = parseFloat(weight)
if (isNaN(floatWeight)) return weight
const newWeight = floatWeight + delta
return String(Number(newWeight.toFixed(10)))
}
type Enclosure = {
start: number
end: number
}
function findNearestEnclosure(
text: string,
cursorPos: number
): Enclosure | null {
let start = cursorPos,
end = cursorPos
let openCount = 0,
closeCount = 0
// Find opening parenthesis before cursor
while (start >= 0) {
start--
if (text[start] === '(' && openCount === closeCount) break
if (text[start] === '(') openCount++
if (text[start] === ')') closeCount++
}
if (start < 0) return null
openCount = 0
closeCount = 0
// Find closing parenthesis after cursor
while (end < text.length) {
if (text[end] === ')' && openCount === closeCount) break
if (text[end] === '(') openCount++
if (text[end] === ')') closeCount++
end++
}
if (end === text.length) return null
return { start: start + 1, end: end }
}
function addWeightToParentheses(text: string): string {
const parenRegex = /^\((.*)\)$/
const parenMatch = text.match(parenRegex)
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
const floatMatch = text.match(floatRegex)
if (parenMatch && !floatMatch) {
return `(${parenMatch[1]}:1.0)`
} else {
return text
}
}
function editAttention(event: KeyboardEvent) {
// @ts-expect-error Runtime narrowing not impl.
const inputField: HTMLTextAreaElement = event.composedPath()[0]
@@ -92,7 +88,6 @@ app.registerExtension({
let end = inputField.selectionEnd
let selectedText = inputField.value.substring(start, end)
// If there is no selection, attempt to find the nearest enclosure, or select the current word
if (!selectedText) {
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
if (nearestEnclosure) {
@@ -100,7 +95,6 @@ app.registerExtension({
end = nearestEnclosure.end
selectedText = inputField.value.substring(start, end)
} else {
// Select the current word, find the start and end of the word
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
while (
@@ -122,13 +116,11 @@ app.registerExtension({
}
}
// If the selection ends with a space, remove it
if (selectedText[selectedText.length - 1] === ' ') {
selectedText = selectedText.substring(0, selectedText.length - 1)
end -= 1
}
// If there are parentheses left and right of the selection, select them
if (
inputField.value[start - 1] === '(' &&
inputField.value[end] === ')'
@@ -138,7 +130,6 @@ app.registerExtension({
selectedText = inputField.value.substring(start, end)
}
// If the selection is not enclosed in parentheses, add them
if (
selectedText[0] !== '(' ||
selectedText[selectedText.length - 1] !== ')'
@@ -146,10 +137,8 @@ app.registerExtension({
selectedText = `(${selectedText})`
}
// If the selection does not have a weight, add a weight of 1.0
selectedText = addWeightToParentheses(selectedText)
// Increment the weight
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
const updatedText = selectedText.replace(
/\((.*):([+-]?\d+(?:\.\d+)?)\)/,

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { getWebpMetadata } from './pnginfo'
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
const strBytes = new TextEncoder().encode(fullStr)
const headerSize = 22
const buf = new Uint8Array(headerSize + strBytes.length)
const dv = new DataView(buf.buffer)
buf.set([0x49, 0x49], 0)
dv.setUint16(2, 0x002a, true)
dv.setUint32(4, 8, true)
dv.setUint16(8, 1, true)
dv.setUint16(10, 0, true)
dv.setUint16(12, 2, true)
dv.setUint32(14, strBytes.length, true)
dv.setUint32(18, 22, true)
buf.set(strBytes, 22)
return buf
}
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
const exifPayload = buildExifPayload(workflowJson)
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
const buffer = new Uint8Array(totalSize)
const dv = new DataView(buffer.buffer)
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
dv.setUint32(4, totalSize - 8, true)
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
dv.setUint32(16, precedingChunkLength, true)
const exifStart = 20 + precedingPadded
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
dv.setUint32(exifStart + 4, exifPayload.length, true)
buffer.set(exifPayload, exifStart + 8)
return new File([buffer], 'test.webp', { type: 'image/webp' })
}
describe('getWebpMetadata', () => {
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
const workflow = '{"nodes":[]}'
const file = buildWebp(3, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
it('finds workflow when preceding chunk has even length (no padding)', async () => {
const workflow = '{"nodes":[1]}'
const file = buildWebp(4, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { ErrorReportData } from './errorReportUtil'
import { generateErrorReport } from './errorReportUtil'
const baseSystemStats: SystemStats = {
system: {
os: 'linux',
comfyui_version: '1.0.0',
python_version: '3.11',
pytorch_version: '2.0',
embedded_python: false,
argv: ['main.py'],
ram_total: 0,
ram_free: 0
},
devices: []
}
const baseWorkflow = { nodes: [], links: [] } as unknown as ISerialisedGraph
function buildError(serverLogs: unknown): ErrorReportData {
return {
exceptionType: 'RuntimeError',
exceptionMessage: 'boom',
systemStats: baseSystemStats,
serverLogs: serverLogs as string,
workflow: baseWorkflow
}
}
describe('generateErrorReport', () => {
it('embeds string serverLogs verbatim', () => {
const report = generateErrorReport(buildError('line one\nline two'))
expect(report).toContain('line one\nline two')
expect(report).not.toContain('[object Object]')
})
it('stringifies object serverLogs instead of rendering [object Object]', () => {
const report = generateErrorReport(
buildError({ entries: [{ msg: 'hello' }] })
)
expect(report).not.toContain('[object Object]')
expect(report).toContain('"entries"')
expect(report).toContain('"msg": "hello"')
})
})