diff --git a/.github/workflows/e2e-testing.yaml b/.github/workflows/e2e-testing.yaml index 59d711f2..3c2b8135 100644 --- a/.github/workflows/e2e-testing.yaml +++ b/.github/workflows/e2e-testing.yaml @@ -8,6 +8,7 @@ on: jobs: test-e2e: strategy: + fail-fast: false matrix: os: [macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} diff --git a/test/e2e/helpers/actions.ts b/test/e2e/helpers/actions.ts index b9fabbcf..01ba2589 100644 --- a/test/e2e/helpers/actions.ts +++ b/test/e2e/helpers/actions.ts @@ -1,4 +1,12 @@ import type { Workbench } from 'wdio-vscode-service' +import { Key } from 'webdriverio' + +export async function clearInput(input: WebdriverIO.Element) { + // Focus the input, sometimes clicking doesn't work + await input.setValue('') + await browser.keys([Key.Ctrl, 'a']) + await browser.keys(Key.Backspace) +} /** * Opens the Radicle view container in the sidebar, by clicking the radicle button in the @@ -13,3 +21,13 @@ export async function openRadicleViewContainer(workbench: Workbench) { await radicleViewControl?.openView() } + +export async function closeRadicleViewContainer(workbench: Workbench) { + const activityBar = workbench.getActivityBar() + await activityBar.wait() + + const radicleViewControl = await activityBar.getViewControl('Radicle') + await radicleViewControl?.wait() + + await radicleViewControl?.closeView() +} diff --git a/test/e2e/helpers/assertions.ts b/test/e2e/helpers/assertions.ts index 539d5c31..20cbcdee 100644 --- a/test/e2e/helpers/assertions.ts +++ b/test/e2e/helpers/assertions.ts @@ -1,5 +1,25 @@ import { browser } from '@wdio/globals' -import type { Workbench } from 'wdio-vscode-service' +import type { OutputView, Workbench } from 'wdio-vscode-service' + +/** + * Asserts the Output Panel contains the expected text. + */ +export async function expectOutputToContain(outputView: OutputView, expected: string) { + await browser.waitUntil( + async () => { + /** + * The text in the Output Panel is split by newlines, which can be affected by the size + * of the window. To avoid this, we join the text into a single string. + */ + const joinedText = (await outputView.getText()).join('') + + return joinedText.includes(expected) + }, + { + timeoutMsg: `expected the output text to contain "${expected}"`, + }, + ) +} /** * Asserts that the CLI Commands and Patches sections are visible in the sidebar. This is diff --git a/test/e2e/specs/onboarding.spec.ts b/test/e2e/specs/onboarding.spec.ts index fff8b4e2..92cba08a 100644 --- a/test/e2e/specs/onboarding.spec.ts +++ b/test/e2e/specs/onboarding.spec.ts @@ -5,7 +5,7 @@ import { $, cd } from 'zx' import type * as VsCode from 'vscode' import isEqual from 'lodash/isEqual' import { expectStandardSidebarViewsToBeVisible } from '../helpers/assertions' -import { openRadicleViewContainer } from '../helpers/actions' +import { closeRadicleViewContainer, openRadicleViewContainer } from '../helpers/actions' import { getFirstWelcomeViewText } from '../helpers/queries' import { backupNodeHomePath, e2eTestDirPath, nodeHomePath } from '../constants/config' @@ -16,6 +16,10 @@ describe('Onboarding Flow', () => { workbench = await browser.getWorkbench() }) + after(async () => { + await closeRadicleViewContainer(workbench) + }) + describe('VS Code, *before* Radicle is installed,', () => { after(async () => { await $`mv ${backupNodeHomePath} ${nodeHomePath}` diff --git a/test/e2e/specs/patch-details.spec.ts b/test/e2e/specs/patch-details.spec.ts new file mode 100644 index 00000000..b16bd906 --- /dev/null +++ b/test/e2e/specs/patch-details.spec.ts @@ -0,0 +1,124 @@ +import type { WebView, Workbench } from 'wdio-vscode-service' +import { $, browser } from '@wdio/globals' +import { $ as zx } from 'zx' +import { Key } from 'webdriverio' +import { openRadicleViewContainer } from '../helpers/actions' + +const selectors = { + openPatchDetailsButton: 'aria/Open Patch Details', + changePathStatusButton: '[title="Change Patch Status"]', + draftRadioButton: 'aria/Draft', + stopEditingPatchStatusButton: '[title="Stop Editing Patch Status"]', + refreshPatchDataButton: '[title="Refresh Patch Data"]', + editPatchTitleButton: '[title="Edit Patch Title and Description"]', + patchTitleInput: 'aria/Patch Title:', + patchDescriptionInput: 'aria/Patch Description:', + savePatchTitleButton: '[title="Save Changes to Radicle (Ctrl + Enter)"]', + openPatchIcon: '.codicon-git-pull-request', + draftPatchIcon: '.codicon-git-pull-request-draft', + closedPatchIcon: '.codicon-git-pull-request-closed', +} + +/** + * These tests are failing because the extension is not picking up httpd. + * An iteration of this suite with adjustments to run locally (with a pre-existing patch) has been + * tested (on macOS) and works as expected. + * Barring any unexpected behavior due to the CI environment, these tests *should* pass once the + * httpd issue is resolved. + * + * TODO: Figure out why the extension is not picking up httpd + */ +describe.skip('Patch Details', () => { + const patchTitle = 'feat: foo bar' + let workbench: Workbench + + before(async () => { + workbench = await browser.getWorkbench() + await zx`git checkout -b branch-1` + await zx`echo "Hello, World!" > hello.txt` + await zx`git add .` + await zx`git commit -m "${patchTitle}"` + await zx`git push rad HEAD:refs/patches` + await openRadicleViewContainer(workbench) + await openPatchDetails(workbench, patchTitle) + }) + + describe("VS Code, when viewing a patch's details,", () => { + it('allows the user to edit the patch status', async () => { + const patchDetails = await switchToFirstWebView(workbench) + + await $(selectors.changePathStatusButton).click() + await $(selectors.draftRadioButton).click() + await $(selectors.stopEditingPatchStatusButton).click() + await $(selectors.refreshPatchDataButton).moveTo() + + await expect($(`header ${selectors.draftPatchIcon}`)).toBeDisplayed() + + await patchDetails?.close() + + await expect( + await (await findPatch(workbench, patchTitle))?.elem.$(selectors.draftPatchIcon), + ).toBeDisplayed() + }) + + it('allows the user to edit that patch title and description', async () => { + const patchDetails = await switchToFirstWebView(workbench) + await $(selectors.editPatchTitleButton).click() + + const newTitle = 'feat: new foo bar' + const newDescription = 'foo bar baz' + await findAndFillInput(selectors.patchTitleInput, newTitle) + await findAndFillInput(selectors.patchDescriptionInput, newDescription) + await $(selectors.savePatchTitleButton).click() + + await expect($(`p=${newTitle}`)).toBeDisplayed() + + await expect($(`p=${newDescription}`)).toBeDisplayed() + + await patchDetails?.close() + + await expect(await findPatch(workbench, newTitle)).toBeDisplayed() + }) + }) +}) + +async function findAndFillInput(selector: string, value: string) { + await $(selector).click() + await browser.keys([Key.Ctrl, 'a']) + await browser.keys(value.split('')) +} + +async function findPatch(workbench: Workbench, label: string) { + const sidebarView = workbench.getSideBar().getContent() + const patchesSection = await sidebarView.getSection('PATCHES') + const patch = await patchesSection.findItem(label) + + return patch +} + +async function openPatchDetails(workbench: Workbench, label: string) { + const patch = await findPatch(workbench, label) + if (!patch) { + return undefined + } + + await patch.elem.moveTo() + await patch.elem.$(selectors.openPatchDetailsButton)?.click() + + return patch +} + +async function switchToFirstWebView(workbench: Workbench) { + let webviews: WebView[] = [] + await browser.waitUntil( + async () => { + webviews = await workbench.getAllWebviews() + + return webviews.length > 0 + }, + { timeoutMsg: 'no webviews found' }, + ) + await webviews[0]?.open() + + return webviews[0] +} diff --git a/test/e2e/specs/settings.spec.ts b/test/e2e/specs/settings.spec.ts new file mode 100644 index 00000000..d72000d2 --- /dev/null +++ b/test/e2e/specs/settings.spec.ts @@ -0,0 +1,216 @@ +import { browser } from '@wdio/globals' +import type { OutputView, Setting, SettingsEditor, Workbench } from 'wdio-vscode-service' +import isEqual from 'lodash/isEqual' +import { Key } from 'webdriverio' +import { $ } from 'zx' +import { getFirstWelcomeViewText } from '../helpers/queries' +import { + expectOutputToContain, + expectStandardSidebarViewsToBeVisible, +} from '../helpers/assertions' +import { + clearInput, + closeRadicleViewContainer, + openRadicleViewContainer, +} from '../helpers/actions' +import { nodeHomePath } from '../constants/config' + +const tempNodeHomePath = `${nodeHomePath}.temp` + +describe('Settings', () => { + let workbench: Workbench + let settings: SettingsEditor + + before(async () => { + workbench = await browser.getWorkbench() + settings = await workbench.openSettings() + await openRadicleViewContainer(workbench) + await expectStandardSidebarViewsToBeVisible(workbench) + }) + + after(async () => { + await closeRadicleViewContainer(workbench) + }) + + describe('VS Code, when updating the "Path to Rad Binary" setting,', () => { + let pathToRadBinarySetting: Setting + + before(async () => { + pathToRadBinarySetting = await settings.findSetting( + 'Path To Rad Binary', + 'Radicle', + 'Advanced', + ) + }) + + after(async () => { + await clearInput(await getSettingsSearchBox(settings)) + }) + + afterEach(async () => { + await clearTextSetting(pathToRadBinarySetting) + + await expectStandardSidebarViewsToBeVisible(workbench) + }) + + it('warns the user if the rad binary is not found', async () => { + await setTextSettingValue(pathToRadBinarySetting, '/tmp') + + await expectRadBinaryNotFoundToBeVisible(workbench) + }) + + it('recognizes the rad binary when a valid path is specified', async () => { + await $`cp -r ${nodeHomePath} ${tempNodeHomePath}` + + await setTextSettingValue(pathToRadBinarySetting, `/tmp`) + + await expectRadBinaryNotFoundToBeVisible(workbench) + + await setTextSettingValue(pathToRadBinarySetting, `${tempNodeHomePath}/bin/rad`) + + await expectStandardSidebarViewsToBeVisible(workbench) + + await $`rm -rf ${tempNodeHomePath}` + }) + + // TODO: Unskip when #172 covered by this case is fixed + it.skip('recognizes if the directory is created *after* the setting is set', async () => { + await setTextSettingValue(pathToRadBinarySetting, tempNodeHomePath) + + await expectRadBinaryNotFoundToBeVisible(workbench) + + await $`cp -r ${nodeHomePath} ${tempNodeHomePath}` + + await expectStandardSidebarViewsToBeVisible(workbench) + + await $`rm -rf ${tempNodeHomePath}` + }) + }) + + /** + * These tests is skipped on Linux CI because the extension is having issues resolving the correct + * node home directory. + * + * TODO: Figure out why the extension is having issues resolving the correct node home directory + * on Linux CI. + */ + // eslint-disable-next-line max-len + describe('VS Code, when updating the "Path to Radicle to Node Home" setting, @skipLinuxCI', () => { + let pathToNodeHomeSetting: Setting + let outputView: OutputView + + before(async () => { + pathToNodeHomeSetting = await settings.findSetting( + 'Path To Node Home', + 'Radicle', + 'Advanced', + ) + await workbench.executeCommand('Show Everything Logged in the Output Panel') + outputView = await workbench.getBottomBar().openOutputView() + }) + + after(async () => { + await clearInput(await getSettingsSearchBox(settings)) + }) + + afterEach(async () => { + await outputView.clearText() + await clearTextSetting(pathToNodeHomeSetting) + + await expectRadicleIdentityToBeFound(outputView) + }) + + it('logs an error in the output console if the path is invalid', async () => { + await outputView.clearText() + + await setTextSettingValue(pathToNodeHomeSetting, '/tmp') + + await expectRadicleProfileNotToBeFound(outputView) + }) + + it('recognizes when a valid path is specified', async () => { + await $`cp -r ${nodeHomePath} ${tempNodeHomePath}` + await outputView.clearText() + + await setTextSettingValue(pathToNodeHomeSetting, tempNodeHomePath) + + await expectRadicleIdentityToBeFound(outputView) + + await $`rm -rf ${tempNodeHomePath}` + }) + + // TODO: Unskip when #172 covered by this case is fixed + it.skip('recognizes if the directory is created *after* the setting is set', async () => { + await outputView.clearText() + + await setTextSettingValue(pathToNodeHomeSetting, tempNodeHomePath) + + await expectRadicleProfileNotToBeFound(outputView) + + await $`cp -r ${nodeHomePath} ${tempNodeHomePath}` + + await expectRadicleIdentityToBeFound(outputView) + + await $`rm -rf ${tempNodeHomePath}` + }) + }) +}) + +async function expectRadBinaryNotFoundToBeVisible(workbench: Workbench) { + await browser.waitUntil( + async () => { + const welcomeText = await getFirstWelcomeViewText(workbench) + + return isEqual(welcomeText, [ + /* eslint-disable max-len */ + 'Failed resolving the Radicle CLI binary.', + "Please ensure it is installed on your machine and either that it is globally accessible in the shell as `rad` or that its path is correctly defined in the extension's settings.", + "Please expect the extention's capabilities to remain severely limited until this issue is resolved.", + /* eslint-enable max-len */ + ]) + }, + { timeoutMsg: 'expected the rad binary not found message to be visible' }, + ) +} + +async function expectRadicleIdentityToBeFound(outputView: OutputView) { + await expectOutputToContain(outputView, 'Using already unsealed Radicle identity') +} + +async function expectRadicleProfileNotToBeFound(outputView: OutputView) { + await expectOutputToContain(outputView, '✗ Error: Radicle profile not found in') +} + +/** + * Workaround to get the value of a `TextSetting`. + * The `getValue` method of a `TextSetting` seems to be wrongly implemented and returns null. + */ +async function getTextSettingValue(setting: Setting) { + return await setting.textSetting$.getValue() +} + +async function setTextSettingValue(setting: Setting, value: string) { + await clearTextSetting(setting) + await setting.setValue(value) +} + +async function clearTextSetting(setting: Setting) { + /** + * `.setValue('')` updates the value of the input but does not trigger an + * update in the extension. Not sure if this is a bug in the extension, vscode, or + * webdriverio. + * + * The following is a workaround that does trigger an update in the extension. + */ + if ((await getTextSettingValue(setting)) === '') { + return + } + + await setting.textSetting$.click() + await browser.keys([Key.Ctrl, 'a']) + await browser.keys(Key.Backspace) +} + +async function getSettingsSearchBox(settings: SettingsEditor) { + return await settings.elem.$(settings.locatorMap.Editor['inputArea'] as string) +} diff --git a/test/e2e/wdio.conf.ts b/test/e2e/wdio.conf.ts index d95a3414..d7372cd6 100644 --- a/test/e2e/wdio.conf.ts +++ b/test/e2e/wdio.conf.ts @@ -1,4 +1,4 @@ -import path from 'node:path' +import path, { sep } from 'node:path' import { $ } from 'zx' import type { Options } from '@wdio/types' import { @@ -18,6 +18,26 @@ if (!process.env['CI']) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const vscodeVersion = (packageJson.engines.vscode as string).replace(/^\^/, '') +const skipTestsGrep = [] + +switch (process.platform) { + case 'darwin': + skipTestsGrep.push('@skipMacOSCI') + break + case 'linux': + skipTestsGrep.push('@skipLinuxCI') + break +} + +const orderedSpecPaths = [ + './specs/onboarding.spec.ts', + './specs/settings.spec.ts', + './specs/patch-details.spec.ts', +].map((specPath) => specPath.replace('/', sep)) +const specsToExcludeFromGlob = orderedSpecPaths.map((specPath) => + specPath.split(sep).at(-1)?.replace('.spec.ts', ''), +) + // TODO: Bump webdriverio to v9 once wdio-vscode-service supports it // Relevant PR: https://github.com/webdriverio-community/wdio-vscode-service/pull/130 // Relevant Issue: https://github.com/webdriverio-community/wdio-vscode-service/issues/140 @@ -29,7 +49,7 @@ export const config: Options.Testrunner = { transpileOnly: true, }, }, - specs: ['./specs/**/*.ts'], + specs: [orderedSpecPaths, `./specs/**/!(${specsToExcludeFromGlob.join('|')}).spec.ts`], maxInstances: 10, capabilities: [ { @@ -50,13 +70,15 @@ export const config: Options.Testrunner = { }, ], logLevel: 'warn', - waitforTimeout: 10000, + waitforTimeout: 20000, services: [['vscode', { cachePath: path.join(rootDirPath, 'node_modules/.cache/wdio') }]], framework: 'mocha', reporters: ['spec'], mochaOpts: { ui: 'bdd', timeout: 60000, + grep: skipTestsGrep.length > 0 ? skipTestsGrep.join('|') : undefined, + invert: true, }, onPrepare: async () => { await $`mkdir -p ${path.join(rootDirPath, 'node_modules/.cache/wdio')}`