@datarecce/ui 0.1.40 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/api.d.mts +1 -1
  2. package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
  3. package/dist/components-DfXnN1Hx.js.map +1 -0
  4. package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
  5. package/dist/components-jh6r4tQn.mjs.map +1 -0
  6. package/dist/components.d.mts +1 -1
  7. package/dist/components.js +1 -1
  8. package/dist/components.mjs +1 -1
  9. package/dist/hooks.d.mts +1 -1
  10. package/dist/{index-CbF0x3kW.d.mts → index-B5bpmv0i.d.mts} +70 -70
  11. package/dist/{index-CbF0x3kW.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
  12. package/dist/index-B9lSPJTi.d.ts.map +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.mjs +1 -1
  16. package/dist/theme.d.mts +1 -1
  17. package/dist/types.d.mts +1 -1
  18. package/package.json +1 -1
  19. package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
  20. package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
  21. package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
  22. package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
  23. package/recce-source/js/src/lib/csv/extractors.ts +468 -0
  24. package/recce-source/js/src/lib/csv/format.test.ts +211 -0
  25. package/recce-source/js/src/lib/csv/format.ts +44 -0
  26. package/recce-source/js/src/lib/csv/index.test.ts +155 -0
  27. package/recce-source/js/src/lib/csv/index.ts +109 -0
  28. package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
  29. package/recce-source/recce/mcp_server.py +54 -30
  30. package/recce-source/recce/models/check.py +10 -2
  31. package/dist/components-B6oaPB5f.mjs.map +0 -1
  32. package/dist/components-DTLQ2djq.js.map +0 -1
@@ -14,7 +14,14 @@ import Typography from "@mui/material/Typography";
14
14
  import { useQueryClient } from "@tanstack/react-query";
15
15
  import { type MouseEvent, ReactNode, Ref, useCallback, useState } from "react";
16
16
  import { IoClose } from "react-icons/io5";
17
- import { PiCaretDown, PiCheck, PiCopy, PiRepeat } from "react-icons/pi";
17
+ import {
18
+ PiCaretDown,
19
+ PiCheck,
20
+ PiDownloadSimple,
21
+ PiImage,
22
+ PiRepeat,
23
+ PiTable,
24
+ } from "react-icons/pi";
18
25
  import { TbCloudUpload } from "react-icons/tb";
19
26
  import YAML from "yaml";
20
27
  import AuthModal from "@/components/AuthModal/AuthModal";
@@ -27,6 +34,7 @@ import {
27
34
  isQueryBaseRun,
28
35
  isQueryDiffRun,
29
36
  isQueryRun,
37
+ type Run,
30
38
  RunParamTypes,
31
39
  } from "@/lib/api/types";
32
40
  import { useApiConfig } from "@/lib/hooks/ApiConfigContext";
@@ -35,6 +43,7 @@ import { useRecceInstanceContext } from "@/lib/hooks/RecceInstanceContext";
35
43
  import { useRecceShareStateContext } from "@/lib/hooks/RecceShareStateContext";
36
44
  import { useCopyToClipboardButton } from "@/lib/hooks/ScreenShot";
37
45
  import { useAppLocation } from "@/lib/hooks/useAppRouter";
46
+ import { useCSVExport } from "@/lib/hooks/useCSVExport";
38
47
  import { useRun } from "@/lib/hooks/useRun";
39
48
  import {
40
49
  LearnHowLink,
@@ -113,12 +122,102 @@ const SingleEnvironmentSetupNotification = ({
113
122
  }
114
123
  };
115
124
 
125
+ const RunResultExportMenu = ({
126
+ run,
127
+ viewOptions,
128
+ disableExport,
129
+ onCopyAsImage,
130
+ onMouseEnter,
131
+ onMouseLeave,
132
+ }: {
133
+ run?: Run;
134
+ viewOptions?: ViewOptionTypes;
135
+ disableExport: boolean;
136
+ onCopyAsImage: () => Promise<void>;
137
+ onMouseEnter: () => void;
138
+ onMouseLeave: () => void;
139
+ }) => {
140
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
141
+ const open = Boolean(anchorEl);
142
+ const { canExportCSV, copyAsCSV, downloadAsCSV } = useCSVExport({
143
+ run,
144
+ viewOptions: viewOptions as Record<string, unknown>,
145
+ });
146
+
147
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
148
+ setAnchorEl(event.currentTarget);
149
+ };
150
+
151
+ const handleClose = () => {
152
+ setAnchorEl(null);
153
+ };
154
+
155
+ return (
156
+ <>
157
+ <Button
158
+ size="small"
159
+ variant="outlined"
160
+ color="neutral"
161
+ onClick={handleClick}
162
+ endIcon={<PiCaretDown />}
163
+ sx={{ textTransform: "none" }}
164
+ >
165
+ Export
166
+ </Button>
167
+ <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
168
+ <MenuItem
169
+ onClick={async () => {
170
+ await onCopyAsImage();
171
+ handleClose();
172
+ }}
173
+ onMouseEnter={onMouseEnter}
174
+ onMouseLeave={onMouseLeave}
175
+ disabled={disableExport}
176
+ >
177
+ <ListItemIcon>
178
+ <PiImage />
179
+ </ListItemIcon>
180
+ <ListItemText>Copy as Image</ListItemText>
181
+ </MenuItem>
182
+ <MenuItem
183
+ onClick={async () => {
184
+ await copyAsCSV();
185
+ handleClose();
186
+ }}
187
+ disabled={disableExport || !canExportCSV}
188
+ >
189
+ <ListItemIcon>
190
+ <PiTable />
191
+ </ListItemIcon>
192
+ <ListItemText>Copy as CSV</ListItemText>
193
+ </MenuItem>
194
+ <MenuItem
195
+ onClick={() => {
196
+ downloadAsCSV();
197
+ handleClose();
198
+ }}
199
+ disabled={disableExport || !canExportCSV}
200
+ >
201
+ <ListItemIcon>
202
+ <PiDownloadSimple />
203
+ </ListItemIcon>
204
+ <ListItemText>Download as CSV</ListItemText>
205
+ </MenuItem>
206
+ </Menu>
207
+ </>
208
+ );
209
+ };
210
+
116
211
  const RunResultShareMenu = ({
212
+ run,
213
+ viewOptions,
117
214
  disableCopyToClipboard,
118
215
  onCopyToClipboard,
119
216
  onMouseEnter,
120
217
  onMouseLeave,
121
218
  }: {
219
+ run?: Run;
220
+ viewOptions?: ViewOptionTypes;
122
221
  disableCopyToClipboard: boolean;
123
222
  onCopyToClipboard: () => Promise<void>;
124
223
  onMouseEnter: () => void;
@@ -129,6 +228,10 @@ const RunResultShareMenu = ({
129
228
  const [showModal, setShowModal] = useState(false);
130
229
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
131
230
  const open = Boolean(anchorEl);
231
+ const { canExportCSV, copyAsCSV, downloadAsCSV } = useCSVExport({
232
+ run,
233
+ viewOptions: viewOptions as Record<string, unknown>,
234
+ });
132
235
 
133
236
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
134
237
  setAnchorEl(event.currentTarget);
@@ -161,9 +264,33 @@ const RunResultShareMenu = ({
161
264
  disabled={disableCopyToClipboard}
162
265
  >
163
266
  <ListItemIcon>
164
- <PiCopy />
267
+ <PiImage />
165
268
  </ListItemIcon>
166
- <ListItemText>Copy to Clipboard</ListItemText>
269
+ <ListItemText>Copy as Image</ListItemText>
270
+ </MenuItem>
271
+ <MenuItem
272
+ onClick={async () => {
273
+ await copyAsCSV();
274
+ handleClose();
275
+ }}
276
+ disabled={disableCopyToClipboard || !canExportCSV}
277
+ >
278
+ <ListItemIcon>
279
+ <PiTable />
280
+ </ListItemIcon>
281
+ <ListItemText>Copy as CSV</ListItemText>
282
+ </MenuItem>
283
+ <MenuItem
284
+ onClick={() => {
285
+ downloadAsCSV();
286
+ handleClose();
287
+ }}
288
+ disabled={disableCopyToClipboard || !canExportCSV}
289
+ >
290
+ <ListItemIcon>
291
+ <PiDownloadSimple />
292
+ </ListItemIcon>
293
+ <ListItemText>Download as CSV</ListItemText>
167
294
  </MenuItem>
168
295
  <Divider />
169
296
  {authed ? (
@@ -289,23 +416,20 @@ export const PrivateLoadableRunView = ({
289
416
  Rerun
290
417
  </Button>
291
418
  {featureToggles.disableShare ? (
292
- <Button
293
- variant="outlined"
294
- color="neutral"
295
- disabled={
419
+ <RunResultExportMenu
420
+ run={run}
421
+ viewOptions={viewOptions}
422
+ disableExport={
296
423
  !runId || !run?.result || !!error || tabValue !== "result"
297
424
  }
425
+ onCopyAsImage={onCopyToClipboard}
298
426
  onMouseEnter={onMouseEnter}
299
427
  onMouseLeave={onMouseLeave}
300
- size="small"
301
- onClick={onCopyToClipboard}
302
- startIcon={<PiCopy />}
303
- sx={{ textTransform: "none" }}
304
- >
305
- Copy to Clipboard
306
- </Button>
428
+ />
307
429
  ) : (
308
430
  <RunResultShareMenu
431
+ run={run}
432
+ viewOptions={viewOptions}
309
433
  disableCopyToClipboard={disableCopyToClipboard}
310
434
  onCopyToClipboard={async () => {
311
435
  await onCopyToClipboard();
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Tests for CSV data extractors
3
+ */
4
+ import {
5
+ type CSVExportOptions,
6
+ extractCSVData,
7
+ supportsCSVExport,
8
+ } from "./extractors";
9
+
10
+ describe("supportsCSVExport", () => {
11
+ test("should return true for supported run types", () => {
12
+ const supportedTypes = [
13
+ "query",
14
+ "query_base",
15
+ "query_diff",
16
+ "profile",
17
+ "profile_diff",
18
+ "row_count",
19
+ "row_count_diff",
20
+ "value_diff",
21
+ "value_diff_detail",
22
+ "top_k_diff",
23
+ ];
24
+
25
+ for (const type of supportedTypes) {
26
+ expect(supportsCSVExport(type)).toBe(true);
27
+ }
28
+ });
29
+
30
+ test("should return false for unsupported run types", () => {
31
+ expect(supportsCSVExport("unknown")).toBe(false);
32
+ expect(supportsCSVExport("lineage")).toBe(false);
33
+ expect(supportsCSVExport("")).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("extractCSVData", () => {
38
+ describe("query extractor", () => {
39
+ test("should extract DataFrame columns and rows", () => {
40
+ const result = {
41
+ columns: [
42
+ { key: "id", name: "id", type: "integer" },
43
+ { key: "name", name: "name", type: "string" },
44
+ ],
45
+ data: [
46
+ [1, "Alice"],
47
+ [2, "Bob"],
48
+ ],
49
+ };
50
+
51
+ const csvData = extractCSVData("query", result);
52
+
53
+ expect(csvData).not.toBeNull();
54
+ expect(csvData?.columns).toEqual(["id", "name"]);
55
+ expect(csvData?.rows).toEqual([
56
+ [1, "Alice"],
57
+ [2, "Bob"],
58
+ ]);
59
+ });
60
+
61
+ test("should return null for empty result", () => {
62
+ expect(extractCSVData("query", null)).toBeNull();
63
+ expect(extractCSVData("query", undefined)).toBeNull();
64
+ expect(extractCSVData("query", {})).toBeNull();
65
+ });
66
+
67
+ test("should return null for missing columns or data", () => {
68
+ expect(extractCSVData("query", { columns: [] })).toBeNull();
69
+ expect(extractCSVData("query", { data: [] })).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("query_diff extractor", () => {
74
+ describe("inline mode (default)", () => {
75
+ test("should merge rows with same values", () => {
76
+ const result = {
77
+ base: {
78
+ columns: [
79
+ { key: "id", name: "id", type: "integer" },
80
+ { key: "value", name: "value", type: "integer" },
81
+ ],
82
+ data: [
83
+ [1, 100],
84
+ [2, 200],
85
+ ],
86
+ },
87
+ current: {
88
+ columns: [
89
+ { key: "id", name: "id", type: "integer" },
90
+ { key: "value", name: "value", type: "integer" },
91
+ ],
92
+ data: [
93
+ [1, 100],
94
+ [2, 200],
95
+ ],
96
+ },
97
+ };
98
+
99
+ const csvData = extractCSVData("query_diff", result);
100
+
101
+ expect(csvData).not.toBeNull();
102
+ expect(csvData?.columns).toEqual(["id", "value"]);
103
+ // Same values should be shown as-is
104
+ expect(csvData?.rows).toEqual([
105
+ [1, 100],
106
+ [2, 200],
107
+ ]);
108
+ });
109
+
110
+ test("should show diff format for different values", () => {
111
+ const result = {
112
+ base: {
113
+ columns: [
114
+ { key: "id", name: "id", type: "integer" },
115
+ { key: "value", name: "value", type: "integer" },
116
+ ],
117
+ data: [[1, 100]],
118
+ },
119
+ current: {
120
+ columns: [
121
+ { key: "id", name: "id", type: "integer" },
122
+ { key: "value", name: "value", type: "integer" },
123
+ ],
124
+ data: [[1, 150]],
125
+ },
126
+ };
127
+
128
+ const csvData = extractCSVData("query_diff", result);
129
+
130
+ expect(csvData).not.toBeNull();
131
+ expect(csvData?.columns).toEqual(["id", "value"]);
132
+ // Different values should show "(base) (current)" format
133
+ expect(csvData?.rows).toEqual([[1, "(100) (150)"]]);
134
+ });
135
+
136
+ test("should handle null values in diff", () => {
137
+ const result = {
138
+ base: {
139
+ columns: [{ key: "value", name: "value", type: "integer" }],
140
+ data: [[null]],
141
+ },
142
+ current: {
143
+ columns: [{ key: "value", name: "value", type: "integer" }],
144
+ data: [[100]],
145
+ },
146
+ };
147
+
148
+ const csvData = extractCSVData("query_diff", result);
149
+
150
+ expect(csvData?.rows).toEqual([["(100)"]]);
151
+ });
152
+ });
153
+
154
+ describe("side_by_side mode", () => {
155
+ test("should create base__ and current__ columns", () => {
156
+ const result = {
157
+ base: {
158
+ columns: [
159
+ { key: "id", name: "id", type: "integer" },
160
+ { key: "value", name: "value", type: "integer" },
161
+ ],
162
+ data: [[1, 100]],
163
+ },
164
+ current: {
165
+ columns: [
166
+ { key: "id", name: "id", type: "integer" },
167
+ { key: "value", name: "value", type: "integer" },
168
+ ],
169
+ data: [[1, 150]],
170
+ },
171
+ };
172
+
173
+ const options: CSVExportOptions = { displayMode: "side_by_side" };
174
+ const csvData = extractCSVData("query_diff", result, options);
175
+
176
+ expect(csvData).not.toBeNull();
177
+ expect(csvData?.columns).toEqual([
178
+ "base__id",
179
+ "current__id",
180
+ "base__value",
181
+ "current__value",
182
+ ]);
183
+ expect(csvData?.rows).toEqual([[1, 1, 100, 150]]);
184
+ });
185
+
186
+ test("should handle different row counts", () => {
187
+ const result = {
188
+ base: {
189
+ columns: [{ key: "id", name: "id", type: "integer" }],
190
+ data: [[1], [2], [3]],
191
+ },
192
+ current: {
193
+ columns: [{ key: "id", name: "id", type: "integer" }],
194
+ data: [[1]],
195
+ },
196
+ };
197
+
198
+ const options: CSVExportOptions = { displayMode: "side_by_side" };
199
+ const csvData = extractCSVData("query_diff", result, options);
200
+
201
+ expect(csvData?.rows).toEqual([
202
+ [1, 1],
203
+ [2, null],
204
+ [3, null],
205
+ ]);
206
+ });
207
+ });
208
+
209
+ describe("joined diff (with primary keys)", () => {
210
+ test("should group rows by primary key in inline mode", () => {
211
+ const result = {
212
+ diff: {
213
+ columns: [
214
+ { key: "id", name: "id", type: "integer" },
215
+ { key: "value", name: "value", type: "integer" },
216
+ { key: "in_a", name: "in_a", type: "boolean" },
217
+ { key: "in_b", name: "in_b", type: "boolean" },
218
+ ],
219
+ data: [
220
+ [1, 100, true, false], // base only
221
+ [1, 150, false, true], // current only
222
+ ],
223
+ },
224
+ };
225
+
226
+ const options: CSVExportOptions = { primaryKeys: ["id"] };
227
+ const csvData = extractCSVData("query_diff", result, options);
228
+
229
+ expect(csvData).not.toBeNull();
230
+ expect(csvData?.columns).toEqual(["id", "value"]);
231
+ // Should merge rows with same primary key
232
+ expect(csvData?.rows).toEqual([[1, "(100) (150)"]]);
233
+ });
234
+
235
+ test("should handle side_by_side mode with primary keys", () => {
236
+ const result = {
237
+ diff: {
238
+ columns: [
239
+ { key: "id", name: "id", type: "integer" },
240
+ { key: "value", name: "value", type: "integer" },
241
+ { key: "in_a", name: "in_a", type: "boolean" },
242
+ { key: "in_b", name: "in_b", type: "boolean" },
243
+ ],
244
+ data: [
245
+ [1, 100, true, false],
246
+ [1, 150, false, true],
247
+ ],
248
+ },
249
+ };
250
+
251
+ const options: CSVExportOptions = {
252
+ displayMode: "side_by_side",
253
+ primaryKeys: ["id"],
254
+ };
255
+ const csvData = extractCSVData("query_diff", result, options);
256
+
257
+ expect(csvData?.columns).toEqual([
258
+ "base__id",
259
+ "current__id",
260
+ "base__value",
261
+ "current__value",
262
+ ]);
263
+ expect(csvData?.rows).toEqual([[1, 1, 100, 150]]);
264
+ });
265
+ });
266
+
267
+ test("should return single DataFrame if only one exists", () => {
268
+ const result = {
269
+ current: {
270
+ columns: [{ key: "id", name: "id", type: "integer" }],
271
+ data: [[1], [2]],
272
+ },
273
+ };
274
+
275
+ const csvData = extractCSVData("query_diff", result);
276
+
277
+ expect(csvData?.columns).toEqual(["id"]);
278
+ expect(csvData?.rows).toEqual([[1], [2]]);
279
+ });
280
+ });
281
+
282
+ describe("row_count_diff extractor", () => {
283
+ test("should extract node counts with calculated diff", () => {
284
+ const result = {
285
+ model_a: { base: 100, curr: 120 },
286
+ model_b: { base: 200, curr: 180 },
287
+ };
288
+
289
+ const csvData = extractCSVData("row_count_diff", result);
290
+
291
+ expect(csvData).not.toBeNull();
292
+ expect(csvData?.columns).toEqual([
293
+ "node",
294
+ "base_count",
295
+ "current_count",
296
+ "diff",
297
+ "diff_percent",
298
+ ]);
299
+ expect(csvData?.rows).toContainEqual(["model_a", 100, 120, 20, "20.00%"]);
300
+ expect(csvData?.rows).toContainEqual([
301
+ "model_b",
302
+ 200,
303
+ 180,
304
+ -20,
305
+ "-10.00%",
306
+ ]);
307
+ });
308
+
309
+ test("should handle null counts", () => {
310
+ const result = {
311
+ model_a: { base: null, curr: 100 },
312
+ };
313
+
314
+ const csvData = extractCSVData("row_count_diff", result);
315
+
316
+ expect(csvData?.rows).toContainEqual(["model_a", null, 100, null, null]);
317
+ });
318
+ });
319
+
320
+ describe("profile_diff extractor", () => {
321
+ test("should combine base and current with source column", () => {
322
+ const result = {
323
+ base: {
324
+ columns: [
325
+ { key: "column", name: "column", type: "string" },
326
+ { key: "count", name: "count", type: "integer" },
327
+ ],
328
+ data: [["col1", 100]],
329
+ },
330
+ current: {
331
+ columns: [
332
+ { key: "column", name: "column", type: "string" },
333
+ { key: "count", name: "count", type: "integer" },
334
+ ],
335
+ data: [["col1", 150]],
336
+ },
337
+ };
338
+
339
+ const csvData = extractCSVData("profile_diff", result);
340
+
341
+ expect(csvData).not.toBeNull();
342
+ expect(csvData?.columns).toEqual(["_source", "column", "count"]);
343
+ expect(csvData?.rows).toEqual([
344
+ ["base", "col1", 100],
345
+ ["current", "col1", 150],
346
+ ]);
347
+ });
348
+ });
349
+
350
+ describe("value_diff extractor", () => {
351
+ test("should extract data from nested data property", () => {
352
+ const result = {
353
+ data: {
354
+ columns: [
355
+ { key: "pk", name: "pk", type: "string" },
356
+ { key: "status", name: "status", type: "string" },
357
+ ],
358
+ data: [
359
+ ["key1", "modified"],
360
+ ["key2", "added"],
361
+ ],
362
+ },
363
+ };
364
+
365
+ const csvData = extractCSVData("value_diff", result);
366
+
367
+ expect(csvData?.columns).toEqual(["pk", "status"]);
368
+ expect(csvData?.rows).toEqual([
369
+ ["key1", "modified"],
370
+ ["key2", "added"],
371
+ ]);
372
+ });
373
+ });
374
+
375
+ describe("top_k_diff extractor", () => {
376
+ test("should extract values and counts from base and current", () => {
377
+ const result = {
378
+ base: {
379
+ values: ["a", "b"],
380
+ counts: [10, 5],
381
+ valids: 15,
382
+ },
383
+ current: {
384
+ values: ["a", "c"],
385
+ counts: [12, 3],
386
+ valids: 15,
387
+ },
388
+ };
389
+
390
+ const csvData = extractCSVData("top_k_diff", result);
391
+
392
+ expect(csvData).not.toBeNull();
393
+ expect(csvData?.columns).toEqual(["_source", "value", "count"]);
394
+ expect(csvData?.rows).toEqual([
395
+ ["base", "a", 10],
396
+ ["base", "b", 5],
397
+ ["current", "a", 12],
398
+ ["current", "c", 3],
399
+ ]);
400
+ });
401
+
402
+ test("should handle missing base or current", () => {
403
+ const result = {
404
+ base: null,
405
+ current: {
406
+ values: ["a"],
407
+ counts: [10],
408
+ valids: 10,
409
+ },
410
+ };
411
+
412
+ const csvData = extractCSVData("top_k_diff", result);
413
+
414
+ expect(csvData?.rows).toEqual([["current", "a", 10]]);
415
+ });
416
+
417
+ test("should return null when both base and current are missing values", () => {
418
+ const result = {
419
+ base: { values: null, counts: [], valids: 0 },
420
+ current: { values: null, counts: [], valids: 0 },
421
+ };
422
+
423
+ const csvData = extractCSVData("top_k_diff", result);
424
+
425
+ expect(csvData).toBeNull();
426
+ });
427
+ });
428
+
429
+ describe("error handling", () => {
430
+ test("should return null for unsupported run type", () => {
431
+ const csvData = extractCSVData("unsupported", { some: "data" });
432
+
433
+ expect(csvData).toBeNull();
434
+ });
435
+
436
+ test("should return null and log error for malformed data", () => {
437
+ const consoleSpy = jest
438
+ .spyOn(console, "error")
439
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional mock
440
+ .mockImplementation(() => {});
441
+
442
+ // Pass data that will cause an error in the extractor
443
+ const result = {
444
+ base: "invalid", // Should be object with columns/data
445
+ current: "invalid",
446
+ };
447
+
448
+ const csvData = extractCSVData("query_diff", result);
449
+
450
+ // Should not throw, just return null
451
+ expect(csvData).toBeNull();
452
+
453
+ consoleSpy.mockRestore();
454
+ });
455
+ });
456
+ });