diff --git a/rtl-spec/components/commands-publish-button.spec.tsx b/rtl-spec/components/commands-publish-button.spec.tsx index b777eac153..f8de7c79a2 100644 --- a/rtl-spec/components/commands-publish-button.spec.tsx +++ b/rtl-spec/components/commands-publish-button.spec.tsx @@ -163,8 +163,10 @@ describe('Action button component', () => { expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); }); - it('resets editorMosaic.isEdited state', async () => { - state.editorMosaic.isEdited = true; + it('marks the Fiddle as saved', async () => { + (state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc123'], + ]); state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); expect(mocktokit.gists.create).toHaveBeenCalledWith(expectedGistOpts); @@ -232,14 +234,12 @@ describe('Action button component', () => { throw new Error(errorMessage); }); - state.editorMosaic.isEdited = true; state.showInputDialog = vi.fn().mockResolvedValueOnce(description); await instance.performGistAction(); expect(state.activeGistAction).toBe(GistActionState.none); // On failure the editor should still be considered edited - expect(state.editorMosaic.isEdited).toBe(true); }); it('can publish secret gists', async () => { diff --git a/rtl-spec/components/editor.spec.tsx b/rtl-spec/components/editor.spec.tsx index 0aeff1805c..5c8c357418 100644 --- a/rtl-spec/components/editor.spec.tsx +++ b/rtl-spec/components/editor.spec.tsx @@ -27,13 +27,13 @@ describe('Editor component', () => { }); } - function initializeEditorMosaic(id: EditorId) { - store.editorMosaic.set({ [id]: '// content' }); + async function initializeEditorMosaic(id: EditorId) { + await store.editorMosaic.set({ [id]: '// content' }); } - it('renders the editor container', () => { + it('renders the editor container', async () => { const id = MAIN_JS; - initializeEditorMosaic(id); + await initializeEditorMosaic(id); const { renderResult } = createEditor(id); @@ -67,13 +67,13 @@ describe('Editor component', () => { it('calls editorMosaic.addEditor', async () => { const id = MAIN_JS; const { editorMosaic } = store; - editorMosaic.set({ [id]: '// content' }); + await editorMosaic.set({ [id]: '// content' }); const addEditorSpy = vi.spyOn(editorMosaic, 'addEditor'); const didMount = vi.fn(); createEditor(id, didMount); - expect(didMount).toHaveBeenCalled(); + await vi.waitFor(() => didMount.mock.calls.length > 0); expect(addEditorSpy).toHaveBeenCalledWith(id, expect.anything()); }); diff --git a/rtl-spec/components/editors.spec.tsx b/rtl-spec/components/editors.spec.tsx index ef302f858d..5cc4f3735a 100644 --- a/rtl-spec/components/editors.spec.tsx +++ b/rtl-spec/components/editors.spec.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { MosaicNode } from 'react-mosaic-component'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -24,12 +25,12 @@ describe('Editors component', () => { let editorMosaic: EditorMosaic; let editorValues: EditorValues; - beforeEach(() => { + beforeEach(async () => { ({ app } = window); ({ state: store } = window.app); editorValues = createEditorValues(); editorMosaic = new EditorMosaic(); - editorMosaic.set(editorValues); + await editorMosaic.set(editorValues); (store as unknown as StateMock).editorMosaic = editorMosaic; }); @@ -64,9 +65,9 @@ describe('Editors component', () => { describe('toggleEditorOption()', () => { const filename = MAIN_JS; - it('handles an error', () => { + it('handles an error', async () => { const editor = new MonacoEditorMock(); - editorMosaic.addEditor(filename, editor as unknown as Editor); + await editorMosaic.addEditor(filename, editor as unknown as Editor); editor.updateOptions.mockImplementationOnce(() => { throw new Error('Bwap bwap'); }); @@ -76,11 +77,11 @@ describe('Editors component', () => { expect(instance.toggleEditorOption('wordWrap')).toBe(false); }); - it('updates a setting', () => { + it('updates a setting', async () => { const { instance } = renderEditors(); const editor = new MonacoEditorMock(); - editorMosaic.addEditor(filename, editor as unknown as Editor); + await editorMosaic.addEditor(filename, editor as unknown as Editor); expect(instance.toggleEditorOption('wordWrap')).toBe(true); expect(editor.updateOptions).toHaveBeenCalledWith({ minimap: { enabled: false }, @@ -105,9 +106,10 @@ describe('Editors component', () => { ]; for (const toolbarTitle of toolbarTitles) { - expect( - toolbars.find((toolbar) => toolbar.textContent?.includes(toolbarTitle)), - ).toBeInTheDocument(); + const el = toolbars.find((toolbar) => + toolbar.textContent?.includes(toolbarTitle), + ); + expect(el).toBeInTheDocument(); } }); @@ -266,10 +268,10 @@ describe('Editors component', () => { expect(editor.setSelection).toHaveBeenCalledWith(range); }); - it('handles the monaco editor option event', () => { + it('handles the monaco editor option event', async () => { const id = MAIN_JS; const editor = new MonacoEditorMock(); - editorMosaic.addEditor(id, editor as unknown as Editor); + await editorMosaic.addEditor(id, editor as unknown as Editor); renderEditors(); emitEvent('toggle-monaco-option', 'wordWrap'); diff --git a/rtl-spec/components/sidebar-file-tree.spec.tsx b/rtl-spec/components/sidebar-file-tree.spec.tsx new file mode 100644 index 0000000000..922df2eb12 --- /dev/null +++ b/rtl-spec/components/sidebar-file-tree.spec.tsx @@ -0,0 +1,360 @@ +import * as React from 'react'; + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorValues, + MAIN_CJS, + MAIN_JS, + PACKAGE_NAME, +} from '../../src/interfaces'; +import { Editors } from '../../src/renderer/components/editors'; +import { SidebarFileTree } from '../../src/renderer/components/sidebar-file-tree'; +import { EditorMosaic, EditorPresence } from '../../src/renderer/editor-mosaic'; +import { AppState } from '../../src/renderer/state'; +import { createEditorValues } from '../../tests/mocks/editor-values'; +import { AppMock, StateMock } from '../../tests/mocks/mocks'; + +describe('SidebarFileTree component', () => { + let store: AppState; + let editorMosaic: EditorMosaic; + let editorValues: EditorValues; + let stateMock: StateMock; + + beforeEach(async () => { + ({ state: stateMock } = window.app as unknown as AppMock); + store = { + showErrorDialog: vi.fn(), + } as unknown as AppState; + editorValues = createEditorValues(); + editorMosaic = new EditorMosaic(); + await editorMosaic.set(editorValues); + (store as unknown as StateMock).editorMosaic = editorMosaic; + stateMock.editorMosaic = editorMosaic; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reflects the visibility state of all icons', () => { + editorMosaic.hide('index.html'); + const { container } = render(); + + // Check that an 'eye-off' icon is present for the hidden file + const eyeOffIcon = container.querySelector('button .bp3-icon-eye-off'); + expect(eyeOffIcon).toBeInTheDocument(); + }); + + it('can bring up the Add File input', async () => { + const user = userEvent.setup(); + const { container } = render(); + + // Click the "Add New File" button (by icon) + const addButton = container.querySelector( + 'button .bp3-icon-add', + )?.parentElement; + expect(addButton).toBeInTheDocument(); + await user.click(addButton!); + + // Input should now be visible + const input = container.querySelector('#new-file-input'); + expect(input).toBeInTheDocument(); + }); + + it('can toggle editor visibility', async () => { + const user = userEvent.setup(); + const { container } = render(); + + // Find and click the visibility toggle button (eye icon) for the first file + const visibilityButtons = container.querySelectorAll( + 'button .bp3-icon-eye-open, button .bp3-icon-eye-off', + ); + const firstButton = visibilityButtons[0]?.parentElement; + await user.click(firstButton!); + + expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); + }); + + it('can create new editors', async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(editorMosaic.files.get('tester.js')).toBe(undefined); + + // Click the "Add New File" button (by icon) + const addButton = container.querySelector( + 'button .bp3-icon-add', + )?.parentElement; + await user.click(addButton!); + + // Type the filename and press Enter + const input = container.querySelector( + '#new-file-input', + ) as HTMLInputElement; + await user.type(input, 'tester.js{Enter}'); + + expect(editorMosaic.files.get('tester.js')).toBe(EditorPresence.Pending); + }); + + it('can delete editors', async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Pending); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + expect(fileLabel).toBeInTheDocument(); + fireEvent.contextMenu(fileLabel); + + // Click the "Delete" menu item + const deleteItem = await screen.findByText('Delete'); + await user.click(deleteItem); + + expect(editorMosaic.files.get('index.html')).toBe(undefined); + }); + + it('can rename editors', async () => { + const user = userEvent.setup(); + const EDITOR_NAME = 'index.html'; + const EDITOR_NEW_NAME = 'new_index.html'; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + + const { container } = render(); + + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for the rename to complete + await waitFor(() => { + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); + expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( + EditorPresence.Pending, + ); + }); + }); + + it('can rename one main entry point file to another main entry point file', async () => { + const user = userEvent.setup(); + const EDITOR_NAME = MAIN_JS; + const EDITOR_NEW_NAME = MAIN_CJS; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + + const { container } = render(); + + // Right-click on the main entry point file to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + await user.pointer({ keys: '[MouseRight>]', target: fileLabel }); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for the rename to complete + await waitFor(() => { + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); + expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( + EditorPresence.Pending, + ); + }); + }); + + it('fails if trying to rename an editor to package(-lock).json', async () => { + const user = userEvent.setup(); + const EDITOR_NAME = 'index.html'; + const EDITOR_NEW_NAME = PACKAGE_NAME; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); + + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot add ${PACKAGE_NAME} or package-lock.json as custom files`, + ); + }); + + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); + }); + + it('fails if trying to rename an editor to an unsupported name', async () => { + const user = userEvent.setup(); + const EDITOR_NAME = 'index.html'; + const EDITOR_NEW_NAME = 'data.txt'; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); + + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === EDITOR_NAME, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Invalid filename "${EDITOR_NEW_NAME}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`, + ); + }); + + expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); + }); + + it('fails if trying to rename an editor to an existing name', async () => { + const user = userEvent.setup(); + const EXISTED_NAME = 'styles.css'; + const TO_BE_NAMED = 'index.html'; + const EDITOR_NEW_NAME = EXISTED_NAME; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); + + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === TO_BE_NAMED, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot rename file to "${EDITOR_NEW_NAME}": File already exists`, + ); + }); + + expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); + }); + + it('fails if trying to rename an editor to another main entry point file', async () => { + const user = userEvent.setup(); + const TO_BE_NAMED = 'index.html'; + const EDITOR_NEW_NAME = MAIN_CJS; + + store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); + store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); + + const { container } = render(); + + // Right-click on index.html to open context menu + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === TO_BE_NAMED, + ) as HTMLElement; + fireEvent.contextMenu(fileLabel); + + // Click the "Rename" menu item + const renameItem = await screen.findByText('Rename'); + await user.click(renameItem); + + // Wait for error dialog to be called + await waitFor(() => { + expect(store.showErrorDialog).toHaveBeenCalledWith( + `Cannot rename file to "${EDITOR_NEW_NAME}": Main entry point ${MAIN_JS} exists`, + ); + }); + + expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); + }); + + it('can reset the editor layout', async () => { + const user = userEvent.setup(); + editorMosaic.resetLayout = vi.fn(); + + const { container } = render(); + + // Click the "Reset Layout" button (by icon) + const resetButton = container.querySelector( + 'button .bp3-icon-grid-view', + )?.parentElement; + await user.click(resetButton!); + + expect(editorMosaic.resetLayout).toHaveBeenCalledTimes(1); + }); + + it('file is visible, click files tree, focus file content', async () => { + const user = userEvent.setup(); + const { container } = render(); + render(); + + // Click on index.html to focus it + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + await user.click(fileLabel); + + // Wait for the file to become visible and focused + await waitFor(() => { + expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); + expect(editorMosaic.focusedFile).toBe('index.html'); + }); + }); + + it('file is hidden, click files tree, make file visible and focus file content', async () => { + const user = userEvent.setup(); + const { container } = render(); + render(); + + // Hide the file first + const visibilityButtons = container.querySelectorAll( + 'button .bp3-icon-eye-open, button .bp3-icon-eye-off', + ); + const firstButton = visibilityButtons[0]?.parentElement; + await user.click(firstButton!); + + expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); + + // Click on index.html to focus it (should also make it visible) + const fileLabel = Array.from(container.querySelectorAll('.pointer')).find( + (el) => el.textContent === 'index.html', + ) as HTMLElement; + await user.click(fileLabel); + + // Wait for the file to become visible and focused + await waitFor(() => { + expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); + expect(editorMosaic.focusedFile).toBe('index.html'); + }); + }); +}); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 0674c616b2..cbb28d2dda 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -60,16 +60,27 @@ export class App { editorValues: EditorValues, { localFiddle, gistId, templateName }: Partial, ) { - const { state } = this; - const { editorMosaic } = state; - - if (editorMosaic.isEdited && !(await this.confirmReplaceUnsaved())) { + if ( + this.state.editorMosaic.isEdited && + !(await this.confirmReplaceUnsaved()) + ) { return false; } - this.state.editorMosaic.set(editorValues); + await this.state.editorMosaic.set(editorValues); this.state.editorMosaic.editorSeverityMap.clear(); + // HACK: editors should be mounted shortly after we load something. + // We could try waiting for every single `editorDidMount` callback + // to fire, but that gets complicated with recycled editors with changed + // values. This is just easier for now. + await new Promise((resolve) => + setTimeout(async () => { + await this.state.editorMosaic.markAsSaved(); + resolve(); + }, 100), + ); + this.state.gistId = gistId || ''; this.state.localPath = localFiddle?.filePath; this.state.templateName = templateName; diff --git a/src/renderer/components/commands-action-button.tsx b/src/renderer/components/commands-action-button.tsx index d5dc7beb32..4efc0d1974 100644 --- a/src/renderer/components/commands-action-button.tsx +++ b/src/renderer/components/commands-action-button.tsx @@ -178,7 +178,7 @@ export const GistActionButton = observer( if (description) { if (await this.publishGist(description)) { - appState.editorMosaic.isEdited = false; + await this.props.appState.editorMosaic.markAsSaved(); } } } finally { @@ -214,7 +214,7 @@ export const GistActionButton = observer( files, }); - appState.editorMosaic.isEdited = false; + await appState.editorMosaic.markAsSaved(); console.log('Updating: Updating done', { gist }); if (!silent) { @@ -256,7 +256,7 @@ export const GistActionButton = observer( gist_id: appState.gistId!, }); - appState.editorMosaic.isEdited = true; + appState.editorMosaic.clearSaved(); console.log('Deleting: Deleting done', { gist }); this.renderToast({ message: 'Successfully deleted gist!' }); } catch (error: any) { diff --git a/src/renderer/components/editor.tsx b/src/renderer/components/editor.tsx index 0d66f837c3..c248bd62b5 100644 --- a/src/renderer/components/editor.tsx +++ b/src/renderer/components/editor.tsx @@ -52,14 +52,14 @@ export class Editor extends React.Component { * mount, not React's. */ public async editorDidMount(editor: MonacoType.editor.IStandaloneCodeEditor) { - const { appState, editorDidMount, id } = this.props; + const { appState, id } = this.props; const { editorMosaic } = appState; - editorMosaic.addEditor(id, editor); + await editorMosaic.addEditor(id, editor); // And notify others - if (editorDidMount) { - editorDidMount(editor); + if (this.props.editorDidMount) { + this.props.editorDidMount(editor); } // Click file tree, if the file is hidden, focus it diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx index 3310a1bc35..23d9cd0ccb 100644 --- a/src/renderer/components/editors.tsx +++ b/src/renderer/components/editors.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { toJS } from 'mobx'; +import { runInAction, toJS } from 'mobx'; import { observer } from 'mobx-react'; import type * as MonacoType from 'monaco-editor'; import { @@ -267,7 +267,9 @@ export const Editors = observer( * Handles a change in the visible nodes */ public onChange(currentNode: MosaicNode | null) { - this.props.appState.editorMosaic.mosaic = currentNode; + runInAction(() => { + this.props.appState.editorMosaic.mosaic = currentNode; + }); } /** diff --git a/src/renderer/components/sidebar-file-tree.tsx b/src/renderer/components/sidebar-file-tree.tsx index 1abc3cee6e..634b055920 100644 --- a/src/renderer/components/sidebar-file-tree.tsx +++ b/src/renderer/components/sidebar-file-tree.tsx @@ -207,7 +207,7 @@ export const SidebarFileTree = observer( } try { - appState.editorMosaic.renameFile(editorId, id); + await appState.editorMosaic.renameFile(editorId, id); if (visible) appState.editorMosaic.show(id); } catch (err: any) { diff --git a/src/renderer/editor-mosaic.ts b/src/renderer/editor-mosaic.ts index ac7e9f4005..325196fd52 100644 --- a/src/renderer/editor-mosaic.ts +++ b/src/renderer/editor-mosaic.ts @@ -1,11 +1,4 @@ -import { - action, - computed, - makeObservable, - observable, - reaction, - runInAction, -} from 'mobx'; +import { makeAutoObservable, observable, reaction, runInAction } from 'mobx'; import type * as MonacoType from 'monaco-editor'; import { MosaicDirection, MosaicNode, getLeaves } from 'react-mosaic-component'; @@ -20,6 +13,10 @@ import { EditorId, EditorValues, PACKAGE_NAME } from '../interfaces'; export type Editor = MonacoType.editor.IStandaloneCodeEditor; +/** + * Editors in Electron Fiddle can be hidden from the current view, but + * still exist in memory and can be re-opened. + */ export enum EditorPresence { /** The file is known to us but we've chosen not to show it, either because the content was boring or because hide() was called. @@ -41,9 +38,34 @@ interface EditorBackup { } export class EditorMosaic { - public isEdited = false; public focusedFile: EditorId | null = null; + /** + * A map of editors and the SHA-1 hashes of their contents + * when last saved. + */ + private savedHashes = new Map(); + /** + * A map of editors and the SHA-1 hashes of their current contents. + */ + private currentHashes = new Map(); + + public get isEdited() { + // If we haven't processed the save state upon initial load yet, don't mark as edited + // (All editors need to be mounted into Fiddle first) + if (this.savedHashes.size === 0) { + return false; + } + + if (this.savedHashes.size !== this.currentHashes.size) { + return true; + } + for (const [id, hash] of this.currentHashes) { + if (this.savedHashes.get(id) !== hash) return true; + } + return false; + } + public get files() { const files = new Map(); @@ -67,32 +89,7 @@ export class EditorMosaic { private readonly editors = new Map(); constructor() { - makeObservable< - EditorMosaic, - 'backups' | 'editors' | 'addFile' | 'setVisible' | 'setEditorFromBackup' - >(this, { - isEdited: observable, - focusedFile: observable, - files: computed, - numVisible: computed, - mosaic: observable, - backups: observable, - editors: observable, - setFocusedFile: action, - resetLayout: action, - set: action, - addFile: action, - show: action, - setVisible: action, - toggle: action, - hide: action, - remove: action, - addEditor: action, - setEditorFromBackup: action, - addNewFile: action, - renameFile: action, - editorSeverityMap: observable, - }); + makeAutoObservable(this); // whenever the mosaics are changed, // update the editor layout @@ -101,17 +98,7 @@ export class EditorMosaic { () => this.layout(), ); - // whenever isEdited is set, stop or start listening to edits again. - reaction( - () => this.isEdited, - () => { - if (this.isEdited) { - this.ignoreAllEdits(); - } else { - this.observeAllEdits(); - } - }, - ); + this.layout = this.layout.bind(this); // TODO: evaluate if we need to dispose of the listener when this class is // destroyed via FinalizationRegistry window.monaco.editor.onDidChangeMarkers(this.setSeverityLevels.bind(this)); @@ -128,14 +115,14 @@ export class EditorMosaic { } /** Reset the layout to the initial layout we had when set() was called */ - resetLayout = () => { - this.set(this.values()); - }; + public async resetLayout() { + await this.set(this.values()); + } /// set / add / get the files in the model /** Set the contents of the mosaic */ - public set(valuesIn: EditorValues) { + public async set(valuesIn: EditorValues) { // set() clears out the previous Fiddle, so clear our previous state // except for this.editors -- we recycle editors below in setFile() this.backups.clear(); @@ -143,16 +130,16 @@ export class EditorMosaic { // add the files to the mosaic, recycling existing editors when possible. const values = new Map(Object.entries(valuesIn)) as Map; - for (const [id, value] of values) this.addFile(id, value); + for (const [id, value] of values) { + await this.addFile(id, value); + } for (const id of this.editors.keys()) { if (!values.has(id)) this.editors.delete(id); } - - this.isEdited = false; } /** Add a file. If we already have a file with that name, replace it. */ - private addFile(id: EditorId, value: string) { + private async addFile(id: EditorId, value: string) { if ( id.endsWith('.json') && [PACKAGE_NAME, 'package-lock.json'].includes(id) @@ -185,11 +172,12 @@ export class EditorMosaic { // if we have an editor available, use the monaco model now. // otherwise, save the file in `this.backups` for future use. const backup: EditorBackup = { model }; + this.backups.set(id, backup); + const editor = this.editors.get(id); if (editor) { this.setEditorFromBackup(editor, backup); - } else { - this.backups.set(id, backup); + this.observeEdits(editor); } // only show the file if it has nontrivial content @@ -198,8 +186,7 @@ export class EditorMosaic { } else { this.hide(id); } - - this.isEdited = true; + await this.updateCurrentHash(); } /// show or hide files in the view @@ -268,16 +255,16 @@ export class EditorMosaic { } /** Remove the specified file and its editor */ - public remove(id: EditorId) { + public async remove(id: EditorId) { this.editors.delete(id); this.backups.delete(id); this.setVisible(getLeaves(this.mosaic).filter((v) => v !== id)); - this.isEdited = true; + await this.updateCurrentHash(); } /** Wire up a newly-mounted Monaco editor */ - public addEditor(id: EditorId, editor: Editor) { + public async addEditor(id: EditorId, editor: Editor) { const backup = this.backups.get(id); if (!backup) throw new Error(`added Editor for unexpected file "${id}"`); @@ -289,14 +276,13 @@ export class EditorMosaic { /** Populate a MonacoEditor with the file's contents */ private setEditorFromBackup(editor: Editor, backup: EditorBackup) { - this.ignoreEdits(editor); // pause this so that isEdited doesn't get set if (backup.viewState) editor.restoreViewState(backup.viewState); editor.setModel(backup.model); this.observeEdits(editor); // resume } /** Add a new file to the mosaic */ - public addNewFile(id: EditorId, value: string = getEmptyContent(id)) { + public async addNewFile(id: EditorId, value: string = getEmptyContent(id)) { if (this.files.has(id)) { throw new Error(`Cannot add file "${id}": File already exists`); } @@ -309,11 +295,11 @@ export class EditorMosaic { ); } - this.addFile(id, value); + await this.addFile(id, value); } /** Rename a file in the mosaic */ - public renameFile(oldId: EditorId, newId: EditorId) { + public async renameFile(oldId: EditorId, newId: EditorId) { if (!this.files.has(oldId)) { throw new Error(`Cannot rename file "${oldId}": File doesn't exist`); } @@ -330,8 +316,8 @@ export class EditorMosaic { ); } - this.addFile(newId, this.value(oldId).trim()); - this.remove(oldId); + await this.addFile(newId, this.value(oldId).trim()); + await this.remove(oldId); } /** Get the contents of a single file. */ @@ -350,18 +336,16 @@ export class EditorMosaic { } /// misc utilities - private layoutDebounce: ReturnType | undefined; - public layout = () => { - const DEBOUNCE_MSEC = 50; - if (!this.layoutDebounce) { - this.layoutDebounce = setTimeout(() => { - for (const editor of this.editors.values()) editor.layout(); - delete this.layoutDebounce; - }, DEBOUNCE_MSEC); - } - }; + public layout() { + clearTimeout(this.layoutDebounce); + this.layoutDebounce = setTimeout(() => { + for (const editor of this.editors.values()) { + editor.layout(); + } + }, 50); + } public getAllEditorIds(): EditorId[] { return [...this.editors.keys()]; @@ -383,29 +367,70 @@ export class EditorMosaic { return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id)); } - //=== Listen for user edits - - private ignoreAllEdits() { - for (const editor of this.editors.values()) this.ignoreEdits(editor); + private observeEdits(editor: Editor) { + editor.onDidChangeModelContent(async () => { + await this.updateCurrentHash(); + }); } - private ignoreEdits(editor: Editor) { - editor.onDidChangeModelContent(() => { - // no-op + private async updateCurrentHash() { + const hashes = await this.getAllHashes(); + runInAction(() => { + this.currentHashes = hashes; }); } - private observeAllEdits() { - for (const editor of this.editors.values()) this.observeEdits(editor); + /** + * Generates a SHA-1 hash for each editor's contents. Visible editors are + * under `this.editors`, and hidden editors are under `this.backups`. + */ + private async getAllHashes() { + const hashes = new Map(); + const encoder = new TextEncoder(); + + for (const [id, editor] of this.editors) { + const txt = editor.getModel()?.getValue(); + const data = encoder.encode(txt); + const digest = await window.crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + hashes.set(id, hash); + } + + for (const [id, backup] of this.backups) { + const txt = backup.model.getValue(); + const data = encoder.encode(txt); + const digest = await window.crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + hashes.set(id, hash); + } + + return hashes; } - private observeEdits(editor: Editor) { - const disposable = editor.onDidChangeModelContent(() => { - this.isEdited ||= true; - disposable.dispose(); + /** + * Marks the current state of all editors as saved. + */ + public async markAsSaved() { + const hashes = await this.getAllHashes(); + runInAction(() => { + this.savedHashes = hashes; + // new map to clone + this.currentHashes = new Map(hashes); }); } + /** + * Forces all editors to be marked as unsaved. + */ + public clearSaved() { + this.savedHashes.clear(); + } public editorSeverityMap = observable.map< EditorId, MonacoType.MarkerSeverity diff --git a/src/renderer/file-manager.ts b/src/renderer/file-manager.ts index bca106fab0..b03a236307 100644 --- a/src/renderer/file-manager.ts +++ b/src/renderer/file-manager.ts @@ -38,17 +38,20 @@ export class FileManager { }, ); - window.ElectronFiddle.addEventListener('saved-local-fiddle', (filePath) => { - const { localPath } = this.appState; + window.ElectronFiddle.addEventListener( + 'saved-local-fiddle', + async (filePath) => { + const { localPath } = this.appState; - if (filePath !== localPath) { - this.appState.localPath = filePath; - this.appState.gistId = undefined; - } - window.ElectronFiddle.setShowMeTemplate(); - this.appState.templateName = undefined; - this.appState.editorMosaic.isEdited = false; - }); + if (filePath !== localPath) { + this.appState.localPath = filePath; + this.appState.gistId = undefined; + } + window.ElectronFiddle.setShowMeTemplate(); + this.appState.templateName = undefined; + await this.appState.editorMosaic.markAsSaved(); + }, + ); window.ElectronFiddle.onGetFiles(this.getFiles); } diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 20d0536e17..c421af435f 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -565,9 +565,9 @@ export class AppState { * @returns the title, e.g. appname, fiddle name, state */ get title(): string { - const { isEdited } = this.editorMosaic; - - return isEdited ? 'Electron Fiddle - Unsaved' : 'Electron Fiddle'; + return this.editorMosaic.isEdited + ? 'Electron Fiddle - Unsaved' + : 'Electron Fiddle'; } /** diff --git a/tests/mocks/monaco.ts b/tests/mocks/monaco.ts index 7bb8e216f4..f06d4732c3 100644 --- a/tests/mocks/monaco.ts +++ b/tests/mocks/monaco.ts @@ -75,6 +75,7 @@ export class MonacoEditorMock { private model = new MonacoModelMock('', 'javascript'); private scrollHeight = 0; + public focus = vi.fn(); public addCommand = vi.fn(); public dispose = vi.fn(); public getAction = vi.fn(() => this.action); diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index 530f39ecd5..ea96ce6af1 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorValues, MAIN_JS, SetFiddleOptions } from '../../src/interfaces'; import { App } from '../../src/renderer/app'; -import { EditorMosaic, EditorPresence } from '../../src/renderer/editor-mosaic'; +import { EditorPresence } from '../../src/renderer/editor-mosaic'; import { defaultDark, defaultLight } from '../../src/themes-defaults'; import { createEditorValues } from '../mocks/mocks'; @@ -26,7 +26,7 @@ describe('App component', () => { document.body.innerHTML = '
'; }); - beforeEach(() => { + beforeEach(async () => { vi.mocked(window.ElectronFiddle.getTemplate).mockResolvedValue({ [MAIN_JS]: '// content', }); @@ -51,7 +51,7 @@ describe('App component', () => { }); window.app = app; - state.editorMosaic.set({ [MAIN_JS]: '// content' }); + await state.editorMosaic.set({ [MAIN_JS]: '// content' }); state.editorMosaic.files.set(MAIN_JS, EditorPresence.Pending); }); @@ -174,8 +174,10 @@ describe('App component', () => { expect(app.state.gistId).toBeFalsy(); expect(app.state.localPath).toBe(localPath); - // ...mark it as edited so a confirm dialog will appear before replacing - app.state.editorMosaic.isEdited = true; + vi.spyOn(app.state.editorMosaic, 'isEdited', 'get').mockReturnValue( + true, + ); + app.state.showConfirmDialog = vi.fn().mockResolvedValue(confirm); // now try to replace @@ -386,11 +388,6 @@ describe('App component', () => { // make a second fiddle that differs from the first const editorValues = createEditorValues(); const editorValues2: EditorValues = { [MAIN_JS]: '// hello world' }; - let editorMosaic: EditorMosaic; - - beforeEach(() => { - ({ editorMosaic } = app.state); - }); async function testDialog(confirm: boolean) { const localPath = '/etc/passwd'; @@ -405,7 +402,7 @@ describe('App component', () => { // ...mark it as edited so that trying a confirm dialog // will be triggered when we try to replace it - editorMosaic.isEdited = true; + vi.spyOn(app.state.editorMosaic, 'isEdited', 'get').mockReturnValue(true); // set up a reaction to confirm the replacement // when it happens @@ -440,13 +437,18 @@ describe('App component', () => { it('can close the window if user accepts the dialog', async () => { app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(true); - // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; - expect(window.onbeforeunload).toBeTruthy(); + // Actually set the editor in a dirty state instead of mocking + // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).savedHashes = new Map([[MAIN_JS, 'def']]); + (app.state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc'], + ]); const e = { returnValue: Boolean, }; + + await vi.waitUntil(() => app.state.editorMosaic.isEdited === true); window.onbeforeunload!(e as any); expect(e.returnValue).toBe(false); @@ -460,8 +462,13 @@ describe('App component', () => { app.state.isQuitting = true; app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(true); - // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; + // Actually set the editor in a dirty state instead of mocking + // because this code path uses an autorun on the computed `isEdited` value + (app.state.editorMosaic as any).savedHashes = new Map([[MAIN_JS, 'def']]); + (app.state.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'abc'], + ]); + expect(window.onbeforeunload).toBeTruthy(); const e = { @@ -482,7 +489,9 @@ describe('App component', () => { app.state.showConfirmDialog = vi.fn().mockResolvedValueOnce(false); // expect the app to be watching for exit if the fiddle is edited - app.state.editorMosaic.isEdited = true; + (app.state.editorMosaic as any) = vi.fn(() => ({ + isEdited: true, + }))(); expect(window.onbeforeunload).toBeTruthy(); const e = { diff --git a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap b/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap deleted file mode 100644 index 2c8f5025c7..0000000000 --- a/tests/renderer/components/__snapshots__/sidebar-file-tree-spec.tsx.snap +++ /dev/null @@ -1,1003 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`SidebarFileTree component > can bring up the Add File input 1`] = ` -
- - - - - } - onClick={[Function]} - > - index.html - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 1, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - main.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - renderer.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - - - - - - , - }, - { - "className": "add-file-input", - "icon": "document", - "id": "add", - "label": , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel": - - - - - - - , - }, - ] - } - /> -
-`; - -exports[`SidebarFileTree component > reflects the visibility state of all icons 1`] = ` -
- - - - - } - onClick={[Function]} - > - index.html - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 1, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - main.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - renderer.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - - - - - - , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel": - - - - - - - , - }, - ] - } - /> -
-`; - -exports[`SidebarFileTree component > renders 1`] = ` -
- - - - - } - onClick={[Function]} - > - index.html - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 1, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - main.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 2, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - preload.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 3, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - renderer.js - , - "secondaryLabel": - - - - - - , - }, - { - "hasCaret": false, - "icon": "document", - "id": 4, - "isSelected": false, - "label": - - - - } - onClick={[Function]} - > - styles.css - , - "secondaryLabel": - - - - - - , - }, - ], - "hasCaret": false, - "icon": "folder-open", - "id": "files", - "isExpanded": true, - "label": "Editors", - "secondaryLabel": - - - - - - - , - }, - ] - } - /> -
-`; diff --git a/tests/renderer/components/sidebar-file-tree-spec.tsx b/tests/renderer/components/sidebar-file-tree-spec.tsx deleted file mode 100644 index 5a45f20144..0000000000 --- a/tests/renderer/components/sidebar-file-tree-spec.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - EditorValues, - MAIN_CJS, - MAIN_JS, - PACKAGE_NAME, -} from '../../../src/interfaces'; -import { Editors } from '../../../src/renderer/components/editors'; -import { SidebarFileTree } from '../../../src/renderer/components/sidebar-file-tree'; -import { - EditorMosaic, - EditorPresence, -} from '../../../src/renderer/editor-mosaic'; -import { AppState } from '../../../src/renderer/state'; -import { createEditorValues } from '../../mocks/editor-values'; -import { AppMock, StateMock } from '../../mocks/mocks'; - -describe('SidebarFileTree component', () => { - let store: AppState; - let editorMosaic: EditorMosaic; - let editorValues: EditorValues; - let stateMock: StateMock; - - beforeEach(() => { - ({ state: stateMock } = window.app as unknown as AppMock); - store = {} as unknown as AppState; - editorValues = createEditorValues(); - editorMosaic = new EditorMosaic(); - editorMosaic.set(editorValues); - (store as unknown as StateMock).editorMosaic = editorMosaic; - stateMock.editorMosaic = editorMosaic; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('renders', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - it('reflects the visibility state of all icons', () => { - editorMosaic.hide('index.html'); - const wrapper = shallow(); - - // snapshot has an 'eye-off' icon - expect(wrapper).toMatchSnapshot(); - }); - - it('can bring up the Add File input', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - instance.setState({ action: 'add' }); - - // snapshot has the input rendered - expect(wrapper).toMatchSnapshot(); - }); - - it('can toggle editor visibility', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - instance.toggleVisibility('index.html'); - - expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); - }); - - it('can create new editors', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - expect(editorMosaic.files.get('tester.js')).toBe(undefined); - instance.createEditor('tester.js'); - expect(editorMosaic.files.get('tester.js')).toBe(EditorPresence.Pending); - }); - - it('can delete editors', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Pending); - instance.removeEditor('index.html'); - expect(editorMosaic.files.get('index.html')).toBe(undefined); - }); - - it('can rename editors', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const EDITOR_NAME = 'index.html'; - const EDITOR_NEW_NAME = 'new_index.html'; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); - - await instance.renameEditor(EDITOR_NAME); - - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); - expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( - EditorPresence.Pending, - ); - }); - - it('can rename one main entry point file to another main entry point file', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const EDITOR_NAME = MAIN_JS; - const EDITOR_NEW_NAME = MAIN_CJS; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - - await instance.renameEditor(EDITOR_NAME); - - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(undefined); - expect(editorMosaic.files.get(EDITOR_NEW_NAME)).toBe( - EditorPresence.Pending, - ); - }); - - it('fails if trying to rename an editor to package(-lock).json', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const EDITOR_NAME = 'index.html'; - const EDITOR_NEW_NAME = PACKAGE_NAME; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - - await instance.renameEditor(EDITOR_NAME); - - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot add ${PACKAGE_NAME} or package-lock.json as custom files`, - ); - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); - }); - - it('fails if trying to rename an editor to an unsupported name', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const EDITOR_NAME = 'index.html'; - const EDITOR_NEW_NAME = 'data.txt'; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - - await instance.renameEditor(EDITOR_NAME); - - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Invalid filename "${EDITOR_NEW_NAME}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`, - ); - expect(editorMosaic.files.get(EDITOR_NAME)).toBe(EditorPresence.Pending); - }); - - it('fails if trying to rename an editor to an existing name', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const EXISTED_NAME = 'styles.css'; - const TO_BE_NAMED = 'index.html'; - const EDITOR_NEW_NAME = EXISTED_NAME; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - - await instance.renameEditor(TO_BE_NAMED); - - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot rename file to "${EDITOR_NEW_NAME}": File already exists`, - ); - expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); - }); - - it('fails if trying to rename an editor to another main entry point file', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const TO_BE_NAMED = 'index.html'; - const EDITOR_NEW_NAME = MAIN_CJS; - - store.showInputDialog = vi.fn().mockResolvedValueOnce(EDITOR_NEW_NAME); - store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - - await instance.renameEditor(TO_BE_NAMED); - - expect(store.showErrorDialog).toHaveBeenCalledWith( - `Cannot rename file to "${EDITOR_NEW_NAME}": Main entry point ${MAIN_JS} exists`, - ); - expect(editorMosaic.files.get(TO_BE_NAMED)).toBe(EditorPresence.Pending); - }); - - it('can reset the editor layout', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - editorMosaic.resetLayout = vi.fn(); - - instance.resetLayout(); - - expect(editorMosaic.resetLayout).toHaveBeenCalledTimes(1); - }); - - it('file is visible, click files tree, focus file content', async () => { - vi.useFakeTimers(); - - const sidebarFileTree = shallow(); - const editors = shallow( - , - ); - const sidebarFileTreeInstance: any = sidebarFileTree.instance(); - const editorsInstance: any = editors.instance(); - - sidebarFileTreeInstance.setFocusedFile('index.html'); - - setTimeout(() => { - expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); - expect(editorsInstance.state.focused).toBe('index.html'); - }); - }); - - it('file is hidden, click files tree, make file visible and focus file content', async () => { - vi.useFakeTimers(); - - const sidebarFileTree = shallow(); - const editors = shallow( - , - ); - const sidebarFileTreeInstance: any = sidebarFileTree.instance(); - const editorsInstance: any = editors.instance(); - - sidebarFileTreeInstance.toggleVisibility('index.html'); - - expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Hidden); - - sidebarFileTreeInstance.setFocusedFile('index.html'); - - setTimeout(() => { - expect(editorMosaic.files.get('index.html')).toBe(EditorPresence.Visible); - expect(editorsInstance.state.focused).toBe('index.html'); - }); - }); -}); diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts index 5ac191b889..5351a11d8c 100644 --- a/tests/renderer/editor-mosaic-spec.ts +++ b/tests/renderer/editor-mosaic-spec.ts @@ -1,4 +1,3 @@ -import { reaction } from 'mobx'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorId, EditorValues, MAIN_JS } from '../../src/interfaces'; @@ -37,67 +36,43 @@ describe('EditorMosaic', () => { const id = MAIN_JS; const content = '// content'; - beforeEach(() => { - editorMosaic.set({ [id]: content }); + beforeEach(async () => { + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); }); - it('throws when called on an unexpected file', () => { + it('throws when called on an unexpected file', async () => { const otherId = 'file.js'; - expect(() => editorMosaic.addEditor(otherId, editor)).toThrow( - /unexpected file/i, - ); + await expect(() => + editorMosaic.addEditor(otherId, editor), + ).rejects.toThrow(/unexpected file/i); }); - it('makes a file visible', () => { - editorMosaic.addEditor(id, editor); + it('makes a file visible', async () => { + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); }); - it('begins listening for changes to the files', () => { - // test that isEdited is not affected by editors that aren't in the - // mosaic (`editor` hasn't been added to the mosaic yet) - expect(editorMosaic.isEdited).toBe(false); - editor.setValue('💩'); - expect(editorMosaic.isEdited).toBe(false); - - // test that isEdited is affected by editors that have been added - editorMosaic.addEditor(id, editor); - expect(editorMosaic.isEdited).toBe(false); - editor.setValue('💩'); - expect(editorMosaic.isEdited).toBe(true); - }); - - describe('does not change isEdited', () => { - it.each([true, false])('...to %p', (value: boolean) => { - // test that isEdited does not change when adding editors - editorMosaic.isEdited = value; - expect(editorMosaic.isEdited).toBe(value); - editorMosaic.addEditor(id, editor); - expect(editorMosaic.isEdited).toBe(value); - }); - }); - - it('restores ViewStates when possible', () => { + it('restores ViewStates when possible', async () => { // setup: put visible file into the mosaic and then hide it. // this should cause EditorMosaic to cache the viewstate offscreen. const viewState = Symbol('some unique viewstate'); vi.mocked(editor.saveViewState).mockReturnValueOnce(viewState as any); - editorMosaic.addEditor(id, editor); + await editorMosaic.addEditor(id, editor); editorMosaic.hide(id); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); // now try to re-show the file const editor2: Editor = new MonacoEditorMock() as unknown as Editor; editorMosaic.show(id); - editorMosaic.addEditor(id, editor2); + await editorMosaic.addEditor(id, editor2); // test that the viewState was reused in the new editor expect(editor2.restoreViewState).toHaveBeenCalledWith(viewState); }); - it('restores values when possible', () => { - editorMosaic.addEditor(id, editor); + it('restores values when possible', async () => { + await editorMosaic.addEditor(id, editor); expect(monaco.editor.createModel).toHaveBeenCalledWith( content, expect.anything(), @@ -111,31 +86,31 @@ describe('EditorMosaic', () => { const hiddenContent = getEmptyContent(id); const visibleContent = '// fnord' as const; - it('excludes hidden files', () => { - editorMosaic.set({ [id]: hiddenContent }); + it('excludes hidden files', async () => { + await editorMosaic.set({ [id]: hiddenContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); expect(editorMosaic.numVisible).toBe(0); }); - it('includes pending files', () => { - editorMosaic.set({ [id]: visibleContent }); + it('includes pending files', async () => { + await editorMosaic.set({ [id]: visibleContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); expect(editorMosaic.numVisible).toBe(1); }); - it('includes visible files', () => { - editorMosaic.set({ [id]: visibleContent }); - editorMosaic.addEditor(id, editor); + it('includes visible files', async () => { + await editorMosaic.set({ [id]: visibleContent }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); expect(editorMosaic.numVisible).toBe(1); }); }); describe('hide()', () => { - it('hides an editor', () => { + it('hides an editor', async () => { const id = MAIN_JS; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); editorMosaic.hide(MAIN_JS); @@ -144,9 +119,9 @@ describe('EditorMosaic', () => { }); describe('show()', () => { - it('shows an editor', () => { + it('shows an editor', async () => { const id = MAIN_JS; - editorMosaic.set({ [id]: getEmptyContent(id) }); + await editorMosaic.set({ [id]: getEmptyContent(id) }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); editorMosaic.show(MAIN_JS); @@ -159,25 +134,25 @@ describe('EditorMosaic', () => { const hiddenContent = getEmptyContent(id); const visibleContent = '// sesquipedalian'; - it('shows files that were hidden', () => { - editorMosaic.set({ [id]: hiddenContent }); + it('shows files that were hidden', async () => { + await editorMosaic.set({ [id]: hiddenContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); editorMosaic.toggle(id); expect(editorMosaic.files.get(id)).not.toBe(EditorPresence.Hidden); }); - it('hides files that were visible', () => { - editorMosaic.set({ [id]: visibleContent }); - editorMosaic.addEditor(id, editor); + it('hides files that were visible', async () => { + await editorMosaic.set({ [id]: visibleContent }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); editorMosaic.toggle(id); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('hides files that were pending', () => { - editorMosaic.set({ [id]: visibleContent }); + it('hides files that were pending', async () => { + await editorMosaic.set({ [id]: visibleContent }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); editorMosaic.toggle(id); @@ -186,34 +161,35 @@ describe('EditorMosaic', () => { }); describe('resetLayout()', () => { - it('resets editors to their original arrangement', () => { + it('resets editors to their original arrangement', async () => { const serializeState = () => [...editorMosaic.files.entries()]; // setup: capture the state of the editorMosaic after set() is called - editorMosaic.set(valuesIn); + await editorMosaic.set(valuesIn); const initialState = serializeState(); // now change the state a bit - for (const filename of Object.keys(valuesIn)) + for (const filename of Object.keys(valuesIn)) { editorMosaic.hide(filename as EditorId); + } expect(serializeState()).not.toStrictEqual(initialState); // test the post-reset state matches the initial state - editorMosaic.resetLayout(); + await editorMosaic.resetLayout(); expect(serializeState()).toStrictEqual(initialState); }); }); describe('values()', () => { - it('works on closed panels', () => { + it('works on closed panels', async () => { const values = createEditorValues(); - editorMosaic.set(values); + await editorMosaic.set(values); expect(editorMosaic.values()).toStrictEqual(values); }); - it('works on open panels', () => { + it('works on open panels', async () => { const values = createEditorValues(); - editorMosaic.set(values); + await editorMosaic.set(values); // now modify values _after_ calling editorMosaic.set() for (const [file, value] of Object.entries(values)) { @@ -223,8 +199,8 @@ describe('EditorMosaic', () => { // and then add Monaco editors for (const [file, value] of Object.entries(values)) { const editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.addEditor(file as EditorId, editor); - editor.setValue(value as string); + await editorMosaic.addEditor(file as EditorId, editor); + editor.setValue(value); } // values() should match the modified values @@ -233,81 +209,87 @@ describe('EditorMosaic', () => { }); describe('addNewFile()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.addNewFile('foo.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.addNewFile('foo.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('renameFile()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.renameFile('renderer.js', 'bar.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.renameFile('renderer.js', 'bar.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('remove()', () => { - it('sets isEdited to true', () => { - editorMosaic.set(createEditorValues()); - editorMosaic.isEdited = false; - editorMosaic.remove('renderer.js'); + it('sets isEdited to true', async () => { + await editorMosaic.set(createEditorValues()); + await editorMosaic.markAsSaved(); + expect(editorMosaic.isEdited).toBe(false); + await editorMosaic.remove('renderer.js'); expect(editorMosaic.isEdited).toBe(true); }); }); describe('set()', () => { - it('resets isEdited to false', () => { - editorMosaic.isEdited = true; - editorMosaic.set(createEditorValues()); + it('resets isEdited to false', async () => { + await editorMosaic.set(valuesIn); + editor.setValue('beep boop'); + vi.waitFor(() => editorMosaic.isEdited === true); + + await editorMosaic.set(createEditorValues()); expect(editorMosaic.isEdited).toBe(false); }); - it('hides files that are empty', () => { + it('hides files that are empty', async () => { const id = MAIN_JS; const content = ''; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('hides files that have default content', () => { + it('hides files that have default content', async () => { const id = MAIN_JS; const content = getEmptyContent(id); expect(content).not.toStrictEqual(''); - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('shows files that have non-default content', () => { + it('shows files that have non-default content', async () => { const id = MAIN_JS; const content = 'fnord'; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).not.toBe(EditorPresence.Hidden); }); - it('does not set a value if none passed in', () => { + it('does not set a value if none passed in', async () => { const id = MAIN_JS; - editorMosaic.set({ [id]: '// content' }); + await editorMosaic.set({ [id]: '// content' }); expect(editorMosaic.files.has('some-file.js')).toBe(false); }); describe('reuses existing editors', () => { - it('when the old file was visible and the new one should be too', () => { + it('when the old file was visible and the new one should be too', async () => { // setup: get a mosaic with a visible editor const id = MAIN_JS; let content = '// first content'; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); // now call set again, same filename DIFFERENT content content = '// second content'; vi.mocked(monaco.editor.getModel).mockReturnValueOnce( monaco.latestModel, ); - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); // test that editorMosaic set the editor to the new content expect(editor.getValue()).toBe(content); expect(editorMosaic.isEdited).toBe(false); @@ -318,10 +300,10 @@ describe('EditorMosaic', () => { monaco.latestModel, ); editor.setValue(content); - expect(editorMosaic.isEdited).toBe(true); + vi.waitUntil(() => editorMosaic.isEdited === true); // now call set again, same filename and SAME content - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.isEdited).toBe(false); // test that the editor still responds to edits @@ -330,15 +312,15 @@ describe('EditorMosaic', () => { monaco.latestModel, ); editor.setValue(content); - expect(editorMosaic.isEdited).toBe(true); + vi.waitUntil(() => editorMosaic.isEdited === true); }); - it('but not when the new file should be hidden', () => { + it('but not when the new file should be hidden', async () => { // set up a fully populated mosaic with visible files - editorMosaic.set(valuesIn); + await editorMosaic.set(valuesIn); for (const [id, presence] of editorMosaic.files) { if (presence === EditorPresence.Pending) { - editorMosaic.addEditor( + await editorMosaic.addEditor( id, new MonacoEditorMock() as unknown as Editor, ); @@ -349,7 +331,7 @@ describe('EditorMosaic', () => { const keys = Object.keys(valuesIn); const [id1, id2] = keys; const values = { [id1]: '// potrzebie', [id2]: '' }; - editorMosaic.set(values); + await editorMosaic.set(values); // test that id1 got recycled but id2 is hidden const { files } = editorMosaic; @@ -359,8 +341,8 @@ describe('EditorMosaic', () => { }); }); - it('does not add unrequested files', () => { - editorMosaic.set(valuesIn); + it('does not add unrequested files', async () => { + await editorMosaic.set(valuesIn); for (const key of editorMosaic.files.keys()) { expect(valuesIn).toHaveProperty([key]); } @@ -369,36 +351,36 @@ describe('EditorMosaic', () => { describe('does not remember files from previous calls', () => { const id = MAIN_JS; - afterEach(() => { + afterEach(async () => { // this is the real test. // the three it()s below each set a different test condition - editorMosaic.set({}); + await editorMosaic.set({}); expect(editorMosaic.files.has(id)).toBe(false); expect(editorMosaic.value(id)).toBe(''); }); - it('even if the file was visible', () => { + it('even if the file was visible', async () => { const content = '// fnord'; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); }); - it('even if the file was hidden', () => { + it('even if the file was hidden', async () => { const content = ''; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); }); - it('even if the file was pending', () => { + it('even if the file was pending', async () => { const content = '// fnord'; - editorMosaic.set({ [id]: content }); + await editorMosaic.set({ [id]: content }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); }); }); - it('uses the expected layout', () => { - editorMosaic.set(valuesIn); + it('uses the expected layout', async () => { + await editorMosaic.set(valuesIn); expect(editorMosaic.mosaic).toStrictEqual({ direction: 'row', first: { @@ -424,26 +406,26 @@ describe('EditorMosaic', () => { const content = '// content'; const emptyContent = getEmptyContent(id); - it('returns values for files that are hidden', () => { + it('returns values for files that are hidden', async () => { const value = emptyContent; - editorMosaic.set({ [id]: value }); + await editorMosaic.set({ [id]: value }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Hidden); expect(editorMosaic.value(id)).toBe(value); }); - it('returns values for files that are pending', () => { + it('returns values for files that are pending', async () => { const value = content; - editorMosaic.set({ [id]: value }); + await editorMosaic.set({ [id]: value }); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Pending); expect(editorMosaic.value(id)).toBe(value); }); - it('returns values for files that are visible', () => { + it('returns values for files that are visible', async () => { const value = content; - editorMosaic.set({ [id]: value }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: value }); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.files.get(id)).toBe(EditorPresence.Visible); expect(editorMosaic.value(id)).toBe(value); @@ -455,17 +437,17 @@ describe('EditorMosaic', () => { }); describe('getFocusedEditor', () => { - it('finds the focused editor if there is one', () => { + it('finds the focused editor if there is one', async () => { const id = MAIN_JS; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); vi.mocked(editor.hasTextFocus).mockReturnValue(true); expect(editorMosaic.getFocusedEditor()).toBe(editor); }); - it('returns undefined if none have focus', () => { - editorMosaic.set(valuesIn); + it('returns undefined if none have focus', async () => { + await editorMosaic.set(valuesIn); expect(editorMosaic.getFocusedEditor()).toBeUndefined(); }); }); @@ -475,8 +457,8 @@ describe('EditorMosaic', () => { const id = MAIN_JS; const content = '// content'; const editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.set({ [id]: content }); - editorMosaic.addEditor(id, editor); + await editorMosaic.set({ [id]: content }); + await editorMosaic.addEditor(id, editor); editorMosaic.layout(); editorMosaic.layout(); @@ -501,54 +483,21 @@ describe('EditorMosaic', () => { const id = MAIN_JS; let editor: Editor; - beforeEach(() => { + beforeEach(async () => { editor = new MonacoEditorMock() as unknown as Editor; - editorMosaic.set(valuesIn); - editorMosaic.addEditor(id, editor); + await editorMosaic.set(valuesIn); + await editorMosaic.addEditor(id, editor); expect(editorMosaic.isEdited).toBe(false); }); - function testForIsEdited() { + it('recognizes edits', async () => { + await editorMosaic.markAsSaved(); expect(editorMosaic.isEdited).toBe(false); editor.setValue(`${editor.getValue()} more text`); - expect(editorMosaic.isEdited).toBe(true); - } - - it('recognizes edits', () => { - testForIsEdited(); - }); - - it('recognizes edits after isEdited has been manually set to false', () => { - editorMosaic.isEdited = false; - testForIsEdited(); - }); - - it('recognizes edits after isEdited has been manually toggled', () => { - editorMosaic.isEdited = true; - editorMosaic.isEdited = false; - testForIsEdited(); - }); - - it('does not re-emit when isEdited is already true', () => { - let changeCount = 0; - const dispose = reaction( - () => editorMosaic.isEdited, - () => ++changeCount, - ); - expect(editorMosaic.isEdited).toBe(false); - expect(changeCount).toBe(0); - - editor.setValue(`${editor.getValue()} more text`); - expect(editorMosaic.isEdited).toBe(true); - expect(changeCount).toBe(1); - - editor.setValue(`${editor.getValue()} and even more text`); - expect(editorMosaic.isEdited).toBe(true); - expect(changeCount).toBe(1); - - dispose(); + // hashes are calculated asynchronously + await vi.waitUntil(() => editorMosaic.isEdited === true); }); }); diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts index 6e89845f20..d4bff37084 100644 --- a/tests/renderer/state-spec.ts +++ b/tests/renderer/state-spec.ts @@ -459,7 +459,7 @@ describe('AppState', () => { it('if there is no current fiddle', async () => { // setup: current fiddle is empty - appState.editorMosaic.set({}); + await appState.editorMosaic.set({}); await appState.setVersion(newVersion); expect(replaceSpy).toHaveBeenCalledTimes(1); @@ -469,8 +469,7 @@ describe('AppState', () => { it('if the current fiddle is an unedited template', async () => { appState.templateName = oldVersion; - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - appState.editorMosaic.isEdited = false; + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); await appState.setVersion(newVersion); const templateName = newVersion; @@ -478,8 +477,13 @@ describe('AppState', () => { }); it('but not if the current fiddle is edited', async () => { - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); - appState.editorMosaic.isEdited = true; + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); + (appState.editorMosaic as any).savedHashes = new Map([ + [MAIN_JS, 'saved'], + ]); + (appState.editorMosaic as any).currentHashes = new Map([ + [MAIN_JS, 'different'], + ]); appState.templateName = oldVersion; await appState.setVersion(newVersion); @@ -487,7 +491,7 @@ describe('AppState', () => { }); it('but not if the current fiddle is not a template', async () => { - appState.editorMosaic.set({ [MAIN_JS]: '// content' }); + await appState.editorMosaic.set({ [MAIN_JS]: '// content' }); appState.localPath = '/some/path/to/a/fiddle'; await appState.setVersion(newVersion); @@ -789,7 +793,12 @@ describe('AppState', () => { it('flags unsaved fiddles', () => { const expected = `${APPNAME} - Unsaved`; - appState.editorMosaic.isEdited = true; + (appState.editorMosaic as any).savedHashes = new Map([ + ['main.js', 'saved'], + ]); + (appState.editorMosaic as any).currentHashes = new Map([ + ['main.js', 'current'], + ]); const actual = appState.title; expect(actual).toBe(expected); }); diff --git a/vitest.config.ts b/vitest.config.ts index a4c81f75bf..62886d086a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: 'jsdom', globalSetup: 'tests/globalSetup.ts', - include: ['**/rtl-spec/**/*.spec.*', '**/tests/**/*-spec.{ts,tsx}'], + include: ['**/rtl-spec/**/*.spec.{ts,tsx}', '**/tests/**/*-spec.{ts,tsx}'], setupFiles: ['tests/setup.ts'], snapshotSerializers: ['enzyme-to-json/serializer'], },