@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
747 lines (613 loc) • 34.4 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2026 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { enableJSDOM } from '../test/jsdom';
const disableJSDOM = enableJSDOM();
import { expect } from 'chai';
import * as sinon from 'sinon';
import {
ShellLayoutRestorer,
PersistedPerspectiveData,
PERSPECTIVE_LAYOUTS_STORAGE_KEY
} from './shell-layout-restorer';
import { ApplicationShell } from './application-shell';
disableJSDOM();
describe('ShellLayoutRestorer - Perspective Support', () => {
let restorer: ShellLayoutRestorer;
let mockStorageService: { setData: sinon.SinonStub; getData: sinon.SinonStub };
let mockLogger: { info: sinon.SinonStub; error: sinon.SinonStub; warn: sinon.SinonStub };
let mockWidgetManager: { getDescription: sinon.SinonStub };
let mockShell: {
getLayoutData: sinon.SinonStub;
setLayoutData: sinon.SinonStub;
};
let mockApp: { shell: typeof mockShell };
let mockProvider: {
getActivePerspectiveId: sinon.SinonStub;
getSavedPerspectiveIds: sinon.SinonStub;
getSavedLayout: sinon.SinonStub;
setSavedLayout: sinon.SinonStub;
setActivePerspectiveId: sinon.SinonStub;
clearSavedLayouts: sinon.SinonStub;
onLayoutRestored: sinon.SinonStub;
defaultPerspectiveId: string;
};
let mockTransformations: { getContributions: sinon.SinonStub };
let mockMigrations: { getContributions: sinon.SinonStub };
let mockWindowService: { isSafeToShutDown: sinon.SinonStub; reload: sinon.SinonStub };
let mockThemeService: { reset: sinon.SinonStub };
let toTearDown: () => void;
beforeEach(() => {
toTearDown = enableJSDOM();
mockStorageService = {
setData: sinon.stub().resolves(),
getData: sinon.stub().resolves(undefined)
};
mockLogger = {
info: sinon.stub(),
error: sinon.stub(),
warn: sinon.stub()
};
mockWidgetManager = {
getDescription: sinon.stub().returns(undefined)
};
mockShell = {
getLayoutData: sinon.stub().returns({ version: 999, mainPanel: {}, bottomPanel: {} }),
setLayoutData: sinon.stub().resolves()
};
mockApp = { shell: mockShell };
mockProvider = {
getActivePerspectiveId: sinon.stub().returns('default'),
getSavedPerspectiveIds: sinon.stub().returns([]),
getSavedLayout: sinon.stub().returns(undefined),
setSavedLayout: sinon.stub(),
setActivePerspectiveId: sinon.stub().returns(true),
clearSavedLayouts: sinon.stub(),
onLayoutRestored: sinon.stub(),
defaultPerspectiveId: 'default'
};
mockTransformations = {
getContributions: sinon.stub().returns([])
};
mockMigrations = {
getContributions: sinon.stub().returns([])
};
mockWindowService = {
isSafeToShutDown: sinon.stub().resolves(true),
reload: sinon.stub()
};
mockThemeService = {
reset: sinon.stub()
};
restorer = new ShellLayoutRestorer(
mockWidgetManager as never,
mockLogger as never,
mockStorageService as never
);
(restorer as unknown as Record<string, unknown>)['perspectiveService'] = mockProvider;
(restorer as unknown as Record<string, unknown>)['transformations'] = mockTransformations;
(restorer as unknown as Record<string, unknown>)['migrations'] = mockMigrations;
(restorer as unknown as Record<string, unknown>)['windowService'] = mockWindowService;
(restorer as unknown as Record<string, unknown>)['themeService'] = mockThemeService;
});
afterEach(() => {
sinon.restore();
toTearDown();
});
describe('storePerspectiveLayouts', () => {
it('should read current shell layout as the active perspective layout', () => {
mockProvider.getActivePerspectiveId.returns('persp-a');
mockProvider.getSavedPerspectiveIds.returns([]);
const currentLayout = { version: 999, mainPanel: { items: ['editor1'] }, bottomPanel: {} };
mockShell.getLayoutData.returns(currentLayout);
restorer.storeLayout(mockApp as never);
const perspCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] !== undefined
);
expect(perspCall).to.not.be.undefined;
const data = perspCall!.args[1] as PersistedPerspectiveData;
expect(data.activePerspectiveId).to.equal('persp-a');
expect(data.layouts['persp-a']).to.be.a('string');
// Verify it contains the current shell layout content
const parsed = JSON.parse(data.layouts['persp-a']);
expect(parsed.mainPanel.items).to.deep.equal(['editor1']);
});
it('should deflate all inactive perspective layouts from provider', () => {
mockProvider.getActivePerspectiveId.returns('active');
mockProvider.getSavedPerspectiveIds.returns(['active', 'inactive1', 'inactive2']);
const inactiveLayout1 = { version: 999, mainPanel: { items: ['w1'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
const inactiveLayout2 = { version: 999, mainPanel: { items: ['w2'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
mockProvider.getSavedLayout.withArgs('inactive1').returns(inactiveLayout1);
mockProvider.getSavedLayout.withArgs('inactive2').returns(inactiveLayout2);
mockProvider.getSavedLayout.withArgs('active').returns(undefined);
restorer.storeLayout(mockApp as never);
const [, data] = mockStorageService.setData.firstCall.args as [string, PersistedPerspectiveData];
expect(Object.keys(data.layouts)).to.have.lengthOf(3);
expect(data.layouts).to.have.property('active');
expect(data.layouts).to.have.property('inactive1');
expect(data.layouts).to.have.property('inactive2');
});
it('should use shell layout for active perspective, not provider', () => {
mockProvider.getActivePerspectiveId.returns('active');
mockProvider.getSavedPerspectiveIds.returns(['active']);
// Provider has stale data for the active perspective
const staleLayout = { version: 999, mainPanel: { items: ['stale'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
mockProvider.getSavedLayout.withArgs('active').returns(staleLayout);
// Shell has the current data
const currentLayout = { version: 999, mainPanel: { items: ['current'] }, bottomPanel: {} };
mockShell.getLayoutData.returns(currentLayout);
restorer.storeLayout(mockApp as never);
const [, data] = mockStorageService.setData.firstCall.args as [string, PersistedPerspectiveData];
const parsed = JSON.parse(data.layouts['active']);
// Should use shell data, not provider data
expect(parsed.mainPanel.items).to.deep.equal(['current']);
});
it('should write to legacy key when only default perspective is used', () => {
mockProvider.getActivePerspectiveId.returns('default');
mockProvider.getSavedPerspectiveIds.returns([]);
restorer.storeLayout(mockApp as never);
// Should write to legacy key for backward compatibility
const legacyCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'layout'
);
expect(legacyCall).to.not.be.undefined;
// Should clear the perspective key
const perspClear = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] === undefined
);
expect(perspClear).to.not.be.undefined;
});
it('should write to perspective key when multiple perspectives are used', () => {
mockProvider.getActivePerspectiveId.returns('persp-a');
mockProvider.getSavedPerspectiveIds.returns(['persp-a', 'persp-b']);
const inactiveLayout = { version: 999, mainPanel: { items: ['b'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
mockProvider.getSavedLayout.withArgs('persp-b').returns(inactiveLayout);
mockProvider.getSavedLayout.withArgs('persp-a').returns(undefined);
restorer.storeLayout(mockApp as never);
const perspCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] !== undefined
);
expect(perspCall).to.not.be.undefined;
// Should clear the legacy key
const legacyClear = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'layout' && c.args[1] === undefined
);
expect(legacyClear).to.not.be.undefined;
});
});
describe('restorePerspectiveLayouts', () => {
it('should inflate all layouts and push to provider', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'persp-a',
layouts: {
'persp-a': JSON.stringify({ version: 999, mainPanel: { items: ['a'] }, bottomPanel: {} }),
'persp-b': JSON.stringify({ version: 999, mainPanel: { items: ['b'] }, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
// Return the inflated layout for the active persp when asked
mockProvider.getSavedLayout.callsFake((id: string) => {
if (id === 'persp-a') {
// After setSavedLayout is called, return the layout
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'persp-a'
);
return call?.args[1];
}
return undefined;
});
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.true;
expect(mockProvider.setSavedLayout.callCount).to.equal(2);
// Verify persp-a was set
const setACall = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'persp-a'
);
expect(setACall).to.not.be.undefined;
// Verify persp-b was set
const setBCall = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'persp-b'
);
expect(setBCall).to.not.be.undefined;
});
it('should set active perspective ID on provider', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'my-persp',
layouts: {
'my-persp': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
expect(mockProvider.setActivePerspectiveId.calledWith('my-persp')).to.be.true;
});
it('should apply the active layout to the shell', async () => {
const layoutData = { version: 999, mainPanel: { items: ['active-item'] }, bottomPanel: {} };
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'active',
layouts: {
'active': JSON.stringify(layoutData)
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
expect(mockShell.setLayoutData.calledOnce).to.be.true;
const applied = mockShell.setLayoutData.firstCall.args[0];
expect(applied.mainPanel.items).to.deep.equal(['active-item']);
});
it('should apply transformations to all inflated layouts, not just the active one', async () => {
const transformer = {
transformLayoutOnRestore: sinon.stub()
};
mockTransformations.getContributions.returns([transformer]);
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'persp-a',
layouts: {
'persp-a': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} }),
'persp-b': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
// Transformer should be called for both layouts
expect(transformer.transformLayoutOnRestore.callCount).to.equal(2);
});
it('should fall back to provider active ID when persisted ID is not registered', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'removed-persp',
layouts: {
'removed-persp': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} }),
'default': JSON.stringify({ version: 999, mainPanel: { items: ['default-w'] }, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
mockProvider.setActivePerspectiveId.returns(false);
mockProvider.getActivePerspectiveId.returns('default');
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.true;
// Should apply 'default' layout to shell, not 'removed-persp'
const applied = mockShell.setLayoutData.firstCall.args[0];
expect(applied.mainPanel.items).to.deep.equal(['default-w']);
// onLayoutRestored should be called with 'default'
expect(mockProvider.onLayoutRestored.calledWith('default')).to.be.true;
});
it('should return false when no perspective data and no legacy data exists', async () => {
mockStorageService.getData.resolves(undefined);
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.false;
});
it('should warn when a perspective layout fails to inflate', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'good',
layouts: {
'good': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} }),
'bad': 'invalid-json{'
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
// 'good' should be inflated successfully
const goodCall = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'good'
);
expect(goodCall).to.not.be.undefined;
// Should warn about 'bad'
expect(mockLogger.warn.called).to.be.true;
const warnCall = mockLogger.warn.getCalls().find(
(c: sinon.SinonSpyCall) => (c.args[0] as string).includes('bad')
);
expect(warnCall).to.not.be.undefined;
});
});
describe('legacy migration', () => {
it('should fall back to legacy key when perspective-layouts is absent', async () => {
const legacyLayout = JSON.stringify({ version: 999, mainPanel: { items: ['legacy'] }, bottomPanel: {} });
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(undefined);
mockStorageService.getData.withArgs('layout').resolves(legacyLayout);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.true;
// Should push layout as 'default' perspective
expect(mockProvider.setSavedLayout.calledOnce).to.be.true;
expect(mockProvider.setSavedLayout.firstCall.args[0]).to.equal('default');
// Should set active to 'default'
expect(mockProvider.setActivePerspectiveId.calledWith('default')).to.be.true;
});
it('should keep the legacy key after a successful migration', async () => {
const legacyLayout = JSON.stringify({ version: 999, mainPanel: { items: ['legacy'] }, bottomPanel: {} });
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(undefined);
mockStorageService.getData.withArgs('layout').resolves(legacyLayout);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
// Restoring must not touch the key: it is rewritten or cleared by the next `storeLayout`,
// so a crash before then still finds the layout where it was.
const legacyWrite = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'layout'
);
expect(legacyWrite).to.be.undefined;
});
it('should preserve legacy key when inflate fails', async () => {
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(undefined);
mockStorageService.getData.withArgs('layout').resolves('not-valid-json{');
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.false;
// Legacy key should NOT be cleared
const legacyClear = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === 'layout' && c.args[1] === undefined
);
expect(legacyClear).to.be.undefined;
// No layout should have been saved
expect(mockProvider.setSavedLayout.called).to.be.false;
// Should warn about the failure
expect(mockLogger.warn.called).to.be.true;
});
it('should apply migrated legacy layout to shell', async () => {
const legacyLayout = JSON.stringify({ version: 999, mainPanel: { items: ['legacy-w'] }, bottomPanel: {} });
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(undefined);
mockStorageService.getData.withArgs('layout').resolves(legacyLayout);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
expect(mockShell.setLayoutData.calledOnce).to.be.true;
});
});
describe('storePerspectiveLayouts - error isolation', () => {
it('should persist other perspectives when one inactive perspective deflate fails', () => {
mockProvider.getActivePerspectiveId.returns('active');
mockProvider.getSavedPerspectiveIds.returns(['active', 'good', 'bad']);
const goodLayout = { version: 999, mainPanel: { items: ['good'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
// Create a layout with a circular reference that will cause JSON.stringify to throw
const badLayout: Record<string, unknown> = { version: 999, mainPanel: {}, bottomPanel: {} };
badLayout['self'] = badLayout;
mockProvider.getSavedLayout.withArgs('good').returns(goodLayout);
mockProvider.getSavedLayout.withArgs('bad').returns(badLayout);
mockProvider.getSavedLayout.withArgs('active').returns(undefined);
restorer.storeLayout(mockApp as never);
// Multiple perspectives => writes to PERSPECTIVE_LAYOUTS_STORAGE_KEY and clears legacy
const perspCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] !== undefined
);
expect(perspCall).to.not.be.undefined;
const data = perspCall!.args[1] as PersistedPerspectiveData;
expect(data.layouts).to.have.property('active');
expect(data.layouts).to.have.property('good');
expect(data.layouts).to.not.have.property('bad');
// Should warn about 'bad'
expect(mockLogger.warn.called).to.be.true;
const warnCall = mockLogger.warn.getCalls().find(
(c: sinon.SinonSpyCall) => (c.args[0] as string).includes('bad')
);
expect(warnCall).to.not.be.undefined;
});
it('should persist inactive perspectives when active perspective deflate fails', () => {
mockProvider.getActivePerspectiveId.returns('active');
mockProvider.getSavedPerspectiveIds.returns(['active', 'inactive']);
// Make the shell return a circular structure for the active perspective
const circularLayout: Record<string, unknown> = { version: 999, mainPanel: {}, bottomPanel: {} };
circularLayout['self'] = circularLayout;
mockShell.getLayoutData.returns(circularLayout);
const inactiveLayout = { version: 999, mainPanel: { items: ['inactive'] }, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
mockProvider.getSavedLayout.withArgs('inactive').returns(inactiveLayout);
restorer.storeLayout(mockApp as never);
// Multiple perspectives (active + inactive) => writes to PERSPECTIVE_LAYOUTS_STORAGE_KEY
const perspCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] !== undefined
);
expect(perspCall).to.not.be.undefined;
const data = perspCall!.args[1] as PersistedPerspectiveData;
expect(data.layouts).to.not.have.property('active');
expect(data.layouts).to.have.property('inactive');
expect(mockLogger.warn.called).to.be.true;
});
it('should clear storage key when setData rejects (multi-perspective)', async () => {
mockProvider.getActivePerspectiveId.returns('persp-a');
mockProvider.getSavedPerspectiveIds.returns(['persp-a', 'persp-b']);
const layout = { version: 999, mainPanel: {}, bottomPanel: {} } as unknown as ApplicationShell.LayoutData;
mockProvider.getSavedLayout.withArgs('persp-b').returns(layout);
// The perspective-layouts setData call rejects only the first time (when data is present)
let perspCallCount = 0;
mockStorageService.setData.callsFake((key: string, data: unknown) => {
if (key === PERSPECTIVE_LAYOUTS_STORAGE_KEY && data !== undefined) {
perspCallCount++;
return Promise.reject(new Error('storage error'));
}
return Promise.resolve();
});
restorer.storeLayout(mockApp as never);
// Allow the .catch() handler to execute
await new Promise(resolve => setTimeout(resolve, 0));
expect(perspCallCount).to.equal(1);
// Should have attempted to clear the perspective key
const clearCall = mockStorageService.setData.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] === undefined
);
expect(clearCall).to.not.be.undefined;
expect(mockLogger.error.called).to.be.true;
});
});
describe('restorePerspectiveLayouts - desync fallback', () => {
it('should fall back to default perspective when active perspective has no saved layout', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'custom',
layouts: {
'default': JSON.stringify({ version: 999, mainPanel: { items: ['default-w'] }, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
// 'custom' is a registered perspective, but has no layout entry
mockProvider.setActivePerspectiveId.returns(true);
// Track calls to setActivePerspectiveId to simulate state changes
let currentActiveId = 'default';
mockProvider.setActivePerspectiveId.callsFake((id: string) => {
currentActiveId = id;
return true;
});
mockProvider.getActivePerspectiveId.callsFake(() => currentActiveId);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.true;
// Should have fallen back to default
expect(mockLogger.warn.called).to.be.true;
const warnCall = mockLogger.warn.getCalls().find(
(c: sinon.SinonSpyCall) => (c.args[0] as string).includes('custom')
);
expect(warnCall).to.not.be.undefined;
// Should have called onLayoutRestored with default
expect(mockProvider.onLayoutRestored.calledWith('default')).to.be.true;
});
it('should call onLayoutRestored even when falling through to no layout', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'custom',
layouts: {}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
let currentActiveId = 'default';
mockProvider.setActivePerspectiveId.callsFake((id: string) => {
currentActiveId = id;
return true;
});
mockProvider.getActivePerspectiveId.callsFake(() => currentActiveId);
mockProvider.getSavedLayout.returns(undefined);
const result = await restorer.restoreLayout(mockApp as never);
expect(result).to.be.false;
// onLayoutRestored should still be called
expect(mockProvider.onLayoutRestored.calledOnce).to.be.true;
});
});
describe('resetLayout', () => {
it('should clear layouts and legacy layout keys', async () => {
// Access private method
await (restorer as unknown as Record<string, () => Promise<void>>)['resetLayout']();
const setDataCalls = mockStorageService.setData.getCalls();
const layoutClear = setDataCalls.find((c: sinon.SinonSpyCall) => c.args[0] === 'layout' && c.args[1] === undefined);
const perspClear = setDataCalls.find((c: sinon.SinonSpyCall) => c.args[0] === PERSPECTIVE_LAYOUTS_STORAGE_KEY && c.args[1] === undefined);
expect(layoutClear).to.not.be.undefined;
expect(perspClear).to.not.be.undefined;
});
it('should call provider.clearSavedLayouts', async () => {
await (restorer as unknown as Record<string, () => Promise<void>>)['resetLayout']();
expect(mockProvider.clearSavedLayouts.calledOnce).to.be.true;
});
it('should reload the window after reset', async () => {
await (restorer as unknown as Record<string, () => Promise<void>>)['resetLayout']();
expect(mockWindowService.reload.calledOnce).to.be.true;
});
});
describe('onLayoutRestored callback', () => {
it('should call onLayoutRestored on provider after successful restore', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'my-persp',
layouts: {
'my-persp': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
let currentActiveId = 'default';
mockProvider.setActivePerspectiveId.callsFake((id: string) => {
currentActiveId = id;
return true;
});
mockProvider.getActivePerspectiveId.callsFake(() => currentActiveId);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
expect(mockProvider.onLayoutRestored.calledOnce).to.be.true;
expect(mockProvider.onLayoutRestored.calledWith('my-persp')).to.be.true;
});
it('should call onLayoutRestored even when no layout is restored', async () => {
mockStorageService.getData.resolves(undefined);
await restorer.restoreLayout(mockApp as never);
expect(mockProvider.onLayoutRestored.calledOnce).to.be.true;
expect(mockProvider.onLayoutRestored.calledWith('default')).to.be.true;
});
it('should call onLayoutRestored after layout is applied to shell', async () => {
const persisted: PersistedPerspectiveData = {
activePerspectiveId: 'active',
layouts: {
'active': JSON.stringify({ version: 999, mainPanel: {}, bottomPanel: {} })
}
};
mockStorageService.getData.withArgs(PERSPECTIVE_LAYOUTS_STORAGE_KEY).resolves(persisted);
let currentActiveId = 'default';
mockProvider.setActivePerspectiveId.callsFake((id: string) => {
currentActiveId = id;
return true;
});
mockProvider.getActivePerspectiveId.callsFake(() => currentActiveId);
mockProvider.getSavedLayout.callsFake((id: string) => {
const call = mockProvider.setSavedLayout.getCalls().find(
(c: sinon.SinonSpyCall) => c.args[0] === id
);
return call?.args[1];
});
await restorer.restoreLayout(mockApp as never);
// onLayoutRestored should be called after setLayoutData
expect(mockShell.setLayoutData.calledBefore(mockProvider.onLayoutRestored)).to.be.true;
});
});
});