@lightdash/cli 2.228.1 → 2.229.0

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 (177) hide show
  1. package/dist/.tsbuildinfo.build +1 -0
  2. package/package.json +4 -4
  3. package/dist/.tsbuildinfo +0 -1
  4. package/dist/config.test.d.ts +0 -2
  5. package/dist/config.test.d.ts.map +0 -1
  6. package/dist/config.test.js +0 -72
  7. package/dist/dbt/context.test.d.ts +0 -2
  8. package/dist/dbt/context.test.d.ts.map +0 -1
  9. package/dist/dbt/context.test.js +0 -152
  10. package/dist/dbt/manifest.test.d.ts +0 -2
  11. package/dist/dbt/manifest.test.d.ts.map +0 -1
  12. package/dist/dbt/manifest.test.js +0 -170
  13. package/dist/dbt/models.test.d.ts +0 -2
  14. package/dist/dbt/models.test.d.ts.map +0 -1
  15. package/dist/dbt/models.test.js +0 -139
  16. package/dist/dbt/profiles.test.d.ts +0 -2
  17. package/dist/dbt/profiles.test.d.ts.map +0 -1
  18. package/dist/dbt/profiles.test.js +0 -50
  19. package/dist/dbt/targets/athena.test.d.ts +0 -2
  20. package/dist/dbt/targets/athena.test.d.ts.map +0 -1
  21. package/dist/dbt/targets/athena.test.js +0 -78
  22. package/dist/dbt/targets/duckdb.test.d.ts +0 -2
  23. package/dist/dbt/targets/duckdb.test.d.ts.map +0 -1
  24. package/dist/dbt/targets/duckdb.test.js +0 -149
  25. package/dist/dbt/targets/redshift.test.d.ts +0 -2
  26. package/dist/dbt/targets/redshift.test.d.ts.map +0 -1
  27. package/dist/dbt/targets/redshift.test.js +0 -198
  28. package/dist/dbt/templating.test.d.ts +0 -2
  29. package/dist/dbt/templating.test.d.ts.map +0 -1
  30. package/dist/dbt/templating.test.js +0 -51
  31. package/dist/dbt/validation.test.d.ts +0 -2
  32. package/dist/dbt/validation.test.d.ts.map +0 -1
  33. package/dist/dbt/validation.test.js +0 -67
  34. package/dist/handlers/apps/appCodeFiles.test.d.ts +0 -2
  35. package/dist/handlers/apps/appCodeFiles.test.d.ts.map +0 -1
  36. package/dist/handlers/apps/appCodeFiles.test.js +0 -391
  37. package/dist/handlers/apps/appsDownload.test.d.ts +0 -2
  38. package/dist/handlers/apps/appsDownload.test.d.ts.map +0 -1
  39. package/dist/handlers/apps/appsDownload.test.js +0 -639
  40. package/dist/handlers/apps/build.test.d.ts +0 -2
  41. package/dist/handlers/apps/build.test.d.ts.map +0 -1
  42. package/dist/handlers/apps/build.test.js +0 -252
  43. package/dist/handlers/apps/createApp.test.d.ts +0 -2
  44. package/dist/handlers/apps/createApp.test.d.ts.map +0 -1
  45. package/dist/handlers/apps/createApp.test.js +0 -340
  46. package/dist/handlers/apps/pathContainment.test.d.ts +0 -2
  47. package/dist/handlers/apps/pathContainment.test.d.ts.map +0 -1
  48. package/dist/handlers/apps/pathContainment.test.js +0 -66
  49. package/dist/handlers/apps/preview.test.d.ts +0 -2
  50. package/dist/handlers/apps/preview.test.d.ts.map +0 -1
  51. package/dist/handlers/apps/preview.test.js +0 -353
  52. package/dist/handlers/apps/previewProxy.test.d.ts +0 -2
  53. package/dist/handlers/apps/previewProxy.test.d.ts.map +0 -1
  54. package/dist/handlers/apps/previewProxy.test.js +0 -178
  55. package/dist/handlers/apps/scaffolding.test.d.ts +0 -2
  56. package/dist/handlers/apps/scaffolding.test.d.ts.map +0 -1
  57. package/dist/handlers/apps/scaffolding.test.js +0 -151
  58. package/dist/handlers/apps/uploadRetry.test.d.ts +0 -2
  59. package/dist/handlers/apps/uploadRetry.test.d.ts.map +0 -1
  60. package/dist/handlers/apps/uploadRetry.test.js +0 -103
  61. package/dist/handlers/apps/validate.test.d.ts +0 -2
  62. package/dist/handlers/apps/validate.test.d.ts.map +0 -1
  63. package/dist/handlers/apps/validate.test.js +0 -526
  64. package/dist/handlers/compile.test.d.ts +0 -2
  65. package/dist/handlers/compile.test.d.ts.map +0 -1
  66. package/dist/handlers/compile.test.js +0 -151
  67. package/dist/handlers/compileProject.test.d.ts +0 -2
  68. package/dist/handlers/compileProject.test.d.ts.map +0 -1
  69. package/dist/handlers/compileProject.test.js +0 -637
  70. package/dist/handlers/compileWarehouseColumnValidation.test.d.ts +0 -2
  71. package/dist/handlers/compileWarehouseColumnValidation.test.d.ts.map +0 -1
  72. package/dist/handlers/compileWarehouseColumnValidation.test.js +0 -493
  73. package/dist/handlers/connectSnowflake.test.d.ts +0 -2
  74. package/dist/handlers/connectSnowflake.test.d.ts.map +0 -1
  75. package/dist/handlers/connectSnowflake.test.js +0 -320
  76. package/dist/handlers/contentAsCode/fileNames.test.d.ts +0 -2
  77. package/dist/handlers/contentAsCode/fileNames.test.d.ts.map +0 -1
  78. package/dist/handlers/contentAsCode/fileNames.test.js +0 -79
  79. package/dist/handlers/contentAsCode/registry.test.d.ts +0 -2
  80. package/dist/handlers/contentAsCode/registry.test.d.ts.map +0 -1
  81. package/dist/handlers/contentAsCode/registry.test.js +0 -18
  82. package/dist/handlers/contentAsCode/resource.test.d.ts +0 -2
  83. package/dist/handlers/contentAsCode/resource.test.d.ts.map +0 -1
  84. package/dist/handlers/contentAsCode/resource.test.js +0 -121
  85. package/dist/handlers/dbt/getDbtVersion.test.d.ts +0 -2
  86. package/dist/handlers/dbt/getDbtVersion.test.d.ts.map +0 -1
  87. package/dist/handlers/dbt/getDbtVersion.test.js +0 -106
  88. package/dist/handlers/deploy.test.d.ts +0 -2
  89. package/dist/handlers/deploy.test.d.ts.map +0 -1
  90. package/dist/handlers/deploy.test.js +0 -244
  91. package/dist/handlers/download.test.d.ts +0 -2
  92. package/dist/handlers/download.test.d.ts.map +0 -1
  93. package/dist/handlers/download.test.js +0 -1739
  94. package/dist/handlers/exportChartImage.test.d.ts +0 -2
  95. package/dist/handlers/exportChartImage.test.d.ts.map +0 -1
  96. package/dist/handlers/exportChartImage.test.js +0 -55
  97. package/dist/handlers/homepagesAsCode.test.d.ts +0 -2
  98. package/dist/handlers/homepagesAsCode.test.d.ts.map +0 -1
  99. package/dist/handlers/homepagesAsCode.test.js +0 -72
  100. package/dist/handlers/lint.test.d.ts +0 -2
  101. package/dist/handlers/lint.test.d.ts.map +0 -1
  102. package/dist/handlers/lint.test.js +0 -221
  103. package/dist/handlers/login.test.d.ts +0 -2
  104. package/dist/handlers/login.test.d.ts.map +0 -1
  105. package/dist/handlers/login.test.js +0 -24
  106. package/dist/handlers/metadataFile.test.d.ts +0 -2
  107. package/dist/handlers/metadataFile.test.d.ts.map +0 -1
  108. package/dist/handlers/metadataFile.test.js +0 -46
  109. package/dist/handlers/organizationContent/customRoles.test.d.ts +0 -2
  110. package/dist/handlers/organizationContent/customRoles.test.d.ts.map +0 -1
  111. package/dist/handlers/organizationContent/customRoles.test.js +0 -179
  112. package/dist/handlers/organizationContent/groups.test.d.ts +0 -2
  113. package/dist/handlers/organizationContent/groups.test.d.ts.map +0 -1
  114. package/dist/handlers/organizationContent/groups.test.js +0 -135
  115. package/dist/handlers/organizationContent/index.test.d.ts +0 -2
  116. package/dist/handlers/organizationContent/index.test.d.ts.map +0 -1
  117. package/dist/handlers/organizationContent/index.test.js +0 -212
  118. package/dist/handlers/organizationContent/themes.test.d.ts +0 -2
  119. package/dist/handlers/organizationContent/themes.test.d.ts.map +0 -1
  120. package/dist/handlers/organizationContent/themes.test.js +0 -180
  121. package/dist/handlers/organizationContent/users.test.d.ts +0 -2
  122. package/dist/handlers/organizationContent/users.test.d.ts.map +0 -1
  123. package/dist/handlers/organizationContent/users.test.js +0 -155
  124. package/dist/handlers/preAggregateAudit.test.d.ts +0 -2
  125. package/dist/handlers/preAggregateAudit.test.d.ts.map +0 -1
  126. package/dist/handlers/preAggregateAudit.test.js +0 -102
  127. package/dist/handlers/renameHandler.test.d.ts +0 -2
  128. package/dist/handlers/renameHandler.test.d.ts.map +0 -1
  129. package/dist/handlers/renameHandler.test.js +0 -189
  130. package/dist/handlers/resolveProjectFlag.test.d.ts +0 -2
  131. package/dist/handlers/resolveProjectFlag.test.d.ts.map +0 -1
  132. package/dist/handlers/resolveProjectFlag.test.js +0 -48
  133. package/dist/handlers/selectProject.test.d.ts +0 -2
  134. package/dist/handlers/selectProject.test.d.ts.map +0 -1
  135. package/dist/handlers/selectProject.test.js +0 -224
  136. package/dist/handlers/setProject.test.d.ts +0 -2
  137. package/dist/handlers/setProject.test.d.ts.map +0 -1
  138. package/dist/handlers/setProject.test.js +0 -63
  139. package/dist/handlers/slugUpdate.test.d.ts +0 -2
  140. package/dist/handlers/slugUpdate.test.d.ts.map +0 -1
  141. package/dist/handlers/slugUpdate.test.js +0 -265
  142. package/dist/handlers/timestampConversion.test.d.ts +0 -2
  143. package/dist/handlers/timestampConversion.test.d.ts.map +0 -1
  144. package/dist/handlers/timestampConversion.test.js +0 -87
  145. package/dist/handlers/upgradeCheck.test.d.ts +0 -2
  146. package/dist/handlers/upgradeCheck.test.d.ts.map +0 -1
  147. package/dist/handlers/upgradeCheck.test.js +0 -227
  148. package/dist/handlers/validate.test.d.ts +0 -2
  149. package/dist/handlers/validate.test.d.ts.map +0 -1
  150. package/dist/handlers/validate.test.js +0 -318
  151. package/dist/handlers/validateSpaceFilter.test.d.ts +0 -2
  152. package/dist/handlers/validateSpaceFilter.test.d.ts.map +0 -1
  153. package/dist/handlers/validateSpaceFilter.test.js +0 -79
  154. package/dist/handlers/warehouseConnection.test.d.ts +0 -2
  155. package/dist/handlers/warehouseConnection.test.d.ts.map +0 -1
  156. package/dist/handlers/warehouseConnection.test.js +0 -75
  157. package/dist/lightdash-config/lightdash-config.test.d.ts +0 -2
  158. package/dist/lightdash-config/lightdash-config.test.d.ts.map +0 -1
  159. package/dist/lightdash-config/lightdash-config.test.js +0 -69
  160. package/dist/prompts/fuzzySearchPrompt.test.d.ts +0 -2
  161. package/dist/prompts/fuzzySearchPrompt.test.d.ts.map +0 -1
  162. package/dist/prompts/fuzzySearchPrompt.test.js +0 -53
  163. package/dist/refsArgument.test.d.ts +0 -2
  164. package/dist/refsArgument.test.d.ts.map +0 -1
  165. package/dist/refsArgument.test.js +0 -42
  166. package/dist/releaseSafety/index.test.d.ts +0 -2
  167. package/dist/releaseSafety/index.test.d.ts.map +0 -1
  168. package/dist/releaseSafety/index.test.js +0 -154
  169. package/dist/releaseSafety/version.test.d.ts +0 -2
  170. package/dist/releaseSafety/version.test.d.ts.map +0 -1
  171. package/dist/releaseSafety/version.test.js +0 -22
  172. package/dist/skills.test.d.ts +0 -2
  173. package/dist/skills.test.d.ts.map +0 -1
  174. package/dist/skills.test.js +0 -112
  175. package/dist/terminal/contentAsCodeOutput.test.d.ts +0 -2
  176. package/dist/terminal/contentAsCodeOutput.test.d.ts.map +0 -1
  177. package/dist/terminal/contentAsCodeOutput.test.js +0 -231
@@ -1,1739 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const tslib_1 = require("tslib");
4
- const common_1 = require("@lightdash/common");
5
- const fs_1 = require("fs");
6
- const os = tslib_1.__importStar(require("os"));
7
- const path = tslib_1.__importStar(require("path"));
8
- const vitest_1 = require("vitest");
9
- const analytics_1 = require("../analytics/analytics");
10
- const globalState_1 = tslib_1.__importDefault(require("../globalState"));
11
- const appsDownload_1 = require("./apps/appsDownload");
12
- const apiClient_1 = require("./dbt/apiClient");
13
- const download_1 = require("./download");
14
- const spacesAsCode_1 = require("./spacesAsCode");
15
- vitest_1.vi.mock('../analytics/analytics', () => ({
16
- LightdashAnalytics: {
17
- track: vitest_1.vi.fn(),
18
- },
19
- }));
20
- vitest_1.vi.mock('../config', () => ({
21
- getConfig: vitest_1.vi.fn().mockResolvedValue({
22
- user: {
23
- userUuid: 'user-uuid',
24
- organizationUuid: 'organization-uuid',
25
- },
26
- context: {
27
- apiKey: 'api-key',
28
- serverUrl: 'http://lightdash.test',
29
- project: 'project-uuid',
30
- projectName: 'Test project',
31
- },
32
- answers: {
33
- metadataFileGitignoreNoticeShown: true,
34
- },
35
- }),
36
- setAnswer: vitest_1.vi.fn(),
37
- }));
38
- vitest_1.vi.mock('./dbt/apiClient', async (importOriginal) => ({
39
- ...(await importOriginal()),
40
- getContentAsCodeUploadPermissions: vitest_1.vi.fn(),
41
- lightdashApi: vitest_1.vi.fn(),
42
- }));
43
- const { assertUniqueSpacePaths, downloadLinkedVirtualViews, downloadSpaces, extractChartTableNames, extractChartTypeRefsFromCharts, getDashboardAppSlugs, getDashboardChartSlugs, getFlatSpaceFileNames, hasContentFilters, isAiAgentsUnavailableError, isExternalConnectionsUnavailableError, isVirtualViewsUnavailableError, countChangeDelta, downloadAiAgents, isFilteredWithNoDashboards, readAiAgentFiles, readSpaceFiles, readSpaceNames, reportOpenDraftsForUpload, sanitizeChartForDownload, shouldFallBackToEmbeddedSpaces, shouldDownloadAiAgents, summarizeUploadChanges, upsertAiAgents, upsertExternalConnections, upsertResources, upsertSpaces, upsertVirtualViews, validateSpaceIdentity, writeSpaceFiles, } = download_1.testHelpers;
44
- describe('reportOpenDraftsForUpload', () => {
45
- beforeEach(() => {
46
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
47
- vitest_1.vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
48
- });
49
- afterEach(() => {
50
- vitest_1.vi.restoreAllMocks();
51
- });
52
- it('stays quiet when the project has no open drafts', async () => {
53
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
54
- openDraftCount: 0,
55
- });
56
- await reportOpenDraftsForUpload('project-uuid');
57
- expect(apiClient_1.lightdashApi).toHaveBeenCalledWith({
58
- method: 'GET',
59
- url: '/api/v1/projects/project-uuid/code/upload-advisory',
60
- body: undefined,
61
- });
62
- expect(globalState_1.default.log).not.toHaveBeenCalled();
63
- });
64
- it('warns without blocking when the project has multiple open drafts', async () => {
65
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
66
- openDraftCount: 3,
67
- });
68
- await expect(reportOpenDraftsForUpload('project-uuid')).resolves.toBeUndefined();
69
- expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('3 open content drafts'));
70
- expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('Upload will continue'));
71
- });
72
- it('warns without blocking when the draft count cannot be loaded', async () => {
73
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(new Error('draft table unavailable'));
74
- await expect(reportOpenDraftsForUpload('project-uuid')).resolves.toBeUndefined();
75
- expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('Could not check for open content drafts'));
76
- expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('Upload will continue'));
77
- });
78
- });
79
- const makeDownloadHandlerOptions = (overrides = {}) => ({
80
- verbose: false,
81
- charts: [],
82
- dashboards: [],
83
- alerts: [],
84
- agents: [],
85
- googleSheets: [],
86
- scheduledDeliveries: [],
87
- virtualViews: [],
88
- externalConnections: [],
89
- apps: [],
90
- includeAgents: false,
91
- includeApps: false,
92
- force: false,
93
- languageMap: false,
94
- skipSpaceCreate: false,
95
- public: false,
96
- includeCharts: false,
97
- nested: false,
98
- rootSpaces: false,
99
- skipSpaces: true,
100
- skipCharts: true,
101
- skipDashboards: true,
102
- skipAlerts: false,
103
- skipAgents: false,
104
- skipGoogleSheets: false,
105
- skipScheduledDeliveries: false,
106
- skipVirtualViews: false,
107
- skipExternalConnections: false,
108
- includeAlerts: false,
109
- includeGoogleSheets: false,
110
- includeScheduledDeliveries: false,
111
- includeVirtualViews: false,
112
- includeExternalConnections: false,
113
- includeAll: false,
114
- stripPivotSeries: false,
115
- concurrency: 1,
116
- organization: false,
117
- ...overrides,
118
- });
119
- const makeContentFilterOptions = (overrides = {}) => ({
120
- spacesOnly: false,
121
- charts: [],
122
- dashboards: [],
123
- agents: [],
124
- alerts: [],
125
- googleSheets: [],
126
- scheduledDeliveries: [],
127
- virtualViews: [],
128
- externalConnections: [],
129
- apps: [],
130
- ...overrides,
131
- });
132
- describe('hasContentFilters', () => {
133
- it('treats explicit homepage names as a filtered download and upload', () => {
134
- expect(hasContentFilters(makeContentFilterOptions({ homepages: ['Team homepage'] }))).toBe(true);
135
- });
136
- it('treats explicit apps as a filtered download and upload', () => {
137
- expect(hasContentFilters(makeContentFilterOptions({ apps: ['app-reference'] }))).toBe(true);
138
- });
139
- it('treats explicit external connections as a filtered upload', () => {
140
- expect(hasContentFilters(makeContentFilterOptions({ externalConnections: ['stripe'] }))).toBe(true);
141
- });
142
- it('keeps an unfiltered content operation unfiltered', () => {
143
- expect(hasContentFilters(makeContentFilterOptions())).toBe(false);
144
- });
145
- it('does not treat content selections as filters in spaces-only mode', () => {
146
- expect(hasContentFilters(makeContentFilterOptions({
147
- spacesOnly: true,
148
- apps: ['app-reference'],
149
- }))).toBe(false);
150
- });
151
- });
152
- describe('data app upload loop filter wiring', () => {
153
- const makeManifest = (overrides = {}) => ({
154
- codeVersion: 1,
155
- appUuid: 'app-uuid-unmatched',
156
- projectUuid: 'proj-uuid-1',
157
- version: 1,
158
- name: 'App',
159
- description: '',
160
- template: null,
161
- downloadedAt: '2026-06-25T00:00:00.000Z',
162
- ...overrides,
163
- });
164
- it('skips a folder matching neither slug nor uuid, and uploads one matched by slug', () => {
165
- // Mirrors the --apps <appReferences...> wiring in the upload loop:
166
- // the filter is built once from the CLI-supplied references, then
167
- // matched per folder via uploadFilterMatches.
168
- const uploadFilter = (0, appsDownload_1.getDataAppUploadFilter)(['sales-app'], false);
169
- const unmatchedFolder = makeManifest({
170
- appUuid: 'other-uuid',
171
- slug: 'other-slug',
172
- });
173
- const matchedBySlugFolder = makeManifest({
174
- appUuid: 'matched-uuid',
175
- slug: 'sales-app',
176
- });
177
- expect((0, appsDownload_1.uploadFilterMatches)(uploadFilter, unmatchedFolder)).toBe(false);
178
- expect((0, appsDownload_1.uploadFilterMatches)(uploadFilter, matchedBySlugFolder)).toBe(true);
179
- });
180
- });
181
- const makeLooseDashboard = (slug, chartSlugs) => ({
182
- slug,
183
- name: slug,
184
- spaceSlug: 'test-space',
185
- version: 1,
186
- tiles: chartSlugs.map((chartSlug) => ({
187
- properties: chartSlug ? { chartSlug } : {},
188
- })),
189
- needsUpdating: false,
190
- });
191
- const writeFolderDashboard = async (baseDir, slug, chartSlugs) => {
192
- const tilesYaml = chartSlugs
193
- .map((s) => ` - properties:\n chartSlug: ${s}\n type: saved_chart`)
194
- .join('\n');
195
- const yaml = `contentType: dashboard\nname: ${slug}\nslug: ${slug}\nspaceSlug: test-space\ntiles:\n${tilesYaml}\nversion: 1\n`;
196
- await fs_1.promises.writeFile(path.join(baseDir, 'dashboards', `${slug}.yml`), yaml);
197
- };
198
- const makeLooseAppDashboard = (slug, appSlugs) => ({
199
- slug,
200
- name: slug,
201
- spaceSlug: 'test-space',
202
- version: 1,
203
- tiles: appSlugs.map((appSlug) => ({
204
- type: 'data_app',
205
- properties: { appSlug },
206
- })),
207
- needsUpdating: false,
208
- });
209
- const writeFolderAppDashboard = async (baseDir, slug, appSlugs) => {
210
- const tilesYaml = appSlugs
211
- .map((s) => ` - properties:\n appSlug: ${s}\n type: data_app`)
212
- .join('\n');
213
- const yaml = `contentType: dashboard\nname: ${slug}\nslug: ${slug}\nspaceSlug: test-space\ntiles:\n${tilesYaml}\nversion: 1\n`;
214
- await fs_1.promises.writeFile(path.join(baseDir, 'dashboards', `${slug}.yml`), yaml);
215
- };
216
- describe('summarizeUploadChanges', () => {
217
- it('reports only phase deltas and warns when resources fail', () => {
218
- expect(summarizeUploadChanges({ 'charts created': 1, 'spaces skipped': 3 }, {
219
- 'charts created': 3,
220
- 'spaces skipped': 3,
221
- 'charts skipped': 2,
222
- 'charts with errors': 1,
223
- })).toEqual({
224
- detail: '2 created, 2 skipped, 1 with errors',
225
- variant: 'warning',
226
- });
227
- });
228
- it('reports an empty phase without inventing a resource count', () => {
229
- expect(summarizeUploadChanges({}, {})).toEqual({
230
- detail: 'no changes',
231
- });
232
- });
233
- });
234
- const pivotedSeries = (pivotValue, overrides = {}) => ({
235
- type: common_1.CartesianSeriesType.BAR,
236
- encode: {
237
- xRef: { field: 'events_date_day' },
238
- yRef: {
239
- field: 'orders_count',
240
- pivotValues: [{ field: 'orders_status', value: pivotValue }],
241
- },
242
- x: 'events_date_day',
243
- y: `orders_count.orders_status.${pivotValue}`,
244
- },
245
- ...overrides,
246
- });
247
- const makeChart = (series) => ({
248
- name: 'pivoted chart',
249
- slug: 'pivoted-chart',
250
- spaceSlug: 'test-space',
251
- tableName: 'orders',
252
- version: 1,
253
- metricQuery: {
254
- additionalMetrics: [],
255
- customDimensions: [],
256
- dimensionOverrides: {},
257
- dimensions: ['orders_status', 'events_date_day'],
258
- exploreName: 'orders',
259
- filters: {},
260
- limit: 500,
261
- metricOverrides: {},
262
- metrics: ['orders_count'],
263
- sorts: [],
264
- tableCalculations: [],
265
- timezone: 'project_timezone',
266
- },
267
- chartConfig: {
268
- type: common_1.ChartType.CARTESIAN,
269
- config: {
270
- layout: {
271
- xField: 'events_date_day',
272
- yField: ['orders_count'],
273
- },
274
- eChartsConfig: {
275
- series,
276
- },
277
- },
278
- },
279
- pivotConfig: {
280
- columns: ['orders_status'],
281
- },
282
- dashboardSlug: undefined,
283
- });
284
- const writeFolderChart = async (baseDir, slug) => {
285
- const yaml = `contentType: chart\nname: ${slug}\nslug: ${slug}\nspaceSlug: test-space\ntableName: orders\nversion: 1\n`;
286
- await fs_1.promises.writeFile(path.join(baseDir, 'charts', `${slug}.yml`), yaml);
287
- };
288
- describe('upload summary counts', () => {
289
- let tmpDir;
290
- beforeEach(async () => {
291
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
292
- vitest_1.vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
293
- vitest_1.vi.spyOn(globalState_1.default, 'debug').mockImplementation(() => undefined);
294
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'download-test-'));
295
- await fs_1.promises.mkdir(path.join(tmpDir, 'charts'));
296
- await fs_1.promises.mkdir(path.join(tmpDir, 'dashboards'));
297
- });
298
- afterEach(async () => {
299
- vitest_1.vi.restoreAllMocks();
300
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
301
- });
302
- it('counts only the uploaded type, not the spaces and tiles echoed by the API', async () => {
303
- await writeFolderChart(tmpDir, 'chart-a');
304
- await writeFolderChart(tmpDir, 'chart-b');
305
- await writeFolderDashboard(tmpDir, 'dash', ['chart-a', 'chart-b']);
306
- const noChanges = { action: common_1.PromotionAction.NO_CHANGES };
307
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockImplementation(async ({ url }) => url.includes('/code/charts/')
308
- ? {
309
- charts: [{ action: common_1.PromotionAction.UPDATE }],
310
- spaces: [noChanges],
311
- dashboards: [],
312
- }
313
- : {
314
- dashboards: [{ action: common_1.PromotionAction.UPDATE }],
315
- charts: [noChanges, noChanges],
316
- spaces: [noChanges],
317
- });
318
- const summarySpy = vitest_1.vi
319
- .spyOn(console, 'info')
320
- .mockImplementation(() => undefined);
321
- const changes = {};
322
- const chartsResult = await upsertResources('charts', 'project-uuid', changes, false, [], true, tmpDir);
323
- const afterCharts = { ...chartsResult.changes };
324
- expect(summarizeUploadChanges({}, afterCharts).detail).toBe('2 updated');
325
- const dashboardsResult = await upsertResources('dashboards', 'project-uuid', changes, false, [], true, tmpDir);
326
- expect(summarizeUploadChanges(afterCharts, dashboardsResult.changes)
327
- .detail).toBe('1 updated');
328
- expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(3);
329
- expect(dashboardsResult.changes).toEqual({
330
- 'charts updated': 2,
331
- 'dashboards updated': 1,
332
- });
333
- (0, spacesAsCode_1.logUploadChanges)(dashboardsResult.changes);
334
- expect(summarySpy.mock.calls.map(([message]) => message)).toEqual([
335
- 'Total charts updated: 2 ',
336
- 'Total dashboards updated: 1 ',
337
- ]);
338
- });
339
- });
340
- describe('getDashboardChartSlugs', () => {
341
- let tmpDir;
342
- beforeEach(async () => {
343
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'download-test-'));
344
- await fs_1.promises.mkdir(path.join(tmpDir, 'dashboards'));
345
- });
346
- afterEach(async () => {
347
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
348
- });
349
- it('extracts chart slugs from folder dashboards', async () => {
350
- await writeFolderDashboard(tmpDir, 'folder-dash', [
351
- 'chart-a',
352
- 'chart-b',
353
- ]);
354
- const slugs = await getDashboardChartSlugs([], tmpDir);
355
- expect(slugs.sort()).toEqual(['chart-a', 'chart-b']);
356
- });
357
- it('extracts chart slugs from loose dashboards (PROD-7869 regression)', async () => {
358
- const loose = makeLooseDashboard('loose-dash', [
359
- 'loose-chart-1',
360
- 'loose-chart-2',
361
- ]);
362
- const slugs = await getDashboardChartSlugs([], tmpDir, [loose]);
363
- expect(slugs.sort()).toEqual(['loose-chart-1', 'loose-chart-2']);
364
- });
365
- it('extracts chart slugs from both folder and loose dashboards when unioned', async () => {
366
- await writeFolderDashboard(tmpDir, 'folder-dash', ['folder-chart']);
367
- const loose = makeLooseDashboard('loose-dash', ['loose-chart']);
368
- const slugs = await getDashboardChartSlugs([], tmpDir, [loose]);
369
- expect(slugs.sort()).toEqual(['folder-chart', 'loose-chart']);
370
- });
371
- it('filters by slug across both folder and loose sources', async () => {
372
- await writeFolderDashboard(tmpDir, 'folder-dash', ['folder-chart']);
373
- const loose = makeLooseDashboard('loose-dash', ['loose-chart']);
374
- const onlyFolder = await getDashboardChartSlugs(['folder-dash'], tmpDir, [loose]);
375
- expect(onlyFolder).toEqual(['folder-chart']);
376
- const onlyLoose = await getDashboardChartSlugs(['loose-dash'], tmpDir, [
377
- loose,
378
- ]);
379
- expect(onlyLoose).toEqual(['loose-chart']);
380
- });
381
- it('returns empty array when slug filter matches no dashboards', async () => {
382
- await writeFolderDashboard(tmpDir, 'folder-dash', ['folder-chart']);
383
- const loose = makeLooseDashboard('loose-dash', ['loose-chart']);
384
- const slugs = await getDashboardChartSlugs(['nonexistent'], tmpDir, [
385
- loose,
386
- ]);
387
- expect(slugs).toEqual([]);
388
- });
389
- it('skips tiles without a chartSlug (e.g. markdown, loom)', async () => {
390
- const loose = makeLooseDashboard('loose-dash', [
391
- 'real-chart',
392
- undefined,
393
- undefined,
394
- ]);
395
- const slugs = await getDashboardChartSlugs([], tmpDir, [loose]);
396
- expect(slugs).toEqual(['real-chart']);
397
- });
398
- });
399
- describe('extractAppSlugsFromDashboards', () => {
400
- const dashboard = (tiles) => ({ slug: 'd', tiles });
401
- it('collects app slugs and ignores other tile types', () => {
402
- expect(download_1.testHelpers.extractAppSlugsFromDashboards([
403
- dashboard([
404
- {
405
- type: 'data_app',
406
- properties: { appSlug: 'revenue-explorer' },
407
- },
408
- {
409
- type: 'saved_chart',
410
- properties: { chartSlug: 'a-chart' },
411
- },
412
- { type: 'markdown', properties: { content: 'hi' } },
413
- ]),
414
- ])).toEqual(['revenue-explorer']);
415
- });
416
- it('dedupes and drops null slugs', () => {
417
- expect(download_1.testHelpers.extractAppSlugsFromDashboards([
418
- dashboard([
419
- { type: 'data_app', properties: { appSlug: 'one' } },
420
- { type: 'data_app', properties: { appSlug: 'one' } },
421
- { type: 'data_app', properties: { appSlug: null } },
422
- ]),
423
- ])).toEqual(['one']);
424
- });
425
- });
426
- describe('extractChartTableNames', () => {
427
- const chart = (tableName) => ({
428
- slug: 'a-chart',
429
- tableName,
430
- });
431
- it('dedupes table names across charts', () => {
432
- expect(extractChartTableNames([
433
- chart('orders'),
434
- chart('customers'),
435
- chart('orders'),
436
- ])).toEqual(['orders', 'customers']);
437
- });
438
- it('drops missing table names (e.g. SQL charts)', () => {
439
- expect(extractChartTableNames([chart(undefined), chart(''), chart('one')])).toEqual(['one']);
440
- });
441
- });
442
- describe('extractChartTypeRefsFromCharts', () => {
443
- const vizChart = (config) => ({
444
- slug: 'a-chart',
445
- chartConfig: { type: 'data_app_viz', config },
446
- });
447
- it('collects slugs from viz charts, deduped, ignoring other chart types', () => {
448
- expect(extractChartTypeRefsFromCharts([
449
- vizChart({ dataAppVizSlug: 'heatmap', fieldMapping: {} }),
450
- vizChart({ dataAppVizSlug: 'heatmap', fieldMapping: {} }),
451
- {
452
- slug: 'bar-chart',
453
- chartConfig: { type: 'cartesian', config: {} },
454
- },
455
- ])).toEqual(['heatmap']);
456
- });
457
- it('falls back to the legacy uuid and skips configless charts', () => {
458
- expect(extractChartTypeRefsFromCharts([
459
- vizChart({ dataAppVizUuid: 'viz-uuid', fieldMapping: {} }),
460
- vizChart(undefined),
461
- ])).toEqual(['viz-uuid']);
462
- });
463
- });
464
- describe('selectVirtualViewCandidates', () => {
465
- const chart = (slug, tableName) => ({
466
- slug,
467
- tableName,
468
- });
469
- it('collects table names of explicitly selected charts', () => {
470
- expect(download_1.testHelpers.selectVirtualViewCandidates({
471
- chartItems: [
472
- chart('wanted', 'my_virtual_view'),
473
- chart('other', 'other_view'),
474
- ],
475
- chartSlugs: ['wanted'],
476
- dashboardItems: [],
477
- dashboardSlugs: [],
478
- })).toEqual(['my_virtual_view']);
479
- });
480
- it('collects table names of the selected dashboards charts only', () => {
481
- expect(download_1.testHelpers.selectVirtualViewCandidates({
482
- chartItems: [
483
- chart('dash-chart', 'my_virtual_view'),
484
- chart('other-dash-chart', 'other_view'),
485
- ],
486
- chartSlugs: [],
487
- dashboardItems: [
488
- makeLooseDashboard('wanted-dash', ['dash-chart']),
489
- makeLooseDashboard('other-dash', ['other-dash-chart']),
490
- ],
491
- dashboardSlugs: ['wanted-dash'],
492
- })).toEqual(['my_virtual_view']);
493
- });
494
- it('ignores dashboards entirely when none are selected', () => {
495
- expect(download_1.testHelpers.selectVirtualViewCandidates({
496
- chartItems: [chart('dash-chart', 'my_virtual_view')],
497
- chartSlugs: [],
498
- dashboardItems: [
499
- makeLooseDashboard('some-dash', ['dash-chart']),
500
- ],
501
- dashboardSlugs: [],
502
- })).toEqual([]);
503
- });
504
- it('dedupes across sources and drops charts without a table name', () => {
505
- expect(download_1.testHelpers.selectVirtualViewCandidates({
506
- chartItems: [
507
- chart('a-chart', 'shared_view'),
508
- chart('b-chart', 'shared_view'),
509
- chart('sql-chart', undefined),
510
- ],
511
- chartSlugs: ['a-chart', 'sql-chart'],
512
- dashboardItems: [makeLooseDashboard('dash', ['b-chart'])],
513
- dashboardSlugs: ['dash'],
514
- })).toEqual(['shared_view']);
515
- });
516
- });
517
- describe('getDashboardAppSlugs', () => {
518
- let tmpDir;
519
- beforeEach(async () => {
520
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'download-test-'));
521
- await fs_1.promises.mkdir(path.join(tmpDir, 'dashboards'));
522
- });
523
- afterEach(async () => {
524
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
525
- });
526
- it('extracts app slugs from folder dashboards', async () => {
527
- await writeFolderAppDashboard(tmpDir, 'folder-dash', [
528
- 'app-a',
529
- 'app-b',
530
- ]);
531
- const slugs = await getDashboardAppSlugs([], tmpDir);
532
- expect(slugs.sort()).toEqual(['app-a', 'app-b']);
533
- });
534
- it('filters to the selected dashboards only', async () => {
535
- await writeFolderAppDashboard(tmpDir, 'wanted', ['wanted-app']);
536
- const loose = makeLooseAppDashboard('other', ['other-app']);
537
- const slugs = await getDashboardAppSlugs(['wanted'], tmpDir, [loose]);
538
- expect(slugs).toEqual(['wanted-app']);
539
- });
540
- it('dedupes across dashboards and drops null slugs', async () => {
541
- await writeFolderAppDashboard(tmpDir, 'folder-dash', ['shared-app']);
542
- const loose = makeLooseAppDashboard('loose-dash', ['shared-app', null]);
543
- const slugs = await getDashboardAppSlugs([], tmpDir, [loose]);
544
- expect(slugs).toEqual(['shared-app']);
545
- });
546
- it('returns an empty array when no dashboard has an app tile', async () => {
547
- await writeFolderDashboard(tmpDir, 'chart-only', ['chart-a']);
548
- const slugs = await getDashboardAppSlugs([], tmpDir);
549
- expect(slugs).toEqual([]);
550
- });
551
- });
552
- describe('isFilteredWithNoDashboards', () => {
553
- it('is true for a filtered upload that selects no dashboards (e.g. --charts only)', () => {
554
- expect(isFilteredWithNoDashboards(true, [])).toBe(true);
555
- });
556
- it('is false for an unfiltered upload even with no dashboard slugs', () => {
557
- expect(isFilteredWithNoDashboards(false, [])).toBe(false);
558
- });
559
- it('is false once dashboard slugs are provided', () => {
560
- expect(isFilteredWithNoDashboards(true, ['my-dashboard'])).toBe(false);
561
- });
562
- });
563
- describe('sanitizeChartForDownload', () => {
564
- it('preserves per-value pivot series customizations by default', () => {
565
- const chart = makeChart([
566
- pivotedSeries('completed', {
567
- name: 'Completed orders',
568
- color: '#1f77b4',
569
- isFilteredOut: false,
570
- }),
571
- pivotedSeries('returned', {
572
- name: 'Returned orders',
573
- color: '#d62728',
574
- isFilteredOut: true,
575
- }),
576
- ]);
577
- const result = sanitizeChartForDownload(chart, false);
578
- expect(result).toBe(chart);
579
- const series = result.chartConfig.config
580
- ?.eChartsConfig.series;
581
- expect(series).toHaveLength(2);
582
- expect(series?.[1]).toEqual(expect.objectContaining({
583
- name: 'Returned orders',
584
- color: '#d62728',
585
- isFilteredOut: true,
586
- }));
587
- expect(series?.[1].encode.yRef.pivotValues).toEqual([
588
- { field: 'orders_status', value: 'returned' },
589
- ]);
590
- });
591
- it('strips and dedupes per-value pivot series when requested', () => {
592
- const chart = makeChart([
593
- pivotedSeries('completed', {
594
- name: 'Completed orders',
595
- color: '#1f77b4',
596
- }),
597
- pivotedSeries('returned', {
598
- name: 'Returned orders',
599
- color: '#d62728',
600
- }),
601
- ]);
602
- const result = sanitizeChartForDownload(chart, true);
603
- expect(result).not.toBe(chart);
604
- const series = result.chartConfig.config
605
- ?.eChartsConfig.series;
606
- expect(series).toHaveLength(1);
607
- expect(series?.[0].name).toBeUndefined();
608
- expect(series?.[0].color).toBeUndefined();
609
- expect(series?.[0].encode).toEqual({
610
- xRef: { field: 'events_date_day' },
611
- yRef: { field: 'orders_count' },
612
- });
613
- });
614
- });
615
- describe('downloadLinkedVirtualViews', () => {
616
- let tmpDir;
617
- const virtualViewDocument = (slug) => ({
618
- contentType: common_1.ContentAsCodeType.VIRTUAL_VIEW,
619
- version: 1,
620
- slug,
621
- name: slug,
622
- sql: 'SELECT 1 AS order_id',
623
- columns: [{ reference: 'order_id', type: 'number' }],
624
- parameters: null,
625
- });
626
- beforeEach(async () => {
627
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
628
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'linked-virtual-views-test-'));
629
- });
630
- afterEach(async () => {
631
- vitest_1.vi.restoreAllMocks();
632
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
633
- });
634
- it('writes matched virtual views and stays silent on regular dbt table names', async () => {
635
- const logSpy = vitest_1.vi
636
- .spyOn(globalState_1.default, 'log')
637
- .mockImplementation(() => undefined);
638
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
639
- virtualViews: [virtualViewDocument('my_virtual_view')],
640
- skipped: [],
641
- missingSlugs: ['orders', 'customers'],
642
- });
643
- const count = await downloadLinkedVirtualViews('project-uuid', ['my_virtual_view', 'orders', 'customers'], tmpDir);
644
- expect(count).toBe(1);
645
- expect(apiClient_1.lightdashApi).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
646
- url: '/api/v1/projects/project-uuid/code/virtualViews?slugs=my_virtual_view&slugs=orders&slugs=customers',
647
- }));
648
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'virtual-views', 'my_virtual_view.yml'), 'utf-8')).resolves.toContain('slug: my_virtual_view');
649
- expect(logSpy).not.toHaveBeenCalled();
650
- });
651
- it('warns about skipped virtual views', async () => {
652
- const logSpy = vitest_1.vi
653
- .spyOn(globalState_1.default, 'log')
654
- .mockImplementation(() => undefined);
655
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
656
- virtualViews: [],
657
- skipped: [{ slug: 'broken_view', reason: 'malformed' }],
658
- missingSlugs: [],
659
- });
660
- await expect(downloadLinkedVirtualViews('project-uuid', ['broken_view'], tmpDir)).resolves.toBe(0);
661
- expect(logSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Skipped virtual view "broken_view"'));
662
- });
663
- it('returns null when the server has no virtual views endpoint', async () => {
664
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(new common_1.LightdashError({
665
- message: 'Not found',
666
- name: 'NotFoundError',
667
- statusCode: 404,
668
- data: {},
669
- }));
670
- await expect(downloadLinkedVirtualViews('project-uuid', ['orders'], tmpDir)).resolves.toBeNull();
671
- });
672
- it('rethrows unexpected errors', async () => {
673
- const serverError = new common_1.LightdashError({
674
- message: 'Boom',
675
- name: 'UnexpectedServerError',
676
- statusCode: 500,
677
- data: {},
678
- });
679
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(serverError);
680
- await expect(downloadLinkedVirtualViews('project-uuid', ['orders'], tmpDir)).rejects.toBe(serverError);
681
- });
682
- it('classifies permission and missing-route errors as unavailable', () => {
683
- [403, 404].forEach((statusCode) => {
684
- expect(isVirtualViewsUnavailableError(new common_1.LightdashError({
685
- message: 'unavailable',
686
- name: 'TestError',
687
- statusCode,
688
- data: {},
689
- }))).toBe(true);
690
- });
691
- expect(isVirtualViewsUnavailableError(new Error('nope'))).toBe(false);
692
- });
693
- });
694
- describe('upsertVirtualViews', () => {
695
- let tmpDir;
696
- const virtualView = (slug) => `columns:
697
- - name: order_id
698
- contentType: virtual_view
699
- name: ${slug}
700
- parameters: null
701
- slug: ${slug}
702
- sql: SELECT 1 AS order_id
703
- version: 1
704
- `;
705
- beforeEach(async () => {
706
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
707
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'virtual-views-test-'));
708
- await fs_1.promises.mkdir(path.join(tmpDir, 'virtual-views'));
709
- });
710
- afterEach(async () => {
711
- vitest_1.vi.restoreAllMocks();
712
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
713
- });
714
- it('continues uploading after one virtual view is denied', async () => {
715
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'a-denied.yml'), virtualView('a-denied'));
716
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'b-allowed.yml'), virtualView('b-allowed'));
717
- vitest_1.vi.mocked(apiClient_1.lightdashApi)
718
- .mockRejectedValueOnce(new Error('Forbidden'))
719
- .mockResolvedValueOnce({ action: 'create' });
720
- const changes = await upsertVirtualViews('project-uuid', [], {}, false, true, tmpDir);
721
- expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
722
- expect(changes).toEqual({
723
- 'virtual views with errors': 1,
724
- 'virtual views created': 1,
725
- });
726
- });
727
- it('does not report missing permissions when there are no local virtual views', async () => {
728
- const logSpy = vitest_1.vi
729
- .spyOn(globalState_1.default, 'log')
730
- .mockImplementation(() => undefined);
731
- const changes = await upsertVirtualViews('project-uuid', [], {}, false, false, tmpDir);
732
- expect(changes).toEqual({});
733
- expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
734
- expect(logSpy).not.toHaveBeenCalled();
735
- });
736
- it('reports one category error without uploading when local virtual views are forbidden', async () => {
737
- const logSpy = vitest_1.vi
738
- .spyOn(globalState_1.default, 'log')
739
- .mockImplementation(() => undefined);
740
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'a-denied.yml'), virtualView('a-denied'));
741
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'b-denied.yml'), virtualView('b-denied'));
742
- const changes = await upsertVirtualViews('project-uuid', [], {}, false, false, tmpDir);
743
- expect(changes).toEqual({});
744
- expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
745
- expect(logSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Error uploading virtual views'));
746
- });
747
- it('uploads candidate matches only, silent on unmatched candidates', async () => {
748
- const logSpy = vitest_1.vi
749
- .spyOn(globalState_1.default, 'log')
750
- .mockImplementation(() => undefined);
751
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'my_view.yml'), virtualView('my_view'));
752
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'unrelated_view.yml'), virtualView('unrelated_view'));
753
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
754
- action: 'create',
755
- });
756
- const changes = await upsertVirtualViews('project-uuid', [], {}, false, true, tmpDir, ['my_view', 'orders', 'customers']);
757
- expect(changes).toEqual({ 'virtual views created': 1 });
758
- expect(apiClient_1.lightdashApi).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
759
- url: expect.stringContaining('/code/virtualViews/my_view'),
760
- }));
761
- expect(logSpy).not.toHaveBeenCalled();
762
- });
763
- it('still warns about explicit slugs missing locally when candidates are present', async () => {
764
- const logSpy = vitest_1.vi
765
- .spyOn(globalState_1.default, 'log')
766
- .mockImplementation(() => undefined);
767
- await fs_1.promises.writeFile(path.join(tmpDir, 'virtual-views', 'my_view.yml'), virtualView('my_view'));
768
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
769
- action: 'update',
770
- });
771
- const changes = await upsertVirtualViews('project-uuid', ['missing_view'], {}, false, true, tmpDir, ['my_view']);
772
- expect(changes).toEqual({ 'virtual views updated': 1 });
773
- expect(logSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Virtual view "missing_view" was not found locally'));
774
- });
775
- });
776
- describe('upsertExternalConnections', () => {
777
- let tmpDir;
778
- const externalConnectionYaml = (slug, extraLines = []) => [
779
- 'allowedContentTypes:',
780
- ' - application/json',
781
- 'allowedMethods:',
782
- ' - GET',
783
- 'allowedPathPrefixes: []',
784
- 'apiKeyLocation: header',
785
- 'apiKeyName: Authorization',
786
- 'authType: api_key',
787
- 'contentType: external_connection',
788
- 'customHeaders: null',
789
- 'instructions: null',
790
- `name: ${slug}`,
791
- 'oauthScopes: null',
792
- 'origin: https://api.example.com',
793
- 'rateLimitPerMinute: null',
794
- 'requestMaxBytes: 262144',
795
- 'responseMaxBytes: 1048576',
796
- `slug: ${slug}`,
797
- 'timeoutMs: 10000',
798
- 'version: 1',
799
- ...extraLines,
800
- '',
801
- ].join('\n');
802
- beforeEach(async () => {
803
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
804
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'external-connections-test-'));
805
- await fs_1.promises.mkdir(path.join(tmpDir, 'external-connections'));
806
- });
807
- afterEach(async () => {
808
- vitest_1.vi.restoreAllMocks();
809
- vitest_1.vi.unstubAllEnvs();
810
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
811
- });
812
- it('omits the secret from the request when the env var is unset', async () => {
813
- await fs_1.promises.writeFile(path.join(tmpDir, 'external-connections', 'stripe-api.yml'), externalConnectionYaml('stripe-api'));
814
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
815
- action: 'no changes',
816
- });
817
- const changes = await upsertExternalConnections('project-uuid', [], {}, false, true, tmpDir);
818
- expect(changes).toEqual({ 'external connections skipped': 1 });
819
- const request = vitest_1.vi.mocked(apiClient_1.lightdashApi).mock.calls[0][0];
820
- expect(request.url).toContain('/code/externalConnections/stripe-api?force=false');
821
- const body = JSON.parse(request.body);
822
- expect(body).not.toHaveProperty('secret');
823
- expect(body.connection.slug).toBe('stripe-api');
824
- });
825
- it('resolves the secret from LIGHTDASH_EXTERNAL_CONNECTION_SECRET_<SLUG>', async () => {
826
- await fs_1.promises.writeFile(path.join(tmpDir, 'external-connections', 'stripe-api.yml'), externalConnectionYaml('stripe-api'));
827
- vitest_1.vi.stubEnv('LIGHTDASH_EXTERNAL_CONNECTION_SECRET_STRIPE_API', 'sk-123');
828
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
829
- action: 'create',
830
- });
831
- const changes = await upsertExternalConnections('project-uuid', [], {}, false, true, tmpDir);
832
- expect(changes).toEqual({ 'external connections created': 1 });
833
- const body = JSON.parse(vitest_1.vi.mocked(apiClient_1.lightdashApi).mock.calls[0][0].body);
834
- expect(body.secret).toBe('sk-123');
835
- // The secret rides next to the document, never inside it
836
- expect(body.connection).not.toHaveProperty('secret');
837
- });
838
- it('strips a secret authored into the YAML and warns', async () => {
839
- const logSpy = vitest_1.vi
840
- .spyOn(globalState_1.default, 'log')
841
- .mockImplementation(() => undefined);
842
- await fs_1.promises.writeFile(path.join(tmpDir, 'external-connections', 'stripe-api.yml'), externalConnectionYaml('stripe-api', ['secret: leaked-secret']));
843
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
844
- action: 'update',
845
- });
846
- await upsertExternalConnections('project-uuid', [], {}, false, true, tmpDir);
847
- const rawBody = vitest_1.vi.mocked(apiClient_1.lightdashApi).mock.calls[0][0].body;
848
- expect(rawBody).not.toContain('leaked-secret');
849
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('secrets must never be stored in YAML'));
850
- });
851
- it('reports one category error without uploading when the caller lacks permission', async () => {
852
- const logSpy = vitest_1.vi
853
- .spyOn(globalState_1.default, 'log')
854
- .mockImplementation(() => undefined);
855
- await fs_1.promises.writeFile(path.join(tmpDir, 'external-connections', 'stripe-api.yml'), externalConnectionYaml('stripe-api'));
856
- const changes = await upsertExternalConnections('project-uuid', [], {}, false, false, tmpDir);
857
- expect(changes).toEqual({});
858
- expect(apiClient_1.lightdashApi).not.toHaveBeenCalled();
859
- expect(logSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Error uploading external connections'));
860
- });
861
- it('appends the env-var hint when a secret-requiring create fails without the env var', async () => {
862
- const logSpy = vitest_1.vi
863
- .spyOn(globalState_1.default, 'log')
864
- .mockImplementation(() => undefined);
865
- await fs_1.promises.writeFile(path.join(tmpDir, 'external-connections', 'stripe-api.yml'), externalConnectionYaml('stripe-api'));
866
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(new Error('type "api_key" requires a secret'));
867
- const changes = await upsertExternalConnections('project-uuid', [], {}, false, true, tmpDir);
868
- expect(changes).toEqual({ 'external connections with errors': 1 });
869
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Set LIGHTDASH_EXTERNAL_CONNECTION_SECRET_STRIPE_API'));
870
- });
871
- });
872
- describe('getExternalConnectionSecretEnvVar', () => {
873
- it('upper-snakes the slug', () => {
874
- expect((0, download_1.getExternalConnectionSecretEnvVar)('stripe-api-v2')).toBe('LIGHTDASH_EXTERNAL_CONNECTION_SECRET_STRIPE_API_V2');
875
- });
876
- });
877
- describe('isExternalConnectionsUnavailableError', () => {
878
- const lightdashError = (statusCode) => new common_1.LightdashError({
879
- message: 'nope',
880
- name: 'TestError',
881
- statusCode,
882
- data: {},
883
- });
884
- it('classifies permission, missing-route and non-EE errors as unavailable', () => {
885
- [403, 404, 422].forEach((statusCode) => {
886
- expect(isExternalConnectionsUnavailableError(lightdashError(statusCode))).toBe(true);
887
- });
888
- });
889
- it('keeps real failures fatal', () => {
890
- expect(isExternalConnectionsUnavailableError(lightdashError(500))).toBe(false);
891
- expect(isExternalConnectionsUnavailableError(new Error('network down'))).toBe(false);
892
- });
893
- });
894
- describe('AI agent downloads', () => {
895
- const unavailableError = new common_1.LightdashError({
896
- message: "Unable to initialize service 'aiAgentCoderService' - no factory or provider.",
897
- name: 'MissingConfigError',
898
- statusCode: 422,
899
- data: {},
900
- });
901
- beforeEach(() => {
902
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
903
- });
904
- it('classifies permission, missing-route and non-EE errors as unavailable', () => {
905
- [403, 404, 422].forEach((statusCode) => {
906
- expect(isAiAgentsUnavailableError(new common_1.LightdashError({
907
- message: 'AI agents unavailable',
908
- name: 'TestError',
909
- statusCode,
910
- data: {},
911
- }))).toBe(true);
912
- });
913
- });
914
- it('skips unavailable AI agents when selected implicitly', async () => {
915
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(unavailableError);
916
- await expect(downloadAiAgents('project-uuid', [], true)).resolves.toBe(0);
917
- });
918
- it('keeps explicit AI agent downloads strict', async () => {
919
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(unavailableError);
920
- await expect(downloadAiAgents('project-uuid', [], false)).rejects.toBe(unavailableError);
921
- });
922
- });
923
- describe('countChangeDelta', () => {
924
- it('sums positive deltas across change keys', () => {
925
- expect(countChangeDelta({ 'AI agents created': 1, 'charts skipped': 2 }, {
926
- 'AI agents created': 3,
927
- 'AI agents updated': 1,
928
- 'charts skipped': 2,
929
- })).toBe(3);
930
- });
931
- it('returns 0 when nothing changed', () => {
932
- expect(countChangeDelta({ 'charts created': 1 }, { 'charts created': 1 })).toBe(0);
933
- });
934
- });
935
- describe('downloadHandler analytics', () => {
936
- beforeEach(() => {
937
- vitest_1.vi.mocked(analytics_1.LightdashAnalytics.track).mockClear();
938
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
939
- });
940
- it('records per-type counts on download.completed, including agents', async () => {
941
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'agents-download-analytics-'));
942
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockImplementation(async ({ url }) => {
943
- if (url.startsWith('/api/v1/health')) {
944
- return { version: '0.0.0' };
945
- }
946
- if (url === '/api/v1/projects/project-uuid') {
947
- return { name: 'Test project' };
948
- }
949
- if (url.includes('/code/aiAgents')) {
950
- return {
951
- agents: [
952
- {
953
- contentType: common_1.ContentAsCodeType.AI_AGENT,
954
- version: 1,
955
- agentVersion: 2,
956
- slug: 'sales-agent',
957
- name: 'Sales agent',
958
- description: null,
959
- imageUrl: null,
960
- instruction: null,
961
- tags: null,
962
- enableDataAccess: true,
963
- enableSelfImprovement: false,
964
- enableContentTools: false,
965
- enableUserContext: false,
966
- modelConfig: null,
967
- },
968
- ],
969
- missingIds: [],
970
- offset: 1,
971
- total: 1,
972
- };
973
- }
974
- throw new Error(`Unexpected request: ${url}`);
975
- });
976
- try {
977
- await (0, download_1.downloadHandler)(makeDownloadHandlerOptions({
978
- includeAgents: true,
979
- skipAlerts: true,
980
- skipGoogleSheets: true,
981
- skipScheduledDeliveries: true,
982
- skipVirtualViews: true,
983
- skipExternalConnections: true,
984
- path: tmpDir,
985
- }));
986
- expect(analytics_1.LightdashAnalytics.track).toHaveBeenCalledWith(expect.objectContaining({
987
- event: 'download.completed',
988
- properties: expect.objectContaining({
989
- projectId: 'project-uuid',
990
- agentsNum: 1,
991
- }),
992
- }));
993
- expect(analytics_1.LightdashAnalytics.track).toHaveBeenCalledWith(expect.objectContaining({
994
- event: 'download.completed',
995
- properties: expect.not.objectContaining({
996
- chartsNum: expect.anything(),
997
- dashboardsNum: expect.anything(),
998
- }),
999
- }));
1000
- }
1001
- finally {
1002
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1003
- }
1004
- });
1005
- });
1006
- describe('downloadHandler failures', () => {
1007
- const unavailableError = new common_1.LightdashError({
1008
- message: "Unable to initialize service 'aiAgentCoderService' - no factory or provider.",
1009
- name: 'MissingConfigError',
1010
- statusCode: 422,
1011
- data: {},
1012
- });
1013
- beforeEach(() => {
1014
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
1015
- });
1016
- it('continues to scheduled content after implicitly skipping unavailable AI agents', async () => {
1017
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'include-all-download-test-'));
1018
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockImplementation(async ({ url }) => {
1019
- if (url.startsWith('/api/v1/health')) {
1020
- return { version: '0.0.0' };
1021
- }
1022
- if (url === '/api/v1/projects/project-uuid') {
1023
- return { name: 'Test project' };
1024
- }
1025
- if (url.includes('/code/virtualViews')) {
1026
- return {
1027
- virtualViews: [],
1028
- skipped: [],
1029
- missingSlugs: [],
1030
- };
1031
- }
1032
- if (url.includes('/code/aiAgents') ||
1033
- url.includes('/code/homepages')) {
1034
- throw unavailableError;
1035
- }
1036
- if (url.includes('/code/alerts')) {
1037
- return { alerts: [], skipped: [] };
1038
- }
1039
- if (url.includes('/code/scheduledDeliveries')) {
1040
- return { scheduledDeliveries: [], skipped: [] };
1041
- }
1042
- if (url.includes('/code/googleSheets')) {
1043
- return { googleSheetsSyncs: [], skipped: [] };
1044
- }
1045
- if (url.includes('/code/externalConnections')) {
1046
- throw unavailableError;
1047
- }
1048
- if (url.includes('/ee/projects/project-uuid/apps')) {
1049
- throw unavailableError;
1050
- }
1051
- throw new Error(`Unexpected request: ${url}`);
1052
- });
1053
- try {
1054
- await expect((0, download_1.downloadHandler)(makeDownloadHandlerOptions({
1055
- includeAll: true,
1056
- path: tmpDir,
1057
- }))).resolves.toBeUndefined();
1058
- expect(vitest_1.vi.mocked(apiClient_1.lightdashApi)).toHaveBeenCalledWith(expect.objectContaining({
1059
- url: expect.stringContaining('/code/scheduledDeliveries'),
1060
- }));
1061
- expect(analytics_1.LightdashAnalytics.track).toHaveBeenCalledWith(expect.objectContaining({
1062
- event: 'download.completed',
1063
- properties: expect.objectContaining({
1064
- agentsNum: 0,
1065
- alertsNum: 0,
1066
- scheduledDeliveriesNum: 0,
1067
- googleSheetsNum: 0,
1068
- virtualViewsNum: 0,
1069
- externalConnectionsNum: 0,
1070
- appsNum: 0,
1071
- }),
1072
- }));
1073
- }
1074
- finally {
1075
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1076
- }
1077
- });
1078
- it('rethrows fatal project download errors', async () => {
1079
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockImplementation(async ({ url }) => {
1080
- if (url.startsWith('/api/v1/health')) {
1081
- return { version: '0.0.0' };
1082
- }
1083
- if (url === '/api/v1/projects/project-uuid') {
1084
- return { name: 'Test project' };
1085
- }
1086
- if (url.includes('/code/aiAgents')) {
1087
- throw unavailableError;
1088
- }
1089
- throw new Error(`Unexpected request: ${url}`);
1090
- });
1091
- await expect((0, download_1.downloadHandler)(makeDownloadHandlerOptions({
1092
- agents: ['sales-agent'],
1093
- }))).rejects.toBe(unavailableError);
1094
- });
1095
- });
1096
- describe('uploadHandler failures', () => {
1097
- beforeEach(() => {
1098
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
1099
- vitest_1.vi.mocked(apiClient_1.getContentAsCodeUploadPermissions).mockReset();
1100
- });
1101
- it('rethrows fatal project upload errors', async () => {
1102
- const fatalError = new Error('Failed to load dbt credentials');
1103
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValue({
1104
- version: '0.0.0',
1105
- });
1106
- vitest_1.vi.mocked(apiClient_1.getContentAsCodeUploadPermissions).mockRejectedValueOnce(fatalError);
1107
- await expect((0, download_1.uploadHandler)(makeDownloadHandlerOptions({}))).rejects.toBe(fatalError);
1108
- });
1109
- });
1110
- describe('readAiAgentFiles', () => {
1111
- let tmpDir;
1112
- beforeEach(async () => {
1113
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'agent-code-test-'));
1114
- await fs_1.promises.mkdir(path.join(tmpDir, 'ai-agents'));
1115
- });
1116
- afterEach(async () => {
1117
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1118
- });
1119
- it('rejects YAML files with an invalid AI agent content type', async () => {
1120
- await fs_1.promises.writeFile(path.join(tmpDir, 'ai-agents', 'invalid.yml'), 'contentType: ai_agnet\nslug: invalid-agent\n');
1121
- await expect(readAiAgentFiles(tmpDir)).rejects.toThrow('Invalid contentType in AI agent file');
1122
- });
1123
- it('reads evaluation suites nested in an AI agent file', async () => {
1124
- await fs_1.promises.writeFile(path.join(tmpDir, 'ai-agents', 'revenue-agent.yml'), [
1125
- 'contentType: ai_agent',
1126
- 'version: 1',
1127
- 'slug: revenue-agent',
1128
- 'evaluations:',
1129
- ' - title: Core regression suite',
1130
- ' prompts:',
1131
- ' - prompt: What was revenue last month?',
1132
- ' expectedResponse: Uses the certified revenue metric.',
1133
- '',
1134
- ].join('\n'));
1135
- const agents = await readAiAgentFiles(tmpDir);
1136
- expect(agents[0].evaluations).toEqual([
1137
- {
1138
- title: 'Core regression suite',
1139
- prompts: [
1140
- {
1141
- prompt: 'What was revenue last month?',
1142
- expectedResponse: 'Uses the certified revenue metric.',
1143
- },
1144
- ],
1145
- },
1146
- ]);
1147
- });
1148
- });
1149
- describe('upsertAiAgents', () => {
1150
- let tmpDir;
1151
- const unavailableError = new common_1.LightdashError({
1152
- message: "Unable to initialize service 'aiAgentCoderService' - no factory or provider.",
1153
- name: 'MissingConfigError',
1154
- statusCode: 422,
1155
- data: {},
1156
- });
1157
- beforeEach(async () => {
1158
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
1159
- vitest_1.vi.spyOn(globalState_1.default, 'log').mockImplementation(() => undefined);
1160
- tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'agent-upload-test-'));
1161
- await fs_1.promises.mkdir(path.join(tmpDir, 'ai-agents'));
1162
- await fs_1.promises.writeFile(path.join(tmpDir, 'ai-agents', 'revenue-agent.yml'), [
1163
- 'contentType: ai_agent',
1164
- 'version: 1',
1165
- 'slug: revenue-agent',
1166
- '',
1167
- ].join('\n'));
1168
- });
1169
- afterEach(async () => {
1170
- vitest_1.vi.restoreAllMocks();
1171
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1172
- });
1173
- it('skips unavailable AI agents when their upload is implicit', async () => {
1174
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(unavailableError);
1175
- await expect(upsertAiAgents('project-uuid', [], {}, false, tmpDir, true)).resolves.toStrictEqual({});
1176
- expect(globalState_1.default.log).toHaveBeenCalledWith(expect.stringContaining('Skipping AI agents'));
1177
- });
1178
- it('fails when unavailable AI agents were selected explicitly', async () => {
1179
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(unavailableError);
1180
- await expect(upsertAiAgents('project-uuid', ['revenue-agent'], {}, false, tmpDir, false)).rejects.toBe(unavailableError);
1181
- });
1182
- });
1183
- describe('shouldDownloadAiAgents', () => {
1184
- it('does not download AI agents by default', () => {
1185
- expect(shouldDownloadAiAgents({
1186
- agents: [],
1187
- includeAgents: false,
1188
- includeAll: false,
1189
- })).toBe(false);
1190
- });
1191
- it.each([
1192
- { agents: [], includeAgents: true, includeAll: false },
1193
- { agents: ['sales-agent'], includeAgents: false, includeAll: false },
1194
- { agents: [], includeAgents: false, includeAll: true },
1195
- ])('downloads AI agents when explicitly selected', (options) => {
1196
- expect(shouldDownloadAiAgents(options)).toBe(true);
1197
- });
1198
- it('does not download AI agents in apps-only mode', () => {
1199
- expect(shouldDownloadAiAgents({
1200
- agents: ['sales-agent'],
1201
- includeAgents: true,
1202
- includeAll: true,
1203
- appsOnly: true,
1204
- })).toBe(false);
1205
- });
1206
- });
1207
- describe('space files', () => {
1208
- const accessSpace = (slug) => ({
1209
- contentType: 'space',
1210
- version: 1,
1211
- spaceName: slug,
1212
- slug,
1213
- access: {
1214
- inheritParentPermissions: false,
1215
- projectMemberAccessRole: null,
1216
- users: [],
1217
- groups: [],
1218
- },
1219
- });
1220
- beforeEach(() => {
1221
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockReset();
1222
- });
1223
- const embeddedSpaceResponse = (spaceName) => ({
1224
- charts: [makeChart([])],
1225
- languageMap: undefined,
1226
- missingIds: [],
1227
- offset: 1,
1228
- spaces: [
1229
- {
1230
- contentType: common_1.ContentAsCodeType.SPACE,
1231
- spaceName,
1232
- slug: 'test-space',
1233
- },
1234
- ],
1235
- total: 1,
1236
- });
1237
- it('writes new flat space files under spaces by default', async () => {
1238
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-flat-folder-'));
1239
- try {
1240
- await writeSpaceFiles([accessSpace('finance')], 'project', tmpDir, 'flat');
1241
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'spaces', 'finance.space.yml'), 'utf-8')).resolves.toContain('slug: finance');
1242
- await expect(fs_1.promises.access(path.join(tmpDir, 'finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
1243
- }
1244
- finally {
1245
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1246
- }
1247
- });
1248
- it('supports the legacy root-spaces flat layout', async () => {
1249
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-root-layout-'));
1250
- try {
1251
- await writeSpaceFiles([accessSpace('finance')], 'project', tmpDir, 'flat', 'root');
1252
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toContain('slug: finance');
1253
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
1254
- }
1255
- finally {
1256
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1257
- }
1258
- });
1259
- it('preserves existing access when filtered metadata updates a space file', async () => {
1260
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-filtered-policy-'));
1261
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValue(embeddedSpaceResponse('Updated finance'));
1262
- const existingFile = path.join(tmpDir, 'finance.space.yml');
1263
- await fs_1.promises.writeFile(existingFile, `access:
1264
- groups: []
1265
- inheritParentPermissions: false
1266
- projectMemberAccessRole: null
1267
- users:
1268
- - email: owner@example.com
1269
- role: admin
1270
- contentType: space
1271
- slug: test-space
1272
- spaceName: Finance
1273
- version: 1
1274
- `);
1275
- try {
1276
- const onProgress = vitest_1.vi.fn();
1277
- await (0, download_1.downloadContent)(['pivoted-chart'], 'charts', 'project-uuid', 'project', tmpDir, false, false, false, false, false, onProgress);
1278
- expect(onProgress).toHaveBeenCalledWith('1 of 1 charts downloaded');
1279
- await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
1280
- expect.objectContaining({
1281
- filePath: existingFile,
1282
- space: {
1283
- contentType: 'space',
1284
- version: 1,
1285
- spaceName: 'Updated finance',
1286
- slug: 'test-space',
1287
- access: {
1288
- inheritParentPermissions: false,
1289
- projectMemberAccessRole: null,
1290
- users: [
1291
- {
1292
- email: 'owner@example.com',
1293
- role: common_1.SpaceMemberRole.ADMIN,
1294
- },
1295
- ],
1296
- groups: [],
1297
- },
1298
- },
1299
- }),
1300
- ]);
1301
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'updated-finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
1302
- }
1303
- finally {
1304
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1305
- }
1306
- });
1307
- it('writes nested definitions inside each full space path', async () => {
1308
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-nested-layout-'));
1309
- try {
1310
- await writeSpaceFiles([
1311
- { ...accessSpace('parent'), spaceName: 'Parent' },
1312
- {
1313
- ...accessSpace('parent/child'),
1314
- spaceName: 'Child',
1315
- },
1316
- ], 'project', tmpDir, 'nested');
1317
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'project', 'parent', 'parent.space.yml'), 'utf-8')).resolves.toContain('slug: parent');
1318
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'project', 'parent', 'child', 'child.space.yml'), 'utf-8')).resolves.toContain('slug: parent/child');
1319
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
1320
- }
1321
- finally {
1322
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1323
- }
1324
- });
1325
- it('downloads metadata-only spaces when access cannot be exported', async () => {
1326
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-metadata-fallback-'));
1327
- const logSpy = vitest_1.vi
1328
- .spyOn(globalState_1.default, 'log')
1329
- .mockImplementation(() => undefined);
1330
- await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), `access:
1331
- groups: []
1332
- inheritParentPermissions: false
1333
- projectMemberAccessRole: null
1334
- users: []
1335
- contentType: space
1336
- slug: finance
1337
- spaceName: Finance
1338
- version: 1
1339
- `);
1340
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValue({
1341
- spaces: [
1342
- {
1343
- contentType: common_1.ContentAsCodeType.SPACE,
1344
- spaceName: 'Finance',
1345
- slug: 'finance',
1346
- },
1347
- ],
1348
- skipped: [
1349
- {
1350
- slug: 'finance',
1351
- reason: 'Direct access contains a user without a portable organization identity',
1352
- },
1353
- ],
1354
- });
1355
- try {
1356
- await expect(downloadSpaces('project-uuid', 'Project', tmpDir)).resolves.toBe(1);
1357
- await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
1358
- expect.objectContaining({
1359
- space: {
1360
- contentType: 'space',
1361
- spaceName: 'Finance',
1362
- slug: 'finance',
1363
- },
1364
- }),
1365
- ]);
1366
- const downloadedFile = await fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8');
1367
- expect(downloadedFile).not.toContain('access:');
1368
- expect(downloadedFile).not.toContain('version:');
1369
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'finance.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
1370
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Downloaded space "finance" without access'));
1371
- expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipped space "finance"'));
1372
- }
1373
- finally {
1374
- logSpy.mockRestore();
1375
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1376
- }
1377
- });
1378
- it('accepts legacy metadata-only files without a version', () => {
1379
- expect(validateSpaceIdentity({
1380
- contentType: 'space',
1381
- spaceName: 'Finance',
1382
- slug: 'company/finance',
1383
- }, 'finance.space.yml')).toEqual({
1384
- contentType: 'space',
1385
- spaceName: 'Finance',
1386
- slug: 'company/finance',
1387
- });
1388
- });
1389
- it('reads legacy space names without validating access policy', async () => {
1390
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-name-test-'));
1391
- try {
1392
- await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), `access:
1393
- unsupported: true
1394
- contentType: space
1395
- spaceName: Finance operations
1396
- slug: finance
1397
- version: 99
1398
- `);
1399
- await fs_1.promises.writeFile(path.join(tmpDir, 'invalid.space.yml'), 'contentType: [invalid\n');
1400
- await expect(readSpaceFiles(tmpDir)).rejects.toThrow('Invalid version');
1401
- await expect(readSpaceNames(tmpDir)).resolves.toEqual({
1402
- finance: 'Finance operations',
1403
- });
1404
- }
1405
- finally {
1406
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1407
- }
1408
- });
1409
- it('requires version 1 when access is present', () => {
1410
- expect(() => validateSpaceIdentity({
1411
- contentType: 'space',
1412
- spaceName: 'Finance',
1413
- slug: 'finance',
1414
- access: {
1415
- inheritParentPermissions: false,
1416
- projectMemberAccessRole: null,
1417
- users: [],
1418
- groups: [],
1419
- },
1420
- }, 'finance.space.yml')).toThrow('version 1 is required when access is present');
1421
- });
1422
- it('normalizes emails and rejects duplicate principals', () => {
1423
- const parsed = validateSpaceIdentity({
1424
- contentType: 'space',
1425
- version: 1,
1426
- spaceName: 'Finance',
1427
- slug: 'finance',
1428
- access: {
1429
- inheritParentPermissions: false,
1430
- projectMemberAccessRole: common_1.SpaceMemberRole.VIEWER,
1431
- users: [
1432
- {
1433
- email: ' Alice@Example.com ',
1434
- role: common_1.SpaceMemberRole.ADMIN,
1435
- },
1436
- ],
1437
- groups: [
1438
- {
1439
- name: 'Finance team',
1440
- role: common_1.SpaceMemberRole.EDITOR,
1441
- },
1442
- ],
1443
- },
1444
- }, 'finance.space.yml');
1445
- expect(parsed.access?.users).toEqual([
1446
- { email: 'alice@example.com', role: common_1.SpaceMemberRole.ADMIN },
1447
- ]);
1448
- expect(() => validateSpaceIdentity({
1449
- ...parsed,
1450
- access: {
1451
- ...parsed.access,
1452
- users: [
1453
- {
1454
- email: 'alice@example.com',
1455
- role: common_1.SpaceMemberRole.ADMIN,
1456
- },
1457
- {
1458
- email: 'ALICE@example.com',
1459
- role: common_1.SpaceMemberRole.VIEWER,
1460
- },
1461
- ],
1462
- },
1463
- }, 'finance.space.yml')).toThrow('Duplicate user email "alice@example.com"');
1464
- expect(() => validateSpaceIdentity({
1465
- ...parsed,
1466
- access: {
1467
- ...parsed.access,
1468
- groups: [
1469
- {
1470
- name: 'Finance team',
1471
- role: common_1.SpaceMemberRole.EDITOR,
1472
- },
1473
- {
1474
- name: 'Finance team',
1475
- role: common_1.SpaceMemberRole.VIEWER,
1476
- },
1477
- ],
1478
- },
1479
- }, 'finance.space.yml')).toThrow('Duplicate group name "Finance team"');
1480
- });
1481
- it('reads nested definitions parent-first', async () => {
1482
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-test-'));
1483
- try {
1484
- await fs_1.promises.mkdir(path.join(tmpDir, 'nested'));
1485
- await fs_1.promises.writeFile(path.join(tmpDir, 'nested', 'child.space.yml'), `contentType: space\nspaceName: Child\nslug: parent/child\n`);
1486
- await fs_1.promises.writeFile(path.join(tmpDir, 'parent.space.yml'), `contentType: space\nspaceName: Parent\nslug: parent\n`);
1487
- await expect(readSpaceFiles(tmpDir)).resolves.toEqual([
1488
- expect.objectContaining({
1489
- space: expect.objectContaining({ slug: 'parent' }),
1490
- }),
1491
- expect.objectContaining({
1492
- space: expect.objectContaining({ slug: 'parent/child' }),
1493
- }),
1494
- ]);
1495
- }
1496
- finally {
1497
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1498
- }
1499
- });
1500
- it('rejects non-canonical and colliding normalized paths', () => {
1501
- expect(() => validateSpaceIdentity({
1502
- contentType: 'space',
1503
- spaceName: 'Finance',
1504
- slug: 'Parent_With.Space',
1505
- }, 'finance.space.yml')).toThrow('canonical lowercase slash-separated path');
1506
- expect(() => assertUniqueSpacePaths([
1507
- {
1508
- filePath: 'one.space.yml',
1509
- space: accessSpace('parent-child'),
1510
- },
1511
- {
1512
- filePath: 'two.space.yml',
1513
- space: {
1514
- ...accessSpace('parent-child'),
1515
- slug: 'parent_child',
1516
- },
1517
- },
1518
- ])).toThrow('resolve to the same normalized path');
1519
- });
1520
- it('uses deterministic collision-safe flat filenames', () => {
1521
- const spaces = [
1522
- { ...accessSpace('finance-a'), spaceName: 'Finance!' },
1523
- { ...accessSpace('finance-b'), spaceName: 'Finance?' },
1524
- ];
1525
- const first = getFlatSpaceFileNames(spaces);
1526
- expect(first).toEqual(getFlatSpaceFileNames([...spaces].reverse()).reverse());
1527
- expect(new Set(first).size).toBe(2);
1528
- expect(first.every((fileName) => /^finance-[a-f0-9]{8}\.space\.yml$/.test(fileName))).toBe(true);
1529
- });
1530
- it('never overwrites an existing filename owned by another slug', async () => {
1531
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-protected-file-'));
1532
- const protectedContent = 'contentType: space\nspaceName: Existing\nslug: existing\n';
1533
- try {
1534
- await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), protectedContent);
1535
- await writeSpaceFiles([
1536
- {
1537
- ...accessSpace('new-finance'),
1538
- spaceName: 'Finance',
1539
- },
1540
- ], 'project', tmpDir, 'flat');
1541
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toBe(protectedContent);
1542
- const newSpaceFile = (await fs_1.promises.readdir(tmpDir)).find((fileName) => fileName !== 'finance.space.yml' &&
1543
- fileName.endsWith('.space.yml'));
1544
- expect(newSpaceFile).toBeDefined();
1545
- expect(await fs_1.promises.readFile(path.join(tmpDir, newSpaceFile), 'utf-8')).toContain('slug: new-finance');
1546
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
1547
- }
1548
- finally {
1549
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1550
- }
1551
- });
1552
- it('uses the legacy root layout for new slugs when a root space file exists', async () => {
1553
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-mixed-layout-'));
1554
- try {
1555
- await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), 'contentType: space\nspaceName: Old finance\nslug: finance\n');
1556
- await writeSpaceFiles([
1557
- { ...accessSpace('finance'), spaceName: 'New finance' },
1558
- { ...accessSpace('operations'), spaceName: 'Operations' },
1559
- ], 'project', tmpDir, 'flat');
1560
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'finance.space.yml'), 'utf-8')).resolves.toContain('spaceName: New finance');
1561
- await expect(fs_1.promises.readFile(path.join(tmpDir, 'operations.space.yml'), 'utf-8')).resolves.toContain('slug: operations');
1562
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces'))).rejects.toMatchObject({ code: 'ENOENT' });
1563
- expect((await readSpaceFiles(tmpDir)).map(({ space }) => space.slug)).toEqual(['finance', 'operations']);
1564
- }
1565
- finally {
1566
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1567
- }
1568
- });
1569
- it('rejects duplicate existing slugs across layouts before writing', async () => {
1570
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-duplicate-layout-'));
1571
- try {
1572
- await fs_1.promises.mkdir(path.join(tmpDir, 'spaces'));
1573
- const duplicate = 'contentType: space\nspaceName: Finance\nslug: finance\n';
1574
- await fs_1.promises.writeFile(path.join(tmpDir, 'finance.space.yml'), duplicate);
1575
- await fs_1.promises.writeFile(path.join(tmpDir, 'spaces', 'finance.space.yml'), duplicate);
1576
- await expect(writeSpaceFiles([accessSpace('operations')], 'project', tmpDir, 'flat')).rejects.toThrow('Multiple existing space files use slug "finance"');
1577
- await expect(fs_1.promises.access(path.join(tmpDir, 'spaces', 'operations.space.yml'))).rejects.toMatchObject({ code: 'ENOENT' });
1578
- }
1579
- finally {
1580
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1581
- }
1582
- });
1583
- it('fails a space download when the space endpoint is unavailable', async () => {
1584
- const tmpDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'space-code-api-failure-'));
1585
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValue(new Error('space endpoint unavailable'));
1586
- try {
1587
- const error = await downloadSpaces('project-uuid', 'Project', tmpDir).catch((caughtError) => caughtError);
1588
- expect(error).toMatchObject({
1589
- name: 'SpaceAsCodeFetchError',
1590
- message: 'space endpoint unavailable',
1591
- });
1592
- expect(shouldFallBackToEmbeddedSpaces(error, false)).toBe(true);
1593
- expect(shouldFallBackToEmbeddedSpaces(error, true)).toBe(false);
1594
- }
1595
- finally {
1596
- await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
1597
- }
1598
- });
1599
- it('uploads parent-first and aggregates API actions', async () => {
1600
- const logSpy = vitest_1.vi
1601
- .spyOn(globalState_1.default, 'log')
1602
- .mockImplementation(() => undefined);
1603
- vitest_1.vi.mocked(apiClient_1.lightdashApi)
1604
- .mockResolvedValueOnce({
1605
- action: common_1.SpaceAsCodeAction.CREATE,
1606
- warnings: ['A direct service-account grant will be removed'],
1607
- })
1608
- .mockResolvedValueOnce({
1609
- action: common_1.SpaceAsCodeAction.UPDATE,
1610
- });
1611
- const changes = await upsertSpaces('project-uuid', [
1612
- { filePath: 'child.space.yml', space: accessSpace('a/b') },
1613
- { filePath: 'parent.space.yml', space: accessSpace('a') },
1614
- ], {}, false, false);
1615
- expect(changes).toEqual({
1616
- 'spaces created': 1,
1617
- 'spaces updated': 1,
1618
- });
1619
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('A direct service-account grant will be removed'));
1620
- expect(vitest_1.vi
1621
- .mocked(apiClient_1.lightdashApi)
1622
- .mock.calls.map(([request]) => JSON.parse(typeof request.body === 'string'
1623
- ? request.body
1624
- : '{}').slug)).toEqual(['a', 'a/b']);
1625
- logSpy.mockRestore();
1626
- });
1627
- it('omits access and warns when skip-space-access is enabled', async () => {
1628
- const logSpy = vitest_1.vi
1629
- .spyOn(globalState_1.default, 'log')
1630
- .mockImplementation(() => undefined);
1631
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockResolvedValueOnce({
1632
- action: common_1.SpaceAsCodeAction.CREATE,
1633
- });
1634
- await upsertSpaces('project-uuid', [{ filePath: 'space.space.yml', space: accessSpace('finance') }], {}, false, true, true);
1635
- const [request] = vitest_1.vi.mocked(apiClient_1.lightdashApi).mock.calls[0];
1636
- expect(JSON.parse(String(request.body))).toEqual({
1637
- contentType: 'space',
1638
- version: 1,
1639
- spaceName: 'finance',
1640
- slug: 'finance',
1641
- });
1642
- expect(request.url).toContain('publicSpaceCreate=true');
1643
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Existing destination access will be preserved'));
1644
- logSpy.mockRestore();
1645
- });
1646
- it.each([
1647
- 'User example@lightdash.com is not a member of this organization',
1648
- 'Group finance does not exist in this organization',
1649
- ])('suggests skip-space-access when a destination identity is missing: %s', async (errorMessage) => {
1650
- const logSpy = vitest_1.vi
1651
- .spyOn(globalState_1.default, 'log')
1652
- .mockImplementation(() => undefined);
1653
- vitest_1.vi.mocked(apiClient_1.lightdashApi).mockRejectedValueOnce(new Error(errorMessage));
1654
- await expect(upsertSpaces('project-uuid', [
1655
- {
1656
- filePath: 'space.space.yml',
1657
- space: accessSpace('finance'),
1658
- },
1659
- ], {}, false, false)).rejects.toThrow('content upload was not started');
1660
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Hint: use --skip-space-access to upload the space without applying its access policy.'));
1661
- logSpy.mockRestore();
1662
- });
1663
- it('treats missing spaces and descendants as nonfatal with skip-space-create', async () => {
1664
- vitest_1.vi.mocked(apiClient_1.lightdashApi)
1665
- .mockRejectedValueOnce(new common_1.LightdashError({
1666
- message: 'Space a does not exist, skipping creation',
1667
- name: 'NotFoundError',
1668
- statusCode: 404,
1669
- data: {},
1670
- }))
1671
- .mockResolvedValueOnce({
1672
- action: common_1.SpaceAsCodeAction.NO_CHANGES,
1673
- });
1674
- await expect(upsertSpaces('project-uuid', [
1675
- { filePath: 'child.space.yml', space: accessSpace('a/b') },
1676
- { filePath: 'other.space.yml', space: accessSpace('z') },
1677
- { filePath: 'parent.space.yml', space: accessSpace('a') },
1678
- ], {}, true, false)).resolves.toEqual({
1679
- 'spaces skipped': 2,
1680
- 'spaces unchanged': 1,
1681
- });
1682
- expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
1683
- expect(vitest_1.vi
1684
- .mocked(apiClient_1.lightdashApi)
1685
- .mock.calls.map(([request]) => JSON.parse(typeof request.body === 'string'
1686
- ? request.body
1687
- : '{}').slug)).toEqual(['a', 'z']);
1688
- });
1689
- it('skips descendants after a parent failure and blocks content', async () => {
1690
- const summarySpy = vitest_1.vi
1691
- .spyOn(console, 'info')
1692
- .mockImplementation(() => undefined);
1693
- vitest_1.vi.mocked(apiClient_1.lightdashApi)
1694
- .mockRejectedValueOnce(new Error('parent rejected'))
1695
- .mockResolvedValueOnce({
1696
- action: common_1.SpaceAsCodeAction.CREATE,
1697
- });
1698
- const changes = {};
1699
- await expect(upsertSpaces('project-uuid', [
1700
- { filePath: 'child.space.yml', space: accessSpace('a/b') },
1701
- { filePath: 'other.space.yml', space: accessSpace('z') },
1702
- { filePath: 'parent.space.yml', space: accessSpace('a') },
1703
- ], changes, false, false)).rejects.toThrow('content upload was not started');
1704
- expect(apiClient_1.lightdashApi).toHaveBeenCalledTimes(2);
1705
- expect(changes).toEqual({
1706
- 'spaces with errors': 1,
1707
- 'spaces created': 1,
1708
- 'spaces dependency skipped': 1,
1709
- });
1710
- expect(summarySpy.mock.calls.map(([message]) => message)).toEqual([
1711
- 'Total spaces with errors: 1 ',
1712
- 'Total spaces created: 1 ',
1713
- 'Total spaces dependency skipped: 1 ',
1714
- ]);
1715
- summarySpy.mockRestore();
1716
- });
1717
- });
1718
- describe('parseContentFilters', () => {
1719
- const { parseContentFilters } = download_1.testHelpers;
1720
- it('passes slugs and uuids through untouched', () => {
1721
- expect(parseContentFilters([
1722
- 'sales-overview',
1723
- '00000000-0000-0000-0000-000000000001',
1724
- ])).toBe('?ids=sales-overview&ids=00000000-0000-0000-0000-000000000001');
1725
- });
1726
- it('extracts uuids from legacy app urls', () => {
1727
- expect(parseContentFilters([
1728
- 'https://app.lightdash.cloud/projects/00000000-0000-0000-0000-0000000000aa/dashboards/00000000-0000-0000-0000-000000000001/view',
1729
- 'https://app.lightdash.cloud/projects/00000000-0000-0000-0000-0000000000aa/saved/00000000-0000-0000-0000-000000000002',
1730
- ])).toBe('?ids=00000000-0000-0000-0000-000000000001&ids=00000000-0000-0000-0000-000000000002');
1731
- });
1732
- it('extracts slugs from app urls that use project and content slugs', () => {
1733
- expect(parseContentFilters([
1734
- 'https://app.lightdash.cloud/projects/jaffle-shop/dashboards/daily-sales/view',
1735
- 'https://app.lightdash.cloud/projects/jaffle-shop/saved/monthly-revenue/view?tab=1',
1736
- 'https://app.lightdash.cloud/projects/jaffle-shop/dashboards/sales-overview/view/tabs/some-tab',
1737
- ])).toBe('?ids=daily-sales&ids=monthly-revenue&ids=sales-overview');
1738
- });
1739
- });