diff --git a/packages/app/cypress/e2e/integration/settings.spec.ts b/packages/app/cypress/e2e/integration/settings.spec.ts index 1fcf1a658ba1..5513775238b1 100644 --- a/packages/app/cypress/e2e/integration/settings.spec.ts +++ b/packages/app/cypress/e2e/integration/settings.spec.ts @@ -1,4 +1,4 @@ -describe('Settings', { viewportWidth: 1200 }, () => { +describe('Settings', { viewportWidth: 600 }, () => { beforeEach(() => { cy.openE2E('component-tests') @@ -38,4 +38,18 @@ describe('Settings', { viewportWidth: 1200 }, () => { cy.findByText('Reconfigure Project').click() cy.wait('@ReconfigureProject') }) + + it('selects well known editor', () => { + cy.visitApp() + cy.get('[href="#/settings"]').click() + cy.contains('Device Settings').click() + cy.findByPlaceholderText('Custom path...').clear().type('/usr/local/bin/vim') + + cy.intercept('POST', 'mutation-SetPreferredEditorBinary', (req) => { + expect(req.body.variables).to.eql({ 'value': '/usr/local/bin/vim' }) + }).as('SetPreferred') + + cy.get('[data-cy="use-custom-editor"]').click() + cy.wait('@SetPreferred') + }) }) diff --git a/packages/app/package.json b/packages/app/package.json index 9094fda68ab1..135e7529c0dc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,6 +42,7 @@ "cross-env": "6.0.3", "cypress-real-events": "1.4.0", "faker": "5.5.3", + "fuzzysort": "^1.1.4", "graphql": "^15.5.1", "graphql-tag": "^2.12.5", "javascript-time-ago": "2.3.8", @@ -84,9 +85,11 @@ "@vueuse/core", "bluebird", "cypress-file-upload", + "cypress-real-events", "dedent", "events", "fake-uuid", + "fuzzysort", "graphql", "graphql-relay", "graphql/jsutils/Path", diff --git a/packages/app/src/settings/SettingsContainer.vue b/packages/app/src/settings/SettingsContainer.vue index 15ad88923d98..03bcbffce90a 100644 --- a/packages/app/src/settings/SettingsContainer.vue +++ b/packages/app/src/settings/SettingsContainer.vue @@ -9,7 +9,9 @@ :icon="IconLaptop" max-height="800px" > - + + + ', () => { - it('renders', () => { - cy.mount(() => ) - }) -}) diff --git a/packages/app/src/settings/device/DeviceSettings.vue b/packages/app/src/settings/device/DeviceSettings.vue deleted file mode 100644 index f95dd2ede27e..000000000000 --- a/packages/app/src/settings/device/DeviceSettings.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/packages/app/src/settings/device/ExternalEditorSettings.spec.tsx b/packages/app/src/settings/device/ExternalEditorSettings.spec.tsx index e46ec06c7291..828c9bfc449e 100644 --- a/packages/app/src/settings/device/ExternalEditorSettings.spec.tsx +++ b/packages/app/src/settings/device/ExternalEditorSettings.spec.tsx @@ -1,34 +1,60 @@ import ExternalEditorSettings from './ExternalEditorSettings.vue' import { defaultMessages } from '@cy/i18n' +import { ExternalEditorSettingsFragmentDoc } from '../../generated/graphql-test' const editorText = defaultMessages.settingsPage.editor describe('', () => { - beforeEach(() => { - cy.mount(() => ) - }) - it('renders the placeholder by default', () => { + cy.mountFragment(ExternalEditorSettingsFragmentDoc, { + render: (gqlVal) => { + return + }, + }) + cy.findByText(editorText.noEditorSelectedPlaceholder).should('be.visible') }) it('renders the title and description', () => { + cy.mountFragment(ExternalEditorSettingsFragmentDoc, { + render: (gqlVal) => { + return + }, + }) + cy.findByText(editorText.description).should('be.visible') cy.findByText(editorText.title).should('be.visible') }) it('can select an editor', () => { + cy.mountFragment(ExternalEditorSettingsFragmentDoc, { + render: (gqlVal) => { + return + }, + }) + const optionsSelector = '[role=option]' const inputSelector = '[aria-haspopup=true]' cy.get(inputSelector).click() .get(optionsSelector).should('be.visible') .get(optionsSelector).then(($options) => { - const text = $options.first().text() - cy.wrap($options.first()).click() - .get(optionsSelector).should('not.exist') - .get(inputSelector).should('have.text', text) }) }) + + it('can input a custom binary', () => { + cy.mountFragment(ExternalEditorSettingsFragmentDoc, { + render: (gqlVal) => { + return + }, + }) + + cy.findByPlaceholderText('Custom path...').type('/usr/bin') + cy.get('[data-cy="use-custom-editor"]').as('custom') + cy.get('@custom').click() + + cy.get('@custom').should('be.focused') + cy.get('[data-cy="use-well-known-editor"]').should('not.be.focused') + }) }) diff --git a/packages/app/src/settings/device/ExternalEditorSettings.vue b/packages/app/src/settings/device/ExternalEditorSettings.vue index 5f479fac986f..540faff1d84c 100644 --- a/packages/app/src/settings/device/ExternalEditorSettings.vue +++ b/packages/app/src/settings/device/ExternalEditorSettings.vue @@ -6,42 +6,81 @@ - + +
+ + + +
+ +
+ + +
+ + + +
+
diff --git a/packages/app/src/settings/device/ProxySettings.spec.tsx b/packages/app/src/settings/device/ProxySettings.spec.tsx index 8a9f8dd442f1..04e221614b5f 100644 --- a/packages/app/src/settings/device/ProxySettings.spec.tsx +++ b/packages/app/src/settings/device/ProxySettings.spec.tsx @@ -1,12 +1,21 @@ +import { ProxySettingsFragmentDoc } from '../../generated/graphql-test' import ProxySettings from './ProxySettings.vue' describe('', () => { it('renders', () => { cy.viewport(400, 400) - .mount(() => ).get('body') - .findByText('Proxy Server').get('body') - .findByText('Proxy Bypass List') - .get('[data-testid=bypass-list]').should('be.visible') - .get('[data-testid=proxy-server]').should('be.visible') + .mountFragment(ProxySettingsFragmentDoc, { + onResult: (ctx) => { + ctx.localSettings.preferences.proxyServer = 'proxy-server' + ctx.localSettings.preferences.proxyBypass = 'proxy-bypass' + }, + render: (gql) => , + }) + + cy.findByText('Proxy Bypass List') + .get('[data-testid=bypass-list]').contains('proxy-bypass') + + cy.findByText('Proxy Server') + .get('[data-testid=proxy-server]').contains('proxy-server') }) }) diff --git a/packages/app/src/settings/device/ProxySettings.vue b/packages/app/src/settings/device/ProxySettings.vue index 019dfa232510..245de538fb4c 100644 --- a/packages/app/src/settings/device/ProxySettings.vue +++ b/packages/app/src/settings/device/ProxySettings.vue @@ -12,14 +12,14 @@ {{ proxyServer }} + >{{ props.gql.localSettings.preferences.proxyServer || '-' }}
{{ t('settingsPage.proxy.bypassList') }} {{ bypassList }} + >{{ props.gql.localSettings.preferences.proxyBypass || '-' }}
@@ -28,10 +28,23 @@ diff --git a/packages/app/src/settings/device/TestingPreferences.spec.tsx b/packages/app/src/settings/device/TestingPreferences.spec.tsx index 2d48d3430899..009a5c568984 100644 --- a/packages/app/src/settings/device/TestingPreferences.spec.tsx +++ b/packages/app/src/settings/device/TestingPreferences.spec.tsx @@ -1,7 +1,10 @@ +import { TestingPreferencesFragmentDoc } from '../../generated/graphql-test' import TestingPreferences from './TestingPreferences.vue' describe('', () => { it('renders', () => { - cy.mount(() =>
) + cy.mountFragment(TestingPreferencesFragmentDoc, { + render: (gql) => , + }) }) }) diff --git a/packages/app/src/settings/device/TestingPreferences.vue b/packages/app/src/settings/device/TestingPreferences.vue index 5e6fcabcefb2..0a13c3d3fc3d 100644 --- a/packages/app/src/settings/device/TestingPreferences.vue +++ b/packages/app/src/settings/device/TestingPreferences.vue @@ -11,14 +11,16 @@ >

- {{ pref.title }}

@@ -33,15 +35,65 @@ import SettingsSection from '../SettingsSection.vue' import { useI18n } from '@cy/i18n' import Switch from '@packages/frontend-shared/src/components/Switch.vue' +import { gql, useMutation } from '@urql/vue' +import { + SetAutoScrollingEnabledDocument, + SetUseDarkSidebarDocument, + SetWatchForSpecChangeDocument, +} from '@packages/data-context/src/gen/all-operations.gen' +import type { TestingPreferencesFragment } from '../../generated/graphql' const { t } = useI18n() +gql` +fragment TestingPreferences on Query { + localSettings { + preferences { + autoScrollingEnabled + useDarkSidebar + watchForSpecChange + } + } +} +` + +gql` +mutation SetAutoScrollingEnabled($value: Boolean!) { + setAutoScrollingEnabled(value: $value) +}` + +gql` +mutation SetUseDarkSidebar($value: Boolean!) { + setUseDarkSidebar(value: $value) +}` + +gql` +mutation SetWatchForSpecChange($value: Boolean!) { + setWatchForSpecChange(value: $value) +}` + const prefs = [ { - title: 'Auto-scrolling', - enabled: true, - description: 'Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + id: 'autoScrollingEnabled', + title: t('settingsPage.testingPreferences.autoScrollingEnabled.title'), + mutation: useMutation(SetAutoScrollingEnabledDocument), + description: t('settingsPage.testingPreferences.autoScrollingEnabled.description'), + }, + { + id: 'useDarkSidebar', + title: t('settingsPage.testingPreferences.useDarkSidebar.title'), + mutation: useMutation(SetUseDarkSidebarDocument), + description: t('settingsPage.testingPreferences.useDarkSidebar.description'), + }, + { + id: 'watchForSpecChange', + title: t('settingsPage.testingPreferences.watchForSpecChange.title'), + mutation: useMutation(SetWatchForSpecChangeDocument), + description: t('settingsPage.testingPreferences.watchForSpecChange.description'), }, -] +] as const +const props = defineProps<{ + gql: TestingPreferencesFragment +}>() diff --git a/packages/app/src/specs/DirectoryItem.vue b/packages/app/src/specs/DirectoryItem.vue index ae27a2e913db..c3a4c39dc8f2 100644 --- a/packages/app/src/specs/DirectoryItem.vue +++ b/packages/app/src/specs/DirectoryItem.vue @@ -5,29 +5,20 @@ :class="{'transform rotate-270': !expanded}" /> - +

diff --git a/packages/app/src/specs/HighlightedText.vue b/packages/app/src/specs/HighlightedText.vue new file mode 100644 index 000000000000..31f0a693f4c4 --- /dev/null +++ b/packages/app/src/specs/HighlightedText.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/app/src/specs/InlineSpecList.spec.tsx b/packages/app/src/specs/InlineSpecList.spec.tsx index 400bfd73016e..be741a3ec9a2 100644 --- a/packages/app/src/specs/InlineSpecList.spec.tsx +++ b/packages/app/src/specs/InlineSpecList.spec.tsx @@ -5,8 +5,14 @@ let specs: Array = [] describe('InlineSpecList', () => { beforeEach(() => { + cy.fixture('found-specs').then((foundSpecs) => specs = foundSpecs) cy.mountFragment(Specs_InlineSpecListFragmentDoc, { onResult: (ctx) => { + if (!ctx.specs) { + return ctx + } + + ctx.specs.edges = specs.map((spec) => ({ __typename: 'SpecEdge', node: { __typename: 'Spec', ...spec, id: spec.relative } })) specs = ctx.specs?.edges || [] return ctx @@ -22,6 +28,12 @@ describe('InlineSpecList', () => { }) it('should render a list of spec', () => { - cy.get('a').should('exist').and('have.length', specs.length) + cy.get('li').should('exist').and('have.length', 7) + }) + + it('should support fuzzy sort', () => { + cy.get('input').type('scomeA') + + cy.get('li').should('have.length', 2).should('contain', 'src/components').and('contain', 'Spec-A.spec.tsx') }) }) diff --git a/packages/app/src/specs/InlineSpecList.vue b/packages/app/src/specs/InlineSpecList.vue index 308dd0d004a1..05678c0074ec 100644 --- a/packages/app/src/specs/InlineSpecList.vue +++ b/packages/app/src/specs/InlineSpecList.vue @@ -1,21 +1,11 @@ diff --git a/packages/app/src/specs/SpecItem.vue b/packages/app/src/specs/SpecItem.vue index cb1c8d9e5272..83f292c8b131 100644 --- a/packages/app/src/specs/SpecItem.vue +++ b/packages/app/src/specs/SpecItem.vue @@ -8,23 +8,31 @@ class="icon-light-gray-50 icon-dark-gray-200 document-icon" /> -
- {{ spec.fileName }} - {{ spec.specFileExtension }} +
+ +
diff --git a/packages/app/src/specs/SpecsList.spec.tsx b/packages/app/src/specs/SpecsList.spec.tsx index ddd0bae76484..10a5fe94117c 100644 --- a/packages/app/src/specs/SpecsList.spec.tsx +++ b/packages/app/src/specs/SpecsList.spec.tsx @@ -1,20 +1,8 @@ import SpecsList from './SpecsList.vue' import { Specs_SpecsListFragmentDoc, SpecNode_SpecsListFragment } from '../generated/graphql-test' -import { defaultMessages } from '@cy/i18n' const rowSelector = '[data-testid=specs-list-row]' const inputSelector = 'input' -const fullFile = (s) => `${s.node.fileName}${s.node.specFileExtension}` -const hasSpecText = (_node: JQuery, spec: SpecNode_SpecsListFragment) => { - const $node = _node as JQuery - - expect($node).to.contain(spec.node.fileName) - expect($node).to.contain(spec.node.fileExtension) - expect($node).to.contain(spec.node.gitInfo?.author) - expect($node).to.contain(defaultMessages.file.git.modified) - - return $node -} let specs: Array = [] @@ -32,45 +20,21 @@ describe('', { keystrokeDelay: 0 }, () => { }) }) - it('renders specs', () => { - cy.get(rowSelector).first() - .should(($node) => hasSpecText($node, specs[0])) - - cy.percySnapshot() - }) + it('should filter specs', () => { + const spec = specs[0].node - it('filters the specs', () => { - cy.get(rowSelector).first() - // Establish a baseline for what the spec rendered is - .should(($node) => hasSpecText($node, specs[0])) - .should('be.visible') - - // Cause an empty spec state - .get(inputSelector).type('garbage ๐Ÿ—‘', { delay: 0 }) + cy.get(inputSelector).type('garbage ๐Ÿ—‘', { delay: 0 }) .get(rowSelector) .should('not.exist') - // Clear the input, make sure that the right spec is showing up - .get(inputSelector) - .type('{selectall}{backspace}', { delay: 0 }) - - // Check the spec and its values - .get(rowSelector).first() - .should('be.visible') - .should(($node) => hasSpecText($node, specs[0])) - .get(inputSelector) - .type(fullFile(specs[0]), { delay: 0 }) - .get(rowSelector).first() - .should('contain.text', specs[0].node.fileName) - .and('contain.text', specs[0].node.fileExtension) - .click() + cy.get(inputSelector).clear().type(spec.relative) + cy.get(rowSelector).first().should('contain', spec.relative.replace(`/${spec.fileName}${spec.specFileExtension}`, '')) + cy.get(rowSelector).last().should('contain', `${spec.fileName}${spec.specFileExtension}`) }) - it('changes to tree view', () => { - cy.get('[data-cy="file-tree-radio-option"]').click() - + it('should close directories', () => { // close all directories - ;['src', 'packages', 'frontend', '__test__', 'lib', 'tests'].forEach((dir) => { + ['src', 'packages', 'frontend', '__test__', 'lib', 'tests'].forEach((dir) => { cy.get('[data-cy="row-directory-depth-0"]').contains(dir).click() }) diff --git a/packages/app/src/specs/SpecsList.vue b/packages/app/src/specs/SpecsList.vue index 65e76bda5b87..76bbc6fff8a5 100644 --- a/packages/app/src/specs/SpecsList.vue +++ b/packages/app/src/specs/SpecsList.vue @@ -12,80 +12,60 @@ @newSpec="showModal = true" /> -
-
-
- {{ t('specPage.componentSpecsHeader') }} -
-
-
{{ t('specPage.gitStatusHeader') }}
- -
+
+
+ {{ t('specPage.componentSpecsHeader') }}
- - - - + + + + + + + +
@@ -95,16 +75,16 @@ import SpecsListHeader from './SpecsListHeader.vue' import SpecListGitInfo from './SpecListGitInfo.vue' import SpecsListRowItem from './SpecsListRowItem.vue' import { gql } from '@urql/vue' -import { computed, ref } from 'vue' +import { computed, ComputedRef, ref, watch } from 'vue' import CreateSpecModal from './CreateSpecModal.vue' import type { Specs_SpecsListFragment, SpecListRowFragment } from '../generated/graphql' import { useI18n } from '@cy/i18n' -import { buildSpecTree } from '@packages/frontend-shared/src/utils/buildSpecTree' +import { buildSpecTree, FuzzyFoundSpec, getIndexes } from '@packages/frontend-shared/src/utils/spec-utils' import { useCollapsibleTree } from '@packages/frontend-shared/src/composables/useCollapsibleTree' import RowDirectory from './RowDirectory.vue' import SpecItem from './SpecItem.vue' -import type { FoundSpec } from '@packages/types/src' -import SelectSpecListView from './SelectSpecListView.vue' +import fuzzySort from 'fuzzysort' +import { useVirtualList } from '@packages/frontend-shared/src/composables/useVirtualList' const { t } = useI18n() @@ -142,42 +122,29 @@ fragment Specs_SpecsList on Query { } ` -export type SpecViewType = 'flat' | 'tree' - const props = defineProps<{ gql: Specs_SpecsListFragment }>() const showModal = ref(false) const search = ref('') -const specViewType = ref('flat') -const updateTab = (tab: SpecViewType) => { - specViewType.value = tab -} +const specs = computed(() => { + const specs = props.gql.currentProject?.specs?.edges.map((x) => x.node) || [] -const flatSpecList = computed(() => { - if (search.value) { - return props.gql.currentProject?.specs?.edges.filter((x) => x.node.absolute.toLowerCase().includes(search.value.toLowerCase())) + if (!search.value) { + return specs.map((spec) => ({ ...spec, indexes: [] as number[] })) } - return props.gql.currentProject?.specs?.edges + return fuzzySort + .go(search.value, specs || [], { key: 'relative' }) + .map(({ obj, indexes }) => ({ ...obj, indexes })) }) -const specTree = computed(() => buildSpecTree(props.gql.currentProject?.specs?.edges.map((x) => x.node) || [])) -const collapsible = useCollapsibleTree(specTree.value, { dropRoot: true }) +const specTree = computed(() => buildSpecTree(specs.value)) +const collapsible = computed(() => useCollapsibleTree(specTree.value, { dropRoot: true })) -const treeSpecList = computed(() => { - if (search.value) { - // todo(lachlan) this will not show the folders of the filtered specs - // we should update the useCollapsibleTree to have some kind of search - // functionality, ideally with fuzzysort, that correctly returns the matched - // specs and the directories to show. - return collapsible.tree.filter(((item) => { - return !item.hidden.value && item.data?.absolute.toLowerCase().includes(search.value.toLowerCase()) - })) - } +const treeSpecList = computed(() => collapsible.value.tree.filter(((item) => !item.hidden.value))) - return collapsible.tree.filter(((item) => !item.hidden.value)) -}) +const { containerProps, list, wrapperProps } = useVirtualList(treeSpecList, { itemHeight: 40, overscan: 10 }) diff --git a/packages/data-context/src/DataActions.ts b/packages/data-context/src/DataActions.ts index 01e668f736f6..0a28fa7d9bac 100644 --- a/packages/data-context/src/DataActions.ts +++ b/packages/data-context/src/DataActions.ts @@ -1,7 +1,16 @@ import type { DataContext } from '.' -import { AppActions, ApplicationDataActions, ProjectConfigDataActions, ElectronActions, FileActions, ProjectActions, WizardActions } from './actions' +import { + AppActions, + ApplicationDataActions, + ProjectConfigDataActions, + ElectronActions, + FileActions, + ProjectActions, + WizardActions, +} from './actions' import { AuthActions } from './actions/AuthActions' import { DevActions } from './actions/DevActions' +import { LocalSettingsActions } from './actions/LocalSettingsActions' import { cached } from './util' export class DataActions { @@ -32,6 +41,11 @@ export class DataActions { return new AuthActions(this.ctx) } + @cached + get localSettings () { + return new LocalSettingsActions(this.ctx) + } + @cached get wizard () { return new WizardActions(this.ctx) diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index 876594f6dad6..92dbd49a1875 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -2,7 +2,7 @@ import type { LaunchArgs, OpenProjectLaunchOptions, PlatformName } from '@packag import fsExtra from 'fs-extra' import path from 'path' -import { AppApiShape, ApplicationDataApiShape, DataEmitterActions, ProjectApiShape } from './actions' +import { AppApiShape, ApplicationDataApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions' import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen' import type { AuthApiShape } from './actions/AuthActions' import type { ElectronApiShape } from './actions/ElectronActions' @@ -52,6 +52,7 @@ export interface DataContextConfig { */ appApi: AppApiShape appDataApi: ApplicationDataApiShape + localSettingsApi: LocalSettingsApiShape authApi: AuthApiShape projectApi: ProjectApiShape electronApi: ElectronApiShape @@ -81,6 +82,10 @@ export class DataContext { return this._config.electronApi } + get localSettingsApi () { + return this._config.localSettingsApi + } + get isGlobalMode () { return !this.currentProject } @@ -92,7 +97,8 @@ export class DataContext { this.actions.app.refreshBrowsers(), // load the cached user & validate the token on start this.actions.auth.getUser(), - + // and grab the user device settings + this.actions.localSettings.refreshLocalSettings(), this.actions.app.refreshNodePathAndVersion(), ] @@ -295,6 +301,7 @@ export class DataContext { authApi: this._config.authApi, projectApi: this._config.projectApi, electronApi: this._config.electronApi, + localSettingsApi: this._config.localSettingsApi, busApi: this._rootBus, } } diff --git a/packages/data-context/src/actions/FileActions.ts b/packages/data-context/src/actions/FileActions.ts index 87fbde11bb6d..d24321d8601d 100644 --- a/packages/data-context/src/actions/FileActions.ts +++ b/packages/data-context/src/actions/FileActions.ts @@ -25,4 +25,14 @@ export class FileActions { await this.ctx.fs.remove(path.join(this.ctx.currentProject?.projectRoot, relativePath)) } + + async checkIfFileExists (relativePath: string) { + if (!this.ctx.currentProject) { + throw new Error(`Cannot check file in project exists without active project`) + } + + const filePath = path.join(this.ctx.currentProject?.projectRoot, relativePath) + + return await this.ctx.fs.stat(filePath) + } } diff --git a/packages/data-context/src/actions/LocalSettingsActions.ts b/packages/data-context/src/actions/LocalSettingsActions.ts new file mode 100644 index 000000000000..d09592fc0c7f --- /dev/null +++ b/packages/data-context/src/actions/LocalSettingsActions.ts @@ -0,0 +1,42 @@ +import type { DevicePreferences, Editor } from '@packages/types' +import pDefer from 'p-defer' + +import type { DataContext } from '..' + +export interface LocalSettingsApiShape { + setPreferredOpener(editor: Editor): Promise + getAvailableEditors(): Promise + + getPreferences (): Promise + setDevicePreference (key: K, value: DevicePreferences[K]): Promise +} + +export class LocalSettingsActions { + constructor (private ctx: DataContext) {} + + setDevicePreference (key: K, value: DevicePreferences[K]) { + // update local data + this.ctx.coreData.localSettings.preferences[key] = value + + // persist to appData + return this.ctx._apis.localSettingsApi.setDevicePreference(key, value) + } + + async refreshLocalSettings () { + if (this.ctx.coreData.localSettings?.refreshing) { + return + } + + const dfd = pDefer() + + this.ctx.coreData.localSettings.refreshing = dfd.promise + + // TODO(tim): global unhandled error concept + const availableEditors = await this.ctx._apis.localSettingsApi.getAvailableEditors() + + this.ctx.coreData.localSettings.availableEditors = availableEditors + this.ctx.coreData.localSettings.preferences = await this.ctx._apis.localSettingsApi.getPreferences() + + dfd.resolve(availableEditors) + } +} diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index fecca6f83245..adf1c444d02f 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -1,4 +1,4 @@ -import type { CodeGenType, MutationAddProjectArgs, MutationAppCreateConfigFileArgs, MutationSetProjectPreferencesArgs, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen' +import type { CodeGenType, MutationAddProjectArgs, MutationSetProjectPreferencesArgs, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen' import type { FindSpecs, FoundBrowser, FoundSpec, FullConfig, LaunchArgs, LaunchOpts, OpenProjectLaunchOptions, Preferences, SettingsOptions } from '@packages/types' import path from 'path' import type { ActiveProjectShape, ProjectShape } from '../data/coreDataShape' @@ -62,22 +62,38 @@ export class ProjectActions { await this.clearActiveProject() // Set initial properties, so we can set the config object on the active project - this.setCurrentProjectProperties({ + await this.setCurrentProjectProperties({ projectRoot, title, ctPluginsInitialized: false, e2ePluginsInitialized: false, config: null, configChildProcess: null, - }) - - this.setCurrentProjectProperties({ - isCTConfigured: await this.ctx.project.isTestingTypeConfigured(projectRoot, 'component'), - isE2EConfigured: await this.ctx.project.isTestingTypeConfigured(projectRoot, 'e2e'), + isMissingConfigFile: false, preferences: await this.ctx.project.getProjectPreferences(title), }) - return this + try { + // read the config and cache it + await this.ctx.project.getConfig(projectRoot) + + this.setCurrentProjectProperties({ + isCTConfigured: await this.ctx.project.isTestingTypeConfigured(projectRoot, 'component'), + isE2EConfigured: await this.ctx.project.isTestingTypeConfigured(projectRoot, 'e2e'), + }) + + return this + } catch (error: any) { + if (error.type === 'NO_DEFAULT_CONFIG_FILE_FOUND') { + this.setCurrentProjectProperties({ + isMissingConfigFile: true, + }) + + return this + } + + throw error + } } // Temporary: remove after other refactor lands @@ -277,14 +293,29 @@ export class ProjectActions { // } - createConfigFile (args: MutationAppCreateConfigFileArgs) { + async createConfigFile (type?: 'component' | 'e2e' | null) { const project = this.ctx.currentProject if (!project) { throw Error(`Cannot create config file without currentProject.`) } - this.ctx.fs.writeFileSync(path.resolve(project.projectRoot, args.configFilename), args.code) + let obj: { [k: string]: object } = { + e2e: {}, + component: {}, + } + + if (type) { + obj = { + [type]: {}, + } + } + + await this.ctx.fs.writeFile(path.resolve(project.projectRoot, 'cypress.config.js'), `module.exports = ${JSON.stringify(obj, null, 2)}`) + + this.setCurrentProjectProperties({ + isMissingConfigFile: false, + }) } async clearLatestProjectCache () { diff --git a/packages/data-context/src/actions/index.ts b/packages/data-context/src/actions/index.ts index a3fbbc923a43..8d294c968f7a 100644 --- a/packages/data-context/src/actions/index.ts +++ b/packages/data-context/src/actions/index.ts @@ -8,6 +8,7 @@ export * from './DataEmitterActions' export * from './DevActions' export * from './ElectronActions' export * from './FileActions' +export * from './LocalSettingsActions' export * from './ProjectActions' export * from './ProjectConfigDataActions' export * from './WizardActions' diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index e1d00e4ef86a..d8f29d93a34f 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -1,4 +1,4 @@ -import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, Preferences, NodePathAndVersion } from '@packages/types' +import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, Preferences, NodePathAndVersion, DevicePreferences, devicePreferenceDefaults, Editor } from '@packages/types' import type { NexusGenEnums, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen' import type { BrowserWindow } from 'electron' import type { ChildProcess } from 'child_process' @@ -19,6 +19,12 @@ export interface DevStateShape { refreshState: null | string } +export interface LocalSettingsDataShape { + refreshing: Promise | null + availableEditors: Editor[] + preferences: DevicePreferences +} + export interface ConfigChildProcessShape { /** * Child process executing the config & sourcing plugin events @@ -43,8 +49,9 @@ export interface ActiveProjectShape extends ProjectShape { specs?: FoundSpec[] config: Promise | null configChildProcess?: ConfigChildProcessShape | null - preferences?: Preferences| null + preferences?: Preferences | null browsers: FoundBrowser[] | null + isMissingConfigFile: boolean } export interface AppDataShape { @@ -82,6 +89,7 @@ export interface BaseErrorDataShape { export interface CoreDataShape { baseError: BaseErrorDataShape | null dev: DevStateShape + localSettings: LocalSettingsDataShape app: AppDataShape currentProject: ActiveProjectShape | null wizard: WizardDataShape @@ -107,6 +115,11 @@ export function makeCoreData (): CoreDataShape { refreshingNodePathAndVersion: null, nodePathAndVersion: null, }, + localSettings: { + availableEditors: [], + preferences: devicePreferenceDefaults, + refreshing: null, + }, isAuthBrowserOpened: false, currentProject: null, wizard: { diff --git a/packages/data-context/src/sources/EnvDataSource.ts b/packages/data-context/src/sources/EnvDataSource.ts index 55f7c310b49c..00e2f2528a8e 100644 --- a/packages/data-context/src/sources/EnvDataSource.ts +++ b/packages/data-context/src/sources/EnvDataSource.ts @@ -6,4 +6,12 @@ import type { DataContext } from '../DataContext' */ export class EnvDataSource { constructor (private ctx: DataContext) {} + + get HTTP_PROXY () { + return process.env.HTTPS_PROXY || process.env.HTTP_PROXY + } + + get NO_PROXY () { + return process.env.NO_PROXY + } } diff --git a/packages/data-context/src/sources/HtmlDataSource.ts b/packages/data-context/src/sources/HtmlDataSource.ts index bfa406bc8f38..2e842087d3e7 100644 --- a/packages/data-context/src/sources/HtmlDataSource.ts +++ b/packages/data-context/src/sources/HtmlDataSource.ts @@ -35,7 +35,28 @@ export class HtmlDataSource { } async fetchAppHtml () { - return this.ctx.fs.readFile(getPathToDist('app', 'index.html'), 'utf8') + if (process.env.CYPRESS_INTERNAL_VITE_DEV) { + const response = await this.ctx.util.fetch(`http://localhost:${process.env.CYPRESS_INTERNAL_VITE_APP_PORT}/`, { method: 'GET' }) + const html = await response.text() + + return html + } + + // Check if the file exists. If it doesn't, it probably means that Vite is re-building + // and we should retry a few times until the file exists + let retryCount = 0 + let err + + while (retryCount < 5) { + try { + return await this.ctx.fs.readFile(getPathToDist('app', 'index.html'), 'utf8') + } catch (e) { + err = e + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + throw err } /** diff --git a/packages/data-context/src/sources/ProjectDataSource.ts b/packages/data-context/src/sources/ProjectDataSource.ts index ba136a319ca1..27a1cb72fae7 100644 --- a/packages/data-context/src/sources/ProjectDataSource.ts +++ b/packages/data-context/src/sources/ProjectDataSource.ts @@ -88,21 +88,29 @@ export class ProjectDataSource { } async isTestingTypeConfigured (projectRoot: string, testingType: 'e2e' | 'component') { - const config = await this.getConfig(projectRoot) + try { + const config = await this.getConfig(projectRoot) - if (!config) { - return true - } + if (!config) { + return true + } - if (testingType === 'e2e') { - return Boolean(Object.keys(config.e2e ?? {}).length) - } + if (testingType === 'e2e') { + return Boolean(Object.keys(config.e2e ?? {}).length) + } - if (testingType === 'component') { - return Boolean(Object.keys(config.component ?? {}).length) - } + if (testingType === 'component') { + return Boolean(Object.keys(config.component ?? {}).length) + } - return false + return false + } catch (error: any) { + if (error.type === 'NO_DEFAULT_CONFIG_FILE_FOUND') { + return false + } + + throw error + } } async getProjectPreferences (projectTitle: string) { diff --git a/packages/data-context/src/util/urqlCacheKeys.ts b/packages/data-context/src/util/urqlCacheKeys.ts index f4f322116f70..4382c8fe3861 100644 --- a/packages/data-context/src/util/urqlCacheKeys.ts +++ b/packages/data-context/src/util/urqlCacheKeys.ts @@ -17,5 +17,7 @@ export const urqlCacheKeys: Partial = { BaseError: () => null, ProjectPreferences: (data) => data.__typename, VersionData: () => null, + LocalSettings: (data) => data.__typename, + LocalSettingsPreferences: () => null, }, } diff --git a/packages/desktop-gui/cypress/integration/settings_spec.js b/packages/desktop-gui/cypress/integration/settings_spec.js index 716b4b151af1..b138152bbb4c 100644 --- a/packages/desktop-gui/cypress/integration/settings_spec.js +++ b/packages/desktop-gui/cypress/integration/settings_spec.js @@ -782,11 +782,11 @@ describe('Settings', () => { describe('file preference panel', () => { const availableEditors = [ - { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' }, - { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' }, - { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' }, - { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' }, - { id: 'other', name: 'Other', isOther: true, openerId: '' }, + { id: 'atom', name: 'Atom', isOther: false, binary: 'atom' }, + { id: 'vim', name: 'Vim', isOther: false, binary: 'vim' }, + { id: 'sublime', name: 'Sublime Text', isOther: false, binary: 'sublime' }, + { id: 'vscode', name: 'Visual Studio Code', isOther: false, binary: 'vscode' }, + { id: 'other', name: 'Other', isOther: true, binary: '' }, ] beforeEach(function () { diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.js b/packages/desktop-gui/cypress/integration/specs_list_spec.js index 212157271ba9..92fbb4dead23 100644 --- a/packages/desktop-gui/cypress/integration/specs_list_spec.js +++ b/packages/desktop-gui/cypress/integration/specs_list_spec.js @@ -904,12 +904,12 @@ describe('Specs List', function () { describe('opens files', function () { beforeEach(function () { this.availableEditors = [ - { id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' }, - { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' }, - { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' }, - { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' }, - { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' }, - { id: 'other', name: 'Other', isOther: true, openerId: '' }, + { id: 'computer', name: 'On Computer', isOther: false, binary: 'computer' }, + { id: 'atom', name: 'Atom', isOther: false, binary: 'atom' }, + { id: 'vim', name: 'Vim', isOther: false, binary: 'vim' }, + { id: 'sublime', name: 'Sublime Text', isOther: false, binary: 'sublime' }, + { id: 'vscode', name: 'Visual Studio Code', isOther: false, binary: 'vscode' }, + { id: 'other', name: 'Other', isOther: true, binary: '' }, ] cy.get('@spec').realHover() diff --git a/packages/desktop-gui/src/settings/file-preference.jsx b/packages/desktop-gui/src/settings/file-preference.jsx index 066502a1d49c..27a0b535036d 100644 --- a/packages/desktop-gui/src/settings/file-preference.jsx +++ b/packages/desktop-gui/src/settings/file-preference.jsx @@ -39,7 +39,7 @@ const FilePreference = observer(() => { setOtherPath: action((otherPath) => { const otherOption = _.find(state.editors, { isOther: true }) - otherOption.openerId = otherPath + otherOption.binary = otherPath save(otherOption) }), })) diff --git a/packages/desktop-gui/src/settings/file-preference_spec.jsx b/packages/desktop-gui/src/settings/file-preference_spec.jsx index eddeb9eeab74..c787cff42198 100644 --- a/packages/desktop-gui/src/settings/file-preference_spec.jsx +++ b/packages/desktop-gui/src/settings/file-preference_spec.jsx @@ -8,11 +8,11 @@ import '../main.scss' /* global cy, Cypress */ describe('FilePreference', () => { const availableEditors = [ - { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' }, - { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' }, - { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' }, - { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' }, - { id: 'other', name: 'Other', isOther: true, openerId: '' }, + { id: 'atom', name: 'Atom', isOther: false, binary: 'atom' }, + { id: 'vim', name: 'Vim', isOther: false, binary: 'vim' }, + { id: 'sublime', name: 'Sublime Text', isOther: false, binary: 'sublime' }, + { id: 'vscode', name: 'Visual Studio Code', isOther: false, binary: 'vscode' }, + { id: 'other', name: 'Other', isOther: true, binary: '' }, ] it('shows editor choice', () => { diff --git a/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts b/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts index 85b396d29782..7094447d1848 100644 --- a/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts +++ b/packages/frontend-shared/cypress/e2e/support/e2eProjectDirs.ts @@ -72,6 +72,7 @@ export const e2eProjectDirs = [ 'todos', 'ts-installed', 'ts-proj', + 'ts-proj-compiler', 'ts-proj-custom-names', 'ts-proj-esmoduleinterop-true', 'ts-proj-tsconfig-in-plugins', diff --git a/packages/frontend-shared/cypress/support/mock-graphql/clientTestContext.ts b/packages/frontend-shared/cypress/support/mock-graphql/clientTestContext.ts index 72cc184c011a..3127f5d1e5c2 100644 --- a/packages/frontend-shared/cypress/support/mock-graphql/clientTestContext.ts +++ b/packages/frontend-shared/cypress/support/mock-graphql/clientTestContext.ts @@ -1,4 +1,4 @@ -import type { CloudUser } from '../generated/test-cloud-graphql-types.gen' +import type { AuthenticatedUserShape } from '@packages/data-context/src/data' import type { WizardStep, CurrentProject, @@ -8,6 +8,7 @@ import type { TestingTypeEnum, GlobalProject, VersionData, + LocalSettings, } from '../generated/test-graphql-types.gen' import { resetTestNodeIdx } from './clientTestUtils' import { stubBrowsers } from './stubgql-Browser' @@ -24,6 +25,7 @@ export interface ClientTestContext { } versions: VersionData isAuthBrowserOpened: boolean + localSettings: LocalSettings wizard: { step: WizardStep canNavigateForward: boolean @@ -37,7 +39,7 @@ export interface ClientTestContext { chosenBrowser: null browserErrorMessage: null } - user: Partial | null + user: AuthenticatedUserShape | null cloudTypes: typeof cloudTypes __mockPartial: any } @@ -89,6 +91,29 @@ export function makeClientTestContext (): ClientTestContext { }, user: null, cloudTypes, + localSettings: { + __typename: 'LocalSettings', + preferences: { + __typename: 'LocalSettingsPreferences', + autoScrollingEnabled: true, + useDarkSidebar: true, + watchForSpecChange: true, + }, + availableEditors: [ + { + __typename: 'Editor', + id: 'code', + name: 'VS Code', + binary: 'code', + }, + { + __typename: 'Editor', + id: 'vim', + name: 'Vim', + binary: 'vim', + }, + ], + }, __mockPartial: {}, } } diff --git a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts index 6e4213bbf1af..e56a53254c78 100644 --- a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts +++ b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts @@ -18,12 +18,6 @@ export const stubMutation: MaybeResolver = { return true }, - appCreateConfigFile: (source, args, ctx) => { - ctx.wizard.chosenManualInstall = true - ctx.wizard.canNavigateForward = true - - return true - }, clearActiveProject (source, args, ctx) { ctx.currentProject = null diff --git a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Query.ts b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Query.ts index ceccc21f24cb..e22b62c92770 100644 --- a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Query.ts +++ b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Query.ts @@ -6,6 +6,9 @@ export const stubQuery: MaybeResolver = { dev () { return {} }, + localSettings (source, args, ctx) { + return ctx.localSettings + }, wizard (source, args, ctx) { return ctx.wizard }, diff --git a/packages/frontend-shared/src/components/Switch.vue b/packages/frontend-shared/src/components/Switch.vue index bc1cf6b0c62c..f2cb274e7ca9 100644 --- a/packages/frontend-shared/src/components/Switch.vue +++ b/packages/frontend-shared/src/components/Switch.vue @@ -48,5 +48,7 @@ const sizeClasses = { }, } -defineEmits(['update']) +defineEmits<{ + (e: 'update', value: boolean): void +}>() diff --git a/packages/frontend-shared/src/composables/useCollapsibleTree.ts b/packages/frontend-shared/src/composables/useCollapsibleTree.ts index 52e14783790d..f52a829e45fc 100644 --- a/packages/frontend-shared/src/composables/useCollapsibleTree.ts +++ b/packages/frontend-shared/src/composables/useCollapsibleTree.ts @@ -68,10 +68,45 @@ function buildTree> (rawNode: T, options: UseCollapsibleTre return acc } +function sortTree> (tree: T) { + if (tree.children.length > 0) { + tree.children = tree.children.sort((a, b) => { + if (a.children.length === 0 && b.children.length === 0) { + return a.name > b.name ? 1 : -1 + } + + if (a.children.length === 0) { + return 1 + } + + if (b.children.length === 0) { + return -1 + } + + return a.name > b.name ? 1 : -1 + }) + + tree.children.forEach(sortTree) + } +} + export function useCollapsibleTree > (tree: T, options: UseCollapsibleTreeOptions = {}) { options.expandInitially = options.expandInitially || true + sortTree(tree) const collapsibleTree = buildTree(tree, options) + collapsibleTree.sort((a, b) => { + if (a.parent === b.parent) { + if (a.children.length && !b.children.length) { + return -1 + } + + return 0 + } + + return 0 + }) + return { tree: options.dropRoot ? collapsibleTree.slice(1) : collapsibleTree, } diff --git a/packages/frontend-shared/src/composables/useVirtualList.ts b/packages/frontend-shared/src/composables/useVirtualList.ts index 710ba8e6817c..e7049b8c8c1a 100644 --- a/packages/frontend-shared/src/composables/useVirtualList.ts +++ b/packages/frontend-shared/src/composables/useVirtualList.ts @@ -1,5 +1,5 @@ -import { watch, Ref, ref, computed } from 'vue' -import { useElementSize } from '@vueuse/core' +import { watch, Ref, ref, computed, shallowRef, CSSProperties } from 'vue' +import { MaybeRef, useElementSize } from '@vueuse/core' export interface UseVirtualListOptions { /** @@ -21,21 +21,16 @@ export type UseVirtualListItem = { index: number } -export function useVirtualList (list: Ref, options: UseVirtualListOptions) { +export function useVirtualList (list: MaybeRef, options: UseVirtualListOptions) { const containerRef: Ref = ref() const size = useElementSize(containerRef) const currentList: Ref[]> = ref([]) - const _list = ref(list) + const source = shallowRef(list) const state: Ref = ref({ start: 0, end: 10 }) const { itemHeight, overscan = 5 } = options - if (!itemHeight) { - // eslint-disable-next-line - console.warn('please enter a valid itemHeight') - } - const getViewCapacity = (containerHeight: number) => { if (typeof itemHeight === 'number') { return Math.ceil(containerHeight / itemHeight) @@ -45,7 +40,7 @@ export function useVirtualList (list: Ref, options: UseVirtualLis let sum = 0 let capacity = 0 - for (let i = start; i < _list.value.length; i++) { + for (let i = start; i < source.value.length; i++) { const height = (itemHeight as (index: number) => number)(i) sum += height @@ -66,7 +61,7 @@ export function useVirtualList (list: Ref, options: UseVirtualLis let sum = 0 let offset = 0 - for (let i = 0; i < _list.value.length; i++) { + for (let i = 0; i < source.value.length; i++) { const height = (itemHeight as (index: number) => number)(i) sum += height @@ -91,33 +86,32 @@ export function useVirtualList (list: Ref, options: UseVirtualLis state.value = { start: from < 0 ? 0 : from, - end: to > _list.value.length ? _list.value.length : to, + end: to > source.value.length + ? source.value.length + : to, } - currentList.value = _list.value.slice(state.value.start, state.value.end).map((ele, index) => { + currentList.value = source.value + .slice(state.value.start, state.value.end) + .map((ele, index) => { return { - data: ele as T, + data: ele, index: index + state.value.start, } }) } } - watch(list, (newList) => { - _list.value = newList as typeof _list.value - calculateRange() - }) - - watch([size.width, size.height], () => { + watch([size.width, size.height, list], () => { calculateRange() }) const totalHeight = computed(() => { if (typeof itemHeight === 'number') { - return _list.value.length * itemHeight + return source.value.length * itemHeight } - return _list.value.reduce((sum, _, index) => sum + itemHeight(index), 0) + return source.value.reduce((sum, _, index) => sum + itemHeight(index), 0) }) const getDistanceTop = (index: number) => { @@ -127,7 +121,9 @@ export function useVirtualList (list: Ref, options: UseVirtualLis return height } - const height = _list.value.slice(0, index).reduce((sum, _, i) => sum + itemHeight(i), 0) + const height = source.value + .slice(0, index) + .reduce((sum, _, i) => sum + itemHeight(i), 0) return height } @@ -150,7 +146,7 @@ export function useVirtualList (list: Ref, options: UseVirtualLis } }) - const containerStyle: Partial = { overflowY: 'auto' } + const containerStyle: CSSProperties = { overflowY: 'auto' } return { list: currentList, diff --git a/packages/frontend-shared/src/gql-components/topnav/TopNav.vue b/packages/frontend-shared/src/gql-components/topnav/TopNav.vue index a229222e1039..dbdea7f0a137 100644 --- a/packages/frontend-shared/src/gql-components/topnav/TopNav.vue +++ b/packages/frontend-shared/src/gql-components/topnav/TopNav.vue @@ -211,9 +211,7 @@ import { allBrowsersIcons } from '@packages/frontend-shared/src/assets/browserLo import { gql, useMutation } from '@urql/vue' import { TopNavFragment, TopNav_LaunchOpenProjectDocument, TopNav_SetBrowserDocument } from '../../generated/graphql' import { useI18n } from '@cy/i18n' -import { computed, ref } from 'vue' -// eslint-disable-next-line no-duplicate-imports -import type { Ref } from 'vue' +import { computed, ref, Ref } from 'vue' const { t } = useI18n() import { onClickOutside, onKeyStroke, useTimeAgo } from '@vueuse/core' import DocsMenuContent from './DocsMenuContent.vue' @@ -284,34 +282,43 @@ const props = defineProps<{ showBrowsers?: boolean }>() -const docsMenuVariant: Ref<'main' | 'orchestration' | 'ci'> = ref('main') - -const promptsEl: Ref = ref(null) - -// reset docs menu if click or keyboard navigation happens outside -// so it doesn't reopen on the one of the prompts - -const versions = computed(() => { +const versions = (() => { if (!props.gql.versions) { - return null + return ref(null) } - return { - current: { - released: useTimeAgo(new Date(props.gql.versions.current.released)).value, - version: props.gql.versions.current.version, - }, - latest: { - released: useTimeAgo(new Date(props.gql.versions.latest.released)).value, - version: props.gql.versions.latest.version, - }, - } -}) + const currentReleased = useTimeAgo(new Date(props.gql.versions.current.released)) + const latestReleased = useTimeAgo(new Date(props.gql.versions.latest.released)) + + return computed(() => { + if (!props.gql.versions) { + return null + } + + return { + current: { + released: currentReleased.value, + version: props.gql.versions.current.version, + }, + latest: { + released: latestReleased.value, + version: props.gql.versions.latest.version, + }, + } + }) +})() const runningOldVersion = computed(() => { return props.gql.versions ? props.gql.versions.current.released < props.gql.versions.latest.released : false }) +const docsMenuVariant: Ref<'main' | 'orchestration' | 'ci'> = ref('main') + +const promptsEl: Ref = ref(null) + +// reset docs menu if click or keyboard navigation happens outside +// so it doesn't reopen on the one of the prompts + onClickOutside(promptsEl, () => { setTimeout(() => { // reset the content of the menu when diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 1be8b889b909..36b50c719058 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -312,7 +312,19 @@ }, "testingPreferences": { "title": "Testing Preferences", - "description": "Configure your testing environment with these flags" + "description": "Configure your testing environment with these flags", + "autoScrollingEnabled": { + "title": "Auto Scrolling Enabled", + "description": "Scroll behavior when running tests." + }, + "watchForSpecChange": { + "title": "Watch for Spec Change", + "description": "Re-run specs when a file changes." + }, + "useDarkSidebar": { + "title": "Dark sidebar", + "description": "Select the color theme of the app sidebar." + } }, "footer": { "text": "You can reconfigure the settings for this project if youโ€™re experiencing issues with your Cypress configuration.", diff --git a/packages/frontend-shared/src/utils/buildSpecTree.ts b/packages/frontend-shared/src/utils/spec-utils.ts similarity index 64% rename from packages/frontend-shared/src/utils/buildSpecTree.ts rename to packages/frontend-shared/src/utils/spec-utils.ts index 1c7d1aa7ca1b..e2d2bd1be681 100644 --- a/packages/frontend-shared/src/utils/buildSpecTree.ts +++ b/packages/frontend-shared/src/utils/spec-utils.ts @@ -1,6 +1,10 @@ import type { FoundSpec } from '@packages/types' +import type { UseCollapsibleTreeNode } from '../composables/useCollapsibleTree' + +export type FuzzyFoundSpec = FoundSpec & { indexes: number[] } export type SpecTreeNode = { + id: string name: string children: SpecTreeNode[] isLeaf: boolean @@ -8,9 +12,8 @@ export type SpecTreeNode = { data?: T } -export function buildSpecTree (specs: FoundSpec[], root: SpecTreeNode = { name: '/', isLeaf: false, children: [] }) { +export function buildSpecTree (specs: FoundSpec[], root: SpecTreeNode = { name: '', isLeaf: false, children: [], id: '' }) { specs.forEach((spec) => buildSpecTreeRecursive(spec.relative, root, spec)) - collapseEmptyChildren(root) return root @@ -18,9 +21,10 @@ export function buildSpecTree (specs: FoundSpec[], root: Sp export function buildSpecTreeRecursive (path: string, tree: SpecTreeNode, data?: T) { const [firstFile, ...rest] = path.split('/') + const id = tree.id ? [tree.id, firstFile].join('/') : firstFile if (rest.length < 1) { - tree.children.push({ name: firstFile, isLeaf: true, children: [], parent: tree, data }) + tree.children.push({ name: firstFile, isLeaf: true, children: [], parent: tree, data, id }) return tree } @@ -33,7 +37,7 @@ export function buildSpecTreeRecursive (path: string, tree: return tree } - const newTree = buildSpecTreeRecursive(rest.join('/'), { name: firstFile, isLeaf: false, children: [], parent: tree }, data) + const newTree = buildSpecTreeRecursive(rest.join('/'), { name: firstFile, isLeaf: false, children: [], parent: tree, id, data }, data) tree.children.push(newTree) @@ -52,8 +56,20 @@ function collapseEmptyChildren (node: SpecTreeNode) { // so we check node.parent.parent if (node.parent && node.parent.parent && (node.parent.children.length === 1)) { node.parent.name = [node.parent.name, node.name].join('/') + node.parent.id = [node.parent.id, node.name].join('/') node.parent.children = node.children } return } + +export function getIndexes (row: UseCollapsibleTreeNode>) { + const indexes = row.data?.indexes || [] + + const maxIndex = row.id.length - 1 + const minIndex = maxIndex - row.name.length + 1 + + const res = indexes?.filter((index) => index >= minIndex && index <= maxIndex) + + return res.map((idx) => idx - minIndex) +} diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index c2d86e27a6bf..2af041789053 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -344,6 +344,16 @@ type DevState { needsRelaunch: Boolean } +"""Represents an editor on the local machine""" +type Editor { + """Binary that opens the editor""" + binary: String! + id: String! + + """name of editor""" + name: String! +} + """Represents a spec on the file system""" type FileParts implements Node { """ @@ -420,6 +430,22 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// """ scalar JSON +"""local settings on a device-by-device basis""" +type LocalSettings { + availableEditors: [Editor!]! + preferences: LocalSettingsPreferences! +} + +"""local setting preferences""" +type LocalSettingsPreferences { + autoScrollingEnabled: Boolean + preferredEditorBinary: String + proxyBypass: String + proxyServer: String + useDarkSidebar: Boolean + watchForSpecChange: Boolean +} + type Mutation { """Add project to projects array and cache it""" addProject( @@ -430,9 +456,6 @@ type Mutation { """Create an Index HTML file for a new component testing project""" appCreateComponentIndexHtml(template: String!): Boolean - - """Create a Cypress config file for a new project""" - appCreateConfigFile(code: String!, configFilename: String!): Boolean clearActiveProject: Boolean """ @@ -482,9 +505,13 @@ type Mutation { """Set active project to run tests on""" setActiveProject(path: String!): Boolean + setAutoScrollingEnabled(value: Boolean!): Boolean + setPreferredEditorBinary(value: String!): Boolean """Save the projects preferences to cache""" setProjectPreferences(browserPath: String!, testingType: TestingTypeEnum!): Query! + setUseDarkSidebar(value: Boolean!): Boolean + setWatchForSpecChange(value: Boolean!): Boolean """show the launchpad at the browser picker step""" showElectronOnAppExit: Boolean @@ -604,6 +631,9 @@ type Query { """Whether the app is in global mode or not""" isInGlobalMode: Boolean! + """editors on the user local machine""" + localSettings: LocalSettings! + """All known projects for the app""" projects: [ProjectLike!]! diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Editor.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Editor.ts new file mode 100644 index 000000000000..ed144ff6f798 --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Editor.ts @@ -0,0 +1,17 @@ +import { objectType } from 'nexus' + +export const Editor = objectType({ + name: 'Editor', + description: 'Represents an editor on the local machine', + definition (t) { + t.nonNull.string('id') + + t.nonNull.string('name', { + description: 'name of editor', + }) + + t.nonNull.string('binary', { + description: 'Binary that opens the editor', + }) + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-LocalSettings.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-LocalSettings.ts new file mode 100644 index 000000000000..d3fc392a5c35 --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-LocalSettings.ts @@ -0,0 +1,34 @@ +import { objectType } from 'nexus' +import { Editor } from './gql-Editor' + +export const LocalSettingsPreferences = objectType({ + name: 'LocalSettingsPreferences', + description: 'local setting preferences', + definition (t) { + t.boolean('autoScrollingEnabled') + t.boolean('watchForSpecChange') + t.boolean('useDarkSidebar') + t.string('preferredEditorBinary') + t.string('proxyServer', { + resolve: (source, args, ctx) => ctx.env.HTTP_PROXY ?? null, + }) + + t.string('proxyBypass', { + resolve: (source, args, ctx) => ctx.env.NO_PROXY ?? null, + }) + }, +}) + +export const LocalSettings = objectType({ + name: 'LocalSettings', + description: 'local settings on a device-by-device basis', + definition (t) { + t.nonNull.list.nonNull.field('availableEditors', { + type: Editor, + }) + + t.nonNull.field('preferences', { + type: LocalSettingsPreferences, + }) + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 2c23e79e9cf2..d9eccdb9d244 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -84,12 +84,16 @@ export const mutation = mutationType({ })), }, resolve: async (_, args, ctx) => { + if (ctx.coreData.currentProject?.isMissingConfigFile) { + await ctx.actions.project.createConfigFile(args.input.testingType) + } + if (args.input.testingType) { - await ctx.actions.wizard.setTestingType(args.input.testingType) + ctx.actions.wizard.setTestingType(args.input.testingType) } if (args.input.direction) { - await ctx.actions.wizard.navigate(args.input.direction) + ctx.actions.wizard.navigate(args.input.direction) } }, }) @@ -133,17 +137,6 @@ export const mutation = mutationType({ }, }) - t.liveMutation('appCreateConfigFile', { - args: { - code: nonNull('String'), - configFilename: nonNull('String'), - }, - description: 'Create a Cypress config file for a new project', - resolve: async (_, args, ctx) => { - await ctx.actions.project.createConfigFile(args) - }, - }) - t.liveMutation('appCreateComponentIndexHtml', { args: { template: nonNull('String'), @@ -300,6 +293,54 @@ export const mutation = mutationType({ }, }) + t.liveMutation('setAutoScrollingEnabled', { + type: 'Boolean', + args: { + value: nonNull(booleanArg()), + }, + resolve: async (_, args, ctx) => { + await ctx.actions.localSettings.setDevicePreference('autoScrollingEnabled', args.value) + + return true + }, + }) + + t.liveMutation('setUseDarkSidebar', { + type: 'Boolean', + args: { + value: nonNull(booleanArg()), + }, + resolve: async (_, args, ctx) => { + await ctx.actions.localSettings.setDevicePreference('useDarkSidebar', args.value) + + return true + }, + }) + + t.liveMutation('setWatchForSpecChange', { + type: 'Boolean', + args: { + value: nonNull(booleanArg()), + }, + resolve: async (_, args, ctx) => { + await ctx.actions.localSettings.setDevicePreference('watchForSpecChange', args.value) + + return true + }, + }) + + t.liveMutation('setPreferredEditorBinary', { + type: 'Boolean', + args: { + value: nonNull(stringArg()), + }, + resolve: async (_, args, ctx) => { + await ctx.actions.localSettings.setDevicePreference('preferredEditorBinary', args.value) + + return true + }, + }) + t.liveMutation('showElectronOnAppExit', { description: 'show the launchpad at the browser picker step', resolve: (_, args, ctx) => { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index 8b7f43ca8598..e95ebb5bd722 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -3,6 +3,7 @@ import { BaseError } from '.' import { ProjectLike } from '..' import { CurrentProject } from './gql-CurrentProject' import { DevState } from './gql-DevState' +import { LocalSettings } from './gql-LocalSettings' import { VersionData } from './gql-VersionData' import { Wizard } from './gql-Wizard' @@ -63,5 +64,13 @@ export const Query = objectType({ description: 'Whether the browser has been opened for auth or not', resolve: (source, args, ctx) => ctx.coreData.isAuthBrowserOpened, }) + + t.nonNull.field('localSettings', { + type: LocalSettings, + description: 'editors on the user local machine', + resolve: (source, args, ctx) => { + return ctx.coreData.localSettings + }, + }) }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/index.ts b/packages/graphql/src/schemaTypes/objectTypes/index.ts index 5c37816a0aa4..e6ed820b7b63 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/index.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/index.ts @@ -6,10 +6,12 @@ export * from './gql-Browser' export * from './gql-CodeGenResult' export * from './gql-CurrentProject' export * from './gql-DevState' +export * from './gql-Editor' export * from './gql-FileParts' export * from './gql-GeneratedSpec' export * from './gql-GitInfo' export * from './gql-GlobalProject' +export * from './gql-LocalSettings' export * from './gql-Mutation' export * from './gql-ProjectPreferences' export * from './gql-Query' diff --git a/packages/launchpad/cypress/e2e/integration/config-files-error-handling.spec.ts b/packages/launchpad/cypress/e2e/integration/config-files-error-handling.spec.ts index 7c69ab8ab855..f4d6d3c87be0 100644 --- a/packages/launchpad/cypress/e2e/integration/config-files-error-handling.spec.ts +++ b/packages/launchpad/cypress/e2e/integration/config-files-error-handling.spec.ts @@ -1,16 +1,14 @@ describe('Config files error handling', () => { - beforeEach(() => { + it('it handles multiples config files', () => { cy.openE2E('pristine-with-config-file') cy.visitLaunchpad() - }) - it('it handles multiple config files', () => { + cy.get('[data-cy-testingType=e2e]').click() + cy.withCtx(async (ctx) => { await ctx.actions.file.writeFileInProject('cypress.config.ts', 'export default {}') }) - cy.get('[data-cy-testingType=e2e]').click() - cy.get('body').should('contain.text', 'Configuration Files') cy.get('button').contains('Continue').click() @@ -28,13 +26,16 @@ describe('Config files error handling', () => { }) it('it handles legacy config file', () => { + cy.openE2E('pristine-with-config-file') + cy.visitLaunchpad() + + cy.get('[data-cy-testingType=e2e]').click() + cy.withCtx(async (ctx) => { await ctx.actions.file.writeFileInProject('cypress.json', '{}') await ctx.actions.file.removeFileInProject('cypress.config.js') }) - cy.get('[data-cy-testingType=e2e]').click() - cy.get('body').should('contain.text', 'Configuration Files') cy.get('button').contains('Continue').click() @@ -62,12 +63,15 @@ describe('Config files error handling', () => { }) it('it handles config files with legacy config file in same project', () => { + cy.openE2E('pristine-with-config-file') + cy.visitLaunchpad() + + cy.get('[data-cy-testingType=e2e]').click() + cy.withCtx(async (ctx) => { await ctx.actions.file.writeFileInProject('cypress.json', '{}') }) - cy.get('[data-cy-testingType=e2e]').click() - cy.get('body').should('contain.text', 'Configuration Files') cy.get('button').contains('Continue').click() @@ -84,4 +88,22 @@ describe('Config files error handling', () => { cy.get('body') .should('not.contain.text', 'Cypress Configuration Error') }) + + it('creates config file if it do not exist', () => { + cy.openE2E('pristine') + cy.visitLaunchpad() + + cy.get('[data-cy-testingType=e2e]').click() + + cy.get('body').should('contain.text', 'Configuration Files') + + cy.get('button').contains('Continue').click() + + cy.get('body') + .should('contain.text', 'Initializing Config') + + cy.withCtx(async (ctx) => { + await ctx.actions.file.checkIfFileExists('cypress.config.js') + }) + }) }) diff --git a/packages/reporter/cypress/support/utils.ts b/packages/reporter/cypress/support/utils.ts index 758f21600fe3..20a12d51fc47 100644 --- a/packages/reporter/cypress/support/utils.ts +++ b/packages/reporter/cypress/support/utils.ts @@ -50,12 +50,12 @@ export const itHandlesFileOpening = ({ getRunner, selector, file, stackTrace = f describe('when user has not already set opener and opens file', () => { const availableEditors = [ - { id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' }, - { id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' }, - { id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' }, - { id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' }, - { id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' }, - { id: 'other', name: 'Other', isOther: true, openerId: '' }, + { id: 'computer', name: 'On Computer', isOther: false, binary: 'computer' }, + { id: 'atom', name: 'Atom', isOther: false, binary: 'atom' }, + { id: 'vim', name: 'Vim', isOther: false, binary: 'vim' }, + { id: 'sublime', name: 'Sublime Text', isOther: false, binary: 'sublime' }, + { id: 'vscode', name: 'Visual Studio Code', isOther: false, binary: 'vscode' }, + { id: 'other', name: 'Other', isOther: true, binary: '' }, ] beforeEach(() => { diff --git a/packages/runner/cypress/support/verify-failures.js b/packages/runner/cypress/support/verify-failures.js index d0975113d1e3..6f0c73c054c1 100644 --- a/packages/runner/cypress/support/verify-failures.js +++ b/packages/runner/cypress/support/verify-failures.js @@ -37,7 +37,7 @@ const verifyFailure = (options) => { preferredOpener: { id: 'foo-editor', name: 'Foo', - openerId: 'foo-editor', + binary: 'foo-editor', isOther: false, }, }) diff --git a/packages/server/lib/gui/events.ts b/packages/server/lib/gui/events.ts index 341f4fb42da2..70723bdf0292 100644 --- a/packages/server/lib/gui/events.ts +++ b/packages/server/lib/gui/events.ts @@ -365,7 +365,24 @@ const handleEvent = function (options, bus, event, id, type, arg) { case 'get:user:editor': return editors.getUserEditor(true) - .then(send) + .then((data) => { + // todo(lachlan): remove post 10.0 + // just here to support an assumption in desktop-gui + // that there will be a "placeholder" empty editor + // where binary is null. + // moving forward, `binary` is non nullable (doesn't make sense). + data = { + ...data, + availableEditors: data.availableEditors.concat({ + id: 'other', + name: 'Other', + binary: null, + isOther: true, + }), + } + + return send(data) + }) .catch(sendErr) case 'set:user:editor': diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index 9c38dcbb04b2..c0414bb9a1cb 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -3,7 +3,7 @@ import os from 'os' import type { App } from 'electron' import specsUtil from './util/specs' -import type { FindSpecs, FoundBrowser, LaunchArgs, LaunchOpts, OpenProjectLaunchOptions, PlatformName, Preferences, SettingsOptions } from '@packages/types' +import type { Editor, FindSpecs, FoundBrowser, LaunchArgs, LaunchOpts, OpenProjectLaunchOptions, PlatformName, Preferences, SettingsOptions } from '@packages/types' import browserUtils from './browsers/utils' import auth from './gui/auth' import user from './user' @@ -17,6 +17,8 @@ import { graphqlSchema } from '@packages/graphql/src/schema' import type { InternalDataContextOptions } from '@packages/data-context/src/DataContext' import { openExternal } from '@packages/server/lib/gui/links' import app_data from './util/app_data' +import { getDevicePreferences, setDevicePreference } from './util/device_preferences' +import { getUserEditor, setUserEditor } from './util/editors' const { getBrowsers, ensureAndGetByNameOrPath } = browserUtils @@ -126,6 +128,23 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext { openExternal(url) }, }, + localSettingsApi: { + setDevicePreference (key, value) { + return setDevicePreference(key, value) + }, + + async getPreferences () { + return getDevicePreferences() + }, + async setPreferredOpener (editor: Editor) { + await setUserEditor(editor) + }, + async getAvailableEditors () { + const { availableEditors } = await getUserEditor(true) + + return availableEditors + }, + }, }) return ctx diff --git a/packages/server/lib/saved_state.js b/packages/server/lib/saved_state.js index bf619f2d1785..ad249721aade 100644 --- a/packages/server/lib/saved_state.js +++ b/packages/server/lib/saved_state.js @@ -35,6 +35,10 @@ ctSpecListWidth firstOpened lastOpened promptsShown +watchForSpecChange +useDarkSidebar +preferredEditorBinary + `.trim().split(/\s+/) const formStatePath = (projectRoot) => { diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 824ee4a15957..9ecc38b646f1 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -197,7 +197,7 @@ export abstract class ServerBase { target: config.baseUrl && testingType === 'component' ? config.baseUrl : undefined, }) - this._socket = new SocketCtor(config) as TSocket + this._socket = new SocketCtor(config, this.ctx) as TSocket clientCertificates.loadClientCertificateConfig(config) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index d4312b9be239..198048eb3439 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -11,13 +11,14 @@ import fixture from './fixture' import task from './task' import { ensureProp } from './util/class-helpers' import { getUserEditor, setUserEditor } from './util/editors' -import { openFile } from './util/file-opener' +import { openFile, OpenFileDetails } from './util/file-opener' import open from './util/open' import type { DestroyableHttpServer } from './util/server_destroy' import * as session from './session' // eslint-disable-next-line no-duplicate-imports import type { Socket } from '@packages/socket' import path from 'path' +import type { DataContext } from '@packages/data-context' type StartListeningCallbacks = { onSocketConnection: (socket: any) => void @@ -77,7 +78,7 @@ export class SocketBase { protected _io?: socketIo.SocketIOServer protected testsDir: string | null - constructor (config: Record) { + constructor (config: Record, private ctx: DataContext) { this.ended = false this.testsDir = null } @@ -485,7 +486,23 @@ export class SocketBase { setUserEditor(editor) }) - socket.on('open:file', (fileDetails) => { + socket.on('open:file', async (fileDetails: OpenFileDetails) => { + // todo(lachlan): post 10.0 we should not pass the + // editor (in the `fileDetails.where` key) from the + // front-end, but rather rely on the server context + // to grab the prefered editor, like I'm doing here, + // so we do not need to + // maintain two sources of truth for the preferred editor + // adding this conditional to maintain backwards compat with + // existing runner and reporter API. + if (process.env.LAUNCHPAD) { + fileDetails.where = { + binary: this.ctx.coreData.localSettings.preferences.preferredEditorBinary || 'computer', + } + } + + debug('opening file %o', fileDetails) + openFile(fileDetails) }) diff --git a/packages/server/lib/socket-ct.ts b/packages/server/lib/socket-ct.ts index 1ce0d17188d5..35eb543abe8e 100644 --- a/packages/server/lib/socket-ct.ts +++ b/packages/server/lib/socket-ct.ts @@ -3,12 +3,13 @@ import type * as socketIo from '@packages/socket' import devServer from '@packages/server/lib/plugins/dev-server' import { SocketBase } from '@packages/server/lib/socket-base' import type { DestroyableHttpServer } from '@packages/server/lib/util/server_destroy' +import type { DataContext } from '@packages/data-context' const debug = Debug('cypress:server:socket-ct') export class SocketCt extends SocketBase { - constructor (config: Record) { - super(config) + constructor (config: Record, ctx: DataContext) { + super(config, ctx) devServer.emitter.on('dev-server:compile:error', (error: string | undefined) => { this.toRunner('dev-server:hmr:error', error) diff --git a/packages/server/lib/socket-e2e.ts b/packages/server/lib/socket-e2e.ts index 2d633831a4c5..505e549e3c7f 100644 --- a/packages/server/lib/socket-e2e.ts +++ b/packages/server/lib/socket-e2e.ts @@ -5,6 +5,7 @@ import { SocketBase } from './socket-base' import { fs } from './util/fs' import type { DestroyableHttpServer } from './util/server_destroy' import * as studio from './studio' +import type { DataContext } from '@packages/data-context' const debug = Debug('cypress:server:socket-e2e') @@ -15,8 +16,8 @@ const isSpecialSpec = (name) => { export class SocketE2E extends SocketBase { private testFilePath: string | null - constructor (config: Record) { - super(config) + constructor (config: Record, ctx: DataContext) { + super(config, ctx) this.testFilePath = null diff --git a/packages/server/lib/util/device_preferences.ts b/packages/server/lib/util/device_preferences.ts new file mode 100644 index 000000000000..b5834072d11c --- /dev/null +++ b/packages/server/lib/util/device_preferences.ts @@ -0,0 +1,23 @@ +import debugModule from 'debug' +import savedState from '../saved_state' +import { DevicePreferences, devicePreferenceDefaults } from '@packages/types/src/devicePreferences' + +const debug = debugModule('cypress:server:preferences') + +export async function setDevicePreference (key: K, value: DevicePreferences[K]) { + debug('set preference: %s: %s', key, value) + + const state = await savedState.create() + + state.set(key, value) +} + +export async function getDevicePreferences (): Promise { + const cached = await (await savedState.create()).get() + + const state = { ...devicePreferenceDefaults, ...cached } + + debug('get preferences: %o', state) + + return state +} diff --git a/packages/server/lib/util/editors.ts b/packages/server/lib/util/editors.ts index f262bfb506e7..1771d408d551 100644 --- a/packages/server/lib/util/editors.ts +++ b/packages/server/lib/util/editors.ts @@ -2,64 +2,46 @@ import _ from 'lodash' import Bluebird from 'bluebird' import debugModule from 'debug' -import { getEnvEditors, Editor } from './env-editors' +import type { Editor, EditorsResult } from '@packages/types' +import { getEnvEditors } from './env-editors' import shell from './shell' import savedState from '../saved_state' -const debug = debugModule('cypress:server:editors') - -interface CyEditor { - id: string - name: string - openerId: string - isOther: boolean -} +export const osFileSystemExplorer = { + darwin: 'Finder', + win32: 'File Explorer', + linux: 'File System', +} as const -interface EditorsResult { - preferredOpener?: CyEditor - availableEditors?: CyEditor[] -} +const debug = debugModule('cypress:server:editors') -const createEditor = (editor: Editor): CyEditor => { +const createEditor = (editor: Editor): Editor => { return { id: editor.id, name: editor.name, - openerId: editor.binary, - isOther: false, + binary: editor.binary, } } -const getOtherEditor = (preferredOpener?: CyEditor) => { +const getOtherEditor = (preferredOpener?: Editor): Editor | undefined => { // if preferred editor is the 'other' option, use it since it has the - // path (openerId) saved with it - if (preferredOpener && preferredOpener.isOther) { + // path (binary) saved with it + if (preferredOpener && preferredOpener.id === 'other') { return preferredOpener } - return { - id: 'other', - name: 'Other', - openerId: '', - isOther: true, - } + return } -const computerOpener = (): CyEditor => { - const names = { - darwin: 'Finder', - win32: 'File Explorer', - linux: 'File System', - } - +const computerOpener = (): Editor => { return { id: 'computer', - name: names[process.platform] || names.linux, - openerId: 'computer', - isOther: false, + name: osFileSystemExplorer[process.platform] || osFileSystemExplorer.linux, + binary: 'computer', } } -const getUserEditors = (): Bluebird => { +const getUserEditors = async (): Promise => { return Bluebird.filter(getEnvEditors(), (editor) => { debug('check if user has editor %s with binary %s', editor.name, editor.binary) @@ -72,18 +54,22 @@ const getUserEditors = (): Bluebird => { .then((state) => { return state.get('preferredOpener') }) - .then((preferredOpener?: CyEditor) => { + .then((preferredOpener?: Editor) => { debug('saved preferred editor: %o', preferredOpener) const cyEditors = _.map(editors, createEditor) + const preferred = getOtherEditor(preferredOpener) + + if (!preferred) { + return [computerOpener()].concat(cyEditors) + } - // @ts-ignore - return [computerOpener()].concat(cyEditors).concat([getOtherEditor(preferredOpener)]) + return [computerOpener()].concat(cyEditors, preferred) }) }) } -export const getUserEditor = (alwaysIncludeEditors = false): Bluebird => { +export const getUserEditor = async (alwaysIncludeEditors = false): Promise => { debug('get user editor') return savedState.create() @@ -106,11 +92,10 @@ export const getUserEditor = (alwaysIncludeEditors = false): Bluebird { +export const setUserEditor = async (editor: Editor) => { debug('set user editor: %o', editor) - return savedState.create() - .then((state) => { - state.set('preferredOpener', editor) - }) + const state = await savedState.create() + + state.set('preferredOpener', editor) } diff --git a/packages/server/lib/util/env-editors.ts b/packages/server/lib/util/env-editors.ts index ab29b264f002..805366d3f32d 100644 --- a/packages/server/lib/util/env-editors.ts +++ b/packages/server/lib/util/env-editors.ts @@ -1,4 +1,6 @@ -const linuxEditors = [ +import type { Editor } from '@packages/types' + +export const linuxEditors = [ { id: 'atom', binary: 'atom', @@ -43,10 +45,14 @@ const linuxEditors = [ id: 'webstorm', binary: 'webstorm', name: 'WebStorm', + }, { + id: 'webstorm64', + binary: 'webstorm64.exe', + name: 'WebStorm 64-bit', }, -] +] as const -const osxEditors = [ +export const macOSEditors = [ { id: 'atom', binary: 'atom', @@ -120,9 +126,9 @@ const osxEditors = [ binary: 'vim', name: 'Vim', }, -] +] as const -const windowsEditors = [ +export const windowsEditors = [ { id: 'brackets', binary: 'Brackets.exe', @@ -187,23 +193,13 @@ const windowsEditors = [ id: 'webstorm', binary: 'webstorm.exe', name: 'WebStorm', - }, { - id: 'webstorm64', - binary: 'webstorm64.exe', - name: 'WebStorm (64-bit)', }, -] - -export interface Editor { - id: string - binary: string - name: string -} +] as const -export const getEnvEditors = (): Editor[] => { +export const getEnvEditors = (): readonly Editor[] => { switch (process.platform) { case 'darwin': - return osxEditors + return macOSEditors case 'win32': return windowsEditors default: diff --git a/packages/server/lib/util/file-opener.ts b/packages/server/lib/util/file-opener.ts index 7e4fdc183cc2..92f2061eb1fc 100644 --- a/packages/server/lib/util/file-opener.ts +++ b/packages/server/lib/util/file-opener.ts @@ -3,12 +3,21 @@ import launchEditor from 'launch-editor' const debug = debugModule('cypress:server:file-opener') -export const openFile = (fileDetails) => { +export interface OpenFileDetails { + file: string + where: { + binary: string + } + line: number + column: number +} + +export const openFile = (fileDetails: OpenFileDetails) => { debug('open file: %o', fileDetails) - const openerId = fileDetails.where.openerId + const binary = fileDetails.where.binary - if (openerId === 'computer') { + if (binary === 'computer') { try { require('electron').shell.showItemInFolder(fileDetails.file) } catch (err) { @@ -20,7 +29,7 @@ export const openFile = (fileDetails) => { const { file, line, column } = fileDetails - launchEditor(`${file}:${line}:${column}`, `"${openerId}"`, (__, errMsg) => { + launchEditor(`${file}:${line}:${column}`, `"${binary}"`, (__, errMsg) => { debug('error opening file: %s', errMsg) }) } diff --git a/packages/server/test/unit/util/editors_spec.ts b/packages/server/test/unit/util/editors_spec.ts index 7e831aa64914..3e962390757a 100644 --- a/packages/server/test/unit/util/editors_spec.ts +++ b/packages/server/test/unit/util/editors_spec.ts @@ -1,4 +1,3 @@ -import _ from 'lodash' import Bluebird from 'bluebird' import chai, { expect } from 'chai' import chaiAsPromised from 'chai-as-promised' @@ -67,37 +66,17 @@ describe('lib/util/editors', () => { sinon.restore() }) - it('returns a list of editors on the user\'s system with an "On Computer" option prepended and an "Other" option appended', () => { - return getUserEditor().then(({ availableEditors }) => { - const names = _.map(availableEditors, 'name') - - expect(names).to.eql(['Finder', 'Sublime Text', 'Visual Studio Code', 'Vim', 'Other']) - expect(availableEditors[0]).to.eql({ - id: 'computer', - name: 'Finder', - isOther: false, - openerId: 'computer', - }) - - expect(availableEditors[4]).to.eql({ - id: 'other', - name: 'Other', - isOther: true, - openerId: '', - }) - }) - }) - it('includes user-set path for "Other" option if available', () => { // @ts-ignore savedState.create.resolves({ get () { - return { isOther: true, openerId: '/path/to/editor' } + return { isOther: true, binary: '/path/to/editor', id: 'other' } }, }) return getUserEditor().then(({ availableEditors }) => { - expect(availableEditors[4].openerId).to.equal('/path/to/editor') + console.log(availableEditors) + expect(availableEditors[4].binary).to.equal('/path/to/editor') }) }) @@ -143,7 +122,7 @@ describe('lib/util/editors', () => { }) return getUserEditor(true).then(({ availableEditors, preferredOpener }) => { - expect(availableEditors).to.have.length(5) + expect(availableEditors).to.have.length(4) expect(preferredOpener).to.equal(preferredOpener) }) }) @@ -168,14 +147,14 @@ describe('lib/util/editors', () => { it('returns available editors if preferred opener has not been saved', () => { return getUserEditor(false).then(({ availableEditors, preferredOpener }) => { - expect(availableEditors).to.have.length(5) + expect(availableEditors).to.have.length(4) expect(preferredOpener).to.be.undefined }) }) it('is default', () => { return getUserEditor().then(({ availableEditors, preferredOpener }) => { - expect(availableEditors).to.have.length(5) + expect(availableEditors).to.have.length(4) expect(preferredOpener).to.be.undefined }) }) diff --git a/packages/types/src/devicePreferences.ts b/packages/types/src/devicePreferences.ts new file mode 100644 index 000000000000..53342f53dcaf --- /dev/null +++ b/packages/types/src/devicePreferences.ts @@ -0,0 +1,13 @@ +export interface DevicePreferences { + watchForSpecChange?: boolean + useDarkSidebar?: boolean + autoScrollingEnabled?: boolean + preferredEditorBinary?: string | undefined +} + +export const devicePreferenceDefaults: DevicePreferences = { + watchForSpecChange: true, + useDarkSidebar: true, + autoScrollingEnabled: true, + preferredEditorBinary: undefined, +} diff --git a/packages/types/src/editors.ts b/packages/types/src/editors.ts new file mode 100644 index 000000000000..718f8d4ddee7 --- /dev/null +++ b/packages/types/src/editors.ts @@ -0,0 +1,10 @@ +export interface Editor { + id: string + binary: string + name: string +} + +export interface EditorsResult { + preferredOpener?: Editor + availableEditors: Editor[] +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 370c059a5f47..82cb43d5ac5c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,10 +2,14 @@ export * from './cache' export * from './constants' +export * from './devicePreferences' + export * from './driver' export * from './spec' +export * from './editors' + export type { AllPackages, AllPackageTypes, diff --git a/packages/ui-components/cypress/integration/editor-picker_spec.jsx b/packages/ui-components/cypress/integration/editor-picker_spec.jsx index 78f944babd20..dfea08ece06a 100644 --- a/packages/ui-components/cypress/integration/editor-picker_spec.jsx +++ b/packages/ui-components/cypress/integration/editor-picker_spec.jsx @@ -15,13 +15,13 @@ describe('', () => { beforeEach(() => { defaultProps = { - chosen: { id: 'vscode', name: 'VS Code', openerId: 'vscode', isOther: false }, + chosen: { id: 'vscode', name: 'VS Code', binary: 'vscode', isOther: false }, editors: [ - { id: 'computer', name: 'On Computer', openerId: 'computer', isOther: false, description: 'Opens on computer etc etc' }, - { id: 'atom', name: 'Atom', openerId: 'atom', isOther: false }, - { id: 'sublime', name: 'Sublime Text', openerId: 'sublime', isOther: false }, - { id: 'vscode', name: 'VS Code', openerId: 'vscode', isOther: false }, - { id: 'other', name: 'Other', openerId: '', isOther: true, description: 'Enter the full path etc etc' }, + { id: 'computer', name: 'On Computer', binary: 'computer', isOther: false, description: 'Opens on computer etc etc' }, + { id: 'atom', name: 'Atom', binary: 'atom', isOther: false }, + { id: 'sublime', name: 'Sublime Text', binary: 'sublime', isOther: false }, + { id: 'vscode', name: 'VS Code', binary: 'vscode', isOther: false }, + { id: 'other', name: 'Other', binary: '', isOther: true, description: 'Enter the full path etc etc' }, ], onSelect: () => {}, } @@ -89,7 +89,7 @@ describe('', () => { }) it('populates path if specified', () => { - defaultProps.editors[4].openerId = '/path/to/my/editor' + defaultProps.editors[4].binary = '/path/to/my/editor' cy.render(render, ) cy.contains('Other').find('input[type="text"]').should('have.value', '/path/to/my/editor') @@ -106,7 +106,7 @@ describe('', () => { setOtherPath: action((otherPath) => { const otherOption = _.find(state.editors, { isOther: true }) - otherOption.openerId = otherPath + otherOption.binary = otherPath }), })) @@ -133,7 +133,7 @@ describe('', () => { cy.contains('Other').find('input[type="text"]').type(` ${path} `, { delay: 0 }) .should(() => { - expect(onSelect.lastCall.args[0].openerId).to.equal(path) + expect(onSelect.lastCall.args[0].binary).to.equal(path) }) }) @@ -148,7 +148,7 @@ describe('', () => { cy.contains('Other').find('input[type="text"]').type(letter, { delay: 0 }) .should(() => { expect(onSelect.lastCall.args[0].id).to.equal('other') - expect(onSelect.lastCall.args[0].openerId).to.equal(pathSoFar) + expect(onSelect.lastCall.args[0].binary).to.equal(pathSoFar) }) }) }) diff --git a/packages/ui-components/cypress/integration/file-opener_spec.jsx b/packages/ui-components/cypress/integration/file-opener_spec.jsx index 302c78611ca2..43bad4fe1b68 100644 --- a/packages/ui-components/cypress/integration/file-opener_spec.jsx +++ b/packages/ui-components/cypress/integration/file-opener_spec.jsx @@ -16,16 +16,16 @@ const fileDetails = { const preferredOpener = { id: 'vscode', name: 'VS Code', - openerId: 'vscode', + binary: 'vscode', isOther: false, } const availableEditors = [ - { id: 'computer', name: 'On Computer', openerId: 'computer', isOther: false, description: 'Opens on computer etc etc' }, - { id: 'atom', name: 'Atom', openerId: 'atom', isOther: false }, - { id: 'sublime', name: 'Sublime Text', openerId: 'sublime', isOther: false }, - { id: 'vscode', name: 'VS Code', openerId: 'vscode', isOther: false }, - { id: 'other', name: 'Other', openerId: '', isOther: true, description: 'Enter the full path etc etc' }, + { id: 'computer', name: 'On Computer', binary: 'computer', isOther: false, description: 'Opens on computer etc etc' }, + { id: 'atom', name: 'Atom', binary: 'atom', isOther: false }, + { id: 'sublime', name: 'Sublime Text', binary: 'sublime', isOther: false }, + { id: 'vscode', name: 'VS Code', binary: 'vscode', isOther: false }, + { id: 'other', name: 'Other', binary: '', isOther: true, description: 'Enter the full path etc etc' }, ] describe('', () => { diff --git a/packages/ui-components/src/file-opener/editor-picker-modal.tsx b/packages/ui-components/src/file-opener/editor-picker-modal.tsx index 7707519c95ba..4bdcc64e5a0c 100644 --- a/packages/ui-components/src/file-opener/editor-picker-modal.tsx +++ b/packages/ui-components/src/file-opener/editor-picker-modal.tsx @@ -25,7 +25,7 @@ const validate = (chosenEditor: Editor) => { let isValid = !!chosenEditor && !!chosenEditor.id let validationMessage = 'Please select a preference' - if (isValid && chosenEditor.isOther && !chosenEditor.openerId) { + if (isValid && chosenEditor.isOther && !chosenEditor.binary) { isValid = false validationMessage = 'Please enter the path for the "Other" editor' } @@ -42,7 +42,7 @@ const EditorPickerModal = observer(({ chosenEditor, editors, isOpen, onClose, on const otherOption = _.find(external.editors, { isOther: true }) if (otherOption) { - otherOption.openerId = otherPath + otherOption.binary = otherPath } }), }), { editors }) diff --git a/packages/ui-components/src/file-opener/editor-picker.tsx b/packages/ui-components/src/file-opener/editor-picker.tsx index 541e70450920..0dd1b78e3ba1 100644 --- a/packages/ui-components/src/file-opener/editor-picker.tsx +++ b/packages/ui-components/src/file-opener/editor-picker.tsx @@ -30,7 +30,7 @@ const EditorPicker = observer(({ chosen = {}, editors, onSelect, onUpdateOtherPa diff --git a/packages/ui-components/src/file-opener/file-model.ts b/packages/ui-components/src/file-opener/file-model.ts index 9e6d1b6734cf..7041f66784d9 100644 --- a/packages/ui-components/src/file-opener/file-model.ts +++ b/packages/ui-components/src/file-opener/file-model.ts @@ -12,7 +12,7 @@ export interface FileDetails { export interface Editor { id: string name: string - openerId: string + binary: string isOther: boolean description?: string } diff --git a/system-tests/projects/e2e/cypress/support/util.js b/system-tests/projects/e2e/cypress/support/util.js index da5ebf6b0ccd..6ec50668203c 100644 --- a/system-tests/projects/e2e/cypress/support/util.js +++ b/system-tests/projects/e2e/cypress/support/util.js @@ -35,7 +35,7 @@ export const verify = (ctx, options) => { preferredOpener: { id: 'foo-editor', name: 'Foo', - openerId: 'foo-editor', + binary: 'foo-editor', isOther: false, }, })