diff --git a/packages/terriajs/CHANGES.md b/packages/terriajs/CHANGES.md index d2b955cddf..d83c3aba5c 100644 --- a/packages/terriajs/CHANGES.md +++ b/packages/terriajs/CHANGES.md @@ -6,6 +6,8 @@ - Added `storyAutoStart` config parameter (default `false`). Set it to `true` to start playing a story as soon as a map containing one loads, instead of asking the user whether they want to view it. A share link that contains a story records the value in effect when it was created (as `settings.storyAutoStart`), so it keeps opening the same way if the map's configuration changes later. Because an auto-starting story sets the camera from its first scene, the current view is left out of share links that contain a story. - The `playStory` hash parameter now overrides `storyAutoStart` and shares its code path. It previously relied on a MobX reaction that never fired if the parameter was already set before `ViewState` was constructed, so `#playStory=1` did nothing in applications that create their `ViewState` after `Terria#start`. - Fixed share links dropping the `=` from the hash parameters they carry over from the current URL (e.g. creating a share while `#playStory=1` was set produced `#playStory%3D1&share=…`). The map read the mangled parameter as the name of an init file and failed to load it. +- Share links (and other init sources) are now resilient to a single broken part: applying init data no longer aborts when one model has invalid traits, a model has a `dereferenced` block but cannot be dereferenced, or `initialCamera`/`homeCamera` are invalid. Every part of the init data that can be applied is applied, and the failures are still reported to the user as a combined error. [#5168](https://github.com/TerriaJS/terriajs/issues/5168) +- Share links (and other init sources) are now resilient to a single broken part: applying init data no longer aborts when one model has invalid traits, a model has a `dereferenced` block but cannot be dereferenced, or `initialCamera`/`homeCamera` are invalid. Every part of the init data that can be applied is applied - including individual workbench items, so the layers that load are added to the workbench even if others fail - and the failures are still reported to the user as a combined error. [#5168](https://github.com/TerriaJS/terriajs/issues/5168) #### 8.13.0 - 2026-09-11 diff --git a/packages/terriajs/lib/Models/Terria.ts b/packages/terriajs/lib/Models/Terria.ts index e268e02f55..1155a08b4d 100644 --- a/packages/terriajs/lib/Models/Terria.ts +++ b/packages/terriajs/lib/Models/Terria.ts @@ -1532,9 +1532,12 @@ export default class Terria { const result = await this._applyInitData({ initData: initSource!.data }); + // Capture the base map promise before handling any error, otherwise it + // is dropped and races `loadPersistedOrInitBaseMap()` below. if (result.baseMapPromise) { baseMapPromise = result.baseMapPromise; } + if (result.error) throw result.error; } catch (e) { errors.push( TerriaError.from(e, { @@ -1596,11 +1599,13 @@ export default class Terria { ): Promise> { const thisModelStratumData = allModelStratumData[modelId] || {}; if (!isJsonObject(thisModelStratumData)) { - throw new TerriaError({ - sender: this, - title: "Invalid model traits", - message: "The traits of a model must be a JSON object." - }); + return Result.error( + new TerriaError({ + sender: this, + title: "Invalid model traits", + message: `The traits of model \`${modelId}\` must be a JSON object.` + }) + ); } const cleanStratumData = { ...thisModelStratumData }; @@ -1617,25 +1622,31 @@ export default class Terria { if (typeof containerId !== "string") { return; } - const container = ( - await this.loadModelStratum( - containerId, - stratumId, - allModelStratumData, - replaceStratum - ) - ).pushErrorTo(errors, `Failed to load container ${containerId}`); - - if (container) { - const dereferenced = ReferenceMixin.isMixedInto(container) - ? container.target - : container; - if (GroupMixin.isMixedInto(dereferenced)) { - (await dereferenced.loadMembers()).pushErrorTo( - errors, - `Failed to load group ${dereferenced.uniqueId}` - ); + try { + const container = ( + await this.loadModelStratum( + containerId, + stratumId, + allModelStratumData, + replaceStratum + ) + ).pushErrorTo(errors, `Failed to load container ${containerId}`); + + if (container) { + const dereferenced = ReferenceMixin.isMixedInto(container) + ? container.target + : container; + if (GroupMixin.isMixedInto(dereferenced)) { + (await dereferenced.loadMembers()).pushErrorTo( + errors, + `Failed to load group ${dereferenced.uniqueId}` + ); + } } + } catch (e) { + errors.push( + TerriaError.from(e, `Failed to load container ${containerId}`) + ); } }) ); @@ -1727,13 +1738,15 @@ export default class Terria { ); } } else if (dereferenced) { - throw new TerriaError({ - sender: this, - title: "Model cannot be dereferenced", - message: `Model ${getName( - loadedModel - )} has a \`dereferenced\` property, but the model cannot be dereferenced.` - }); + errors.push( + new TerriaError({ + sender: this, + title: "Model cannot be dereferenced", + message: `Model ${getName( + loadedModel + )} has a \`dereferenced\` property, but the model cannot be dereferenced.` + }) + ); } if (loadedModel) { @@ -1809,7 +1822,8 @@ export default class Terria { // This is for eg, set to true when switching through story slides. canUnsetFeaturePickingState?: boolean; }): Promise { - await this._applyInitData(params); + const { error } = await this._applyInitData(params); + if (error) throw error; } /** @@ -1824,131 +1838,188 @@ export default class Terria { initData: InitSourceData; replaceStratum?: boolean; canUnsetFeaturePickingState?: boolean; - }): Promise<{ baseMapPromise: Promise | undefined }> { + }): Promise<{ + baseMapPromise: Promise | undefined; + error: TerriaError | undefined; + }> { const errors: TerriaError[] = []; initData = toJS(initData); let baseMapPromise: Promise | undefined; + /** + * Applies one part of the init data, collecting any error instead of + * throwing it. + * + * Returns whatever `apply` returned, or `undefined` if it threw. + */ + const applyPart = (name: string, apply: () => T): T | undefined => { + try { + return apply(); + } catch (e) { + errors.push( + TerriaError.from(e, `Failed to apply \`${name}\` from init data`) + ); + return undefined; + } + }; + + const applyPartAsync = async ( + name: string, + apply: () => Promise + ): Promise => { + try { + return await apply(); + } catch (e) { + errors.push( + TerriaError.from(e, `Failed to apply \`${name}\` from init data`) + ); + return undefined; + } + }; + const stratumId = typeof initData.stratum === "string" ? initData.stratum : CommonStrata.definition; // Extract the list of CORS-ready domains. - if (Array.isArray(initData.corsDomains)) { - this.corsProxy.corsDomains.push(...(initData.corsDomains as string[])); - } + applyPart("corsDomains", () => { + if (Array.isArray(initData.corsDomains)) { + this.corsProxy.corsDomains.push(...(initData.corsDomains as string[])); + } + }); // Add catalog members - if (initData.catalog !== undefined) { - this.catalog.group - .addMembersFromJson(stratumId, initData.catalog) - .pushErrorTo(errors); - } + applyPart("catalog", () => { + if (initData.catalog !== undefined) { + this.catalog.group + .addMembersFromJson(stratumId, initData.catalog) + .pushErrorTo(errors); + } + }); // Show/hide elements in mapNavigationModel - if (isJsonObject(initData.elements)) { - this.elements.merge(initData.elements); - // we don't want to go through all elements unless they are added. - if (this.mapNavigationModel.items.length > 0) { - this.elements.forEach((element, key) => { - if (isDefined(element.visible)) { - if (element.visible) { - this.mapNavigationModel.show(key); - } else { - this.mapNavigationModel.hide(key); + applyPart("elements", () => { + if (isJsonObject(initData.elements)) { + this.elements.merge(initData.elements); + // we don't want to go through all elements unless they are added. + if (this.mapNavigationModel.items.length > 0) { + this.elements.forEach((element, key) => { + if (isDefined(element.visible)) { + if (element.visible) { + this.mapNavigationModel.show(key); + } else { + this.mapNavigationModel.hide(key); + } } - } - }); + }); + } } - } + }); // Add stories - if (Array.isArray(initData.stories)) { - this.stories = initData.stories; - this.storyPromptShown++; - } + applyPart("stories", () => { + if (Array.isArray(initData.stories)) { + this.stories = initData.stories; + this.storyPromptShown++; + } + }); // Add map settings - if (isJsonString(initData.viewerMode)) { - const viewerMode = initData.viewerMode.toLowerCase(); - if (isViewerMode(viewerMode)) { - setViewerMode(viewerMode, this.mainViewer); + applyPart("viewerMode", () => { + if (isJsonString(initData.viewerMode)) { + const viewerMode = initData.viewerMode.toLowerCase(); + if (isViewerMode(viewerMode)) { + setViewerMode(viewerMode, this.mainViewer); + } } - } + }); - if (isJsonObject(initData.baseMaps)) { - this.baseMapsModel - .loadFromJson(CommonStrata.definition, initData.baseMaps) - .pushErrorTo(errors, "Failed to load basemaps"); - } + applyPart("baseMaps", () => { + if (isJsonObject(initData.baseMaps)) { + this.baseMapsModel + .loadFromJson(CommonStrata.definition, initData.baseMaps) + .pushErrorTo(errors, "Failed to load basemaps"); + } + }); - if (isJsonObject(initData.homeCamera)) { - this.loadHomeCamera(initData.homeCamera); - } + applyPart("homeCamera", () => { + if (isJsonObject(initData.homeCamera)) { + this.loadHomeCamera(initData.homeCamera); + } + }); - if (isJsonObject(initData.initialCamera)) { - // When initialCamera is set: - // - try to construct a CameraView and zoom to it - // - otherwise, if `initialCamera.focusWorkbenchItems` is `true` flag it - // so that we can zoom after the workbench items are loaded. - // - If there are multiple initSources, the setting from the last source takes effect - try { - const initialCamera = CameraView.fromJson(initData.initialCamera); - this.currentViewer.zoomTo(initialCamera, 2.0); - // reset in case this was enabled by a previous initSource - this.focusWorkbenchItemsAfterLoadingInitSources = false; - } catch (error) { - // Not a CameraView but does it specify focusWorkbenchItems? - if (typeof initData.initialCamera.focusWorkbenchItems === "boolean") { - this.focusWorkbenchItemsAfterLoadingInitSources = - initData.initialCamera.focusWorkbenchItems; - } else { - throw error; + applyPart("initialCamera", () => { + if (isJsonObject(initData.initialCamera)) { + // When initialCamera is set: + // - try to construct a CameraView and zoom to it + // - otherwise, if `initialCamera.focusWorkbenchItems` is `true` flag it + // so that we can zoom after the workbench items are loaded. + // - If there are multiple initSources, the setting from the last source takes effect + try { + const initialCamera = CameraView.fromJson(initData.initialCamera); + this.currentViewer.zoomTo(initialCamera, 2.0); + // reset in case this was enabled by a previous initSource + this.focusWorkbenchItemsAfterLoadingInitSources = false; + } catch (error) { + // Not a CameraView but does it specify focusWorkbenchItems? + if (typeof initData.initialCamera.focusWorkbenchItems === "boolean") { + this.focusWorkbenchItemsAfterLoadingInitSources = + initData.initialCamera.focusWorkbenchItems; + } else { + throw error; + } } } - } - - if (isJsonBoolean(initData.showSplitter)) { - this.showSplitter = initData.showSplitter; - } - - if (isJsonNumber(initData.splitPosition)) { - this.splitPosition = initData.splitPosition; - } + }); - if (isJsonObject(initData.settings)) { - if (isJsonNumber(initData.settings.baseMaximumScreenSpaceError)) { - this.setBaseMaximumScreenSpaceError( - initData.settings.baseMaximumScreenSpaceError - ); - } - if (isJsonBoolean(initData.settings.useNativeResolution)) { - this.setUseNativeResolution(initData.settings.useNativeResolution); + applyPart("showSplitter", () => { + if (isJsonBoolean(initData.showSplitter)) { + this.showSplitter = initData.showSplitter; } - if (isJsonBoolean(initData.settings.alwaysShowTimeline)) { - this.timelineStack.setAlwaysShowTimeline( - initData.settings.alwaysShowTimeline - ); - } - if (isJsonString(initData.settings.baseMapId)) { - baseMapPromise = this.mainViewer.setBaseMap( - this.baseMapsModel.findBaseMapById(initData.settings.baseMapId)?.item - ); - } - if (isJsonNumber(initData.settings.terrainSplitDirection)) { - this.terrainSplitDirection = initData.settings.terrainSplitDirection; - } - if (isJsonBoolean(initData.settings.depthTestAgainstTerrainEnabled)) { - this.depthTestAgainstTerrainEnabled = - initData.settings.depthTestAgainstTerrainEnabled; + }); + + applyPart("splitPosition", () => { + if (isJsonNumber(initData.splitPosition)) { + this.splitPosition = initData.splitPosition; } - if (isJsonBoolean(initData.settings.storyAutoStart)) { - this._storyAutoStartFromInitSource = initData.settings.storyAutoStart; + }); + + applyPart("settings", () => { + if (isJsonObject(initData.settings)) { + if (isJsonNumber(initData.settings.baseMaximumScreenSpaceError)) { + this.setBaseMaximumScreenSpaceError( + initData.settings.baseMaximumScreenSpaceError + ); + } + if (isJsonBoolean(initData.settings.useNativeResolution)) { + this.setUseNativeResolution(initData.settings.useNativeResolution); + } + if (isJsonBoolean(initData.settings.alwaysShowTimeline)) { + this.timelineStack.setAlwaysShowTimeline( + initData.settings.alwaysShowTimeline + ); + } + if (isJsonString(initData.settings.baseMapId)) { + baseMapPromise = this.mainViewer.setBaseMap( + this.baseMapsModel.findBaseMapById(initData.settings.baseMapId) + ?.item + ); + } + if (isJsonNumber(initData.settings.terrainSplitDirection)) { + this.terrainSplitDirection = initData.settings.terrainSplitDirection; + } + if (isJsonBoolean(initData.settings.depthTestAgainstTerrainEnabled)) { + this.depthTestAgainstTerrainEnabled = + initData.settings.depthTestAgainstTerrainEnabled; + } + if (isJsonBoolean(initData.settings.storyAutoStart)) { + this._storyAutoStartFromInitSource = initData.settings.storyAutoStart; + } } - } + }); // Copy but don't yet load the workbench. const workbench = Array.isArray(initData.workbench) @@ -1963,132 +2034,168 @@ export default class Terria { const models = initData.models; if (isJsonObject(models, false)) { await Promise.all( - Object.keys(models).map(async (modelId) => { - ( - await this.loadModelStratum( - modelId, - stratumId, - models, - replaceStratum - ) - ).pushErrorTo(errors); - }) + Object.keys(models).map((modelId) => + applyPartAsync(`model ${modelId}`, async () => { + ( + await this.loadModelStratum( + modelId, + stratumId, + models, + replaceStratum + ) + ).pushErrorTo(errors); + }) + ) ); } - runInAction(() => { - if (isJsonString(initData.previewedItemId)) { - this._previewedItemId = initData.previewedItemId; - } + applyPart("previewedItemId", () => { + runInAction(() => { + if (isJsonString(initData.previewedItemId)) { + this._previewedItemId = initData.previewedItemId; + } + }); }); // Set the new contents of the workbench. - const newItemsRaw = filterOutUndefined( - workbench.map((modelId) => { - if (typeof modelId !== "string") { - errors.push( - new TerriaError({ - sender: this, - title: "Invalid model ID in workbench", - message: "A model ID in the workbench list is not a string." - }) - ); - } else { - return this.getModelByIdOrShareKey(BaseModel, modelId); - } - }) - ); - + // Note `newItems` is collected outside of `applyPartAsync` so that items + // which loaded successfully are added to the workbench even if others fail. const newItems: BaseModel[] = []; - // Maintain the model order in the workbench. - for (;;) { - const model = newItemsRaw.shift(); - if (model) { - await this.pushAndLoadMapItems(model, newItems, errors); - } else { - break; - } - } - - newItems.forEach((item) => { - // fire the google analytics event - this.analytics.logEvent( - Category.dataSource, - DataSourceAction.addFromShareOrInit, - getPath(item) - ); - }); - - runInAction(() => (this.workbench.items = newItems)); - - // For ids that don't correspond to models resolve an id by share keys - const timelineWithShareKeysResolved = new Set( - filterOutUndefined( - timeline.map((modelId) => { + await applyPartAsync("workbench", async () => { + const newItemsRaw = filterOutUndefined( + workbench.map((modelId) => { if (typeof modelId !== "string") { errors.push( new TerriaError({ sender: this, - title: "Invalid model ID in timeline", - message: "A model ID in the timneline list is not a string." + title: "Invalid model ID in workbench", + message: "A model ID in the workbench list is not a string." }) ); - } else { - if (this.getModelById(BaseModel, modelId) !== undefined) { - return modelId; - } else { - return this.getModelIdByShareKey(modelId); - } + return undefined; } + return applyPart(`workbench item \`${modelId}\``, () => + this.getModelByIdOrShareKey(BaseModel, modelId) + ); }) - ) - ); - - // TODO: the timelineStack should be populated from the `timeline` property, - // not from the workbench. - runInAction( - () => - (this.timelineStack.items = this.workbench.items - .filter((item) => { - return ( - item.uniqueId && timelineWithShareKeysResolved.has(item.uniqueId) - ); - // && TODO: what is a good way to test if an item is of type TimeVarying. - }) - .map((item) => item as TimeVarying)) - ); + ); - if (isJsonObject(initData.pickedFeatures)) { - when(() => !(this.currentViewer instanceof NoViewer)).then(() => { - if (isJsonObject(initData.pickedFeatures)) { - this.loadPickedFeatures(initData.pickedFeatures); + // Maintain the model order in the workbench. + for (;;) { + const model = newItemsRaw.shift(); + if (model) { + await applyPartAsync(`workbench item \`${model.uniqueId}\``, () => + this.pushAndLoadMapItems(model, newItems, errors) + ); + } else { + break; } + } + + newItems.forEach((item) => { + applyPart(`workbench analytics for \`${item.uniqueId}\``, () => { + // fire the google analytics event + this.analytics.logEvent( + Category.dataSource, + DataSourceAction.addFromShareOrInit, + getPath(item) + ); + }); }); - } else if (canUnsetFeaturePickingState) { - runInAction(() => { - this.pickedFeatures = undefined; - this.selectedFeature = undefined; - }); - } + }); - if (initData.settings?.shortenShareUrls !== undefined) { - this.setLocalProperty( - "shortenShareUrls", - initData.settings.shortenShareUrls + runInAction(() => (this.workbench.items = newItems)); + + // For ids that don't correspond to models resolve an id by share keys + applyPart("timeline", () => { + const timelineWithShareKeysResolved = new Set( + filterOutUndefined( + timeline.map((modelId) => { + if (typeof modelId !== "string") { + errors.push( + new TerriaError({ + sender: this, + title: "Invalid model ID in timeline", + message: "A model ID in the timneline list is not a string." + }) + ); + } else { + if (this.getModelById(BaseModel, modelId) !== undefined) { + return modelId; + } else { + return this.getModelIdByShareKey(modelId); + } + } + }) + ) ); - } - if (errors.length > 0) - throw TerriaError.combine(errors, { - message: { - key: keyFromSelector( - ($) => $.models.terria.loadingInitSourceErrorTitle - ) - } - }); + // TODO: the timelineStack should be populated from the `timeline` property, + // not from the workbench. + runInAction( + () => + (this.timelineStack.items = this.workbench.items + .filter((item) => { + return ( + item.uniqueId && + timelineWithShareKeysResolved.has(item.uniqueId) + ); + // && TODO: what is a good way to test if an item is of type TimeVarying. + }) + .map((item) => item as TimeVarying)) + ); + }); + + applyPart("pickedFeatures", () => { + if (isJsonObject(initData.pickedFeatures)) { + when(() => !(this.currentViewer instanceof NoViewer)).then(() => { + if (isJsonObject(initData.pickedFeatures)) { + this.loadPickedFeatures(initData.pickedFeatures).catch((e) => { + // Note: not raised to the user - `pickedFeatures.error` is + // already surfaced in the feature info panel, and Leaflet + // rejects `allFeaturesAvailablePromise` by design. + const error = TerriaError.from( + e, + "Failed to load picked features" + ); + this.errorService.error(error); + error.log(); + }); + } + }); + } else if (canUnsetFeaturePickingState) { + runInAction(() => { + this.pickedFeatures = undefined; + this.selectedFeature = undefined; + }); + } + }); + + applyPart("shortenShareUrls", () => { + if (initData.settings?.shortenShareUrls !== undefined) { + this.setLocalProperty( + "shortenShareUrls", + initData.settings.shortenShareUrls + ); + } + }); + + // Note the error is returned rather than thrown so that callers can still + // await `baseMapPromise` - dropping it races the base map loaded here + // against the persisted base map fallback. + const error = + errors.length > 0 + ? TerriaError.combine(errors, { + message: { + key: keyFromSelector( + ($) => $.models.terria.loadingInitSourceErrorTitle + ) + } + }) + : undefined; - return { baseMapPromise }; + return { baseMapPromise, error }; } @action diff --git a/packages/terriajs/test/Models/TerriaSpec.ts b/packages/terriajs/test/Models/TerriaSpec.ts index 699ed5a6e5..e2aed962fe 100644 --- a/packages/terriajs/test/Models/TerriaSpec.ts +++ b/packages/terriajs/test/Models/TerriaSpec.ts @@ -969,6 +969,168 @@ describe("TerriaSpec", function () { }); }); + describe("is resilient to broken init data", function () { + // A share link should still load everything it can, even when part of it + // is broken - see https://github.com/TerriaJS/terriajs/issues/5168 + const goodItem = { + type: "csv", + id: "good-item", + name: "A good item", + csvString: "lat,lon,value\n-37.8,144.9,10" + }; + + const anotherGoodItem = { + type: "csv", + id: "another-good-item", + name: "Another good item", + csvString: "lat,lon,value\n-33.9,151.2,20" + }; + + beforeEach(function () { + // Do not call through - we only care about which items make it to the + // workbench, not about actually loading them. + spyOn(CsvCatalogItem.prototype, "loadMapItems").and.callFake(() => + Promise.resolve(Result.none()) + ); + }); + + /** Applies init data, returning any error instead of throwing it */ + async function applyInitData(initData: any): Promise { + try { + await terria.applyInitData({ initData }); + } catch (e) { + return e; + } + return undefined; + } + + it("loads the workbench when nothing is broken", async function () { + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error).toBeUndefined(); + }); + + it("loads the good models when a model has non-object traits", async function () { + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + models: { + "broken-item": "this should be an object", + "good-item": { show: true } + }, + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error) + .withContext("the broken model should still be reported") + .toBeDefined(); + }); + + it("loads the good models when a model cannot be dereferenced", async function () { + // Share links of catalog items that used to be references contain a + // `dereferenced` block. If the catalog has since changed (or the item's + // type is no longer registered) the model is no longer a reference. + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + models: { + "broken-item": { + type: "a-type-that-does-not-exist", + dereferenced: { type: "csv", name: "Dereferenced item" } + }, + "good-item": { show: true } + }, + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error) + .withContext("the broken model should still be reported") + .toBeDefined(); + }); + + it("loads the workbench when a container of a model is broken", async function () { + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + models: { + "broken-container": "this should be an object", + "another-good-item": { + show: true, + knownContainerUniqueIds: ["broken-container"] + } + }, + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error) + .withContext("the broken container should still be reported") + .toBeDefined(); + }); + + it("loads the workbench when `initialCamera` is invalid", async function () { + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + initialCamera: { west: "not a number" }, + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error) + .withContext("the invalid camera should still be reported") + .toBeDefined(); + }); + + it("loads the workbench when `homeCamera` is invalid", async function () { + const error = await applyInitData({ + catalog: [goodItem, anotherGoodItem], + homeCamera: { west: "not a number" }, + workbench: ["good-item", "another-good-item"] + }); + + expect(terria.workbench.itemIds).toEqual([ + "good-item", + "another-good-item" + ]); + expect(error) + .withContext("the invalid camera should still be reported") + .toBeDefined(); + }); + + it("still applies init data that comes after a broken model", async function () { + await applyInitData({ + catalog: [goodItem], + models: { + "broken-item": "this should be an object" + }, + previewedItemId: "good-item", + workbench: ["good-item"], + timeline: ["good-item"] + }); + + expect(terria.workbench.itemIds).toEqual(["good-item"]); + expect(terria.previewedItemId).toBe("good-item"); + }); + }); + describe("Enable/disable shorten share URL via init data", function () { beforeEach(function () { window.localStorage.clear();