patch: basic E2E tests for macOS

This commit is contained in:
JOASSART Edwin 2024-04-26 00:18:00 +02:00 committed by Edwin Joassart
parent 62ac0b98b9
commit 73b19401c0
18 changed files with 2885 additions and 5074 deletions

View File

@ -46,9 +46,20 @@ runs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Setup Virtual Drive on MacOS
if: runner.os == 'macOS'
shell: bash
run: |
hdiutil create -size 4096m -layout NONE -o virtual_test_disk.dmg
virtual_path=$(hdiutil attach -nomount virtual_test_disk.dmg | awk '{print $1}')
echo "TARGET_DRIVE=${virtual_path}" >> $GITHUB_ENV
echo "ETCHER_INCLUDE_VIRTUAL_DRIVES=1" >> $GITHUB_ENV
- name: Test release - name: Test release
shell: bash shell: bash
run: | run: |
# Build and Test release
## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled ## FIXME: causes issues with `xxhash` which tries to load a debug build which doens't exist and cannot be compiled
# if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then # if [[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]]; then
# export DEBUG='electron-forge:*,sidecar' # export DEBUG='electron-forge:*,sidecar'
@ -57,11 +68,29 @@ runs:
npm ci npm ci
npm run lint npm run lint
npm run package npm run package
npm run wdio # test stage, note that it requires the package to be done first
# tests requires the app to already be built
# # only run e2e tests on Mac as it's the only supported platform atm
if [[ '${{ runner.os }}' == 'macOS' ]]; then
# run all tests on macOS including E2E
# E2E tests can't input the administrative password, therefore the tests need to run as root
wget -q -O ${{ env.TEST_SOURCE_FILE }} ${{ env.TEST_SOURCE_URL }}
sudo \
TARGET_DRIVE=${{ env.TARGET_DRIVE }} \
ETCHER_INCLUDE_VIRTUAL_DRIVES=1 \
TEST_SOURCE_FILE: $(pwd)/${{ env.TEST_SOURCE_FILE }} \
TEST_SOURCE_URL: ${{ env.TEST_SOURCE_URL }} \
npm run wdio:ci
else
npm run wdio:unit
fi
env: env:
# https://www.electronjs.org/docs/latest/api/environment-variables # https://www.electronjs.org/docs/latest/api/environment-variables
ELECTRON_NO_ATTACH_CONSOLE: 'true' ELECTRON_NO_ATTACH_CONSOLE: 'true'
TEST_SOURCE_URL: 'https://api.balena-cloud.com/download?deviceType=raspberrypi4-64&version=5.2.8&fileType=.zip'
TEST_SOURCE_FILE: 'raspberrypi4-64-5.2.8-v16.1.10.img.zip'
- name: Compress custom source - name: Compress custom source
if: runner.os != 'Windows' if: runner.os != 'Windows'

6
.gitignore vendored
View File

@ -121,3 +121,9 @@ secrets/WINDOWS_SIGNING.pfx
#local development #local development
.yalc .yalc
yalc.lock yalc.lock
# Test assets
virtual_test_disk.dmg
virtual_test_disk.img
virtual_test_disk.vhd
screenshots/

View File

@ -419,6 +419,7 @@ export class DriveSelector extends React.Component<
primary: !showWarnings, primary: !showWarnings,
warning: showWarnings, warning: showWarnings,
disabled: !hasAvailableDrives(), disabled: !hasAvailableDrives(),
'data-testid': 'validate-target-button',
}} }}
{...props} {...props}
> >

View File

@ -25,7 +25,7 @@ export interface FlashAnotherProps {
export const FlashAnother = (props: FlashAnotherProps) => { export const FlashAnother = (props: FlashAnotherProps) => {
return ( return (
<BaseButton primary onClick={props.onClick}> <BaseButton primary data-testid="flash-another" onClick={props.onClick}>
{i18next.t('flash.another')} {i18next.t('flash.another')}
</BaseButton> </BaseButton>
); );

View File

@ -163,7 +163,7 @@ export function FlashResults({
/> />
<Txt>{middleEllipsis(image, 24)}</Txt> <Txt>{middleEllipsis(image, 24)}</Txt>
</Flex> </Flex>
<Txt fontSize={24} color="#fff" mb="17px"> <Txt data-testid="flash-results" fontSize={24} color="#fff" mb="17px">
{allFailed {allFailed
? i18next.t('flash.flashFailed') ? i18next.t('flash.flashFailed')
: i18next.t('flash.flashCompleted')} : i18next.t('flash.flashCompleted')}

View File

@ -104,7 +104,9 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
}} }}
> >
<Flex> <Flex>
<Txt color="#fff">{status}&nbsp;</Txt> <Txt data-testid="flash-status" color="#fff">
{status}&nbsp;
</Txt>
<Txt color={colors[type]}>{position}</Txt> <Txt color={colors[type]}>{position}</Txt>
</Flex> </Flex>
{type && ( {type && (
@ -125,6 +127,7 @@ export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
warning={warning} warning={warning}
onClick={this.props.callback} onClick={this.props.callback}
disabled={this.props.disabled} disabled={this.props.disabled}
data-testid={'flash-now'}
style={{ style={{
marginTop: 30, marginTop: 30,
}} }}

View File

@ -165,6 +165,7 @@ const URLSelector = ({
cancel={cancel} cancel={cancel}
primaryButtonProps={{ primaryButtonProps={{
disabled: loading || !imageURL, disabled: loading || !imageURL,
'data-testid': 'source-url-ok',
}} }}
action={loading ? <Spinner /> : i18next.t('ok')} action={loading ? <Spinner /> : i18next.t('ok')}
done={async () => { done={async () => {
@ -186,6 +187,7 @@ const URLSelector = ({
</Txt> </Txt>
<Input <Input
value={imageURL} value={imageURL}
data-testid="source-url-input"
placeholder={i18next.t('source.enterValidURL')} placeholder={i18next.t('source.enterValidURL')}
type="text" type="text"
onChange={(evt: React.ChangeEvent<HTMLInputElement>) => onChange={(evt: React.ChangeEvent<HTMLInputElement>) =>
@ -638,6 +640,7 @@ export class SourceSelector extends React.Component<
</StepNameButton> </StepNameButton>
{!flashing && !imageLoading && ( {!flashing && !imageLoading && (
<ChangeButton <ChangeButton
data-testid="change-image"
plain plain
mb={14} mb={14}
onClick={() => this.reselectSource()} onClick={() => this.reselectSource()}
@ -655,6 +658,7 @@ export class SourceSelector extends React.Component<
disabled={this.state.imageSelectorOpen} disabled={this.state.imageSelectorOpen}
primary={this.state.defaultFlowActive} primary={this.state.defaultFlowActive}
key="Flash from file" key="Flash from file"
data-testid="flash-from-file"
flow={{ flow={{
onClick: () => this.openImageSelector(), onClick: () => this.openImageSelector(),
label: i18next.t('source.fromFile'), label: i18next.t('source.fromFile'),
@ -665,6 +669,7 @@ export class SourceSelector extends React.Component<
/> />
<FlowSelector <FlowSelector
key="Flash from URL" key="Flash from URL"
data-testid="flash-from-url"
flow={{ flow={{
onClick: () => this.openURLSelector(), onClick: () => this.openURLSelector(),
label: i18next.t('source.fromURL'), label: i18next.t('source.fromURL'),

View File

@ -150,6 +150,7 @@ export function TargetSelectorButton(props: TargetSelectorProps) {
tabIndex={targets.length > 0 ? -1 : 2} tabIndex={targets.length > 0 ? -1 : 2}
disabled={props.disabled} disabled={props.disabled}
onClick={props.openDriveSelector} onClick={props.openDriveSelector}
data-testid="select-target"
> >
{i18next.t('target.selectTarget')} {i18next.t('target.selectTarget')}
</StepButton> </StepButton>

View File

@ -34,8 +34,6 @@ export function fromFlashState({
status: string; status: string;
position?: string; position?: string;
} { } {
console.log(i18next.t('progress.starting'));
if (type === undefined) { if (type === undefined) {
return { status: i18next.t('progress.starting') }; return { status: i18next.t('progress.starting') };
} else if (type === 'decompressing') { } else if (type === 'decompressing') {

View File

@ -23,6 +23,7 @@ import * as settings from '../../../gui/app/models/settings';
import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats'; import { SUPPORTED_EXTENSIONS } from '../../../shared/supported-formats';
import * as i18next from 'i18next'; import * as i18next from 'i18next';
// FIXME: this is probably useless now
async function mountSourceDrive() { async function mountSourceDrive() {
// sourceDrivePath is the name of the link in /dev/disk/by-path // sourceDrivePath is the name of the link in /dev/disk/by-path
const sourceDrivePath = await settings.get('automountOnFileSelect'); const sourceDrivePath = await settings.get('automountOnFileSelect');
@ -43,6 +44,17 @@ async function mountSourceDrive() {
*/ */
export async function selectImage(): Promise<string | undefined> { export async function selectImage(): Promise<string | undefined> {
await mountSourceDrive(); await mountSourceDrive();
// For automated E2E testing, we can't set the source file by interacting with the OS dialog,
// so we use an ENV var instead and bypass the dialog. Note that we still need to press the "flash from file" button.
if (
process.env.TEST_SOURCE_FILE !== undefined &&
typeof process.env.TEST_SOURCE_FILE === 'string'
) {
console.log(`test mode: loading ${process.env.TEST_SOURCE_FILE}`);
return process.env.TEST_SOURCE_FILE;
}
const options: electron.OpenDialogOptions = { const options: electron.OpenDialogOptions = {
// This variable is set when running in GNU/Linux from // This variable is set when running in GNU/Linux from
// inside an AppImage, and represents the working directory // inside an AppImage, and represents the working directory

View File

@ -25,6 +25,8 @@ import { geteuid, platform } from 'process';
const adapters: Adapter[] = [ const adapters: Adapter[] = [
new BlockDeviceAdapter({ new BlockDeviceAdapter({
includeSystemDrives: () => true, includeSystemDrives: () => true,
includeVirtualDrives: () =>
process.env.ETCHER_INCLUDE_VIRTUAL_DRIVES !== 'undefined',
}), }),
]; ];

7712
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,9 @@
"package": "electron-forge package", "package": "electron-forge package",
"start": "electron-forge start", "start": "electron-forge start",
"make": "electron-forge make", "make": "electron-forge make",
"wdio": "xvfb-maybe wdio run ./wdio.conf.ts" "wdio:unit": "xvfb-maybe wdio run ./wdio.conf.ts --suite gui --suite shared",
"wdio:e2e": "xvfb-maybe wdio run ./wdio.conf.ts --suite e2e",
"wdio:ci": "xvfb-maybe wdio run ./wdio.conf.ts --suite ci"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@ -101,7 +103,7 @@
"tslib": "2.6.2", "tslib": "2.6.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"url-loader": "4.1.1", "url-loader": "4.1.1",
"wdio-electron-service": "^6.4.1", "wdio-electron-service": "^6.5.0",
"xvfb-maybe": "^0.2.1" "xvfb-maybe": "^0.2.1"
}, },
"hostDependencies": { "hostDependencies": {

69
tests/e2e/e2e-common.ts Normal file
View File

@ -0,0 +1,69 @@
import { browser } from '@wdio/globals';
const prepare = () => {
browser.pause(2000);
};
const itShouldSelectVirtualTarget = () => {
it('should "select a virtual target"', async () => {
const selectTargetButton = $('button[data-testid="select-target"]');
await selectTargetButton.waitForClickable({ timeout: 30000 });
await selectTargetButton.click();
// target drive is set in the github custom test action
// if you run the test locally, pass the varibale
const targetVirtualDrive = $(`=${process.env.TARGET_DRIVE}`);
await targetVirtualDrive.waitForClickable({ timeout: 10000 });
await targetVirtualDrive.click();
const validateTargetButton = $(
'button[data-testid="validate-target-button"]',
);
await validateTargetButton.waitForClickable({ timeout: 10000 });
await validateTargetButton.click();
});
};
const itShouldStartFlashing = () => {
it('should "start flashing"', async () => {
const flashNowButton = $('button[data-testid="flash-now"]');
await flashNowButton.waitForClickable({ timeout: 10000 });
await flashNowButton.click();
});
};
const itShouldGetTheFlashCompletedScreen = () => {
it('should get the "Flash Completed" screen', async () => {
const flashResults = $('[data-testid="flash-results"]');
// 5' should be enough from CI, but might be too short for local testing on slow connections
await flashResults.waitForDisplayed({ timeout: 300000 });
const flashResultsText = await flashResults.getText();
expect(flashResultsText).toBe('Flash Completed!');
});
};
const itShouldGetBackToHomeScreen = () => {
it('should get back to the "Home" screen', async () => {
const flashAnotherButton = $('button[data-testid="flash-another"]');
await flashAnotherButton.waitForClickable({ timeout: 10000 });
await flashAnotherButton.click();
// previously flashes image is still seclected, remove it
const changeSource = $('button[data-testid="change-image"]');
await changeSource.waitForClickable({ timeout: 10000 });
await changeSource.click();
// we're good;
});
};
export {
prepare,
itShouldSelectVirtualTarget,
itShouldStartFlashing,
itShouldGetTheFlashCompletedScreen,
itShouldGetBackToHomeScreen,
};

View File

@ -0,0 +1,23 @@
import '@wdio/globals';
import {
prepare,
itShouldSelectVirtualTarget,
itShouldStartFlashing,
itShouldGetTheFlashCompletedScreen,
itShouldGetBackToHomeScreen,
} from './e2e-common';
describe('Flash From File E2E Test', () => {
before(prepare);
it('should select a file as source', async () => {
const flashFromFileButton = $('button[data-testid="flash-from-file"]');
await flashFromFileButton.waitForClickable({ timeout: 10000 });
await flashFromFileButton.click();
});
itShouldSelectVirtualTarget();
itShouldStartFlashing();
itShouldGetTheFlashCompletedScreen();
itShouldGetBackToHomeScreen();
});

View File

@ -0,0 +1,31 @@
import '@wdio/globals';
import {
prepare,
itShouldSelectVirtualTarget,
itShouldStartFlashing,
itShouldGetTheFlashCompletedScreen,
itShouldGetBackToHomeScreen,
} from './e2e-common';
describe('Flash From URL E2E test', () => {
before(prepare);
it('should select an url as source', async () => {
const flashFromUrlButton = $('button[data-testid="flash-from-url"]');
await flashFromUrlButton.waitForClickable({ timeout: 10000 });
await flashFromUrlButton.click();
const enterValidUrlInput = $('input[data-testid="source-url-input"]');
await enterValidUrlInput.waitForDisplayed({ timeout: 10000 });
await enterValidUrlInput.setValue(process.env.TEST_SOURCE_URL as string);
const sourceUrlOkButton = $('button[data-testid="source-url-ok"]');
await sourceUrlOkButton.waitForClickable({ timeout: 10000 });
await sourceUrlOkButton.click();
});
itShouldSelectVirtualTarget();
itShouldStartFlashing();
itShouldGetTheFlashCompletedScreen();
itShouldGetBackToHomeScreen();
});

View File

@ -1,7 +0,0 @@
import { browser } from '@wdio/globals';
describe('Electron Testing', () => {
it('should print application title', async () => {
console.log('Hello', await browser.getTitle(), 'application!');
});
});

View File

@ -35,16 +35,36 @@ export const config: Options.Testrunner = {
// Patterns to exclude. // Patterns to exclude.
// FIXME: Remove the following exclusions once the tests are ported to WDIO // FIXME: Remove the following exclusions once the tests are ported to WDIO
exclude: [ exclude: [
'tests/gui/modules/image-writer.spec.ts', './tests/gui/modules/image-writer.spec.ts',
'tests/gui/os/window-progress.spec.ts', './tests/gui/os/window-progress.spec.ts',
'tests/gui/models/available-drives.spec.ts', './tests/gui/models/available-drives.spec.ts',
'tests/gui/models/flash-state.spec.ts', './tests/gui/models/flash-state.spec.ts',
'tests/gui/models/selection-state.spec.ts', './tests/gui/models/selection-state.spec.ts',
'tests/gui/models/settings.spec.ts', './tests/gui/models/settings.spec.ts',
'tests/shared/drive-constraints.spec.ts', './tests/shared/drive-constraints.spec.ts',
'tests/shared/messages.spec.ts', './tests/shared/messages.spec.ts',
'tests/gui/modules/progress-status.spec.ts', './tests/gui/modules/progress-status.spec.ts',
], ],
suites: {
'gui': ['./tests/gui/**/*.spec.ts'],
'shared': ['./tests/shared/**/*.spec.ts'],
'e2e': [
[
'./tests/e2e/e2e-flash-from-file.spec.ts',
'./tests/e2e/e2e-flash-from-url.spec.ts',
]
],
// CI needs to runs e2e tests and other tests sequencially
'ci': [
[
'./tests/e2e/e2e-flash-from-url.spec.ts',
'./tests/e2e/e2e-flash-from-file.spec.ts',
'./tests/gui/**/*.spec.ts',
'./tests/shared/**/*.spec.ts',
],
]
},
// //
// ============ // ============
// Capabilities // Capabilities
@ -85,7 +105,7 @@ export const config: Options.Testrunner = {
// Define all options that are relevant for the WebdriverIO instance here // Define all options that are relevant for the WebdriverIO instance here
// //
// Level of logging verbosity: trace | debug | info | warn | error | silent // Level of logging verbosity: trace | debug | info | warn | error | silent
logLevel: 'info', logLevel: 'warn',
// //
// Set specific log levels per logger // Set specific log levels per logger
// loggers: // loggers: