diff --git a/rtl-spec/components/dialog-add-theme.spec.tsx b/rtl-spec/components/dialog-add-theme.spec.tsx new file mode 100644 index 0000000000..6fdfa40fef --- /dev/null +++ b/rtl-spec/components/dialog-add-theme.spec.tsx @@ -0,0 +1,200 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AddThemeDialog } from '../../src/renderer/components/dialog-add-theme'; +import { AppState } from '../../src/renderer/state'; +import { LoadedFiddleTheme, defaultLight } from '../../src/themes-defaults'; +import { overrideRendererPlatform } from '../../tests/utils'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; + +class FileMock extends Blob { + public lastModified: number; + public webkitRelativePath: string; + + constructor( + private bits: string[], + public name: string, + public path: string, + type: string, + ) { + super(bits, { type }); + this.lastModified = Date.now(); + this.webkitRelativePath = ''; + } + + async text() { + return this.bits.join(''); + } +} + +describe('AddThemeDialog component', () => { + let store: AppState; + + beforeEach(() => { + overrideRendererPlatform('darwin'); + ({ state: store } = window.app); + store.isThemeDialogShowing = true; + }); + + function renderAddThemeDialog() { + return renderClassComponentWithInstanceRef(AddThemeDialog, { + appState: store, + }); + } + + it('renders the dialog when open', () => { + renderAddThemeDialog(); + + expect(screen.getByText('Add theme')).toBeInTheDocument(); + expect(screen.getByText('Select the Monaco file...')).toBeInTheDocument(); + }); + + it('displays Add button as disabled when no file selected', () => { + renderAddThemeDialog(); + + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).toBeDisabled(); + }); + + it('displays Cancel button that closes dialog', () => { + renderAddThemeDialog(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).toBeInTheDocument(); + + fireEvent.click(cancelButton); + expect(store.isThemeDialogShowing).toBe(false); + }); + + describe('createNewThemeFromMonaco()', () => { + it('handles invalid input', async () => { + const { instance } = renderAddThemeDialog(); + + await expect( + instance.createNewThemeFromMonaco('', {} as LoadedFiddleTheme), + ).rejects.toThrow('Filename not found'); + + expect(window.ElectronFiddle.createThemeFile).toHaveBeenCalledTimes(0); + expect(store.setTheme).toHaveBeenCalledTimes(0); + }); + + it('handles valid input', async () => { + const { instance } = renderAddThemeDialog(); + + const themePath = '~/.electron-fiddle/themes/testingLight'; + vi.mocked(window.ElectronFiddle.createThemeFile).mockResolvedValue({ + file: themePath, + } as LoadedFiddleTheme); + + await instance.createNewThemeFromMonaco('testingLight', defaultLight); + + expect(window.ElectronFiddle.createThemeFile).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Fiddle (Light)', + common: expect.anything(), + file: 'defaultLight', + }), + 'testingLight', + ); + expect(store.setTheme).toHaveBeenCalledWith(themePath); + }); + }); + + describe('onSubmit()', () => { + it('does nothing if there is no file currently set', async () => { + const { instance } = renderAddThemeDialog(); + + instance.createNewThemeFromMonaco = vi.fn(); + const onCloseSpy = vi.spyOn(instance, 'onClose'); + + await instance.onSubmit(); + + expect(instance.createNewThemeFromMonaco).toHaveBeenCalledTimes(0); + expect(onCloseSpy).toHaveBeenCalledTimes(0); + }); + + it('loads a theme if a file is currently set', async () => { + const { instance } = renderAddThemeDialog(); + + const file = new FileMock( + [JSON.stringify(defaultLight.editor)], + 'file.json', + '/test/file.json', + 'application/json', + ); + const spy = vi.spyOn(file, 'text'); + + instance.setState({ file: file as unknown as File }); + + instance.createNewThemeFromMonaco = vi.fn(); + const onCloseSpy = vi.spyOn(instance, 'onClose'); + + await instance.onSubmit(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(instance.createNewThemeFromMonaco).toHaveBeenCalledTimes(1); + expect(onCloseSpy).toHaveBeenCalledTimes(1); + }); + + it('shows an error dialog for a malformed theme', async () => { + store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); + const { instance } = renderAddThemeDialog(); + + const file = new FileMock( + [JSON.stringify(defaultLight.editor)], + 'file.json', + '/test/file.json', + 'application/json', + ); + vi.spyOn(file, 'text').mockResolvedValue('{}'); + + instance.setState({ file: file as unknown as File }); + + const onCloseSpy = vi.spyOn(instance, 'onClose'); + + await instance.onSubmit(); + + expect(store.showErrorDialog).toHaveBeenCalledWith( + expect.stringMatching(/file does not match specifications/i), + ); + expect(onCloseSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onChangeFile()', () => { + it('handles valid input', async () => { + const { instance } = renderAddThemeDialog(); + + const files = ['one', 'two']; + await instance.onChangeFile({ + target: { files } as unknown as EventTarget, + } as React.FormEvent); + + await waitFor(() => { + expect(instance.state.file).toBe(files[0]); + }); + }); + + it('handles no input', async () => { + const { instance } = renderAddThemeDialog(); + + await instance.onChangeFile({ + target: { files: null } as unknown as EventTarget, + } as React.FormEvent); + + expect(instance.state.file).toBeUndefined(); + }); + }); + + describe('onClose()', () => { + it('resets state and hides dialog', () => { + const { instance } = renderAddThemeDialog(); + + instance.setState({ file: {} as File }); + instance.onClose(); + + expect(instance.state.file).toBeUndefined(); + expect(store.isThemeDialogShowing).toBe(false); + }); + }); +}); diff --git a/rtl-spec/components/dialog-add-version.spec.tsx b/rtl-spec/components/dialog-add-version.spec.tsx new file mode 100644 index 0000000000..609f4ba5e9 --- /dev/null +++ b/rtl-spec/components/dialog-add-version.spec.tsx @@ -0,0 +1,260 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AddVersionDialog } from '../../src/renderer/components/dialog-add-version'; +import { AppState } from '../../src/renderer/state'; +import { overrideRendererPlatform } from '../../tests/utils'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; + +describe('AddVersionDialog component', () => { + let store: AppState; + + const mockFile = '/test/file'; + + beforeEach(() => { + overrideRendererPlatform('darwin'); + ({ state: store } = window.app); + store.isAddVersionDialogShowing = true; + }); + + function renderAddVersionDialog() { + return renderClassComponentWithInstanceRef(AddVersionDialog, { + appState: store, + }); + } + + it('renders the dialog when open', () => { + renderAddVersionDialog(); + + expect(screen.getByText('Add local Electron build')).toBeInTheDocument(); + expect( + screen.getByText(/Select the folder containing/), + ).toBeInTheDocument(); + }); + + it('renders with valid electron and version', () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidVersion: true, + isValidElectron: true, + folderPath: mockFile, + }); + + expect(screen.getByText(/We found an/)).toBeInTheDocument(); + }); + + it('renders with invalid version', () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidVersion: false, + isValidElectron: true, + folderPath: mockFile, + }); + + expect(screen.getByText(/We found an/)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('4.0.0')).toBeInTheDocument(); + }); + + it('renders with existing local version', () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidVersion: true, + isValidElectron: true, + existingLocalVersion: { + version: '2.2.2', + localPath: mockFile, + }, + folderPath: mockFile, + }); + + expect( + screen.getByText(/This folder is already in use as version "2.2.2"/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /switch/i })).toBeInTheDocument(); + }); + + it('overrides default input with Electron dialog', async () => { + renderAddVersionDialog(); + + const fileInput = screen.getByText(/Select the folder containing/); + const input = fileInput.closest('label')?.querySelector('input'); + + expect(input).toBeInTheDocument(); + + if (input) { + fireEvent.click(input); + expect(window.ElectronFiddle.selectLocalVersion).toHaveBeenCalled(); + } + }); + + describe('selectLocalVersion()', () => { + it('updates state', async () => { + const { instance } = renderAddVersionDialog(); + + vi.mocked(window.ElectronFiddle.selectLocalVersion).mockResolvedValue({ + folderPath: '/test/', + isValidElectron: true, + localName: 'Test', + }); + + await instance.selectLocalVersion(); + + await waitFor(() => { + expect(instance.state.isValidElectron).toBe(true); + expect(instance.state.folderPath).toBe('/test/'); + expect(instance.state.localName).toBe('Test'); + }); + }); + }); + + describe('onChangeVersion()', () => { + it('handles valid input', async () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidElectron: true, + folderPath: mockFile, + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText('4.0.0')).toBeInTheDocument(); + }); + + const versionInput = screen.getByPlaceholderText('4.0.0'); + await userEvent.type(versionInput, '3.3.3'); + + await waitFor(() => { + expect(instance.state.isValidVersion).toBe(true); + expect(instance.state.version).toBe('3.3.3'); + }); + }); + + it('handles invalid input', async () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidElectron: true, + folderPath: mockFile, + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText('4.0.0')).toBeInTheDocument(); + }); + + const versionInput = screen.getByPlaceholderText('4.0.0'); + await userEvent.type(versionInput, 'foo'); + + await waitFor(() => { + expect(instance.state.isValidVersion).toBe(false); + expect(instance.state.version).toBe('foo'); + }); + }); + }); + + describe('onSubmit', () => { + it('does not do anything without a file', async () => { + const { instance } = renderAddVersionDialog(); + + await instance.onSubmit(); + + expect(store.addLocalVersion).toHaveBeenCalledTimes(0); + }); + + it('adds a local version using the given data', async () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + version: '3.3.3', + folderPath: '/test/path', + isValidVersion: true, + isValidElectron: true, + }); + + await instance.onSubmit(); + + expect(store.addLocalVersion).toHaveBeenCalledTimes(1); + expect(store.addLocalVersion).toHaveBeenCalledWith( + expect.objectContaining({ + localPath: '/test/path', + version: '3.3.3', + }), + ); + }); + + it('switches to existing local version when duplicate', async () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidElectron: true, + folderPath: '/test/path', + version: '3.3.3', + existingLocalVersion: { + version: '2.2.2', + localPath: '/test/path', + }, + }); + + await instance.onSubmit(); + + expect(store.setVersion).toHaveBeenCalledWith('2.2.2'); + expect(store.addLocalVersion).not.toHaveBeenCalled(); + }); + }); + + describe('buttons', () => { + it('disables Add button when no valid electron or version', () => { + renderAddVersionDialog(); + + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).toBeDisabled(); + }); + + it('enables Add button when valid electron and version', async () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidElectron: true, + isValidVersion: true, + folderPath: mockFile, + }); + + await waitFor(() => { + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).not.toBeDisabled(); + }); + }); + + it('Cancel button closes dialog', () => { + renderAddVersionDialog(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(store.isAddVersionDialogShowing).toBe(false); + }); + }); + + describe('onClose()', () => { + it('hides dialog and resets state', () => { + const { instance } = renderAddVersionDialog(); + + instance.setState({ + isValidVersion: true, + isValidElectron: true, + folderPath: mockFile, + version: '3.3.3', + }); + + instance.onClose(); + + expect(store.isAddVersionDialogShowing).toBe(false); + expect(instance.state.isValidVersion).toBe(false); + expect(instance.state.isValidElectron).toBe(false); + expect(instance.state.folderPath).toBeUndefined(); + }); + }); +}); diff --git a/rtl-spec/components/dialog-bisect.spec.tsx b/rtl-spec/components/dialog-bisect.spec.tsx new file mode 100644 index 0000000000..897cfdcc48 --- /dev/null +++ b/rtl-spec/components/dialog-bisect.spec.tsx @@ -0,0 +1,278 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ElectronReleaseChannel, + InstallState, + RunResult, + VersionSource, +} from '../../src/interfaces'; +import { Bisector } from '../../src/renderer/bisect'; +import { BisectDialog } from '../../src/renderer/components/dialog-bisect'; +import { Runner } from '../../src/renderer/runner'; +import { AppState } from '../../src/renderer/state'; +import { StateMock } from '../../tests/mocks/mocks'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; + +vi.mock('../../src/renderer/bisect'); + +describe.each([8, 15])('BisectDialog component', (numVersions) => { + let runner: Runner; + let store: AppState; + + const generateVersionRange = (rangeLength: number) => + new Array(rangeLength).fill(0).map((_, i) => ({ + state: InstallState.installed, + version: `${i + 1}.0.0`, + source: VersionSource.local, + })); + + beforeEach(() => { + ({ runner, state: store } = window.app); + + (store as unknown as StateMock).versionsToShow = + generateVersionRange(numVersions); + (store as unknown as StateMock).versions = Object.fromEntries( + store.versionsToShow.map((ver) => [ver.version, ver]), + ); + (store as unknown as StateMock).channelsToShow = [ + ElectronReleaseChannel.stable, + ]; + store.isBisectDialogShowing = true; + + vi.mocked(Bisector).mockClear(); + }); + + function renderBisectDialog() { + return renderClassComponentWithInstanceRef(BisectDialog, { + appState: store, + }); + } + + it('renders the dialog when open', () => { + renderBisectDialog(); + + expect(screen.getByText('Start a bisect session')).toBeInTheDocument(); + expect( + screen.getByText(/Earliest Version \(Last "known good" version\)/), + ).toBeInTheDocument(); + expect( + screen.getByText(/Latest Version \(First "known bad" version\)/), + ).toBeInTheDocument(); + }); + + it('renders the help section', () => { + renderBisectDialog(); + + expect( + screen.getByText(/A "bisect" is a popular method/), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /show help/i }), + ).toBeInTheDocument(); + }); + + it('shows expanded help when Show help button is clicked', () => { + const { instance } = renderBisectDialog(); + + const helpButton = screen.getByRole('button', { name: /show help/i }); + fireEvent.click(helpButton); + + expect(instance.state.showHelp).toBe(true); + expect( + screen.getByText(/First, write a fiddle that reproduces/), + ).toBeInTheDocument(); + }); + + it('renders Begin, Auto, and Cancel buttons', () => { + renderBisectDialog(); + + expect(screen.getByRole('button', { name: /begin/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /auto/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('Cancel button closes dialog', () => { + renderBisectDialog(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(store.isBisectDialogShowing).toBe(false); + }); + + describe('onBeginSelect()', () => { + it('sets the begin version', () => { + const { instance } = renderBisectDialog(); + + expect(instance.state.startIndex).toBe( + numVersions > 10 ? 10 : numVersions - 1, + ); + instance.onBeginSelect(store.versionsToShow[2]); + expect(instance.state.startIndex).toBe(2); + }); + }); + + describe('onEndSelect()', () => { + it('sets the end version', () => { + const { instance } = renderBisectDialog(); + + expect(instance.state.endIndex).toBe(0); + instance.onEndSelect(store.versionsToShow[2]); + expect(instance.state.endIndex).toBe(2); + }); + }); + + describe('onSubmit()', () => { + it('initiates a bisect instance and sets a version', async () => { + const version = '1.0.0'; + vi.mocked(Bisector).mockReturnValue({ + getCurrentVersion: () => ({ version }), + } as any); + + const versions = generateVersionRange(numVersions); + + const { instance } = renderBisectDialog(); + instance.setState({ + allVersions: versions, + endIndex: 0, + startIndex: versions.length - 1, + }); + + await instance.onSubmit(); + + expect(Bisector).toHaveBeenCalledWith(versions.reverse()); + expect(store.Bisector).toBeDefined(); + expect(store.setVersion).toHaveBeenCalledWith(version); + }); + + it('does nothing if range is invalid (endIndex > startIndex)', async () => { + const { instance } = renderBisectDialog(); + + // Set invalid range where end > start + instance.setState({ + startIndex: 0, + endIndex: 4, + }); + + await instance.onSubmit(); + expect(Bisector).not.toHaveBeenCalled(); + }); + }); + + describe('onAuto()', () => { + it('initiates autobisect', async () => { + const { instance } = renderBisectDialog(); + instance.setState({ + allVersions: generateVersionRange(numVersions), + endIndex: 0, + startIndex: 4, + }); + + vi.mocked(runner.autobisect).mockResolvedValue(RunResult.SUCCESS); + + await instance.onAuto(); + + expect(runner.autobisect).toHaveBeenCalled(); + }); + + it('does nothing if range is invalid (endIndex > startIndex)', async () => { + const { instance } = renderBisectDialog(); + + // Set invalid range where end > start + instance.setState({ + startIndex: 0, + endIndex: 4, + }); + + await instance.onAuto(); + expect(runner.autobisect).not.toHaveBeenCalled(); + }); + }); + + describe('items disabled', () => { + let instance: InstanceType; + + beforeEach(() => { + ({ instance } = renderBisectDialog()); + }); + + describe('isEarliestItemDisabled', () => { + const endIndex = 2; + + it('enables a version older than the "latest version"', () => { + instance.setState({ endIndex }); + expect( + instance.isEarliestItemDisabled(store.versionsToShow[endIndex + 1]), + ).toBeFalsy(); + }); + + it('disables a version newer than the "latest version"', () => { + instance.setState({ endIndex }); + expect( + instance.isEarliestItemDisabled(store.versionsToShow[endIndex - 1]), + ).toBeTruthy(); + }); + }); + + describe('isLatestItemDisabled', () => { + const startIndex = 2; + + it('enables a version newer than the "earliest version"', () => { + instance.setState({ startIndex }); + expect( + instance.isLatestItemDisabled(store.versionsToShow[startIndex - 1]), + ).toBeFalsy(); + }); + + it('disables a version older than the "earliest version"', () => { + instance.setState({ startIndex }); + expect( + instance.isLatestItemDisabled(store.versionsToShow[startIndex + 1]), + ).toBeTruthy(); + }); + }); + }); + + describe('canSubmit', () => { + it('returns true when startIndex > endIndex', () => { + const { instance } = renderBisectDialog(); + instance.setState({ startIndex: 3, endIndex: 0 }); + expect(instance.canSubmit).toBe(true); + }); + + it('returns false when startIndex <= endIndex', () => { + const { instance } = renderBisectDialog(); + instance.setState({ startIndex: 0, endIndex: 3 }); + expect(instance.canSubmit).toBe(false); + }); + }); + + describe('button states', () => { + it('disables Begin and Auto when selection is invalid', async () => { + const { instance } = renderBisectDialog(); + + instance.setState({ startIndex: 0, endIndex: 3 }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /begin/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /auto/i })).toBeDisabled(); + }); + }); + + it('enables Begin and Auto when selection is valid', async () => { + const { instance } = renderBisectDialog(); + + instance.setState({ startIndex: 4, endIndex: 0 }); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /begin/i }), + ).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: /auto/i }), + ).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/rtl-spec/components/dialog-generic.spec.tsx b/rtl-spec/components/dialog-generic.spec.tsx new file mode 100644 index 0000000000..d183fd013e --- /dev/null +++ b/rtl-spec/components/dialog-generic.spec.tsx @@ -0,0 +1,175 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { GenericDialogType } from '../../src/interfaces'; +import { GenericDialog } from '../../src/renderer/components/dialog-generic'; +import { AppState } from '../../src/renderer/state'; +import { overrideRendererPlatform } from '../../tests/utils'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; + +describe('GenericDialog component', () => { + let store: AppState; + + beforeEach(() => { + overrideRendererPlatform('darwin'); + ({ state: store } = window.app); + store.isGenericDialogShowing = true; + store.genericDialogOptions = { + type: GenericDialogType.confirm, + ok: 'OK', + cancel: 'Cancel', + label: 'Test dialog label', + }; + }); + + function renderGenericDialog() { + return renderClassComponentWithInstanceRef(GenericDialog, { + appState: store, + }); + } + + describe('renders', () => { + it('warning dialog', () => { + store.genericDialogOptions.type = GenericDialogType.warning; + renderGenericDialog(); + + expect(screen.getByText('Test dialog label')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + }); + + it('confirmation dialog', () => { + store.genericDialogOptions.type = GenericDialogType.confirm; + renderGenericDialog(); + + expect(screen.getByText('Test dialog label')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + }); + + it('success dialog', () => { + store.genericDialogOptions.type = GenericDialogType.success; + renderGenericDialog(); + + expect(screen.getByText('Test dialog label')).toBeInTheDocument(); + }); + + it('with an input prompt', () => { + store.genericDialogOptions.type = GenericDialogType.confirm; + store.genericDialogOptions.wantsInput = true; + renderGenericDialog(); + + expect(screen.getByText('Test dialog label')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('with an input prompt and placeholder', () => { + store.genericDialogOptions.type = GenericDialogType.confirm; + store.genericDialogOptions.wantsInput = true; + store.genericDialogOptions.placeholder = 'Enter something'; + renderGenericDialog(); + + expect(screen.getByText('Test dialog label')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Enter something'), + ).toBeInTheDocument(); + }); + }); + + it('onClose() closes itself with true result', () => { + const { instance } = renderGenericDialog(); + + instance.onClose(true); + + expect(store.isGenericDialogShowing).toBe(false); + expect(store.genericDialogLastResult).toBe(true); + }); + + it('onClose() closes itself with false result', () => { + const { instance } = renderGenericDialog(); + + instance.onClose(false); + + expect(store.isGenericDialogShowing).toBe(false); + expect(store.genericDialogLastResult).toBe(false); + }); + + it('clicking OK button closes dialog with true result', () => { + renderGenericDialog(); + + const okButton = screen.getByRole('button', { name: 'OK' }); + fireEvent.click(okButton); + + expect(store.isGenericDialogShowing).toBe(false); + expect(store.genericDialogLastResult).toBe(true); + }); + + it('clicking Cancel button closes dialog with false result', () => { + renderGenericDialog(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(store.isGenericDialogShowing).toBe(false); + expect(store.genericDialogLastResult).toBe(false); + }); + + it('enter key submits the dialog when input is focused', async () => { + store.genericDialogOptions.wantsInput = true; + renderGenericDialog(); + + const input = screen.getByRole('textbox'); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(store.isGenericDialogShowing).toBe(false); + expect(store.genericDialogLastResult).toBe(true); + }); + }); + + it('enterSubmit() closes dialog on Enter key', () => { + const { instance } = renderGenericDialog(); + + const event = { key: 'Enter' } as React.KeyboardEvent; + instance.enterSubmit(event); + + expect(store.isGenericDialogShowing).toBe(false); + }); + + it('enterSubmit() does not close dialog on other keys', () => { + const { instance } = renderGenericDialog(); + + const event = { key: 'Escape' } as React.KeyboardEvent; + instance.enterSubmit(event); + + expect(store.isGenericDialogShowing).toBe(true); + }); + + it('captures input value on close', async () => { + store.genericDialogOptions.wantsInput = true; + renderGenericDialog(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'test input value' } }); + + const okButton = screen.getByRole('button', { name: 'OK' }); + fireEvent.click(okButton); + + await waitFor(() => { + expect(store.genericDialogLastInput).toBe('test input value'); + }); + }); + + it('sets genericDialogLastInput to null when input is empty', async () => { + store.genericDialogOptions.wantsInput = true; + renderGenericDialog(); + + const okButton = screen.getByRole('button', { name: 'OK' }); + fireEvent.click(okButton); + + await waitFor(() => { + expect(store.genericDialogLastInput).toBeNull(); + }); + }); +}); diff --git a/tests/renderer/components/dialog-token-spec.tsx b/rtl-spec/components/dialog-token.spec.tsx similarity index 54% rename from tests/renderer/components/dialog-token-spec.tsx rename to rtl-spec/components/dialog-token.spec.tsx index a9835563c5..e0b2ba543a 100644 --- a/tests/renderer/components/dialog-token-spec.tsx +++ b/rtl-spec/components/dialog-token.spec.tsx @@ -1,15 +1,15 @@ -import * as React from 'react'; - import { Octokit } from '@octokit/rest'; -import { shallow } from 'enzyme'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { TokenDialog } from '../../../src/renderer/components/dialog-token'; -import { AppState } from '../../../src/renderer/state'; -import { getOctokit } from '../../../src/renderer/utils/octokit'; -import { overrideRendererPlatform } from '../../utils'; +import { TokenDialog } from '../../src/renderer/components/dialog-token'; +import { AppState } from '../../src/renderer/state'; +import { getOctokit } from '../../src/renderer/utils/octokit'; +import { overrideRendererPlatform } from '../../tests/utils'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; -vi.mock('../../../src/renderer/utils/octokit'); +vi.mock('../../src/renderer/utils/octokit'); describe('TokenDialog component', () => { const mockValidToken = 'ghp_muuHkYenGrOHrTBQKDALW8WtSD929EXMz63n'; @@ -17,62 +17,87 @@ describe('TokenDialog component', () => { let store: AppState; beforeEach(() => { - // We render the buttons different depending on the - // platform, so let' have a uniform platform for unit tests overrideRendererPlatform('darwin'); - ({ state: store } = window.app); - /* - store = { - isTokenDialogShowing: true, - }; - */ + store.isTokenDialogShowing = true; + }); + + function renderTokenDialog() { + return renderClassComponentWithInstanceRef(TokenDialog, { + appState: store, + }); + } + + it('renders the dialog when open', () => { + renderTokenDialog(); + + expect(screen.getByText('GitHub Token')).toBeInTheDocument(); + expect(screen.getByText(/Generate a/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders Done and Cancel buttons', () => { + renderTokenDialog(); + + expect(screen.getByRole('button', { name: /done/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('Done button is disabled when no token input', () => { + renderTokenDialog(); + + const doneButton = screen.getByRole('button', { name: /done/i }); + expect(doneButton).toBeDisabled(); }); - it('renders', () => { - const wrapper = shallow(); + it('Done button is enabled when token input exists', async () => { + renderTokenDialog(); + + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'some-token'); - expect(wrapper).toMatchSnapshot(); + const doneButton = screen.getByRole('button', { name: /done/i }); + expect(doneButton).not.toBeDisabled(); }); it('tries to read the clipboard on focus and enters it if valid', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockValidToken, ); + await instance.onTokenInputFocused(); expect(window.navigator.clipboard.readText).toHaveBeenCalled(); - expect(wrapper.state('tokenInput')).toBe(mockValidToken); + expect(instance.state.tokenInput).toBe(mockValidToken); }); it('tries to read the clipboard on focus and does not enter it if invalid', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); vi.mocked(window.navigator.clipboard.readText).mockResolvedValueOnce( mockInvalidToken, ); + await instance.onTokenInputFocused(); expect(window.navigator.clipboard.readText).toHaveBeenCalled(); - expect(wrapper.state('tokenInput')).toBe(''); + expect(instance.state.tokenInput).toBe(''); }); it('reset() resets the component', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - wrapper.setState({ + instance.setState({ verifying: true, tokenInput: 'hello', errorMessage: 'test error', }); + instance.reset(); - expect(wrapper.state()).toEqual({ + expect(instance.state).toEqual({ verifying: false, error: false, errorMessage: undefined, @@ -80,45 +105,63 @@ describe('TokenDialog component', () => { }); }); - it('onClose() resets the component', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + it('onClose() resets the component and hides dialog', () => { + const { instance } = renderTokenDialog(); - wrapper.setState({ + instance.setState({ verifying: true, tokenInput: 'hello', errorMessage: 'test error', }); + instance.onClose(); - expect(wrapper.state()).toEqual({ + expect(instance.state).toEqual({ verifying: false, error: false, errorMessage: undefined, tokenInput: '', }); + expect(store.isTokenDialogShowing).toBe(false); + }); + + it('Cancel button closes dialog', () => { + renderTokenDialog(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(store.isTokenDialogShowing).toBe(false); }); - it('handleChange() handles the change event', () => { - const wrapper = shallow(); - wrapper.setState({ verifying: true, tokenInput: 'hello' }); + it('handleChange() handles the change event', async () => { + renderTokenDialog(); - const instance: any = wrapper.instance(); - instance.handleChange({ target: { value: 'hi' } }); + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'hi'); - expect(wrapper.state('tokenInput')).toBe('hi'); + await waitFor(() => { + expect(input).toHaveValue('hi'); + }); }); it('openGenerateTokenExternal() tries to open the link', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - wrapper.setState({ verifying: true, tokenInput: 'hello' }); instance.openGenerateTokenExternal(); expect(window.open).toHaveBeenCalled(); }); + it('clicking the generate token link opens external link', () => { + renderTokenDialog(); + + const link = screen.getByText('GitHub Personal Access Token'); + fireEvent.click(link); + + expect(window.open).toHaveBeenCalled(); + }); + describe('onSubmitToken()', () => { let mockOctokit: Octokit; const mockUser = { @@ -144,9 +187,8 @@ describe('TokenDialog component', () => { }); it('handles missing input', async () => { - const wrapper = shallow(); - wrapper.setState({ tokenInput: '' }); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: '' }); await instance.onSubmitToken(); @@ -154,9 +196,8 @@ describe('TokenDialog component', () => { }); it('tries to sign the user in', async () => { - const wrapper = shallow(); - wrapper.setState({ tokenInput: mockValidToken }); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: mockValidToken }); await instance.onSubmitToken(); @@ -164,7 +205,7 @@ describe('TokenDialog component', () => { expect(store.gitHubLogin).toBe(mockUser.login); expect(store.gitHubName).toBe(mockUser.name); expect(store.gitHubAvatarUrl).toBe(mockUser.avatar_url); - expect(wrapper.state('error')).toBe(false); + expect(instance.state.error).toBe(false); expect(store.isTokenDialogShowing).toBe(false); }); @@ -173,14 +214,13 @@ describe('TokenDialog component', () => { new Error('Bad credentials'), ); - const wrapper = shallow(); - wrapper.setState({ tokenInput: mockValidToken }); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: mockValidToken }); await instance.onSubmitToken(); - expect(wrapper.state('error')).toBe(true); - expect(wrapper.state('errorMessage')).toBe( + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe( 'Invalid GitHub token. Please check your token and try again.', ); expect(store.gitHubToken).toEqual(null); @@ -190,18 +230,17 @@ describe('TokenDialog component', () => { vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ data: mockUser, headers: { - 'x-oauth-scopes': 'repo, user', // Missing 'gist' scope + 'x-oauth-scopes': 'repo, user', }, } as any); - const wrapper = shallow(); - wrapper.setState({ tokenInput: mockValidToken }); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: mockValidToken }); await instance.onSubmitToken(); - expect(wrapper.state('error')).toBe(true); - expect(wrapper.state('errorMessage')).toBe( + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe( 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', ); expect(store.gitHubToken).toEqual(null); @@ -210,23 +249,39 @@ describe('TokenDialog component', () => { it('handles empty scopes header', async () => { vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({ data: mockUser, - headers: { - // No x-oauth-scopes header - }, + headers: {}, } as any); - const wrapper = shallow(); - wrapper.setState({ tokenInput: mockValidToken }); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: mockValidToken }); await instance.onSubmitToken(); - expect(wrapper.state('error')).toBe(true); - expect(wrapper.state('errorMessage')).toBe( + expect(instance.state.error).toBe(true); + expect(instance.state.errorMessage).toBe( 'Token is missing the "gist" scope. Please generate a new token with gist permissions.', ); expect(store.gitHubToken).toEqual(null); }); + + it('shows error callout when error occurs', async () => { + vi.mocked(mockOctokit.users.getAuthenticated).mockRejectedValue( + new Error('Bad credentials'), + ); + + const { instance } = renderTokenDialog(); + instance.setState({ tokenInput: mockValidToken }); + + await instance.onSubmitToken(); + + await waitFor(() => { + expect( + screen.getByText( + 'Invalid GitHub token. Please check your token and try again.', + ), + ).toBeInTheDocument(); + }); + }); }); describe('validateGitHubToken()', () => { @@ -255,10 +310,9 @@ describe('TokenDialog component', () => { }, } as any); - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - const result = await instance.validateGitHubToken('valid-token'); + const result = await (instance as any).validateGitHubToken('valid-token'); expect(result).toEqual({ isValid: true, @@ -276,10 +330,11 @@ describe('TokenDialog component', () => { }, } as any); - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - const result = await instance.validateGitHubToken('token-without-gist'); + const result = await (instance as any).validateGitHubToken( + 'token-without-gist', + ); expect(result).toEqual({ isValid: true, @@ -294,10 +349,11 @@ describe('TokenDialog component', () => { new Error('Bad credentials'), ); - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - const result = await instance.validateGitHubToken('invalid-token'); + const result = await (instance as any).validateGitHubToken( + 'invalid-token', + ); expect(result).toEqual({ isValid: false, @@ -313,10 +369,9 @@ describe('TokenDialog component', () => { headers: {}, } as any); - const wrapper = shallow(); - const instance: any = wrapper.instance(); + const { instance } = renderTokenDialog(); - const result = await instance.validateGitHubToken( + const result = await (instance as any).validateGitHubToken( 'token-no-scopes-header', ); diff --git a/rtl-spec/components/dialogs.spec.tsx b/rtl-spec/components/dialogs.spec.tsx new file mode 100644 index 0000000000..c44db62e58 --- /dev/null +++ b/rtl-spec/components/dialogs.spec.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; + +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ElectronReleaseChannel, + GenericDialogType, + InstallState, + VersionSource, +} from '../../src/interfaces'; +import { Dialogs } from '../../src/renderer/components/dialogs'; +import { AppState } from '../../src/renderer/state'; +import { StateMock } from '../../tests/mocks/mocks'; +import { overrideRendererPlatform } from '../../tests/utils'; +import { renderClassComponentWithInstanceRef } from '../test-utils/renderClassComponentWithInstanceRef'; + +// Mock the Settings component to avoid complex dependencies +vi.mock('../../src/renderer/components/settings', () => ({ + Settings: () => + React.createElement('div', { 'data-testid': 'settings-mock' }, 'Settings'), +})); + +describe('Dialogs component', () => { + let store: AppState; + + const generateVersionRange = (rangeLength: number) => + new Array(rangeLength).fill(0).map((_, i) => ({ + state: InstallState.installed, + version: `${i + 1}.0.0`, + source: VersionSource.local, + })); + + beforeEach(() => { + overrideRendererPlatform('darwin'); + ({ state: store } = window.app); + + // Reset all dialog flags + store.isTokenDialogShowing = false; + store.isSettingsShowing = false; + store.isAddVersionDialogShowing = false; + store.isThemeDialogShowing = false; + store.isBisectDialogShowing = false; + store.isGenericDialogShowing = false; + + // Setup generic dialog options for when it's shown + store.genericDialogOptions = { + type: GenericDialogType.confirm, + ok: 'OK', + cancel: 'Cancel', + label: 'Test label', + }; + + // Setup versions needed by BisectDialog + const versions = generateVersionRange(10); + (store as unknown as StateMock).versionsToShow = versions; + (store as unknown as StateMock).versions = Object.fromEntries( + versions.map((ver) => [ver.version, ver]), + ); + (store as unknown as StateMock).channelsToShow = [ + ElectronReleaseChannel.stable, + ]; + }); + + function renderDialogs() { + return renderClassComponentWithInstanceRef(Dialogs, { + appState: store, + }); + } + + it('renders the container div', () => { + const { renderResult } = renderDialogs(); + + const container = renderResult.container.querySelector('.dialogs'); + expect(container).toBeInTheDocument(); + }); + + it('renders the token dialog when isTokenDialogShowing is true', () => { + store.isTokenDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('GitHub Token')).toBeInTheDocument(); + }); + + it('does not render token dialog when isTokenDialogShowing is false', () => { + store.isTokenDialogShowing = false; + renderDialogs(); + + expect(screen.queryByText('GitHub Token')).not.toBeInTheDocument(); + }); + + it('renders the settings dialog when isSettingsShowing is true', () => { + store.isSettingsShowing = true; + renderDialogs(); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('does not render settings dialog when isSettingsShowing is false', () => { + store.isSettingsShowing = false; + renderDialogs(); + + expect(screen.queryByText('Settings')).not.toBeInTheDocument(); + }); + + it('renders the add version dialog when isAddVersionDialogShowing is true', () => { + store.isAddVersionDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('Add local Electron build')).toBeInTheDocument(); + }); + + it('does not render add version dialog when isAddVersionDialogShowing is false', () => { + store.isAddVersionDialogShowing = false; + renderDialogs(); + + expect( + screen.queryByText('Add local Electron build'), + ).not.toBeInTheDocument(); + }); + + it('renders the add theme dialog when isThemeDialogShowing is true', () => { + store.isThemeDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('Add theme')).toBeInTheDocument(); + }); + + it('does not render add theme dialog when isThemeDialogShowing is false', () => { + store.isThemeDialogShowing = false; + renderDialogs(); + + expect(screen.queryByText('Add theme')).not.toBeInTheDocument(); + }); + + it('renders the bisect dialog when isBisectDialogShowing is true', () => { + store.isBisectDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('Start a bisect session')).toBeInTheDocument(); + }); + + it('does not render bisect dialog when isBisectDialogShowing is false', () => { + store.isBisectDialogShowing = false; + renderDialogs(); + + expect( + screen.queryByText('Start a bisect session'), + ).not.toBeInTheDocument(); + }); + + it('renders the generic dialog when isGenericDialogShowing is true', () => { + store.isGenericDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('Test label')).toBeInTheDocument(); + }); + + it('does not render generic dialog when isGenericDialogShowing is false', () => { + store.isGenericDialogShowing = false; + renderDialogs(); + + expect(screen.queryByText('Test label')).not.toBeInTheDocument(); + }); + + it('can render multiple dialogs simultaneously', () => { + store.isTokenDialogShowing = true; + store.isGenericDialogShowing = true; + renderDialogs(); + + expect(screen.getByText('GitHub Token')).toBeInTheDocument(); + expect(screen.getByText('Test label')).toBeInTheDocument(); + }); +}); diff --git a/tests/renderer/components/__snapshots__/dialog-add-theme-spec.tsx.snap b/tests/renderer/components/__snapshots__/dialog-add-theme-spec.tsx.snap deleted file mode 100644 index 2d1d61b1df..0000000000 --- a/tests/renderer/components/__snapshots__/dialog-add-theme-spec.tsx.snap +++ /dev/null @@ -1,48 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`AddThemeDialog component > renders 1`] = ` - -
- -
-
-
-
- - -
-
-
-`; diff --git a/tests/renderer/components/__snapshots__/dialog-add-version-spec.tsx.snap b/tests/renderer/components/__snapshots__/dialog-add-version-spec.tsx.snap deleted file mode 100644 index 21624556a1..0000000000 --- a/tests/renderer/components/__snapshots__/dialog-add-version-spec.tsx.snap +++ /dev/null @@ -1,228 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`AddVersionDialog component > onSubmit > shows dialog warning when adding duplicate local versions 1`] = ` - -
- -
- - This folder is already in use as version "2.2.2". Would you like to switch to that version now? - -
-
-
- - -
-
-
-`; - -exports[`AddVersionDialog component > renders 1`] = ` - -
- -
- - We found an Electron.app in this folder. -

- Please specify a version, used for typings and the name. Must be - - - semver - - compliant. -

- -
-
-
-
- - -
-
-
-`; - -exports[`AddVersionDialog component > renders 2`] = ` - -
- -
- - We found an Electron.app in this folder. -

- Please specify a version, used for typings and the name. Must be - - - semver - - compliant. -

- -
-
-
-
- - -
-
-
-`; - -exports[`AddVersionDialog component > renders 3`] = ` - -
- -
- - This folder is already in use as version "2.2.2". Would you like to switch to that version now? - -
-
-
- - -
-
-
-`; diff --git a/tests/renderer/components/__snapshots__/dialog-bisect-spec.tsx.snap b/tests/renderer/components/__snapshots__/dialog-bisect-spec.tsx.snap deleted file mode 100644 index 5def60775d..0000000000 --- a/tests/renderer/components/__snapshots__/dialog-bisect-spec.tsx.snap +++ /dev/null @@ -1,1731 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`BisectDialog component > renders 1`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 2`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 3`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 4`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 5`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

-

- First, write a fiddle that reproduces a bug or an issue. Then, select the earliest version to start your search with. Typically, that's the "last known good" version that did not have the bug. Then, select that latest version to end the search with, usually the "first known bad" version. -

-

- Once you begin your bisect, Fiddle will run your fiddle with a number of Electron versions, closing in on the version that introduced the bug. Once completed, you will know which Electron version introduced your issue. -

-
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 6`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 7`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 8`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 9`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

- -
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; - -exports[`BisectDialog component > renders 10`] = ` - -
- -

- A "bisect" is a popular method - - - borrowed from - - git - - - - for learning which version of Electron introduced a bug. This tool helps you perform a bisect. -

-

- First, write a fiddle that reproduces a bug or an issue. Then, select the earliest version to start your search with. Typically, that's the "last known good" version that did not have the bug. Then, select that latest version to end the search with, usually the "first known bad" version. -

-

- Once you begin your bisect, Fiddle will run your fiddle with a number of Electron versions, closing in on the version that introduced the bug. Once completed, you will know which Electron version introduced your issue. -

-
- - Earliest Version (Last "known good" version) - - - - - - Latest Version (First "known bad" version) - - - - -
-
-
- - - -
-
-
-`; diff --git a/tests/renderer/components/__snapshots__/dialog-generic-spec.tsx.snap b/tests/renderer/components/__snapshots__/dialog-generic-spec.tsx.snap deleted file mode 100644 index ed35a117ca..0000000000 --- a/tests/renderer/components/__snapshots__/dialog-generic-spec.tsx.snap +++ /dev/null @@ -1,85 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`GenericDialog component > renders > confirmation 1`] = ` - -

- -`; - -exports[`GenericDialog component > renders > success 1`] = ` - -

- -`; - -exports[`GenericDialog component > renders > warning 1`] = ` - -

- -`; - -exports[`GenericDialog component > renders > with an input prompt 1`] = ` - -

- - -`; - -exports[`GenericDialog component > renders > with an input prompt and placeholder 1`] = ` - -

- - -`; diff --git a/tests/renderer/components/__snapshots__/dialog-token-spec.tsx.snap b/tests/renderer/components/__snapshots__/dialog-token-spec.tsx.snap deleted file mode 100644 index 0eb6eca0f0..0000000000 --- a/tests/renderer/components/__snapshots__/dialog-token-spec.tsx.snap +++ /dev/null @@ -1,53 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`TokenDialog component > renders 1`] = ` - -

-

- Generate a - - - GitHub Personal Access Token - - - and paste it here: -

- -
-
-
- - -
-
- -`; diff --git a/tests/renderer/components/dialog-add-theme-spec.tsx b/tests/renderer/components/dialog-add-theme-spec.tsx deleted file mode 100644 index 5357e4aa15..0000000000 --- a/tests/renderer/components/dialog-add-theme-spec.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AddThemeDialog } from '../../../src/renderer/components/dialog-add-theme'; -import { AppState } from '../../../src/renderer/state'; -import { LoadedFiddleTheme, defaultLight } from '../../../src/themes-defaults'; -import { overrideRendererPlatform } from '../../utils'; - -class FileMock extends Blob { - public lastModified: number; - public webkitRelativePath: string; - - constructor( - private bits: string[], - public name: string, - public path: string, - type: string, - ) { - super(bits, { type }); - this.lastModified = Date.now(); - this.webkitRelativePath = ''; - } - - async text() { - return this.bits.join(''); - } -} - -describe('AddThemeDialog component', () => { - let store: AppState; - - beforeEach(() => { - // We render the buttons different depending on the - // platform, so let' have a uniform platform for unit tests - overrideRendererPlatform('darwin'); - - ({ state: store } = window.app); - }); - - // TODO(dsanders11): Update this test to be accurate - it('renders', () => { - const wrapper = shallow(); - - wrapper.setState({ - file: '/test/file', - }); - - expect(wrapper).toMatchSnapshot(); - }); - - describe('createNewThemeFromMonaco()', () => { - it('handles invalid input', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - try { - await instance.createNewThemeFromMonaco('', {} as LoadedFiddleTheme); - } catch (err: any) { - expect(err.message).toEqual(`Filename not found`); - expect(window.ElectronFiddle.createThemeFile).toHaveBeenCalledTimes(0); - expect(store.setTheme).toHaveBeenCalledTimes(0); - } - }); - - it('handles valid input', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - wrapper.setState({ - file: new FileMock( - [JSON.stringify(defaultLight.editor)], - 'file.json', - '/test/file.json', - 'application/json', - ), - }); - - const themePath = '~/.electron-fiddle/themes/testingLight'; - vi.mocked(window.ElectronFiddle.createThemeFile).mockResolvedValue({ - file: themePath, - } as LoadedFiddleTheme); - - await instance.createNewThemeFromMonaco('testingLight', defaultLight); - - expect(window.ElectronFiddle.createThemeFile).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Fiddle (Light)', - common: expect.anything(), - file: 'defaultLight', - }), - 'testingLight', - ); - expect(store.setTheme).toHaveBeenCalledWith(themePath); - }); - }); - - describe('onSubmit()', () => { - it('does nothing if there is no file currently set', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - instance.createNewThemeFromMonaco = vi.fn(); - instance.onClose = vi.fn(); - - await instance.onSubmit(); - - expect(instance.createNewThemeFromMonaco).toHaveBeenCalledTimes(0); - expect(instance.onClose).toHaveBeenCalledTimes(0); - }); - - it('loads a theme if a file is currently set', async () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const file = new FileMock( - [JSON.stringify(defaultLight.editor)], - 'file.json', - '/test/file.json', - 'application/json', - ); - const spy = vi.spyOn(file, 'text'); - wrapper.setState({ file }); - - instance.createNewThemeFromMonaco = vi.fn(); - instance.onClose = vi.fn(); - - await instance.onSubmit(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(instance.createNewThemeFromMonaco).toHaveBeenCalledTimes(1); - expect(instance.onClose).toHaveBeenCalledTimes(1); - }); - - it('shows an error dialog for a malformed theme', async () => { - store.showErrorDialog = vi.fn().mockResolvedValueOnce(true); - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - const file = new FileMock( - [JSON.stringify(defaultLight.editor)], - 'file.json', - '/test/file.json', - 'application/json', - ); - const spy = vi.spyOn(file, 'text').mockResolvedValue('{}'); - wrapper.setState({ file }); - - instance.onClose = vi.fn(); - - await instance.onSubmit(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(store.showErrorDialog).toHaveBeenCalledWith( - expect.stringMatching(/file does not match specifications/i), - ); - }); - }); - - describe('onChangeFile()', () => { - it('handles valid input', async () => { - const wrapper = shallow(); - - const files = ['one', 'two']; - await (wrapper.instance() as any).onChangeFile({ - target: { files } as unknown as EventTarget, - } as React.FormEvent); - expect(wrapper.state('file')).toBe(files[0]); - }); - - it('handles no input', () => { - const wrapper = shallow(); - - (wrapper.instance() as any).onChangeFile({ - target: { files: null } as unknown as EventTarget, - } as React.FormEvent); - expect(wrapper.state('file')).toBeUndefined(); - }); - }); -}); diff --git a/tests/renderer/components/dialog-add-version-spec.tsx b/tests/renderer/components/dialog-add-version-spec.tsx deleted file mode 100644 index ced7f72d9b..0000000000 --- a/tests/renderer/components/dialog-add-version-spec.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AddVersionDialog } from '../../../src/renderer/components/dialog-add-version'; -import { AppState } from '../../../src/renderer/state'; -import { overrideRendererPlatform } from '../../utils'; - -describe('AddVersionDialog component', () => { - let store: AppState; - - const mockFile = '/test/file'; - - beforeEach(() => { - // We render the buttons different depending on the - // platform, so let' have a uniform platform for unit tests - overrideRendererPlatform('darwin'); - - ({ state: store } = window.app); - }); - - it('renders', () => { - const wrapper = shallow(); - - wrapper.setState({ - isValidVersion: true, - isValidElectron: true, - folderPath: mockFile, - }); - - expect(wrapper).toMatchSnapshot(); - - wrapper.setState({ - isValidVersion: false, - isValidElectron: true, - folderPath: mockFile, - }); - - expect(wrapper).toMatchSnapshot(); - - wrapper.setState({ - isValidVersion: true, - isValidElectron: true, - existingLocalVersion: { - version: '2.2.2', - localPath: mockFile, - }, - folderPath: mockFile, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - it('overrides default input with Electron dialog', () => { - const preventDefault = vi.fn(); - - const wrapper = shallow(); - const inp = wrapper.find('#custom-electron-version'); - inp.dive().find('input[type="file"]').simulate('click', { preventDefault }); - - expect(window.ElectronFiddle.selectLocalVersion).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - }); - - describe('selectLocalVersion()', () => { - it('updates state', async () => { - const wrapper = shallow(); - vi.mocked(window.ElectronFiddle.selectLocalVersion).mockResolvedValue({ - folderPath: '/test/', - isValidElectron: true, - localName: 'Test', - }); - await (wrapper.instance() as any).selectLocalVersion(); - - expect(wrapper.state('isValidElectron')).toBe(true); - expect(wrapper.state('folderPath')).toBe('/test/'); - expect(wrapper.state('localName')).toBe('Test'); - }); - }); - - describe('onChangeVersion()', () => { - it('handles valid input', () => { - const wrapper = shallow(); - - (wrapper.instance() as any).onChangeVersion({ - target: { value: '3.3.3' }, - }); - expect(wrapper.state('isValidVersion')).toBe(true); - expect(wrapper.state('version')).toBe('3.3.3'); - }); - - it('handles invalid input', () => { - const wrapper = shallow(); - - (wrapper.instance() as any).onChangeVersion({ target: { value: 'foo' } }); - expect(wrapper.state('isValidVersion')).toBe(false); - expect(wrapper.state('version')).toBe('foo'); - - (wrapper.instance() as any).onChangeVersion({ target: {} }); - expect(wrapper.state('isValidVersion')).toBe(false); - expect(wrapper.state('version')).toBe(''); - }); - }); - - describe('onSubmit', () => { - it('does not do anything without a file', async () => { - const wrapper = shallow(); - - await (wrapper.instance() as any).onSubmit(); - - expect(store.addLocalVersion).toHaveBeenCalledTimes(0); - }); - - it('adds a local version using the given data', async () => { - const wrapper = shallow(); - - wrapper.setState({ - version: '3.3.3', - folderPath: '/test/path', - }); - - await (wrapper.instance() as any).onSubmit(); - - expect(store.addLocalVersion).toHaveBeenCalledTimes(1); - expect(store.addLocalVersion).toHaveBeenCalledWith( - expect.objectContaining({ - localPath: '/test/path', - version: '3.3.3', - }), - ); - }); - - it('shows dialog warning when adding duplicate local versions', async () => { - const wrapper = shallow(); - - wrapper.setState({ - isValidElectron: true, - folderPath: '/test/path', - version: '3.3.3', - existingLocalVersion: { - version: '2.2.2', - localPath: '/test/path', - }, - }); - - expect(wrapper).toMatchSnapshot(); - }); - }); -}); diff --git a/tests/renderer/components/dialog-bisect-spec.tsx b/tests/renderer/components/dialog-bisect-spec.tsx deleted file mode 100644 index abfe166c9b..0000000000 --- a/tests/renderer/components/dialog-bisect-spec.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - ElectronReleaseChannel, - InstallState, - RunResult, - VersionSource, -} from '../../../src/interfaces'; -import { Bisector } from '../../../src/renderer/bisect'; -import { BisectDialog } from '../../../src/renderer/components/dialog-bisect'; -import { Runner } from '../../../src/renderer/runner'; -import { AppState } from '../../../src/renderer/state'; -import { StateMock } from '../../mocks/mocks'; - -vi.mock('../../../src/renderer/bisect'); - -describe.each([8, 15])('BisectDialog component', (numVersions) => { - let runner: Runner; - let store: AppState; - - const generateVersionRange = (rangeLength: number) => - new Array(rangeLength).fill(0).map((_, i) => ({ - state: InstallState.installed, - version: `${i + 1}.0.0`, - source: VersionSource.local, - })); - - beforeEach(() => { - ({ runner, state: store } = window.app); - - (store as unknown as StateMock).versionsToShow = - generateVersionRange(numVersions); - (store as unknown as StateMock).versions = Object.fromEntries( - store.versionsToShow.map((ver) => [ver.version, ver]), - ); - (store as unknown as StateMock).channelsToShow = [ - ElectronReleaseChannel.stable, - ]; - }); - - it('renders', () => { - const wrapper = shallow(); - // start and end selected - wrapper.setState({ - startIndex: 3, - endIndex: 0, - allVersions: generateVersionRange(numVersions), - }); - expect(wrapper).toMatchSnapshot(); - - // no selection - wrapper.setState({ - startIndex: undefined, - endIndex: undefined, - allVersions: generateVersionRange(numVersions), - }); - expect(wrapper).toMatchSnapshot(); - - // only start selected - wrapper.setState({ - startIndex: 3, - endIndex: undefined, - allVersions: generateVersionRange(numVersions), - }); - expect(wrapper).toMatchSnapshot(); - - // Incorrect order - wrapper.setState({ - startIndex: 3, - endIndex: 4, - allVersions: generateVersionRange(numVersions), - }); - expect(wrapper).toMatchSnapshot(); - - // Help displayed - (wrapper.instance() as any).showHelp(); - expect(wrapper).toMatchSnapshot(); - }); - - describe('onBeginSelect()', () => { - it('sets the begin version', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - expect(instance.state.startIndex).toBe( - numVersions > 10 ? 10 : numVersions - 1, - ); - instance.onBeginSelect(store.versionsToShow[2]); - expect(instance.state.startIndex).toBe(2); - }); - }); - - describe('onEndSelect()', () => { - it('sets the end version', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - expect(instance.state.endIndex).toBe(0); - instance.onEndSelect(store.versionsToShow[2]); - expect(instance.state.endIndex).toBe(2); - }); - }); - - describe('onSubmit()', () => { - it('initiates a bisect instance and sets a version', async () => { - const version = '1.0.0'; - vi.mocked(Bisector).mockReturnValue({ - getCurrentVersion: () => ({ version }), - } as any); - - const versions = generateVersionRange(numVersions); - - const wrapper = shallow(); - wrapper.setState({ - allVersions: versions, - endIndex: 0, - startIndex: versions.length - 1, - }); - - const instance: any = wrapper.instance(); - await instance.onSubmit(); - expect(Bisector).toHaveBeenCalledWith(versions.reverse()); - expect(store.Bisector).toBeDefined(); - expect(store.setVersion).toHaveBeenCalledWith(version); - }); - - it('does nothing if endIndex or startIndex are falsy', async () => { - const wrapper = shallow(); - - wrapper.setState({ - startIndex: undefined, - endIndex: 0, - }); - const instance1: any = wrapper.instance(); - await instance1.onSubmit(); - expect(Bisector).not.toHaveBeenCalled(); - - wrapper.setState({ - startIndex: 4, - endIndex: undefined, - }); - - const instance2: any = wrapper.instance(); - await instance2.onSubmit(); - expect(Bisector).not.toHaveBeenCalled(); - }); - }); - - describe('onAuto()', () => { - it('initiates autobisect', async () => { - // setup: dialog state - const wrapper = shallow(); - wrapper.setState({ - allVersions: generateVersionRange(numVersions), - endIndex: 0, - startIndex: 4, - }); - - vi.mocked(runner.autobisect).mockResolvedValue(RunResult.SUCCESS); - - // click the 'auto' button - const instance1: any = wrapper.instance(); - await instance1.onAuto(); - - // check the results - expect(runner.autobisect).toHaveBeenCalled(); - }); - - it('does nothing if endIndex or startIndex are falsy', async () => { - const wrapper = shallow(); - - wrapper.setState({ - startIndex: undefined, - endIndex: 0, - }); - const instance1: any = wrapper.instance(); - await instance1.onAuto(); - expect(Bisector).not.toHaveBeenCalled(); - - wrapper.setState({ - startIndex: 4, - endIndex: undefined, - }); - - const instance2: any = wrapper.instance(); - await instance2.onAuto(); - expect(Bisector).not.toHaveBeenCalled(); - }); - }); - - describe('items disabled', () => { - let instance: any; - - beforeEach(() => { - const wrapper = shallow(); - instance = wrapper.instance(); - }); - - describe('isEarliestItemDisabled', () => { - const endIndex = 2; - - it('enables a version older than the "latest version"', () => { - instance.setState({ endIndex }); - expect( - instance.isEarliestItemDisabled(store.versionsToShow[endIndex + 1]), - ).toBeFalsy(); - }); - - it('disables a version newer than the "latest version"', () => { - instance.setState({ endIndex }); - expect( - instance.isEarliestItemDisabled(store.versionsToShow[endIndex - 1]), - ).toBeTruthy(); - }); - }); - - describe('isLatestItemDisabled', () => { - const startIndex = 2; - - it('enables a version newer than the "earliest version"', () => { - instance.setState({ startIndex }); - expect( - instance.isLatestItemDisabled(store.versionsToShow[startIndex - 1]), - ).toBeFalsy(); - }); - - it('disables a version older than the "earliest version"', () => { - instance.setState({ startIndex }); - expect( - instance.isLatestItemDisabled(store.versionsToShow[startIndex + 1]), - ).toBeTruthy(); - }); - }); - }); -}); diff --git a/tests/renderer/components/dialog-generic-spec.tsx b/tests/renderer/components/dialog-generic-spec.tsx deleted file mode 100644 index e3331a1712..0000000000 --- a/tests/renderer/components/dialog-generic-spec.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { GenericDialogType } from '../../../src/interfaces'; -import { GenericDialog } from '../../../src/renderer/components/dialog-generic'; -import { AppState } from '../../../src/renderer/state'; -import { overrideRendererPlatform } from '../../utils'; - -describe('GenericDialog component', () => { - let store: AppState; - - beforeEach(() => { - // We render the buttons different depending on the - // platform, so let' have a uniform platform for unit tests - overrideRendererPlatform('darwin'); - - ({ state: store } = window.app); - }); - - describe('renders', () => { - function expectDialogTypeToMatchSnapshot(type: GenericDialogType) { - store.genericDialogOptions.type = type; - store.isGenericDialogShowing = true; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - } - - it('warning', () => { - expectDialogTypeToMatchSnapshot(GenericDialogType.warning); - }); - - it('confirmation', () => { - expectDialogTypeToMatchSnapshot(GenericDialogType.confirm); - }); - - it('success', () => { - expectDialogTypeToMatchSnapshot(GenericDialogType.success); - }); - - it('with an input prompt', () => { - store.genericDialogOptions.wantsInput = true; - expectDialogTypeToMatchSnapshot(GenericDialogType.confirm); - }); - - it('with an input prompt and placeholder', () => { - store.genericDialogOptions.wantsInput = true; - store.genericDialogOptions.placeholder = 'placeholder'; - expectDialogTypeToMatchSnapshot(GenericDialogType.confirm); - }); - }); - - it('onClose() closes itself', () => { - store.isGenericDialogShowing = true; - const wrapper = shallow(); - const instance: any = wrapper.instance(); - - instance.onClose(true); - expect(store.isGenericDialogShowing).toBe(false); - }); - - it('enter submit', () => { - const wrapper = shallow(); - const instance: any = wrapper.instance(); - const event = { key: 'Enter' } as React.KeyboardEvent; - - store.isGenericDialogShowing = true; - - instance.enterSubmit(event); - - expect(store.isGenericDialogShowing).toBe(false); - }); -}); diff --git a/tests/renderer/components/dialogs-spec.tsx b/tests/renderer/components/dialogs-spec.tsx deleted file mode 100644 index 301b3a310e..0000000000 --- a/tests/renderer/components/dialogs-spec.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import * as React from 'react'; - -import { shallow } from 'enzyme'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { Dialogs } from '../../../src/renderer/components/dialogs'; -import { AppState } from '../../../src/renderer/state'; -import { overrideRendererPlatform } from '../../utils'; - -describe('Dialogs component', () => { - let store: AppState; - - beforeEach(() => { - // We render the buttons different depending on the - // platform, so let' have a uniform platform for unit tests - overrideRendererPlatform('darwin'); - - ({ state: store } = window.app); - store.isGenericDialogShowing = true; - }); - - it('renders the token dialog', () => { - store.isTokenDialogShowing = true; - const wrapper = shallow(); - expect(wrapper.text()).toBe(''); - }); - - it('renders the settings dialog', () => { - store.isSettingsShowing = true; - const wrapper = shallow(); - expect(wrapper.text()).toBe(''); - }); - - it('renders the add version dialog', () => { - store.isAddVersionDialogShowing = true; - const wrapper = shallow(); - expect(wrapper.text()).toBe(''); - }); -});