Redesign versions checker dialog (#11540)

* fix versions checker dialog

* Adress the issues

* Fixes

* Fix integration tests

* fix prettier

* Fix dialog

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Sergei Garin 2024-11-15 10:49:30 +03:00 committed by GitHub
parent 008aa19530
commit 9dc4e9b090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 302 additions and 109 deletions

View File

@ -365,10 +365,13 @@
"startWithATemplate": "Discover",
"openInfoMenu": "Open info menu",
"noProjectIsCurrentlyOpen": "No project is currently open.",
"versionOutdatedTitle": "Upgrade Enso Now",
"versionOutdatedPrompt": "Download the latest version to get the latest upgrades and Cloud functionality.",
"versionOutdatedTitle": "Update available",
"versionOutdatedPrompt": "A new version of Enso is available. We recommend updating to access the latest features, improvements, and security enhancements.",
"yourVersion": "Your version:",
"latestVersion": "Latest version:",
"latestVersion": "$0 ($1)",
"changeLog": "View changelog",
"downloadingAppMessage": "The latest version of Enso is now downloading, which may take a while depending on your internet speed. Please check your downloads folder, and you may close this dialog.",
"remindMeLater": "Remind me later",
"offlineTitle": "You are offline",
"offlineErrorMessage": "It seems like you are offline. Please make sure you are connected to the internet and try again",
"offlineToastMessage": "You are offline. Some features may be unavailable.",

View File

@ -145,6 +145,8 @@ interface PlaceholderOverrides {
readonly arbitraryFieldTooLarge: [maxSize: string]
readonly arbitraryFieldTooSmall: [minSize: string]
readonly uploadLargeFileStatus: [uploadedParts: number, totalParts: number]
readonly latestVersion: [version: string, date: string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 14C4.16421 14 4.5 14.3358 4.5 14.75V17.25C4.5 18.4926 5.50736 19.5 6.75 19.5H17.25C18.4926 19.5 19.5 18.4926 19.5 17.25V14.75C19.5 14.3358 19.8358 14 20.25 14C20.6642 14 21 14.3358 21 14.75V17.25C21 19.3211 19.3211 21 17.25 21H6.75C4.67893 21 3 19.3211 3 17.25V14.75C3 14.3358 3.33579 14 3.75 14Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15.75C12.1989 15.75 12.3897 15.671 12.5303 15.5303L16.0303 12.0303C16.3232 11.7374 16.3232 11.2626 16.0303 10.9697C15.7374 10.6768 15.2626 10.6768 14.9697 10.9697L12.75 13.1893V3.75C12.75 3.33579 12.4142 3 12 3C11.5858 3 11.25 3.33579 11.25 3.75V13.1893L9.03033 10.9697C8.73744 10.6768 8.26256 10.6768 7.96967 10.9697C7.67678 11.2626 7.67678 11.7374 7.96967 12.0303L11.4697 15.5303C11.6103 15.671 11.8011 15.75 12 15.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 5C13.4477 5 13 4.55228 13 4C13 3.44772 13.4477 3 14 3H20C20.5523 3 21 3.44772 21 4V10C21 10.5523 20.5523 11 20 11C19.4477 11 19 10.5523 19 10V6.41421L11.7071 13.7071C11.3166 14.0976 10.6834 14.0976 10.2929 13.7071C9.90237 13.3166 9.90237 12.6834 10.2929 12.2929L17.5858 5H14ZM8.7587 5L9 5C9.55229 5 10 5.44772 10 6C10 6.55228 9.55229 7 9 7H8.8C7.94342 7 7.36113 7.00078 6.91104 7.03755C6.47262 7.07337 6.24842 7.1383 6.09202 7.21799C5.7157 7.40973 5.40973 7.71569 5.21799 8.09202C5.1383 8.24842 5.07337 8.47262 5.03755 8.91104C5.00078 9.36113 5 9.94342 5 10.8V15.2C5 16.0566 5.00078 16.6389 5.03755 17.089C5.07337 17.5274 5.1383 17.7516 5.21799 17.908C5.40973 18.2843 5.7157 18.5903 6.09202 18.782C6.24842 18.8617 6.47262 18.9266 6.91104 18.9624C7.36113 18.9992 7.94342 19 8.8 19H13.2C14.0566 19 14.6389 18.9992 15.089 18.9624C15.5274 18.9266 15.7516 18.8617 15.908 18.782C16.2843 18.5903 16.5903 18.2843 16.782 17.908C16.8617 17.7516 16.9266 17.5274 16.9624 17.089C16.9992 16.6389 17 16.0566 17 15.2V15C17 14.4477 17.4477 14 18 14C18.5523 14 19 14.4477 19 15V15.2413C19 16.0463 19 16.7106 18.9558 17.2518C18.9099 17.8139 18.8113 18.3306 18.564 18.816C18.1805 19.5686 17.5686 20.1805 16.816 20.564C16.3306 20.8113 15.8139 20.9099 15.2518 20.9558C14.7106 21 14.0463 21 13.2413 21H8.75868C7.95372 21 7.28936 21 6.74818 20.9558C6.18608 20.9099 5.66937 20.8113 5.18404 20.564C4.43139 20.1805 3.81947 19.5686 3.43597 18.816C3.18868 18.3306 3.09012 17.8139 3.04419 17.2518C2.99998 16.7106 2.99999 16.0463 3 15.2413V10.7587C2.99999 9.95373 2.99998 9.28937 3.04419 8.74817C3.09012 8.18608 3.18868 7.66937 3.43597 7.18404C3.81947 6.43139 4.43139 5.81947 5.18404 5.43597C5.66937 5.18868 6.18608 5.09012 6.74817 5.04419C7.28937 4.99998 7.95373 4.99999 8.7587 5Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.75 1C18.3358 1 18 1.33579 18 1.75C18 2.16421 18.3358 2.5 18.75 2.5V1ZM22.25 1.75L22.842 2.21046C23.0179 1.98435 23.0496 1.67781 22.9237 1.42049C22.7979 1.16316 22.5364 1 22.25 1V1.75ZM18.75 6.25L18.158 5.78954C17.9821 6.01565 17.9504 6.32219 18.0763 6.57951C18.2021 6.83684 18.4635 7 18.75 7V6.25ZM22.25 7C22.6642 7 23 6.66421 23 6.25C23 5.83579 22.6642 5.5 22.25 5.5V7ZM12.75 7.75C12.75 7.33579 12.4142 7 12 7C11.5858 7 11.25 7.33579 11.25 7.75H12.75ZM12 12H11.25C11.25 12.1989 11.329 12.3897 11.4697 12.5303L12 12ZM13.9697 15.0303C14.2626 15.3232 14.7374 15.3232 15.0303 15.0303C15.3232 14.7374 15.3232 14.2626 15.0303 13.9697L13.9697 15.0303ZM15.216 4.12926C15.5994 4.28609 16.0373 4.10243 16.1942 3.71906C16.351 3.33568 16.1673 2.89776 15.784 2.74093L15.216 4.12926ZM21.6304 9.29774C21.5187 8.89887 21.1048 8.66608 20.7059 8.77779C20.3071 8.88949 20.0743 9.3034 20.186 9.70226L21.6304 9.29774ZM18.75 2.5H22.25V1H18.75V2.5ZM21.658 1.28954L18.158 5.78954L19.342 6.71046L22.842 2.21046L21.658 1.28954ZM18.75 7H22.25V5.5H18.75V7ZM11.25 7.75V12H12.75V7.75H11.25ZM11.4697 12.5303L13.9697 15.0303L15.0303 13.9697L12.5303 11.4697L11.4697 12.5303ZM20.5 12C20.5 16.6944 16.6944 20.5 12 20.5V22C17.5228 22 22 17.5228 22 12H20.5ZM12 20.5C7.30558 20.5 3.5 16.6944 3.5 12H2C2 17.5228 6.47715 22 12 22V20.5ZM3.5 12C3.5 7.30558 7.30558 3.5 12 3.5V2C6.47715 2 2 6.47715 2 12H3.5ZM12 3.5C13.1396 3.5 14.225 3.72384 15.216 4.12926L15.784 2.74093C14.6157 2.26304 13.3377 2 12 2V3.5ZM20.186 9.70226C20.3904 10.4323 20.5 11.2027 20.5 12H22C22 11.0647 21.8714 10.1581 21.6304 9.29774L20.186 9.70226Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -3,17 +3,15 @@
*
* Close button for a dialog.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as button from '../Button'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { type ButtonProps, Button } from '../Button'
import * as dialogProvider from './DialogProvider'
/** Props for {@link Close} component. */
export type CloseProps = button.ButtonProps
export type CloseProps = ButtonProps
/** Close button for a dialog. */
export function Close(props: CloseProps) {
@ -21,12 +19,10 @@ export function Close(props: CloseProps) {
invariant(dialogContext, 'Close must be used inside a DialogProvider')
const onPressCallback = eventCallback.useEventCallback<
NonNullable<button.ButtonProps['onPress']>
>((event) => {
const onPressCallback = useEventCallback<NonNullable<ButtonProps['onPress']>>((event) => {
dialogContext.close()
return props.onPress?.(event)
})
return <button.Button {...props} onPress={onPressCallback} />
return <Button {...props} onPress={onPressCallback} />
}

View File

@ -17,6 +17,7 @@ import type { Spring } from '#/utilities/motion'
import { motion } from '#/utilities/motion'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { Close } from './Close'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import type * as types from './types'
@ -109,11 +110,11 @@ const DIALOG_STYLES = tv({
padding: {
none: { content: 'p-0' },
small: { content: 'px-1 pt-3.5 pb-3.5' },
medium: { content: 'px-3.5 pt-3.5 pb-3.5' },
large: { content: 'px-8 pt-3.5 pb-5' },
xlarge: { content: 'p-12 pt-3.5 pb-8' },
xxlarge: { content: 'p-16 pt-3.5 pb-12' },
xxxlarge: { content: 'p-20 pt-3.5 pb-16' },
medium: { content: 'px-4 pt-3 pb-4' },
large: { content: 'px-8 pt-5 pb-5' },
xlarge: { content: 'p-12 pt-6 pb-8' },
xxlarge: { content: 'p-16 pt-8 pb-12' },
xxxlarge: { content: 'p-20 pt-10 pb-16' },
},
scrolledToTop: { true: { header: 'border-transparent' } },
},
@ -140,7 +141,7 @@ const DIALOG_STYLES = tv({
hideCloseButton: false,
size: 'medium',
padding: 'medium',
rounded: 'xxlarge',
rounded: 'xxxlarge',
},
})
@ -351,3 +352,5 @@ const TYPE_TO_DIALOG_TYPE: Record<
modal: 'dialog',
fullscreen: 'dialog-fullscreen',
}
Dialog.Close = Close

View File

@ -28,7 +28,7 @@ export interface StepperProps {
| ((props: BaseRenderProps) => string | null | undefined)
| null
| undefined
readonly renderStep: (props: RenderStepProps) => React.ReactNode
readonly renderStep?: ((props: RenderStepProps) => React.ReactNode) | null
readonly style?:
| React.CSSProperties
| ((props: BaseRenderProps) => React.CSSProperties | undefined)
@ -101,29 +101,37 @@ export function Stepper(props: StepperProps) {
<stepperProvider.StepperProvider
value={{ totalSteps, currentStep, goToStep, nextStep, previousStep, state }}
>
<div className={styles.steps()}>
{Array.from({ length: totalSteps }).map((_, index) => {
const renderStepProps = {
index,
currentStep,
totalSteps,
isFirst: index === 0,
isLast: index === totalSteps - 1,
nextStep,
previousStep,
goToStep,
isCompleted: index < currentStep,
isCurrent: index === currentStep,
isDisabled: index > currentStep,
} satisfies RenderStepProps
{renderStep == null ? null : (
<div className={styles.steps()}>
{Array.from({ length: totalSteps }).map((_, index) => {
const renderStepProps = {
index,
currentStep,
totalSteps,
isFirst: index === 0,
isLast: index === totalSteps - 1,
nextStep,
previousStep,
goToStep,
isCompleted: index < currentStep,
isCurrent: index === currentStep,
isDisabled: index > currentStep,
} satisfies RenderStepProps
return (
<div key={index} className={styles.step({})}>
{renderStep(renderStepProps)}
</div>
)
})}
</div>
const nextRenderStep = renderStep(renderStepProps)
if (nextRenderStep == null) {
return null
}
return (
<div key={index} className={styles.step({})}>
{nextRenderStep}
</div>
)
})}
</div>
)}
<div className={styles.content()}>
<AnimatePresence initial={false} mode="sync" custom={direction}>

View File

@ -45,6 +45,7 @@ export interface UseStepperStateResult {
readonly percentComplete: number
readonly nextStep: () => void
readonly previousStep: () => void
readonly resetStepper: () => void
}
/**
@ -67,6 +68,10 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
const onStepChangeStableCallback = eventCallbackHooks.useEventCallback(onStepChange)
const onCompletedStableCallback = eventCallbackHooks.useEventCallback(onCompleted)
const resetStepper = eventCallbackHooks.useEventCallback(() => {
privateSetCurrentStep(() => ({ current: defaultStep, direction: 'initial' }))
})
const setCurrentStep = eventCallbackHooks.useEventCallback(
(step: number | ((current: number) => number)) => {
React.startTransition(() => {
@ -128,5 +133,6 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
percentComplete,
nextStep,
previousStep,
resetStepper,
} satisfies UseStepperStateResult
}

View File

@ -1,88 +1,250 @@
/** @file Check the version. */
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import DownloadIcon from '#/assets/download.svg'
import NewTabIcon from '#/assets/new_tab.svg'
import SnoozeIcon from '#/assets/snooze.svg'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useEnableVersionChecker } from '#/components/Devtools'
import { useEnableVersionChecker, useSetEnableVersionChecker } from '#/components/Devtools'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useText } from '#/providers/TextProvider'
import { Button, ButtonGroup, Dialog, Text } from '#/components/AriaComponents'
import { Stepper } from '#/components/Stepper'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { download } from '#/utilities/download'
import { getDownloadUrl, getLatestRelease, LATEST_RELEASE_PAGE_URL } from '#/utilities/github'
import { getDownloadUrl, getLatestRelease } from '#/utilities/github'
import { startTransition, useState } from 'react'
// ======================
// === VersionChecker ===
// ======================
const CURRENT_VERSION = process.env.ENSO_CLOUD_DASHBOARD_VERSION ?? 'unknown-dev'
const CURRENT_VERSION_IS_DEV = CURRENT_VERSION.endsWith('-dev')
const CURRENT_VERSION_IS_NIGHTLY = CURRENT_VERSION.includes('-nightly')
const CURRENT_VERSION_NUMBER = getVersionNumber(CURRENT_VERSION)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const STALE_TIME = 24 * 60 * 60 * 1000 // 1 day
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const STALE_TIME_ERROR = 10 * 60 * 1000 // 10 minutes
/** Check the version. */
export default function VersionChecker() {
const [isOpen, setIsOpen] = React.useState(false)
const { getText } = useText()
const [isOpen, setIsOpen] = useState(false)
const { getText, locale } = useText()
const toastAndLog = useToastAndLog()
const localBackend = useLocalBackend()
const supportsLocalBackend = localBackend != null
const enableVersionChecker = useEnableVersionChecker() ?? (!IS_DEV_MODE && supportsLocalBackend)
const overrideValue = useEnableVersionChecker()
const setOverrideValue = useSetEnableVersionChecker()
const shouldOverride = overrideValue ?? false
const enableVersionChecker = IS_DEV_MODE ? shouldOverride : supportsLocalBackend
const queryClient = useQueryClient()
const metadataQuery = useQuery({
queryKey: ['latestRelease', enableVersionChecker],
queryFn: () => (enableVersionChecker ? getLatestRelease() : null),
})
const latestVersion = metadataQuery.data?.tag_name
queryKey: ['latestRelease'],
queryFn: async () => {
const latestRelease = await getLatestRelease()
return { ...latestRelease, isPostponed: false }
},
select: (data) => {
const versionNumber = getVersionNumber(data.tag_name)
const publishedAt = new Date(data.published_at).toLocaleString(locale, {
dateStyle: 'long',
})
React.useEffect(() => {
if (latestVersion != null && latestVersion !== process.env.ENSO_CLOUD_DASHBOARD_VERSION) {
setIsOpen(true)
if (versionNumber == null) {
return {
versionNumber: CURRENT_VERSION_NUMBER,
publishedAt,
tagName: CURRENT_VERSION,
htmlUrl: data.html_url,
isPostponed: data.isPostponed,
}
}
return {
versionNumber,
publishedAt,
tagName: data.tag_name,
htmlUrl: data.html_url,
isPostponed: data.isPostponed,
}
},
enabled: enableVersionChecker,
meta: { persist: false },
staleTime: (query) => {
if (query.state.error) {
return STALE_TIME_ERROR
}
return STALE_TIME
},
})
const { stepperState, isLastStep, resetStepper } = Stepper.useStepperState({ steps: 2 })
const remindLater = useEventCallback(() => {
setIsOpen(false)
// User asked to be reminded later, so we suppress the dialog from showing again for next 24 hours.
queryClient.setQueryData(['latestRelease'], { ...metadataQuery.data, isPostponed: true })
})
const onDownload = useEventCallback(async () => {
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
stepperState.nextStep()
}
}, [latestVersion])
})
if (!metadataQuery.isSuccess) {
return null
}
if (metadataQuery.data.isPostponed) {
return null
}
const { versionNumber, tagName, htmlUrl, publishedAt } = metadataQuery.data
const latestVersionNumber = versionNumber
const latestVersion = tagName
const shouldBeShown = (() => {
if (shouldOverride) {
return true
}
if (latestVersionNumber == null) {
return false
}
if (CURRENT_VERSION_NUMBER == null || CURRENT_VERSION_IS_DEV || CURRENT_VERSION_IS_NIGHTLY) {
return false
}
return latestVersionNumber > CURRENT_VERSION_NUMBER
})()
if (!shouldBeShown) {
return null
}
if (!isOpen && !isLastStep) {
startTransition(() => {
setIsOpen(true)
})
}
return (
<Dialog
title={getText('versionOutdatedTitle')}
size="large"
modalProps={{ isOpen }}
onOpenChange={setIsOpen}
isDismissable={isLastStep}
hideCloseButton={!isLastStep}
isKeyboardDismissDisabled={!isLastStep}
onOpenChange={(openChange) => {
startTransition(() => {
if (!openChange && overrideValue === true) {
setOverrideValue(false)
}
if (!isLastStep) {
remindLater()
}
resetStepper()
setIsOpen(openChange)
})
}}
>
<div className="flex flex-col gap-3">
<Text className="text-center text-sm">{getText('versionOutdatedPrompt')}</Text>
<div className="flex flex-col">
<Text className="text-center text-sm">
{getText('yourVersion')}{' '}
<Text className="text-sm font-semibold text-danger">
{process.env.ENSO_CLOUD_DASHBOARD_VERSION ?? getText('unknownPlaceholder')}
</Text>
</Text>
<Text className="text-center text-sm">
{getText('latestVersion')}{' '}
<Text className="text-sm font-semibold text-accent">
{latestVersion ?? getText('unknownPlaceholder')}
</Text>
</Text>
</div>
<ButtonGroup className="justify-center">
<Button
size="medium"
variant="accent"
onPress={async () => {
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
}
}}
>
{getText('download')}
</Button>
<Button size="medium" href={LATEST_RELEASE_PAGE_URL} target="_blank">
{getText('seeLatestRelease')}
</Button>
</ButtonGroup>
</div>
{() => (
<Stepper state={stepperState} renderStep={null}>
<Stepper.StepContent index={0}>
<div className="flex flex-col">
<Text className="text-center text-sm" balance>
{getText('versionOutdatedPrompt')}
</Text>
<div className="mb-4 mt-3 flex flex-col items-center">
<Text.Group>
<Text variant="h1">{getText('latestVersion', latestVersion, publishedAt)}</Text>
<Button
variant="link"
href={htmlUrl}
target="_blank"
icon={NewTabIcon}
iconPosition="end"
>
{getText('changeLog')}
</Button>
<Text variant="body-sm">
{getText('yourVersion')}{' '}
<Text weight="bold" variant="body">
{CURRENT_VERSION}
</Text>
</Text>
</Text.Group>
</div>
<ButtonGroup className="justify-center">
<Button
size="medium"
variant="outline"
fullWidth
onPress={remindLater}
icon={SnoozeIcon}
iconPosition="end"
>
{getText('remindMeLater')}
</Button>
<Button
size="medium"
fullWidth
variant="primary"
onPress={onDownload}
icon={DownloadIcon}
iconPosition="end"
>
{getText('download')}
</Button>
</ButtonGroup>
</div>
</Stepper.StepContent>
<Stepper.StepContent index={1}>
<div className="flex flex-col items-center text-center">
<Text balance variant="body">
{getText('downloadingAppMessage')}
</Text>
<Dialog.Close variant="primary" className="mt-4 min-w-48">
{getText('close')}
</Dialog.Close>
</div>
</Stepper.StepContent>
</Stepper>
)}
</Dialog>
)
}
/**
* Get the version number from a version string.
* @param version - The version string.
* @returns The version number, or null if the version string is not a valid version number.
*/
function getVersionNumber(version: string) {
const versionNumber = Number(version.replace('.', ''))
return isNaN(versionNumber) ? null : versionNumber
}

View File

@ -1,11 +1,13 @@
/** @file Commonly used functions for electron tests */
import { _electron, expect, type Page, test } from '@playwright/test'
import { TEXTS } from 'enso-common/src/text'
import fs from 'node:fs/promises'
import os from 'node:os'
import pathModule from 'node:path'
const LOADING_TIMEOUT = 10000
const TEXT = TEXTS.english
/**
* Tests run on electron executable.
@ -45,19 +47,20 @@ export async function loginAsTestUser(page: Page) {
'Cannot log in; `ENSO_TEST_USER` and `ENSO_TEST_USER_PASSWORD` env variables are not provided',
)
}
await page.getByRole('textbox', { name: 'email' }).click()
await page.keyboard.insertText(process.env.ENSO_TEST_USER)
await page.keyboard.press('Tab')
await page.keyboard.insertText(process.env.ENSO_TEST_USER_PASSWORD)
await page.keyboard.press('Enter')
await page.getByRole('textbox', { name: 'email' }).fill(process.env.ENSO_TEST_USER)
await page.getByRole('textbox', { name: 'password' }).fill(process.env.ENSO_TEST_USER_PASSWORD)
await page.getByTestId('form-submit-button').click()
// Accept terms screen
await expect(page.getByText('I agree')).toHaveCount(2)
await expect(page.getByRole('button')).toHaveCount(1)
for (const checkbox of await page.getByText('I agree').all()) {
await checkbox.click()
}
await page.getByRole('button').click()
await page
.getByRole('group', { name: TEXT.licenseAgreementCheckbox })
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await page
.getByRole('group', { name: TEXT.privacyPolicyCheckbox })
.getByText(TEXT.privacyPolicyCheckbox)
.click()
await page.getByTestId('form-submit-button').click()
}
/**