Improve UX: accessibility, field guidance, and error prevention (#1010)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
This commit is contained in:
@@ -8,15 +8,29 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
|
||||
|
||||
if (connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="abort-button">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}
|
||||
onClick={toggle}
|
||||
data-testid="abort-button"
|
||||
aria-label="Cancel connection attempt"
|
||||
>
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
Cancel
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="connect-button">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}
|
||||
onClick={toggle}
|
||||
data-testid="connect-button"
|
||||
aria-label="Connect to MQTT broker"
|
||||
>
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, connectionManagerActions } from '../../actions'
|
||||
import { connectionActions, connectionManagerActions, globalActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
@@ -17,14 +17,12 @@ import { ToggleSwitch } from './ToggleSwitch'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +30,7 @@ interface Props {
|
||||
classes: { [s: string]: string }
|
||||
actions: typeof connectionActions
|
||||
managerActions: typeof connectionManagerActions
|
||||
globalActions: typeof globalActions
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
}
|
||||
@@ -41,6 +40,17 @@ const protocols = ['mqtt', 'ws']
|
||||
function ConnectionSettings(props: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const confirmed = await props.globalActions.requestConfirmation(
|
||||
'Delete Connection',
|
||||
`Are you sure you want to delete the connection "${props.connection.name}"?\n\nThis action cannot be undone.`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
props.managerActions.deleteConnection(props.connection.id)
|
||||
}
|
||||
}, [props.connection.id, props.connection.name, props.globalActions, props.managerActions])
|
||||
|
||||
const toggleConnect = useCallback(() => {
|
||||
if (props.connecting) {
|
||||
props.actions.disconnect()
|
||||
@@ -76,7 +86,7 @@ function ConnectionSettings(props: Props) {
|
||||
className={props.classes.textField}
|
||||
value={props.connection.basePath}
|
||||
onChange={handleChange('basePath')}
|
||||
margin="normal"
|
||||
margin="dense"
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
@@ -101,21 +111,26 @@ function ConnectionSettings(props: Props) {
|
||||
|
||||
const protocolItems = protocols.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{value}://
|
||||
{value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
return (
|
||||
<Tooltip title="Use 'mqtt' for standard connections or 'ws' for WebSocket connections" arrow>
|
||||
<TextField
|
||||
select={true}
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={connection.protocol}
|
||||
onChange={updateProtocol}
|
||||
margin="normal"
|
||||
margin="dense"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT protocol'
|
||||
}}
|
||||
>
|
||||
{protocolItems}
|
||||
</TextField>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,9 +159,15 @@ function ConnectionSettings(props: Props) {
|
||||
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Toggle password visibility" onClick={props.toggle}>
|
||||
<Tooltip title={props.showPassword ? "Hide password" : "Show password"} arrow>
|
||||
<IconButton
|
||||
aria-label={props.showPassword ? "Hide password" : "Show password"}
|
||||
onClick={props.toggle}
|
||||
edge="end"
|
||||
>
|
||||
{props.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
@@ -154,9 +175,9 @@ function ConnectionSettings(props: Props) {
|
||||
const { classes, connection } = props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Grid container={true} spacing={2}>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
autoFocus={true}
|
||||
@@ -164,7 +185,11 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.name}
|
||||
onChange={handleChange('name')}
|
||||
margin="normal"
|
||||
margin="dense"
|
||||
placeholder="My MQTT Connection"
|
||||
inputProps={{
|
||||
'aria-label': 'Connection name'
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
@@ -187,8 +212,12 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.host}
|
||||
onChange={handleChange('host')}
|
||||
margin="normal"
|
||||
inputProps={{ 'data-testid': 'host-input' }}
|
||||
margin="dense"
|
||||
placeholder="broker.example.com"
|
||||
inputProps={{
|
||||
'data-testid': 'host-input',
|
||||
'aria-label': 'MQTT broker host'
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
@@ -197,7 +226,14 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.port}
|
||||
onChange={handleChange('port')}
|
||||
margin="normal"
|
||||
margin="dense"
|
||||
type="number"
|
||||
placeholder="1883"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT broker port',
|
||||
min: 1,
|
||||
max: 65535
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{requiresBasePath() ? renderBasePathInput() : null}
|
||||
@@ -207,54 +243,74 @@ function ConnectionSettings(props: Props) {
|
||||
className={classes.textField}
|
||||
value={connection.username}
|
||||
onChange={handleChange('username')}
|
||||
margin="normal"
|
||||
margin="dense"
|
||||
placeholder="Optional"
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT username',
|
||||
'autoComplete': 'username'
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
<TextField
|
||||
label="Password"
|
||||
className={classes.textField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={handleChange('password')}
|
||||
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
|
||||
margin="dense"
|
||||
placeholder="Optional"
|
||||
InputProps={{
|
||||
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />
|
||||
}}
|
||||
inputProps={{
|
||||
'aria-label': 'MQTT password',
|
||||
'autoComplete': 'current-password'
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<br />
|
||||
</form>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(0, 0, 0, 0.12)' }}>
|
||||
<div>
|
||||
<div style={{ float: 'left' }}>
|
||||
<Tooltip title="Delete this connection permanently" arrow>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
className={classes.button}
|
||||
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete connection"
|
||||
>
|
||||
Delete <Delete />
|
||||
<Delete /> Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Advanced connection settings" arrow>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="advanced-button"
|
||||
aria-label="Show advanced settings"
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<div>
|
||||
<Tooltip title="Save connection settings" arrow>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.saveConnectionSettings}
|
||||
aria-label="Save connection"
|
||||
>
|
||||
<Save /> Save
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -270,6 +326,7 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionActions, dispatch),
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
globalActions: bindActionCreators(globalActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,25 @@ import { FormControlLabel, Switch } from '@mui/material'
|
||||
|
||||
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
|
||||
const { classes, value, toggle, label } = props
|
||||
const toggleSwitch = <Switch checked={value} onChange={toggle} color="primary" />
|
||||
const toggleSwitch = (
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={toggle}
|
||||
color="primary"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
inputProps={{
|
||||
'aria-label': label
|
||||
}}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
|
||||
<FormControlLabel
|
||||
control={toggleSwitch}
|
||||
label={`${label} (${value ? 'On' : 'Off'})`}
|
||||
labelPlacement="bottom"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
408
ux-exploration.ts
Normal file
408
ux-exploration.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { chromium, Browser, Page, devices } from 'playwright'
|
||||
import * as path from 'path'
|
||||
|
||||
const PIXEL_6 = devices['Pixel 6']
|
||||
|
||||
interface UXIssue {
|
||||
category: string
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
description: string
|
||||
location: string
|
||||
recommendation?: string
|
||||
}
|
||||
|
||||
const uxIssues: UXIssue[] = []
|
||||
|
||||
function reportIssue(issue: UXIssue) {
|
||||
uxIssues.push(issue)
|
||||
console.log(`[${issue.severity.toUpperCase()}] ${issue.category}: ${issue.description}`)
|
||||
console.log(` Location: ${issue.location}`)
|
||||
if (issue.recommendation) {
|
||||
console.log(` Recommendation: ${issue.recommendation}`)
|
||||
}
|
||||
console.log('')
|
||||
}
|
||||
|
||||
async function checkAccessibility(page: Page, context: string) {
|
||||
console.log(`\n=== Checking accessibility: ${context} ===\n`)
|
||||
|
||||
// Check for proper ARIA labels
|
||||
const buttonsWithoutAriaLabel = await page.locator('button:not([aria-label])').count()
|
||||
if (buttonsWithoutAriaLabel > 0) {
|
||||
reportIssue({
|
||||
category: 'Accessibility',
|
||||
severity: 'medium',
|
||||
description: `${buttonsWithoutAriaLabel} buttons without aria-label found`,
|
||||
location: context,
|
||||
recommendation: 'Add aria-label attributes to all interactive buttons for screen reader support'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for input fields without labels
|
||||
const inputsWithoutLabel = await page.locator('input:not([aria-label]):not([aria-labelledby])').count()
|
||||
if (inputsWithoutLabel > 0) {
|
||||
reportIssue({
|
||||
category: 'Accessibility',
|
||||
severity: 'medium',
|
||||
description: `${inputsWithoutLabel} input fields without proper labels`,
|
||||
location: context,
|
||||
recommendation: 'Add aria-label or associated label elements to all inputs'
|
||||
})
|
||||
}
|
||||
|
||||
// Check contrast - look for text with potential low contrast
|
||||
const textElements = await page.locator('p, span, div, h1, h2, h3, h4, h5, h6, label, button').all()
|
||||
console.log(` Checked ${textElements.length} text elements for potential contrast issues`)
|
||||
}
|
||||
|
||||
async function checkTouchTargets(page: Page, context: string) {
|
||||
console.log(`\n=== Checking touch target sizes: ${context} ===\n`)
|
||||
|
||||
// Check button sizes (should be at least 44x44px for touch)
|
||||
const buttons = await page.locator('button, a, [role="button"]').all()
|
||||
let smallButtons = 0
|
||||
|
||||
for (const button of buttons) {
|
||||
const box = await button.boundingBox()
|
||||
if (box && (box.width < 44 || box.height < 44)) {
|
||||
smallButtons++
|
||||
}
|
||||
}
|
||||
|
||||
if (smallButtons > 0) {
|
||||
reportIssue({
|
||||
category: 'Touch Usability',
|
||||
severity: 'high',
|
||||
description: `${smallButtons} interactive elements smaller than 44x44px`,
|
||||
location: context,
|
||||
recommendation: 'Increase touch target sizes to at least 44x44px for better mobile usability'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFormUsability(page: Page, context: string) {
|
||||
console.log(`\n=== Checking form usability: ${context} ===\n`)
|
||||
|
||||
// Check for password visibility toggle
|
||||
const passwordInputs = await page.locator('input[type="password"]').all()
|
||||
const visibilityToggles = await page.locator('[aria-label*="password" i], [title*="password" i]').count()
|
||||
|
||||
if (passwordInputs.length > 0 && visibilityToggles === 0) {
|
||||
reportIssue({
|
||||
category: 'Form Usability',
|
||||
severity: 'medium',
|
||||
description: 'Password fields lack visibility toggle',
|
||||
location: context,
|
||||
recommendation: 'Add a toggle button to show/hide password for better UX'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for required field indicators
|
||||
const requiredInputs = await page.locator('input[required]').count()
|
||||
const requiredIndicators = await page.locator('input[required] + label:has-text("*"), label:has-text("*") + input[required]').count()
|
||||
|
||||
if (requiredInputs > 0 && requiredIndicators === 0) {
|
||||
reportIssue({
|
||||
category: 'Form Usability',
|
||||
severity: 'low',
|
||||
description: 'Required fields may not be visually indicated',
|
||||
location: context,
|
||||
recommendation: 'Add visual indicators (e.g., asterisk) for required fields'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for autocomplete attributes
|
||||
const emailInputs = await page.locator('input[type="email"], input[name*="email" i]').count()
|
||||
const emailAutocomplete = await page.locator('input[type="email"][autocomplete], input[name*="email" i][autocomplete]').count()
|
||||
|
||||
if (emailInputs > 0 && emailAutocomplete === 0) {
|
||||
reportIssue({
|
||||
category: 'Form Usability',
|
||||
severity: 'low',
|
||||
description: 'Email inputs lack autocomplete attributes',
|
||||
location: context,
|
||||
recommendation: 'Add autocomplete="email" to email inputs for better UX'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLoadingStates(page: Page, context: string) {
|
||||
console.log(`\n=== Checking loading states: ${context} ===\n`)
|
||||
|
||||
// Check for loading indicators
|
||||
const loadingIndicators = await page.locator('[role="progressbar"], .loading, [aria-busy="true"]').count()
|
||||
console.log(` Found ${loadingIndicators} loading indicators`)
|
||||
|
||||
// Check if buttons show loading state
|
||||
const buttons = await page.locator('button').all()
|
||||
let buttonsWithDisabledState = 0
|
||||
|
||||
for (const button of buttons) {
|
||||
const isDisabled = await button.isDisabled()
|
||||
if (isDisabled) {
|
||||
buttonsWithDisabledState++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ${buttonsWithDisabledState} buttons in disabled state`)
|
||||
}
|
||||
|
||||
async function checkErrorMessages(page: Page, context: string) {
|
||||
console.log(`\n=== Checking error message visibility: ${context} ===\n`)
|
||||
|
||||
// Check if error messages have proper ARIA roles
|
||||
const errorElements = await page.locator('[role="alert"], .error, [aria-live="assertive"]').count()
|
||||
console.log(` Found ${errorElements} elements with error/alert roles`)
|
||||
|
||||
// Check for error text visibility
|
||||
const errorTexts = await page.locator('text=/error/i, text=/invalid/i, text=/failed/i').all()
|
||||
for (const errorText of errorTexts) {
|
||||
const isVisible = await errorText.isVisible()
|
||||
if (!isVisible) {
|
||||
reportIssue({
|
||||
category: 'Error Handling',
|
||||
severity: 'medium',
|
||||
description: 'Error message exists but may not be visible',
|
||||
location: context,
|
||||
recommendation: 'Ensure error messages are visible and have proper ARIA roles'
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkKeyboardNavigation(page: Page, context: string) {
|
||||
console.log(`\n=== Checking keyboard navigation: ${context} ===\n`)
|
||||
|
||||
// Check for focusable elements
|
||||
const focusableElements = await page.locator('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').count()
|
||||
console.log(` Found ${focusableElements} focusable elements`)
|
||||
|
||||
// Check for skip links
|
||||
const skipLinks = await page.locator('a[href*="#"]:has-text(/skip/i)').count()
|
||||
if (skipLinks === 0) {
|
||||
reportIssue({
|
||||
category: 'Keyboard Navigation',
|
||||
severity: 'low',
|
||||
description: 'No skip navigation links found',
|
||||
location: context,
|
||||
recommendation: 'Add skip to content links for keyboard users'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function checkResponsiveDesign(page: Page, context: string) {
|
||||
console.log(`\n=== Checking responsive design: ${context} ===\n`)
|
||||
|
||||
// Check for horizontal scroll
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
})
|
||||
|
||||
if (hasHorizontalScroll) {
|
||||
reportIssue({
|
||||
category: 'Responsive Design',
|
||||
severity: 'high',
|
||||
description: 'Page has horizontal scrolling',
|
||||
location: context,
|
||||
recommendation: 'Ensure content fits within viewport width without horizontal scrolling'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for viewport meta tag
|
||||
const hasViewportMeta = await page.locator('meta[name="viewport"]').count()
|
||||
if (hasViewportMeta === 0) {
|
||||
reportIssue({
|
||||
category: 'Responsive Design',
|
||||
severity: 'critical',
|
||||
description: 'Missing viewport meta tag',
|
||||
location: context,
|
||||
recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreDesktopUX(browser: Browser) {
|
||||
console.log('\n\n========================================')
|
||||
console.log('DESKTOP UX EXPLORATION')
|
||||
console.log('========================================\n')
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
try {
|
||||
// Start server and wait for it
|
||||
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Check initial page load
|
||||
await checkAccessibility(page, 'Desktop: Initial Load')
|
||||
await checkResponsiveDesign(page, 'Desktop: Initial Load')
|
||||
await checkKeyboardNavigation(page, 'Desktop: Initial Load')
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/desktop-initial.png', fullPage: true })
|
||||
console.log('Screenshot saved: /tmp/desktop-initial.png')
|
||||
|
||||
// Check login dialog if present
|
||||
const loginDialog = page.locator('[role="dialog"]').first()
|
||||
if (await loginDialog.isVisible()) {
|
||||
console.log('\nLogin dialog detected')
|
||||
await checkFormUsability(page, 'Desktop: Login Dialog')
|
||||
await checkTouchTargets(page, 'Desktop: Login Dialog')
|
||||
await page.screenshot({ path: '/tmp/desktop-login.png' })
|
||||
console.log('Screenshot saved: /tmp/desktop-login.png')
|
||||
}
|
||||
|
||||
// Check connection dialog
|
||||
const connectionDialog = page.locator('text=MQTT Connection').first()
|
||||
if (await connectionDialog.isVisible()) {
|
||||
console.log('\nConnection dialog detected')
|
||||
await checkFormUsability(page, 'Desktop: Connection Dialog')
|
||||
await checkTouchTargets(page, 'Desktop: Connection Dialog')
|
||||
await page.screenshot({ path: '/tmp/desktop-connection.png' })
|
||||
console.log('Screenshot saved: /tmp/desktop-connection.png')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during desktop exploration:', error)
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreMobileUX(browser: Browser) {
|
||||
console.log('\n\n========================================')
|
||||
console.log('MOBILE UX EXPLORATION')
|
||||
console.log('========================================\n')
|
||||
|
||||
const context = await browser.newContext({
|
||||
...PIXEL_6,
|
||||
viewport: { width: 412, height: 914 }
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Check mobile-specific issues
|
||||
await checkAccessibility(page, 'Mobile: Initial Load')
|
||||
await checkResponsiveDesign(page, 'Mobile: Initial Load')
|
||||
await checkTouchTargets(page, 'Mobile: Initial Load')
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/mobile-initial.png', fullPage: true })
|
||||
console.log('Screenshot saved: /tmp/mobile-initial.png')
|
||||
|
||||
// Check login dialog on mobile
|
||||
const loginDialog = page.locator('[role="dialog"]').first()
|
||||
if (await loginDialog.isVisible()) {
|
||||
console.log('\nLogin dialog on mobile detected')
|
||||
await checkFormUsability(page, 'Mobile: Login Dialog')
|
||||
await checkTouchTargets(page, 'Mobile: Login Dialog')
|
||||
await page.screenshot({ path: '/tmp/mobile-login.png' })
|
||||
console.log('Screenshot saved: /tmp/mobile-login.png')
|
||||
}
|
||||
|
||||
// Check connection dialog on mobile
|
||||
const connectionDialog = page.locator('text=MQTT Connection').first()
|
||||
if (await connectionDialog.isVisible()) {
|
||||
console.log('\nConnection dialog on mobile detected')
|
||||
await checkFormUsability(page, 'Mobile: Connection Dialog')
|
||||
await checkTouchTargets(page, 'Mobile: Connection Dialog')
|
||||
|
||||
// Check if dialog fits on screen
|
||||
const dialogBox = await connectionDialog.boundingBox()
|
||||
if (dialogBox && dialogBox.width > 412) {
|
||||
reportIssue({
|
||||
category: 'Responsive Design',
|
||||
severity: 'high',
|
||||
description: `Dialog width (${dialogBox.width}px) exceeds mobile viewport (412px)`,
|
||||
location: 'Mobile: Connection Dialog',
|
||||
recommendation: 'Make dialog responsive to fit within mobile viewports'
|
||||
})
|
||||
}
|
||||
|
||||
await page.screenshot({ path: '/tmp/mobile-connection.png' })
|
||||
console.log('Screenshot saved: /tmp/mobile-connection.png')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during mobile exploration:', error)
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting UX exploration of MQTT Explorer...\n')
|
||||
console.log('This script will identify UX flaws in the application.\n')
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
})
|
||||
|
||||
try {
|
||||
await exploreDesktopUX(browser)
|
||||
await exploreMobileUX(browser)
|
||||
|
||||
// Summary report
|
||||
console.log('\n\n========================================')
|
||||
console.log('UX ISSUES SUMMARY')
|
||||
console.log('========================================\n')
|
||||
|
||||
const critical = uxIssues.filter(i => i.severity === 'critical')
|
||||
const high = uxIssues.filter(i => i.severity === 'high')
|
||||
const medium = uxIssues.filter(i => i.severity === 'medium')
|
||||
const low = uxIssues.filter(i => i.severity === 'low')
|
||||
|
||||
console.log(`Total Issues Found: ${uxIssues.length}`)
|
||||
console.log(` Critical: ${critical.length}`)
|
||||
console.log(` High: ${high.length}`)
|
||||
console.log(` Medium: ${medium.length}`)
|
||||
console.log(` Low: ${low.length}`)
|
||||
console.log('')
|
||||
|
||||
if (critical.length > 0) {
|
||||
console.log('CRITICAL ISSUES:')
|
||||
critical.forEach((issue, i) => {
|
||||
console.log(`${i + 1}. ${issue.description} (${issue.location})`)
|
||||
})
|
||||
console.log('')
|
||||
}
|
||||
|
||||
if (high.length > 0) {
|
||||
console.log('HIGH PRIORITY ISSUES:')
|
||||
high.forEach((issue, i) => {
|
||||
console.log(`${i + 1}. ${issue.description} (${issue.location})`)
|
||||
})
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// Save detailed report
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
totalIssues: uxIssues.length,
|
||||
bySeverity: {
|
||||
critical: critical.length,
|
||||
high: high.length,
|
||||
medium: medium.length,
|
||||
low: low.length
|
||||
},
|
||||
issues: uxIssues
|
||||
}
|
||||
|
||||
const fs = require('fs')
|
||||
fs.writeFileSync('/tmp/ux-report.json', JSON.stringify(report, null, 2))
|
||||
console.log('Detailed report saved to: /tmp/ux-report.json\n')
|
||||
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
Reference in New Issue
Block a user