Skip to content

Commit 26c67b3

Browse files
authored
fix(ui): Large arguments are downloaded as files instead of rendered (#5268)
1 parent 0f3bfe9 commit 26c67b3

21 files changed

Lines changed: 1559 additions & 312 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ data-alloy/
1818
/packaging/windows/LICENSE
1919
/packaging/windows/agent-windows-amd64.exe
2020
internal/web/ui/dist
21+
internal/web/ui/src/test/generated_fixtures/
2122

2223
.DS_Store
2324
buildx-v*

internal/web/ui/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ Normal, local development is done via `npm run dev` from this folder (internal/w
1212

1313
However, API responses are not currently mocked, so most of the UI will not show any data.
1414

15+
### Mock mode
16+
17+
To develop against fixture data without a running Alloy instance, use:
18+
19+
```sh
20+
npm run dev:mock
21+
```
22+
23+
This generates fixture files and starts the Vite dev server with a mock API layer that intercepts requests to `/api/` and serves responses from `src/test/generated_fixtures/`.
24+
25+
### Full build testing
26+
1527
To fully test the UI, run `npm run build`, then run Alloy as normal. This will use the "no built-in assets" version.
1628

1729
You can also run Alloy _with_ built-in assets if you'd like to test it that way.

internal/web/ui/package-lock.json

Lines changed: 867 additions & 294 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/web/ui/package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
8+
"generate:fixtures": "npx tsx src/test/fixtures/writeFixture.ts",
9+
"dev:mock": "npm run generate:fixtures && MOCK=true vite",
810
"build": "tsc -b && vite build",
911
"format": "prettier --check .",
1012
"format:fix": "prettier --write .",
1113
"lint": "eslint .",
1214
"lint:fix": "eslint --fix .",
13-
"preview": "vite preview"
15+
"preview": "vite preview",
16+
"test": "vitest",
17+
"test:run": "vitest run"
1418
},
1519
"dependencies": {
1620
"@dagrejs/dagre": "^1.1.8",
@@ -34,8 +38,11 @@
3438
},
3539
"devDependencies": {
3640
"@eslint/js": "9.39.4",
41+
"@testing-library/jest-dom": "^6.9.1",
42+
"@testing-library/react": "^16.3.2",
3743
"@types/d3": "7.4.3",
3844
"@types/d3-zoom": "3.0.8",
45+
"@types/node": "^25.5.0",
3946
"@types/react": "18.3.28",
4047
"@types/react-dom": "18.3.7",
4148
"@vitejs/plugin-react": "5.2.0",
@@ -47,6 +54,7 @@
4754
"prettier": "3.8.1",
4855
"typescript": "5.9.3",
4956
"typescript-eslint": "8.57.1",
50-
"vite": "7.3.1"
57+
"vite": "7.3.1",
58+
"vitest": "^4.1.2"
5159
}
5260
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { act, render, screen } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { largeDiscOutput } from '../../test/fixtures/generateLargeDiscOutput';
5+
import { type Value, ValueType } from '../alloy-syntax-js/types';
6+
import AsyncStringifiedValue from './AsyncStringifiedValue';
7+
8+
describe('AsyncStringifiedValue', () => {
9+
describe('simple values render synchronously', () => {
10+
it('renders number values immediately', () => {
11+
const numberValue: Value = { type: ValueType.NUMBER, value: 42 };
12+
13+
render(<AsyncStringifiedValue value={numberValue} />);
14+
15+
expect(screen.getByText('42')).toBeInTheDocument();
16+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
17+
});
18+
19+
it('renders boolean values immediately', () => {
20+
const boolValue: Value = { type: ValueType.BOOL, value: true };
21+
22+
render(<AsyncStringifiedValue value={boolValue} />);
23+
24+
expect(screen.getByText('true')).toBeInTheDocument();
25+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
26+
});
27+
28+
it('renders null values immediately', () => {
29+
const nullValue: Value = { type: ValueType.NULL };
30+
31+
render(<AsyncStringifiedValue value={nullValue} />);
32+
33+
expect(screen.getByText('null')).toBeInTheDocument();
34+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
35+
});
36+
});
37+
38+
describe('complex values render asynchronously with size check', () => {
39+
beforeEach(() => {
40+
vi.useFakeTimers();
41+
});
42+
43+
afterEach(() => {
44+
vi.useRealTimers();
45+
});
46+
47+
it('shows nothing initially for strings (delayed loading indicator)', () => {
48+
const stringValue: Value = { type: ValueType.STRING, value: 'hello world' };
49+
50+
const { container } = render(<AsyncStringifiedValue value={stringValue} />);
51+
52+
// During the delay threshold, nothing is shown (avoids flickering for fast ops)
53+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
54+
expect(container.textContent).toBe('');
55+
});
56+
57+
it('skips loading state entirely when processing completes before delay threshold', async () => {
58+
const stringValue: Value = { type: ValueType.STRING, value: 'hello world' };
59+
60+
render(<AsyncStringifiedValue value={stringValue} />);
61+
62+
// Run all timers - for fast operations, loading state is never shown
63+
await act(async () => {
64+
vi.runAllTimers();
65+
});
66+
67+
// The result should appear without ever showing "Loading..."
68+
expect(screen.getByText('"hello world"')).toBeInTheDocument();
69+
// Verify loading was never shown (it was skipped because processing was fast)
70+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
71+
});
72+
73+
it('renders small strings after processing', async () => {
74+
const stringValue: Value = { type: ValueType.STRING, value: 'hello world' };
75+
76+
render(<AsyncStringifiedValue value={stringValue} />);
77+
78+
await act(async () => {
79+
vi.runAllTimers();
80+
});
81+
82+
expect(screen.getByText('"hello world"')).toBeInTheDocument();
83+
});
84+
85+
it('shows download link for large strings', async () => {
86+
const largeString = 'x'.repeat(3000);
87+
const stringValue: Value = { type: ValueType.STRING, value: largeString };
88+
89+
render(<AsyncStringifiedValue value={stringValue} maxLength={100} />);
90+
91+
await act(async () => {
92+
vi.runAllTimers();
93+
});
94+
95+
const downloadButton = screen.getByRole('button', { name: 'Download the value contents' });
96+
expect(downloadButton).toBeInTheDocument();
97+
});
98+
99+
it('shows nothing initially for arrays (delayed loading indicator)', () => {
100+
const arrayValue: Value = {
101+
type: ValueType.ARRAY,
102+
value: [{ type: ValueType.NUMBER, value: 1 }],
103+
};
104+
105+
const { container } = render(<AsyncStringifiedValue value={arrayValue} />);
106+
107+
// During the delay threshold, nothing is shown (avoids flickering for fast ops)
108+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
109+
expect(container.textContent).toBe('');
110+
});
111+
112+
it('renders small arrays after processing', async () => {
113+
const arrayValue: Value = {
114+
type: ValueType.ARRAY,
115+
value: [
116+
{ type: ValueType.NUMBER, value: 1 },
117+
{ type: ValueType.NUMBER, value: 2 },
118+
],
119+
};
120+
121+
render(<AsyncStringifiedValue value={arrayValue} />);
122+
123+
// Run all timers and frames
124+
await act(async () => {
125+
vi.runAllTimers();
126+
});
127+
128+
// Small array should render successfully
129+
expect(screen.getByText('[1, 2]')).toBeInTheDocument();
130+
});
131+
132+
it('shows download link for values exceeding maxLength', async () => {
133+
// The large value is in exports[0].value (the targets array)
134+
const largeValue = largeDiscOutput.exports[0].value as Value;
135+
136+
// Use a small maxLength to ensure it exceeds
137+
render(<AsyncStringifiedValue value={largeValue} maxLength={100} />);
138+
139+
await act(async () => {
140+
vi.runAllTimers();
141+
});
142+
143+
// Should show download link instead of error message
144+
const downloadButton = screen.getByRole('button', { name: 'Download the value contents' });
145+
expect(downloadButton).toBeInTheDocument();
146+
});
147+
148+
it('shows download link with default 50000 char limit', async () => {
149+
const largeValue = largeDiscOutput.exports[0].value as Value;
150+
151+
render(<AsyncStringifiedValue value={largeValue} />);
152+
153+
await act(async () => {
154+
vi.runAllTimers();
155+
});
156+
157+
// Should show download link (fixture is much larger than 50000 chars)
158+
const downloadButton = screen.getByRole('button', { name: 'Download the value contents' });
159+
expect(downloadButton).toBeInTheDocument();
160+
});
161+
162+
it('cleans up properly when unmounted during processing', async () => {
163+
const largeValue = largeDiscOutput.exports[0].value as Value;
164+
165+
const { unmount } = render(<AsyncStringifiedValue value={largeValue} />);
166+
167+
// Unmount while still processing (before loading or result appears)
168+
unmount();
169+
170+
// Run all timers - should not throw any errors
171+
await act(async () => {
172+
vi.runAllTimers();
173+
});
174+
175+
expect(true).toBe(true);
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)