@sdeverywhere/plugin-check 0.3.36 → 0.3.38

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.
@@ -0,0 +1,66 @@
1
+ // Copyright (c) 2026 Climate Interactive / New Venture Fund
2
+
3
+ /**
4
+ * Identifies one side of the model-check comparison.
5
+ */
6
+ export type BundleSide = 'left' | 'right'
7
+
8
+ /**
9
+ * Describes the bundle that is selected for one side of the comparison.
10
+ */
11
+ export interface BundleMetadata {
12
+ /** The name of the bundle. */
13
+ name: string
14
+ /** The URL of the bundle, or 'current' for the bundle built into the report app. */
15
+ url: string
16
+ }
17
+
18
+ /**
19
+ * Return the `LocalStorage` key used to hold the selected bundle for the given side.
20
+ *
21
+ * @param side The side of the comparison.
22
+ * @returns The `LocalStorage` key.
23
+ */
24
+ function keyForSide(side: BundleSide): string {
25
+ return `sde-check-selected-bundle-${side}`
26
+ }
27
+
28
+ /**
29
+ * Return the metadata for the bundle that was previously selected for the given side.
30
+ *
31
+ * @param side The side of the comparison.
32
+ * @returns The saved bundle metadata, or undefined if there is no valid saved metadata.
33
+ */
34
+ export function loadBundleMetadata(side: BundleSide): BundleMetadata | undefined {
35
+ const metadataJson = localStorage.getItem(keyForSide(side))
36
+ if (metadataJson) {
37
+ try {
38
+ const parsed = JSON.parse(metadataJson)
39
+ if (parsed.name && parsed.url) {
40
+ return parsed as BundleMetadata
41
+ }
42
+ } catch (_e) {
43
+ // Treat malformed metadata the same as if no bundle was selected
44
+ }
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ /**
50
+ * Save the metadata for the bundle that is selected for the given side.
51
+ *
52
+ * @param side The side of the comparison.
53
+ * @param metadata The bundle metadata to be saved.
54
+ */
55
+ export function saveBundleMetadata(side: BundleSide, metadata: BundleMetadata): void {
56
+ localStorage.setItem(keyForSide(side), JSON.stringify(metadata))
57
+ }
58
+
59
+ /**
60
+ * Forget the bundle that is selected for the given side.
61
+ *
62
+ * @param side The side of the comparison.
63
+ */
64
+ export function clearBundleMetadata(side: BundleSide): void {
65
+ localStorage.removeItem(keyForSide(side))
66
+ }
@@ -6,9 +6,11 @@ import type { BundleLocation, BundleSpec } from '@sdeverywhere/check-ui-shell'
6
6
  import { initAppShell } from '@sdeverywhere/check-ui-shell'
7
7
  import '@sdeverywhere/check-ui-shell/dist/style.css'
8
8
 
9
- import type { BundleMetadata, BundleResult } from './load-bundle'
9
+ import type { BundleMetadata } from './bundle-metadata'
10
+ import { loadBundleMetadata, saveBundleMetadata } from './bundle-metadata'
10
11
  import { loadBundle } from './load-bundle'
11
12
  import { initOverlay } from './overlay'
13
+ import { resolveBundle } from './resolve-bundle'
12
14
 
13
15
  import './global.css'
14
16
 
@@ -17,36 +19,6 @@ import { createBundle as createBaselineBundle } from '@_baseline_bundle_'
17
19
  import { createBundle as createCurrentBundle } from '@_current_bundle_'
18
20
  import { getConfigOptions } from '@_test_config_'
19
21
 
20
- function loadBundleMetadata(side: 'left' | 'right'): BundleMetadata | undefined {
21
- if (import.meta.hot) {
22
- const metadataJson = localStorage.getItem(`sde-check-selected-bundle-${side}`)
23
- if (metadataJson) {
24
- const parsed = JSON.parse(metadataJson)
25
- if (parsed.name && parsed.url) {
26
- return parsed as BundleMetadata
27
- }
28
- }
29
- }
30
- return undefined
31
- }
32
-
33
- function saveBundleMetadata(side: 'left' | 'right', metadata: BundleMetadata): void {
34
- if (import.meta.hot) {
35
- localStorage.setItem(`sde-check-selected-bundle-${side}`, JSON.stringify(metadata))
36
- }
37
- }
38
-
39
- // For local development mode, use the bundle metadata saved in `LocalStorage`
40
- let savedBundleMetadataL: BundleMetadata | undefined
41
- let savedBundleMetadataR: BundleMetadata | undefined
42
- // The following value will be injected by `vite-config-for-report.ts`
43
- const bundlesPath = './bundles/**/*.txt'
44
- if (import.meta.hot && bundlesPath) {
45
- // Restore the previously selected bundles (from before the page was reloaded)
46
- savedBundleMetadataL = loadBundleMetadata('left')
47
- savedBundleMetadataR = loadBundleMetadata('right')
48
- }
49
-
50
22
  async function initForProduction(): Promise<void> {
51
23
  // For "production" builds, load the summary from a JSON file that
52
24
  // was generated as part of the build process. This makes the
@@ -87,56 +59,27 @@ async function initForProduction(): Promise<void> {
87
59
  }
88
60
 
89
61
  async function initForLocal(): Promise<void> {
90
- async function createBundle(
91
- bundleMetadata: BundleMetadata | undefined,
92
- side: 'left' | 'right'
93
- ): Promise<BundleResult> {
94
- if (bundleMetadata === undefined) {
95
- bundleMetadata = {
96
- name: 'current',
97
- url: 'current'
98
- }
99
- }
100
-
101
- if (bundleMetadata.url.startsWith('http') || bundleMetadata.url.startsWith('file://')) {
102
- // Load bundles (both local and remote) via the Vite dev server
103
- try {
104
- console.log(`Loading bundle for ${side} side: name=${bundleMetadata.name} url=${bundleMetadata.url}`)
105
- const result = await loadBundle(bundleMetadata)
106
- if (result) {
107
- return result
108
- } else {
109
- console.error(`ERROR: Failed to load bundle ${bundleMetadata.name}; will use "current" bundle instead`)
110
- }
111
- } catch (e) {
112
- console.error(
113
- `ERROR: Failed to load bundle from ${bundleMetadata.url}; will use "current" bundle instead. Cause:`,
114
- e
115
- )
116
- }
117
- }
118
-
119
- // Load the "current" bundle if it was requested or if the other loading
120
- // processes failed
121
- console.log(`Loading current bundle for ${side} side`)
122
- const bundle = createCurrentBundle()
123
- return {
124
- bundle,
125
- bundleName: 'current',
126
- bundleUrl: 'current'
127
- }
62
+ const resolveBundleDeps = {
63
+ loadBundle,
64
+ createCurrentBundle
128
65
  }
129
66
 
67
+ // Restore the bundles that were previously selected (from before the page was reloaded).
68
+ // Note that we read these each time the app is initialized, since `resolveBundle` will
69
+ // clear a saved selection if the bundle cannot be loaded.
70
+ const savedBundleMetadataL = loadBundleMetadata('left')
71
+ const savedBundleMetadataR = loadBundleMetadata('right')
72
+
130
73
  const {
131
74
  bundle: bundleL,
132
75
  bundleName: bundleNameL,
133
76
  bundleUrl: bundleUrlL
134
- } = await createBundle(savedBundleMetadataL, 'left')
77
+ } = await resolveBundle('left', savedBundleMetadataL, resolveBundleDeps)
135
78
  const {
136
79
  bundle: bundleR,
137
80
  bundleName: bundleNameR,
138
81
  bundleUrl: bundleUrlR
139
- } = await createBundle(savedBundleMetadataR, 'right')
82
+ } = await resolveBundle('right', savedBundleMetadataR, resolveBundleDeps)
140
83
 
141
84
  // Prepare the model check/comparison configuration
142
85
  const configInitOptions: ConfigInitOptions = {
@@ -197,13 +140,9 @@ if (import.meta.hot) {
197
140
  document.addEventListener('sde-check-bundle', e => {
198
141
  // Change the selected bundle
199
142
  const info = (e as CustomEvent).detail
200
- const bundleMetadata = { name: info.name, url: info.url }
201
- if (info.side === 'left') {
202
- saveBundleMetadata('left', bundleMetadata)
203
- savedBundleMetadataL = bundleMetadata
204
- } else if (info.side === 'right') {
205
- saveBundleMetadata('right', bundleMetadata)
206
- savedBundleMetadataR = bundleMetadata
143
+ const bundleMetadata: BundleMetadata = { name: info.name, url: info.url }
144
+ if (info.side === 'left' || info.side === 'right') {
145
+ saveBundleMetadata(info.side, bundleMetadata)
207
146
  }
208
147
 
209
148
  // Reinitialize using the chosen bundles
@@ -2,10 +2,7 @@
2
2
 
3
3
  import type { Bundle } from '@sdeverywhere/check-core'
4
4
 
5
- export interface BundleMetadata {
6
- name: string
7
- url: string
8
- }
5
+ import type { BundleMetadata } from './bundle-metadata'
9
6
 
10
7
  export interface BundleResult {
11
8
  bundle: Bundle
@@ -0,0 +1,109 @@
1
+ // Copyright (c) 2026 Climate Interactive / New Venture Fund
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import type { Bundle } from '@sdeverywhere/check-core'
6
+
7
+ import type { BundleMetadata } from './bundle-metadata'
8
+ import { loadBundleMetadata, saveBundleMetadata } from './bundle-metadata'
9
+ import type { BundleResult } from './load-bundle'
10
+ import type { ResolveBundleDeps } from './resolve-bundle'
11
+ import { resolveBundle } from './resolve-bundle'
12
+
13
+ /**
14
+ * A minimal in-memory implementation of the parts of the `Storage` API that are
15
+ * used by the bundle metadata functions.
16
+ */
17
+ class FakeStorage {
18
+ private readonly items: Map<string, string> = new Map()
19
+
20
+ getItem(key: string): string | null {
21
+ const value = this.items.get(key)
22
+ return value !== undefined ? value : null
23
+ }
24
+
25
+ setItem(key: string, value: string): void {
26
+ this.items.set(key, value)
27
+ }
28
+
29
+ removeItem(key: string): void {
30
+ this.items.delete(key)
31
+ }
32
+ }
33
+
34
+ const currentBundle = { version: 1 } as unknown as Bundle
35
+ const remoteBundle = { version: 1 } as unknown as Bundle
36
+
37
+ const remoteMetadata: BundleMetadata = { name: 'remote-2', url: 'http://localhost:9000/remote-2.js' }
38
+
39
+ function deps(loadBundle: ResolveBundleDeps['loadBundle']): ResolveBundleDeps {
40
+ return {
41
+ loadBundle,
42
+ createCurrentBundle: () => currentBundle
43
+ }
44
+ }
45
+
46
+ beforeEach(() => {
47
+ vi.stubGlobal('localStorage', new FakeStorage())
48
+ vi.spyOn(console, 'log').mockImplementation(() => {})
49
+ vi.spyOn(console, 'error').mockImplementation(() => {})
50
+ })
51
+
52
+ describe('resolveBundle', () => {
53
+ it('should use the current bundle if no metadata is provided', async () => {
54
+ const loadBundle = vi.fn()
55
+ const result = await resolveBundle('right', undefined, deps(loadBundle))
56
+ expect(result).toEqual({ bundle: currentBundle, bundleName: 'current', bundleUrl: 'current' })
57
+ expect(loadBundle).not.toHaveBeenCalled()
58
+ })
59
+
60
+ it('should use the current bundle if the saved metadata refers to the current bundle', async () => {
61
+ const loadBundle = vi.fn()
62
+ const metadata: BundleMetadata = { name: 'current', url: 'current' }
63
+ const result = await resolveBundle('right', metadata, deps(loadBundle))
64
+ expect(result).toEqual({ bundle: currentBundle, bundleName: 'current', bundleUrl: 'current' })
65
+ expect(loadBundle).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('should use the loaded bundle and keep the saved metadata if the bundle is loaded successfully', async () => {
69
+ saveBundleMetadata('right', remoteMetadata)
70
+ const loaded: BundleResult = {
71
+ bundle: remoteBundle,
72
+ bundleName: remoteMetadata.name,
73
+ bundleUrl: remoteMetadata.url
74
+ }
75
+ const loadBundle = vi.fn(async () => loaded)
76
+ const result = await resolveBundle('right', remoteMetadata, deps(loadBundle))
77
+ expect(result).toEqual(loaded)
78
+ expect(loadBundle).toHaveBeenCalledWith(remoteMetadata)
79
+ expect(loadBundleMetadata('right')).toEqual(remoteMetadata)
80
+ })
81
+
82
+ it('should fall back to the current bundle and clear the saved metadata if the bundle cannot be loaded', async () => {
83
+ saveBundleMetadata('right', remoteMetadata)
84
+ const loadBundle = vi.fn(async () => undefined)
85
+ const result = await resolveBundle('right', remoteMetadata, deps(loadBundle))
86
+ expect(result).toEqual({ bundle: currentBundle, bundleName: 'current', bundleUrl: 'current' })
87
+ expect(loadBundleMetadata('right')).toBeUndefined()
88
+ })
89
+
90
+ it('should fall back to the current bundle and clear the saved metadata if the load fails with an error', async () => {
91
+ saveBundleMetadata('left', remoteMetadata)
92
+ const loadBundle = vi.fn(async () => {
93
+ throw new Error('fetch failed')
94
+ })
95
+ const result = await resolveBundle('left', remoteMetadata, deps(loadBundle))
96
+ expect(result).toEqual({ bundle: currentBundle, bundleName: 'current', bundleUrl: 'current' })
97
+ expect(loadBundleMetadata('left')).toBeUndefined()
98
+ })
99
+
100
+ it('should clear the saved metadata for the failing side only', async () => {
101
+ const localMetadata: BundleMetadata = { name: 'previous', url: 'file:///previous.js' }
102
+ saveBundleMetadata('left', localMetadata)
103
+ saveBundleMetadata('right', remoteMetadata)
104
+ const loadBundle = vi.fn(async () => undefined)
105
+ await resolveBundle('right', remoteMetadata, deps(loadBundle))
106
+ expect(loadBundleMetadata('left')).toEqual(localMetadata)
107
+ expect(loadBundleMetadata('right')).toBeUndefined()
108
+ })
109
+ })
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2026 Climate Interactive / New Venture Fund
2
+
3
+ import type { Bundle } from '@sdeverywhere/check-core'
4
+
5
+ import type { BundleMetadata, BundleSide } from './bundle-metadata'
6
+ import { clearBundleMetadata } from './bundle-metadata'
7
+ import type { BundleResult } from './load-bundle'
8
+
9
+ /**
10
+ * The functions used by `resolveBundle` to load or create a bundle.
11
+ */
12
+ export interface ResolveBundleDeps {
13
+ /** Load a bundle (local or remote) via the Vite dev server. */
14
+ loadBundle: (metadata: BundleMetadata) => Promise<BundleResult | undefined>
15
+ /** Create the "current" bundle that is built into the report app. */
16
+ createCurrentBundle: () => Bundle
17
+ }
18
+
19
+ /**
20
+ * Resolve the bundle to be used for one side of the comparison.
21
+ *
22
+ * If the given metadata refers to a local or remote bundle, that bundle will be loaded
23
+ * via the Vite dev server. If it cannot be loaded, the saved selection is cleared (so
24
+ * that we don't try and fail to load the same bundle every time the app is reloaded)
25
+ * and the "current" bundle is used instead.
26
+ *
27
+ * @param side The side of the comparison.
28
+ * @param metadata The metadata for the selected bundle, or undefined if no bundle was selected.
29
+ * @param deps The functions used to load or create a bundle.
30
+ * @returns The resolved bundle along with its name and URL.
31
+ */
32
+ export async function resolveBundle(
33
+ side: BundleSide,
34
+ metadata: BundleMetadata | undefined,
35
+ deps: ResolveBundleDeps
36
+ ): Promise<BundleResult> {
37
+ if (metadata && (metadata.url.startsWith('http') || metadata.url.startsWith('file://'))) {
38
+ // Load the bundle (either local or remote) via the Vite dev server
39
+ let failure: string
40
+ try {
41
+ console.log(`Loading bundle for ${side} side: name=${metadata.name} url=${metadata.url}`)
42
+ const result = await deps.loadBundle(metadata)
43
+ if (result) {
44
+ return result
45
+ }
46
+ failure = `ERROR: Failed to load bundle ${metadata.name}; will use "current" bundle instead`
47
+ } catch (e) {
48
+ failure = `ERROR: Failed to load bundle from ${metadata.url}; will use "current" bundle instead. Cause: ${e}`
49
+ }
50
+
51
+ // Forget the failed selection, otherwise we would try (and fail) to load the same
52
+ // bundle each time the app is reloaded
53
+ console.error(failure)
54
+ clearBundleMetadata(side)
55
+ }
56
+
57
+ // Use the "current" bundle if it was requested or if the load above failed
58
+ console.log(`Loading current bundle for ${side} side`)
59
+ return {
60
+ bundle: deps.createCurrentBundle(),
61
+ bundleName: 'current',
62
+ bundleUrl: 'current'
63
+ }
64
+ }