@rancher/shell 3.0.10 → 3.0.12-rc.1

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 (154) hide show
  1. package/assets/styles/base/_mixins.scss +31 -0
  2. package/assets/styles/base/_variables.scss +2 -0
  3. package/assets/styles/themes/_modern.scss +6 -5
  4. package/assets/translations/en-us.yaml +12 -9
  5. package/assets/translations/zh-hans.yaml +0 -3
  6. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  7. package/chart/rancher-backup/index.vue +41 -2
  8. package/components/BrandImage.vue +6 -5
  9. package/components/ConsumptionGauge.vue +12 -4
  10. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  11. package/components/EmptyProductPage.vue +76 -0
  12. package/components/ExplorerProjectsNamespaces.vue +1 -4
  13. package/components/LazyImage.vue +2 -1
  14. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  15. package/components/Resource/Detail/CopyToClipboard.vue +1 -2
  16. package/components/Resource/Detail/Metadata/KeyValueRow.vue +9 -3
  17. package/components/Resource/Detail/TitleBar/__tests__/__snapshots__/index.test.ts.snap +31 -0
  18. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +45 -1
  19. package/components/Resource/Detail/TitleBar/index.vue +1 -1
  20. package/components/Resource/Detail/ViewOptions/__tests__/__snapshots__/index.test.ts.snap +9 -0
  21. package/components/Resource/Detail/ViewOptions/__tests__/index.test.ts +62 -0
  22. package/components/Resource/Detail/ViewOptions/index.vue +2 -1
  23. package/components/ResourceList/Masthead.vue +25 -2
  24. package/components/SideNav.vue +13 -0
  25. package/components/Tabbed/index.vue +6 -0
  26. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  27. package/components/__tests__/PromptModal.test.ts +2 -0
  28. package/components/fleet/FleetClusters.vue +1 -0
  29. package/components/fleet/__tests__/FleetClusters.test.ts +71 -0
  30. package/components/form/NodeScheduling.vue +17 -3
  31. package/components/form/PrivateRegistry.vue +69 -0
  32. package/components/form/ProjectMemberEditor.vue +0 -10
  33. package/components/form/__tests__/PrivateRegistry.test.ts +133 -0
  34. package/components/formatter/WorkloadHealthScale.vue +3 -1
  35. package/components/nav/Group.vue +26 -3
  36. package/components/nav/Header.vue +32 -7
  37. package/components/nav/TopLevelMenu.helper.ts +7 -79
  38. package/components/nav/TopLevelMenu.vue +15 -1
  39. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  40. package/config/pagination-table-headers.js +8 -1
  41. package/config/private-label.js +2 -1
  42. package/config/product/apps.js +3 -1
  43. package/config/product/auth.js +1 -0
  44. package/config/product/backup.js +1 -0
  45. package/config/product/compliance.js +1 -1
  46. package/config/product/explorer.js +25 -6
  47. package/config/product/fleet.js +1 -0
  48. package/config/product/gatekeeper.js +1 -0
  49. package/config/product/istio.js +1 -0
  50. package/config/product/logging.js +1 -0
  51. package/config/product/longhorn.js +2 -1
  52. package/config/product/manager.js +1 -0
  53. package/config/product/monitoring.js +1 -0
  54. package/config/product/navlinks.js +1 -0
  55. package/config/product/neuvector.js +2 -1
  56. package/config/product/settings.js +1 -0
  57. package/config/product/uiplugins.js +1 -0
  58. package/core/__tests__/extension-manager-impl.test.js +187 -2
  59. package/core/__tests__/plugin-products-helpers.test.ts +454 -0
  60. package/core/__tests__/plugin-products.test.ts +3219 -0
  61. package/core/extension-manager-impl.js +34 -3
  62. package/core/plugin-helpers.ts +31 -0
  63. package/core/plugin-products-base.ts +375 -0
  64. package/core/plugin-products-extending.ts +44 -0
  65. package/core/plugin-products-helpers.ts +262 -0
  66. package/core/plugin-products-top-level.ts +66 -0
  67. package/core/plugin-products-type-guards.ts +33 -0
  68. package/core/plugin-products.ts +50 -0
  69. package/core/plugin-types.ts +222 -0
  70. package/core/plugin.ts +45 -10
  71. package/core/productDebugger.js +48 -0
  72. package/core/types.ts +95 -11
  73. package/detail/__tests__/__snapshots__/fleet.cattle.io.bundle.test.ts.snap +52 -0
  74. package/detail/__tests__/fleet.cattle.io.bundle.test.ts +171 -0
  75. package/detail/__tests__/node.test.ts +83 -0
  76. package/detail/fleet.cattle.io.bundle.vue +21 -34
  77. package/detail/management.cattle.io.oidcclient.vue +2 -1
  78. package/detail/node.vue +1 -0
  79. package/dialog/ExtensionCatalogInstallDialog.vue +1 -1
  80. package/dialog/InstallExtensionDialog.vue +6 -27
  81. package/dialog/UninstallExistingExtensionDialog.vue +141 -0
  82. package/dialog/UninstallExtensionDialog.vue +4 -26
  83. package/dialog/__tests__/UninstallExistingExtensionDialog.test.ts +114 -0
  84. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -0
  85. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  86. package/edit/cloudcredential.vue +2 -1
  87. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  88. package/edit/provisioning.cattle.io.cluster/__tests__/Ingress.test.ts +176 -0
  89. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  90. package/edit/provisioning.cattle.io.cluster/rke2.vue +4 -1
  91. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  92. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +6 -0
  93. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +7 -2
  94. package/edit/secret/generic.vue +1 -0
  95. package/edit/secret/index.vue +2 -1
  96. package/edit/service.vue +2 -14
  97. package/list/management.cattle.io.feature.vue +7 -1
  98. package/list/provisioning.cattle.io.cluster.vue +0 -50
  99. package/list/workload.vue +11 -4
  100. package/mixins/brand.js +2 -1
  101. package/mixins/resource-fetch.js +12 -3
  102. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  103. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  104. package/models/management.cattle.io.authconfig.js +2 -1
  105. package/models/management.cattle.io.cluster.js +4 -3
  106. package/models/monitoring.coreos.com.receiver.js +11 -6
  107. package/models/pod.js +18 -0
  108. package/models/provisioning.cattle.io.cluster.js +2 -2
  109. package/models/workload.js +20 -2
  110. package/package.json +5 -6
  111. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +0 -1
  112. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  113. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  114. package/pages/c/_cluster/istio/index.vue +4 -2
  115. package/pages/c/_cluster/longhorn/index.vue +2 -1
  116. package/pages/c/_cluster/monitoring/index.vue +2 -2
  117. package/pages/c/_cluster/neuvector/index.vue +2 -1
  118. package/pages/c/_cluster/settings/brand.vue +4 -4
  119. package/pages/c/_cluster/settings/performance.vue +0 -5
  120. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  121. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +231 -13
  122. package/pages/c/_cluster/uiplugins/index.vue +145 -38
  123. package/plugins/dashboard-store/__tests__/resource-class.test.ts +1 -0
  124. package/plugins/dashboard-store/actions.js +3 -2
  125. package/plugins/dashboard-store/resource-class.js +62 -6
  126. package/plugins/plugin.js +16 -0
  127. package/plugins/steve/steve-pagination-utils.ts +8 -2
  128. package/plugins/steve/subscribe.js +29 -4
  129. package/rancher-components/RcButton/RcButton.vue +3 -3
  130. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  131. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  132. package/rancher-components/RcButtonSplit/index.ts +1 -0
  133. package/scripts/test-plugins-build.sh +4 -4
  134. package/scripts/typegen.sh +13 -1
  135. package/store/__tests__/type-map.test.ts +84 -24
  136. package/store/type-map.js +42 -3
  137. package/tsconfig.paths.json +1 -0
  138. package/types/resources/pod.ts +18 -0
  139. package/types/shell/index.d.ts +8506 -2908
  140. package/types/store/dashboard-store.types.ts +5 -0
  141. package/types/store/pagination.types.ts +6 -0
  142. package/utils/__tests__/require-asset.test.ts +98 -0
  143. package/utils/async.ts +1 -5
  144. package/utils/axios.js +1 -4
  145. package/utils/brand.ts +3 -1
  146. package/utils/dynamic-importer.js +3 -2
  147. package/utils/favicon.js +4 -3
  148. package/utils/pagination-utils.ts +1 -1
  149. package/utils/require-asset.ts +95 -0
  150. package/utils/uiplugins.ts +12 -16
  151. package/utils/validators/__tests__/private-registry.test.ts +76 -0
  152. package/utils/validators/private-registry.ts +28 -0
  153. package/vue.config.js +4 -3
  154. package/components/HarvesterServiceAddOnConfig.vue +0 -207
@@ -29,7 +29,8 @@ export function init(store) {
29
29
  weight: 97,
30
30
  ifHaveGroup: 'catalog.cattle.io',
31
31
  icon: 'marketplace',
32
- showNamespaceFilter: true
32
+ showNamespaceFilter: true,
33
+ extendable: true,
33
34
  });
34
35
 
35
36
  virtualType({
@@ -53,6 +54,7 @@ export function init(store) {
53
54
 
54
55
  configureType(CATALOG.APP, { isCreatable: false, isEditable: false });
55
56
  configureType(CATALOG.OPERATION, { isCreatable: false, isEditable: false });
57
+ configureType(CATALOG.CLUSTER_REPO, { listCreateButtonLabelKey: 'catalog.repo.add' });
56
58
 
57
59
  const repoType = {
58
60
  name: 'type',
@@ -34,6 +34,7 @@ export function init(store) {
34
34
  removable: false,
35
35
  showClusterSwitcher: false,
36
36
  category: 'configuration',
37
+ extendable: true,
37
38
  });
38
39
 
39
40
  virtualType({
@@ -16,6 +16,7 @@ export function init(store) {
16
16
  product({
17
17
  ifHaveGroup: /^(.*\.)*resources\.cattle\.io$/,
18
18
  icon: 'backup-restore',
19
+ extendable: true,
19
20
  });
20
21
 
21
22
  weightType(BACKUP_RESTORE.BACKUP, 99, true);
@@ -14,7 +14,7 @@ export function init(store) {
14
14
  headers
15
15
  } = DSL(store, NAME);
16
16
 
17
- product({ ifHaveGroup: /^(.*\.)*compliance\.cattle\.io$/ });
17
+ product({ ifHaveGroup: /^(.*\.)*compliance\.cattle\.io$/, extendable: true });
18
18
 
19
19
  weightType(COMPLIANCE.CLUSTER_SCAN, 3, true);
20
20
  weightType(COMPLIANCE.CLUSTER_SCAN_PROFILE, 2, true);
@@ -27,13 +27,15 @@ import {
27
27
 
28
28
  import { DSL } from '@shell/store/type-map';
29
29
  import {
30
- STEVE_AGE_COL, STEVE_EVENT_FIRST_SEEN, STEVE_EVENT_LAST_SEEN, STEVE_EVENT_OBJECT, STEVE_EVENT_TYPE, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_NAME_COL, STEVE_STATE_COL
30
+ STEVE_AGE_COL, STEVE_EVENT_FIRST_SEEN, STEVE_EVENT_LAST_SEEN, STEVE_EVENT_OBJECT, STEVE_EVENT_TYPE, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_NAME_COL, STEVE_STATE_COL,
31
+ STEVE_WORKLOAD_HEALTH_SCALE
31
32
  } from '@shell/config/pagination-table-headers';
32
33
 
33
34
  import { COLUMN_BREAKPOINTS } from '@shell/types/store/type-map';
34
35
  import { STEVE_CACHE } from '@shell/store/features';
35
36
  import { configureConditionalDepaginate } from '@shell/store/type-map.utils';
36
37
  import { CATTLE_PUBLIC_ENDPOINTS, STORAGE } from '@shell/config/labels-annotations';
38
+ import { POD_LAST_RESTART_FIELD as POD_RESTARTS_LAST_FIELD, POD_RESTART_FIELD as POD_RESTARTS_COUNT_FIELD } from '@shell/types/resources/pod';
37
39
 
38
40
  export const NAME = 'explorer';
39
41
 
@@ -58,6 +60,7 @@ export function init(store) {
58
60
  weight: 3,
59
61
  showNamespaceFilter: true,
60
62
  icon: 'compass',
63
+ extendable: true,
61
64
  typeStoreMap: {
62
65
  [MANAGEMENT.PROJECT]: 'management',
63
66
  [MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING]: 'management',
@@ -386,11 +389,11 @@ export function init(store) {
386
389
  headers(WORKLOAD, [STATE, NAME_COL, NAMESPACE_COL, TYPE, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE]);
387
390
  headers(WORKLOAD_TYPES.DEPLOYMENT,
388
391
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Up-to-date', 'Available', POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
389
- [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(6), STEVE_WORKLOAD_ENDPOINTS, 'Ready', 'Up-to-date', 'Available', STEVE_AGE_COL],
392
+ [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(6), STEVE_WORKLOAD_ENDPOINTS, 'Ready', 'Up-to-date', 'Available', STEVE_AGE_COL, STEVE_WORKLOAD_HEALTH_SCALE],
390
393
  );
391
394
  headers(WORKLOAD_TYPES.DAEMON_SET,
392
395
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
393
- [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(9), STEVE_WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', STEVE_AGE_COL]
396
+ [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(9), STEVE_WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', STEVE_AGE_COL, STEVE_WORKLOAD_HEALTH_SCALE]
394
397
  );
395
398
  headers(WORKLOAD_TYPES.REPLICA_SET,
396
399
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', 'Current', 'Desired', POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
@@ -398,7 +401,7 @@ export function init(store) {
398
401
  );
399
402
  headers(WORKLOAD_TYPES.STATEFUL_SET,
400
403
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Ready', POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
401
- [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(4), STEVE_WORKLOAD_ENDPOINTS, 'Ready', STEVE_AGE_COL],
404
+ [STEVE_STATE_COL, STEVE_NAME_COL, STEVE_NAMESPACE_COL, createSteveWorkloadImageCol(4), STEVE_WORKLOAD_ENDPOINTS, 'Ready', STEVE_AGE_COL, STEVE_WORKLOAD_HEALTH_SCALE],
402
405
  );
403
406
  headers(WORKLOAD_TYPES.JOB,
404
407
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Completions', DURATION, POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
@@ -408,7 +411,7 @@ export function init(store) {
408
411
  sort: 'metadata.fields.3',
409
412
  search: 'metadata.fields.3',
410
413
  formatter: undefined, // Now that sort/search is remote we're not doing weird things with start time (see `duration` in model)
411
- }, STEVE_AGE_COL],
414
+ }, STEVE_AGE_COL, STEVE_WORKLOAD_HEALTH_SCALE],
412
415
  );
413
416
  headers(WORKLOAD_TYPES.CRON_JOB,
414
417
  [STATE, NAME_COL, NAMESPACE_COL, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, 'Schedule', 'Last Schedule', POD_RESTARTS, AGE, WORKLOAD_HEALTH_SCALE],
@@ -428,7 +431,23 @@ export function init(store) {
428
431
  ...POD_IMAGES,
429
432
  sort: false,
430
433
  search: 'spec.containers.image'
431
- }, 'Ready', 'Restarts', 'IP', {
434
+ },
435
+ 'Ready',
436
+ {
437
+ name: 'pod-restart',
438
+ labelKey: 'tableHeaders.podRestarts',
439
+ search: false,
440
+ sort: [POD_RESTARTS_COUNT_FIELD, POD_RESTARTS_LAST_FIELD, 'metadata.name'],
441
+ value: 'restartsCount',
442
+ }, {
443
+ name: 'pod-last-restart',
444
+ labelKey: 'tableHeaders.podLastRestart',
445
+ value: 'restartsLaster',
446
+ search: false,
447
+ sort: [POD_RESTARTS_LAST_FIELD, POD_RESTARTS_COUNT_FIELD, 'metadata.name'],
448
+ },
449
+ 'IP',
450
+ {
432
451
  ...NODE_COL,
433
452
  search: 'spec.nodeName'
434
453
  },
@@ -32,6 +32,7 @@ export function init(store) {
32
32
  removable: false,
33
33
  showClusterSwitcher: false,
34
34
  showWorkspaceSwitcher: true,
35
+ extendable: true,
35
36
  to: {
36
37
  name: 'c-cluster-fleet',
37
38
  params: { cluster: BLANK_CLUSTER }
@@ -21,6 +21,7 @@ export function init(store) {
21
21
  product({
22
22
  ifHaveGroup: /^(.*\.)?gatekeeper\.sh$/,
23
23
  icon: 'gatekeeper',
24
+ extendable: true,
24
25
  });
25
26
 
26
27
  mapGroup(/^(.*\.)?gatekeeper\.sh$/, 'OPA Gatekeeper');
@@ -18,6 +18,7 @@ export function init(store) {
18
18
  ifHave: IF_HAVE.NOT_V1_ISTIO,
19
19
  icon: 'istio',
20
20
  showNamespaceFilter: true,
21
+ extendable: true,
21
22
  });
22
23
 
23
24
  virtualType({
@@ -21,6 +21,7 @@ export function init(store) {
21
21
  icon: 'logging',
22
22
  showNamespaceFilter: true,
23
23
  weight: 89,
24
+ extendable: true,
24
25
  });
25
26
 
26
27
  basicType([
@@ -20,7 +20,8 @@ export function init(store) {
20
20
 
21
21
  product({
22
22
  ifHaveGroup: 'longhorn.io',
23
- icon: 'longhorn'
23
+ icon: 'longhorn',
24
+ extendable: true,
24
25
  });
25
26
 
26
27
  virtualType({
@@ -38,6 +38,7 @@ export function init(store) {
38
38
  removable: false,
39
39
  showClusterSwitcher: false,
40
40
  weight: -1, // Place at the top
41
+ extendable: true,
41
42
  to: {
42
43
  name: 'c-cluster-product-resource',
43
44
  params: {
@@ -38,6 +38,7 @@ export function init(store) {
38
38
  icon: 'monitoring',
39
39
  showNamespaceFilter: true,
40
40
  weight: 90,
41
+ extendable: true,
41
42
  });
42
43
 
43
44
  virtualType({
@@ -10,6 +10,7 @@ export function init(store) {
10
10
  product({
11
11
  ifHaveType: UI.NAV_LINK,
12
12
  icon: 'external-link',
13
+ extendable: false,
13
14
  });
14
15
 
15
16
  headers(UI.NAV_LINK, [
@@ -12,7 +12,8 @@ export function init(store) {
12
12
  product({
13
13
  ifHaveGroup: 'neuvector.com',
14
14
  ifHave: IF_HAVE.NEUVECTOR_NAMESPACE,
15
- icon: 'neuvector'
15
+ icon: 'neuvector',
16
+ extendable: true,
16
17
  });
17
18
 
18
19
  virtualType({
@@ -28,6 +28,7 @@ export function init(store) {
28
28
  showClusterSwitcher: false,
29
29
  category: 'configuration',
30
30
  weight: 100,
31
+ extendable: true,
31
32
  });
32
33
 
33
34
  virtualType({
@@ -14,5 +14,6 @@ export function init(store) {
14
14
  showClusterSwitcher: false,
15
15
  category: 'configuration',
16
16
  weight: 50,
17
+ extendable: false,
17
18
  });
18
19
  }
@@ -38,8 +38,30 @@ jest.mock('@shell/core/plugin', () => {
38
38
  };
39
39
  });
40
40
 
41
- // Mock ExtensionPoint
42
- jest.mock('@shell/core/types', () => ({ ExtensionPoint: { EDIT_YAML: 'edit-yaml' } }));
41
+ // Mock ExtensionPoint — only EDIT_YAML, simulating an older dashboard (e.g. 2.13)
42
+ // that does not know about newer ExtensionPoints like 'Table'.
43
+ // The ensureUIConfigCompat tests override this to include TABLE for plugin-helpers,
44
+ // simulating an extension shipping a newer shell.
45
+ jest.mock('@shell/core/types', () => ({
46
+ ExtensionPoint: { EDIT_YAML: 'edit-yaml' },
47
+ ActionLocation: { TABLE: 'table-action' },
48
+ CardLocation: {},
49
+ }));
50
+
51
+ jest.mock('@shell/utils/platform', () => ({ isMac: false }));
52
+ jest.mock('@shell/utils/string', () => ({
53
+ ucFirst: jest.fn((s) => s),
54
+ randomStr: jest.fn(() => 'abc123'),
55
+ }));
56
+ jest.mock('@shell/config/query-params', () => ({
57
+ _EDIT: 'edit',
58
+ _CONFIG: 'config',
59
+ _DETAIL: 'detail',
60
+ _LIST: 'list',
61
+ _CREATE: 'create',
62
+ }));
63
+ jest.mock('@shell/utils/router', () => ({ getProductFromRoute: jest.fn() }));
64
+ jest.mock('@shell/utils/object', () => ({ isEqual: jest.fn() }));
43
65
 
44
66
  // Mock PluginRoutes
45
67
  jest.mock('@shell/core/plugin-routes', () => {
@@ -299,6 +321,28 @@ describe('extension Manager', () => {
299
321
  delete window[pluginId];
300
322
  });
301
323
 
324
+ it('surfaces the plugin id and original error message when initialization fails', async() => {
325
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
326
+ const pluginId = 'surface-error-plugin';
327
+ const originalError = new Error('something went wrong');
328
+ const mockPluginInit = jest.fn().mockImplementation(() => {
329
+ throw originalError;
330
+ });
331
+
332
+ window[pluginId] = { default: mockPluginInit };
333
+
334
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
335
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
336
+
337
+ script.onload();
338
+
339
+ await expect(loadPromise).rejects.toThrow(`Could not initialize plugin ${ pluginId } - ${ originalError.message }`);
340
+ expect(consoleErrorSpy).toHaveBeenCalledWith(`Could not initialize plugin ${ pluginId }`, originalError);
341
+
342
+ consoleErrorSpy.mockRestore();
343
+ delete window[pluginId];
344
+ });
345
+
302
346
  it('rejects if script load fails', async() => {
303
347
  const pluginId = 'fail-plugin';
304
348
  const loadPromise = manager.loadAsync(pluginId, 'bad-url.js');
@@ -433,5 +477,146 @@ describe('extension Manager', () => {
433
477
 
434
478
  expect(config).toStrictEqual([]);
435
479
  });
480
+
481
+ it('returns empty array for non-existent type', () => {
482
+ // Simulates an extension using a newer ExtensionPoint (e.g. 'Table')
483
+ // that the running dashboard does not have in its uiConfig
484
+ const config = manager.getUIConfig('unknown-type', 'some-area');
485
+
486
+ expect(config).toStrictEqual([]);
487
+ });
488
+ });
489
+
490
+ describe('ensureUIConfigCompat (via plugin-helpers)', () => {
491
+ // These tests verify that getApplicableExtensionEnhancements tracks missing
492
+ // ExtensionPoint keys and exits early when accessing them.
493
+ // This is the forwards-compatibility mechanism for extensions running on
494
+ // older dashboards that don't know about newer ExtensionPoints (e.g. 'Table').
495
+
496
+ const mockRoute = {
497
+ name: 'test',
498
+ params: {},
499
+ query: {},
500
+ meta: {},
501
+ hash: '',
502
+ path: '/test',
503
+ };
504
+
505
+ // Each test needs a fresh plugin-helpers module to reset the _uiConfigPatched tracking.
506
+ // The extension-manager is created with the "old" ExtensionPoint (only EDIT_YAML),
507
+ // then plugin-helpers is re-required with an "upgraded" ExtensionPoint that includes TABLE,
508
+ // simulating an extension built with a newer shell running on an older dashboard.
509
+ let freshGetApplicable;
510
+ let freshManager;
511
+
512
+ beforeEach(() => {
513
+ freshManager = createExtensionManager(context);
514
+
515
+ // Reset modules so plugin-helpers._uiConfigPatched starts as empty object
516
+ jest.resetModules();
517
+
518
+ // Override the types mock so plugin-helpers sees a newer ExtensionPoint with TABLE
519
+ jest.doMock('@shell/core/types', () => ({
520
+ ExtensionPoint: {
521
+ EDIT_YAML: 'edit-yaml',
522
+ TABLE: 'Table',
523
+ },
524
+ ActionLocation: { TABLE: 'table-action' },
525
+ CardLocation: {},
526
+ }));
527
+
528
+ freshGetApplicable = require('@shell/core/plugin-helpers').getApplicableExtensionEnhancements;
529
+ });
530
+
531
+ it('returns empty array for missing ExtensionPoint and does not mutate uiConfig', () => {
532
+ // Verify 'Table' is NOT in uiConfig before the call
533
+ const uiConfig = freshManager.getAllUIConfig();
534
+
535
+ expect(uiConfig['Table']).toBeUndefined();
536
+
537
+ // Call getApplicableExtensionEnhancements which triggers ensureUIConfigCompat
538
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
539
+
540
+ // Should return empty array for missing extension point
541
+ expect(result).toStrictEqual([]);
542
+
543
+ // uiConfig should NOT be mutated - 'Table' should remain undefined
544
+ expect(uiConfig['Table']).toBeUndefined();
545
+ });
546
+
547
+ it('does not affect existing ExtensionPoint keys', () => {
548
+ const mockAction = { label: 'Test', locationConfig: {} };
549
+ const plugin = {
550
+ types: {},
551
+ uiConfig: { 'edit-yaml': { header: [mockAction] } },
552
+ l10n: {},
553
+ modelExtensions: {},
554
+ stores: [],
555
+ locales: [],
556
+ routes: [],
557
+ validators: {},
558
+ productNames: [],
559
+ };
560
+
561
+ freshManager.applyPlugin(plugin);
562
+
563
+ const result = freshGetApplicable({ $extension: freshManager }, 'edit-yaml', 'header', mockRoute);
564
+
565
+ // Should return the actual action
566
+ expect(result).toHaveLength(1);
567
+ expect(result[0].label).toBe('Test');
568
+
569
+ // Existing config should be untouched
570
+ const uiConfig = freshManager.getAllUIConfig();
571
+
572
+ expect(uiConfig['edit-yaml'].header).toHaveLength(1);
573
+ expect(uiConfig['edit-yaml'].header[0]).toBe(mockAction);
574
+ });
575
+
576
+ it('logs a warning for missing ExtensionPoints', () => {
577
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
578
+
579
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
580
+
581
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
582
+ expect.stringContaining('Table')
583
+ );
584
+
585
+ consoleWarnSpy.mockRestore();
586
+ });
587
+
588
+ it('dashboard-side getUIConfig calls remain safe with optional chaining', () => {
589
+ // getUIConfig('Table', ...) should not crash even though 'Table' is missing
590
+ // Returns empty array due to optional chaining in extension-manager
591
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
592
+
593
+ // Trigger ensureUIConfigCompat via getApplicableExtensionEnhancements
594
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
595
+
596
+ // getApplicableExtensionEnhancements exits early and returns empty array
597
+ expect(result).toStrictEqual([]);
598
+
599
+ // uiConfig is NOT mutated - 'Table' key still doesn't exist
600
+ const uiConfig = freshManager.getAllUIConfig();
601
+
602
+ expect(uiConfig['Table']).toBeUndefined();
603
+
604
+ // Direct getUIConfig calls still work safely (optional chaining)
605
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
606
+ });
607
+
608
+ it('only logs warning once for each missing ExtensionPoint', () => {
609
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
610
+
611
+ // First call logs warning and tracks 'Table' as missing
612
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
613
+ // Second call should not log warning again (already tracked)
614
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
615
+
616
+ // Warning should only be logged once
617
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
618
+
619
+ consoleWarnSpy.mockRestore();
620
+ });
436
621
  });
437
622
  });