Skip to content

Commit a48a330

Browse files
author
Sébastien Henau
committed
tests: add more tests for component hierarchy frames
1 parent 308b312 commit a48a330

File tree

3 files changed

+215
-4
lines changed

3 files changed

+215
-4
lines changed

packages/vue/tests/FlareErrorBoundary.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
33
import { defineComponent, h, nextTick } from 'vue';
44

55
import { FlareErrorBoundary } from '../src/FlareErrorBoundary';
6-
import { FlareVueContext } from '../src/types';
6+
import { ComponentHierarchyFrame, FlareVueContext } from '../src/types';
77

88
const mockReport = vi.fn();
99

@@ -62,7 +62,7 @@ describe('FlareErrorBoundary', () => {
6262
expect(mockReport.mock.calls[0][0]).toBe(testError);
6363
});
6464

65-
test('passes vue context with info, componentName, and componentHierarchy', async () => {
65+
test('passes vue context with info, componentName, componentHierarchy, and componentHierarchyFrames', async () => {
6666
mount(FlareErrorBoundary, {
6767
slots: {
6868
default: () => h(ThrowingComponent),
@@ -77,6 +77,10 @@ describe('FlareErrorBoundary', () => {
7777
expect(context.vue.componentName).toBe('ThrowingComponent');
7878
expect(context.vue.componentHierarchy).toBeInstanceOf(Array);
7979
expect(context.vue.componentHierarchy).toContain('ThrowingComponent');
80+
expect(context.vue.componentHierarchyFrames).toBeInstanceOf(Array);
81+
expect(context.vue.componentHierarchyFrames[0].component).toBe('ThrowingComponent');
82+
expect(context.vue.componentHierarchyFrames[0]).toHaveProperty('file');
83+
expect(context.vue.componentHierarchyFrames[0]).toHaveProperty('props');
8084
});
8185

8286
test('passes instance and info as extra solution parameters', async () => {
@@ -137,6 +141,30 @@ describe('FlareErrorBoundary', () => {
137141
expect(wrapper.find('.hierarchy').text()).toContain('ThrowingComponent');
138142
});
139143

144+
test('fallback slot receives componentHierarchyFrames', async () => {
145+
let receivedFrames: ComponentHierarchyFrame[] = [];
146+
147+
mount(FlareErrorBoundary, {
148+
slots: {
149+
default: () => h(ThrowingComponent),
150+
fallback: (props: { componentHierarchyFrames: ComponentHierarchyFrame[] }) => {
151+
receivedFrames = props.componentHierarchyFrames;
152+
return h('div', 'Error');
153+
},
154+
},
155+
});
156+
157+
await nextTick();
158+
159+
expect(receivedFrames).toBeInstanceOf(Array);
160+
expect(receivedFrames.length).toBeGreaterThan(0);
161+
expect(receivedFrames[0]).toMatchObject({
162+
component: 'ThrowingComponent',
163+
});
164+
expect(receivedFrames[0]).toHaveProperty('file');
165+
expect(receivedFrames[0]).toHaveProperty('props');
166+
});
167+
140168
test('fallback slot receives resetErrorBoundary function', async () => {
141169
const wrapper = mount(FlareErrorBoundary, {
142170
slots: {
@@ -376,6 +404,7 @@ describe('FlareErrorBoundary', () => {
376404
expect(beforeSubmit.mock.calls[0][0].instance).toBeDefined();
377405
expect(beforeSubmit.mock.calls[0][0].info).toEqual(expect.any(String));
378406
expect(beforeSubmit.mock.calls[0][0].context.vue.componentHierarchy).toBeInstanceOf(Array);
407+
expect(beforeSubmit.mock.calls[0][0].context.vue.componentHierarchyFrames).toBeInstanceOf(Array);
379408
expect(beforeSubmit.mock.calls[0][0].context.vue.componentName).toBe('ThrowingComponent');
380409
});
381410

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, expect, test } from 'vitest';
2+
import type { ComponentPublicInstance } from 'vue';
3+
4+
import { buildComponentHierarchyFrames } from '../src/buildComponentHierarchyFrames';
5+
import { MAX_HIERARCHY_DEPTH } from '../src/constants';
6+
7+
function createMockInstance(
8+
name: string,
9+
{
10+
parent = null,
11+
file = undefined,
12+
props = undefined,
13+
}: {
14+
parent?: ComponentPublicInstance | null;
15+
file?: string;
16+
props?: Record<string, unknown>;
17+
} = {}
18+
): ComponentPublicInstance {
19+
return {
20+
$options: {
21+
__name: name,
22+
...(file !== undefined ? { __file: file } : {}),
23+
},
24+
$parent: parent,
25+
$props: props ?? {},
26+
} as unknown as ComponentPublicInstance;
27+
}
28+
29+
describe('buildComponentHierarchyFrames', () => {
30+
test('returns an empty array for null instance', () => {
31+
expect(buildComponentHierarchyFrames(null)).toEqual([]);
32+
});
33+
34+
test('returns a single frame for a root component', () => {
35+
const instance = createMockInstance('App');
36+
37+
expect(buildComponentHierarchyFrames(instance)).toEqual([{ component: 'App', file: null, props: {} }]);
38+
});
39+
40+
test('builds frames through $parent chain', () => {
41+
const grandparent = createMockInstance('App');
42+
const parent = createMockInstance('Layout', { parent: grandparent });
43+
const child = createMockInstance('Button', { parent });
44+
45+
const frames = buildComponentHierarchyFrames(child);
46+
47+
expect(frames).toEqual([
48+
{ component: 'Button', file: null, props: {} },
49+
{ component: 'Layout', file: null, props: {} },
50+
{ component: 'App', file: null, props: {} },
51+
]);
52+
});
53+
54+
test('includes __file when available', () => {
55+
const parent = createMockInstance('App', { file: 'src/App.vue' });
56+
const child = createMockInstance('Button', {
57+
parent,
58+
file: 'src/components/Button.vue',
59+
});
60+
61+
const frames = buildComponentHierarchyFrames(child);
62+
63+
expect(frames[0].file).toBe('src/components/Button.vue');
64+
expect(frames[1].file).toBe('src/App.vue');
65+
});
66+
67+
test('sets file to null when __file is not present', () => {
68+
const instance = createMockInstance('App');
69+
70+
const frames = buildComponentHierarchyFrames(instance);
71+
72+
expect(frames[0].file).toBeNull();
73+
});
74+
75+
test('includes props from the component instance', () => {
76+
const parent = createMockInstance('App');
77+
const child = createMockInstance('UserCard', {
78+
parent,
79+
props: { userId: 42, name: 'Alice' },
80+
});
81+
82+
const frames = buildComponentHierarchyFrames(child);
83+
84+
expect(frames[0].props).toEqual({ userId: 42, name: 'Alice' });
85+
expect(frames[1].props).toEqual({});
86+
});
87+
88+
test('creates a shallow copy of props', () => {
89+
const originalProps = { userId: 42 };
90+
const instance = createMockInstance('UserCard', { props: originalProps });
91+
92+
const frames = buildComponentHierarchyFrames(instance);
93+
94+
expect(frames[0].props).toEqual({ userId: 42 });
95+
expect(frames[0].props).not.toBe(originalProps);
96+
});
97+
98+
test('sets props to null when $props is null', () => {
99+
const instance = {
100+
$options: { __name: 'App' },
101+
$parent: null,
102+
$props: null,
103+
} as unknown as ComponentPublicInstance;
104+
105+
const frames = buildComponentHierarchyFrames(instance);
106+
107+
expect(frames[0].props).toBeNull();
108+
});
109+
110+
test('uses AnonymousComponent for unnamed components in the chain', () => {
111+
const parent = createMockInstance('App');
112+
const child = {
113+
$options: {},
114+
$parent: parent,
115+
$props: {},
116+
} as unknown as ComponentPublicInstance;
117+
118+
const frames = buildComponentHierarchyFrames(child);
119+
120+
expect(frames[0].component).toBe('AnonymousComponent');
121+
expect(frames[1].component).toBe('App');
122+
});
123+
124+
test('respects MAX_HIERARCHY_DEPTH limit', () => {
125+
let current: ComponentPublicInstance | null = null;
126+
127+
for (let i = 0; i < MAX_HIERARCHY_DEPTH + 50; i++) {
128+
current = createMockInstance(`Component${i}`, { parent: current });
129+
}
130+
131+
const frames = buildComponentHierarchyFrames(current);
132+
133+
expect(frames).toHaveLength(MAX_HIERARCHY_DEPTH);
134+
expect(frames[0].component).toBe(`Component${MAX_HIERARCHY_DEPTH + 49}`);
135+
expect(frames[MAX_HIERARCHY_DEPTH - 1].component).toBe('Component50');
136+
});
137+
138+
test('combines file, props, and hierarchy correctly', () => {
139+
const root = createMockInstance('App', { file: 'src/App.vue' });
140+
const layout = createMockInstance('Layout', {
141+
parent: root,
142+
file: 'src/layouts/Layout.vue',
143+
props: { sidebar: true },
144+
});
145+
const page = createMockInstance('UserProfile', {
146+
parent: layout,
147+
file: 'src/pages/UserProfile.vue',
148+
props: { userId: 42, tab: 'settings' },
149+
});
150+
151+
const frames = buildComponentHierarchyFrames(page);
152+
153+
expect(frames).toEqual([
154+
{
155+
component: 'UserProfile',
156+
file: 'src/pages/UserProfile.vue',
157+
props: { userId: 42, tab: 'settings' },
158+
},
159+
{
160+
component: 'Layout',
161+
file: 'src/layouts/Layout.vue',
162+
props: { sidebar: true },
163+
},
164+
{
165+
component: 'App',
166+
file: 'src/App.vue',
167+
props: {},
168+
},
169+
]);
170+
});
171+
});

packages/vue/tests/flareVue.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ function createMockApp(initialHandler?: (...args: unknown[]) => void) {
2020
};
2121
}
2222

23-
function createMockInstance(name: string, parent: ComponentPublicInstance | null = null): ComponentPublicInstance {
23+
function createMockInstance(
24+
name: string,
25+
parent: ComponentPublicInstance | null = null,
26+
props: Record<string, unknown> = {}
27+
): ComponentPublicInstance {
2428
return {
2529
$options: { __name: name },
2630
$parent: parent,
31+
$props: props,
2732
} as unknown as ComponentPublicInstance;
2833
}
2934

@@ -75,7 +80,7 @@ describe('flareVue', () => {
7580
expect(reportedError.message).toBe('string error');
7681
});
7782

78-
test('passes vue context with info, componentName, and componentHierarchy', () => {
83+
test('passes vue context with info, componentName, componentHierarchy, and componentHierarchyFrames', () => {
7984
const app = createMockApp();
8085
(flareVue as Function)(app);
8186

@@ -89,6 +94,11 @@ describe('flareVue', () => {
8994
expect(context.vue.info).toBe('setup function');
9095
expect(context.vue.componentName).toBe('Button');
9196
expect(context.vue.componentHierarchy).toEqual(['Button', 'Layout', 'App']);
97+
expect(context.vue.componentHierarchyFrames).toEqual([
98+
{ component: 'Button', file: null, props: {} },
99+
{ component: 'Layout', file: null, props: {} },
100+
{ component: 'App', file: null, props: {} },
101+
]);
92102
});
93103

94104
test('passes instance and info as extra solution parameters', () => {
@@ -178,6 +188,7 @@ describe('flareVue', () => {
178188
const context = mockReport.mock.calls[0][1];
179189
expect(context.vue.componentName).toBe('AnonymousComponent');
180190
expect(context.vue.componentHierarchy).toEqual([]);
191+
expect(context.vue.componentHierarchyFrames).toEqual([]);
181192
});
182193

183194
test('re-throws the converted error, not the raw value, when no initial handler exists', () => {

0 commit comments

Comments
 (0)