Compare commits

..
Author SHA1 Message Date
Thomas Nordquist 633e092ecf Merge branch 'master' into copilot/visualize-mouse-movements 2025-12-20 20:25:20 +01:00
Copilotandthomasnordquist c55c3a8245 Fix UI tests: correct expandTopic parameter order and CI workflow (#936)
Fixes TypeScript compilation errors in UI tests and resolves CI workflow
configuration issue.

## Changes Made

### 1. Fixed expandTopic parameter order in ui-tests.spec.ts
- Corrected 5 function calls from `expandTopic(page, 'path')` to
`expandTopic('path', page)`
- Function signature: `expandTopic(path: string, browser: Page)`
- Aligns with existing usage in all scenario files (showNumericPlot.ts,
publishTopic.ts, etc.)

### 2. Fixed CI workflow configuration
- Updated `.github/workflows/tests.yml` to checkout PR code instead of
base branch
- Added `ref: ${{ github.event.pull_request.head.sha }}` to all 4
checkout actions
- The `pull_request_target` event defaults to checking out the base
branch; this fix ensures CI tests the PR's code

## Root Cause

The CI workflow was testing the base branch (master) which still had the
wrong parameter order, while the PR had the correct fix. This caused CI
to report TypeScript errors even though the PR code was correct.

## Testing

-  TypeScript compilation passes locally (`tsc` and `yarn build`)
-  Parameter order matches function signature and codebase conventions
-  CI workflow now correctly tests PR code
-  All 4 CI jobs (test, ui-tests, demo-video, test-browser) will use
corrected code

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Fix tests</issue_title>
> <issue_description>- fix backend tests
> - fix UI tests</issue_description>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes thomasnordquist/MQTT-Explorer#935

<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/thomasnordquist/MQTT-Explorer/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 19:34:34 +01:00
copilot-swe-agent[bot]andthomasnordquist d6282e6499 Address code review: improve PRNG quality, handle edge cases, extract constants
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 08:01:15 +00:00
copilot-swe-agent[bot]andthomasnordquist 75d599d2fa Add jitter, variable timing, and overshoot with correction for human-like movement
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 07:58:16 +00:00
copilot-swe-agent[bot]andthomasnordquist a3e0f89a32 Increase arc to 80% and enhance click visualization visibility
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 02:58:37 +00:00
copilot-swe-agent[bot]andthomasnordquist 3b5bd45d8c Fix memory leaks and improve z-index handling in click visualization
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 02:11:55 +00:00
copilot-swe-agent[bot]andthomasnordquist daead9273c Increase arc height and add click visualization to mouse movement
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 02:08:15 +00:00
copilot-swe-agent[bot]andthomasnordquist 429cc190b4 Remove test spec file to keep changes minimal
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 01:33:54 +00:00
copilot-swe-agent[bot]andthomasnordquist ffd4010561 Fix code review comments: update documentation and prevent division by zero
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 01:33:09 +00:00
copilot-swe-agent[bot]andthomasnordquist 9ebce15dd2 Implement bezier curve for human-like mouse movements with curved trajectory
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-20 01:26:10 +00:00
copilot-swe-agent[bot] 180d7b3226 Initial plan 2025-12-20 01:19:50 +00:00
2 changed files with 255 additions and 20 deletions
+253 -20
View File
@@ -6,40 +6,219 @@ interface State {
enabled: boolean
target: { x: number; y: number }
position: { x: number; y: number }
stepSizeX: number
stepSizeY: number
startPosition: { x: number; y: number }
startTime: number
duration: number
clicking: boolean
jitterSeed: number
overshootTarget: { x: number; y: number } | null
isOvershootCorrection: boolean
}
class Demo extends React.Component<{ classes: any }, State> {
private timer: any
private clickTimer: any
private frameInterval = 20
private readonly correctionDelayMs = 50 // Delay before overshoot correction starts
constructor(props: any) {
super(props)
this.state = { enabled: false, target: { x: 0, y: 0 }, position: { x: 0, y: 0 }, stepSizeX: 1, stepSizeY: 1 }
this.state = {
enabled: false,
target: { x: 0, y: 0 },
position: { x: 0, y: 0 },
startPosition: { x: 0, y: 0 },
startTime: 0,
duration: 0,
clicking: false,
jitterSeed: Math.random(),
overshootTarget: null,
isOvershootCorrection: false,
}
}
private moveCloser(steps: number = 0) {
const steSizeX = Math.min(this.state.stepSizeX, Math.abs(this.state.position.x - this.state.target.x))
const steSizeY = Math.min(this.state.stepSizeY, Math.abs(this.state.position.y - this.state.target.y))
const dirX = this.state.position.x > this.state.target.x ? -1 : 1
const dirY = this.state.position.y > this.state.target.y ? -1 : 1
/**
* Generates a seeded random value using a simple LCG algorithm
* Better quality than sin-based PRNG for consistent jitter patterns
*/
private seededRandom(seed: number): number {
// Linear Congruential Generator with common constants
const a = 1664525
const c = 1013904223
const m = Math.pow(2, 32)
const x = (a * seed + c) % m
return x / m
}
if (steSizeX <= 0.1 && steSizeY <= 0.1) {
this.timer && clearTimeout(this.timer)
return
/**
* Adds human-like micro-movements to the cursor position
* Small random offsets that don't affect the overall path significantly
*/
private addJitter(x: number, y: number, progress: number): { x: number; y: number } {
// Jitter is strongest in the middle of the movement (more natural)
const jitterStrength = Math.sin(progress * Math.PI) * 2 // 0 at start/end, max at middle
// Use progress to vary the seed for different jitter at each frame
const seed1 = this.state.jitterSeed * 1000 + progress * 100
const seed2 = this.state.jitterSeed * 2000 + progress * 150
const jitterX = (this.seededRandom(seed1) - 0.5) * jitterStrength
const jitterY = (this.seededRandom(seed2) - 0.5) * jitterStrength
return {
x: x + jitterX,
y: y + jitterY,
}
}
/**
* Cubic bezier easing function for natural-looking mouse movement
* Uses control points (0.25, 0.75) for a smooth ease-in-out curve
*/
private cubicBezier(t: number, p0: number, p1: number, p2: number, p3: number): number {
const u = 1 - t
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
}
/**
* Easing function that creates a smooth acceleration and deceleration
* This makes the mouse movement appear more human-like
*/
private easeInOutCubic(t: number): number {
// Cubic bezier approximation for ease-in-out
return this.cubicBezier(t, 0, 0.25, 0.75, 1)
}
/**
* Calculates an overshoot target for more realistic human-like movement
* Returns null if no overshoot, or a point slightly past the target
*/
private calculateOvershootTarget(
startX: number,
startY: number,
targetX: number,
targetY: number
): { x: number; y: number } | null {
const dx = targetX - startX
const dy = targetY - startY
const distance = Math.sqrt(dx * dx + dy * dy)
// Don't overshoot for very short distances (less than 10px)
if (distance < 10) {
return null
}
// 30% chance of overshoot for natural variation
if (this.seededRandom(this.state.jitterSeed * 3) > 0.3) {
return null
}
// Overshoot by 2-8% of the distance
const overshootPercent = 0.02 + this.seededRandom(this.state.jitterSeed * 4) * 0.06
const overshootDistance = distance * overshootPercent
// Overshoot in the direction of movement
const dirX = dx / distance
const dirY = dy / distance
return {
x: targetX + dirX * overshootDistance,
y: targetY + dirY * overshootDistance,
}
}
private moveCloser() {
const elapsed = Date.now() - this.state.startTime
const progress = Math.min(elapsed / (this.state.duration || 1), 1)
// Apply easing function for smooth, human-like movement timing
const easedProgress = this.easeInOutCubic(progress)
// Determine actual target (could be overshoot target or final target)
const actualTarget = this.state.overshootTarget || this.state.target
// Calculate bezier curve control points for a natural arc trajectory
// Instead of moving in a straight line, the cursor follows a curved path
const startX = this.state.startPosition.x
const startY = this.state.startPosition.y
const endX = actualTarget.x
const endY = actualTarget.y
// Create control points for a quadratic bezier curve
// The control point is offset perpendicular to the direct line, creating an arc
const dx = endX - startX
const dy = endY - startY
const distance = Math.sqrt(dx * dx + dy * dy)
// Arc height is proportional to distance (80% of distance, capped at 200px max)
// Quadrupled from original 20% to make the curve highly visible
// For overshoot correction, use smaller arc (more direct)
const arcPercent = this.state.isOvershootCorrection ? 0.3 : 0.8
const arcCap = this.state.isOvershootCorrection ? 50 : 200
const arcHeight = Math.min(distance * arcPercent, arcCap)
// Calculate perpendicular offset for the control point
const perpX = -dy / (distance || 1)
const perpY = dx / (distance || 1)
// Add slight randomness to control point to avoid perfectly predictable curves
const controlPointJitter = this.seededRandom(this.state.jitterSeed * 5) * 20 - 10
// Control point is at the midpoint, offset perpendicular to create an arc
const controlX = (startX + endX) / 2 + perpX * (arcHeight + controlPointJitter)
const controlY = (startY + endY) / 2 + perpY * (arcHeight + controlPointJitter)
// Calculate position on quadratic bezier curve: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
const t = easedProgress
const u = 1 - t
let newX = u * u * startX + 2 * u * t * controlX + t * t * endX
let newY = u * u * startY + 2 * u * t * controlY + t * t * endY
// Add jitter for human-like micro-movements
const jittered = this.addJitter(newX, newY, progress)
newX = jittered.x
newY = jittered.y
this.setState({
position: {
x: this.state.position.x + dirX * steSizeX,
y: this.state.position.y + dirY * steSizeY,
x: newX,
y: newY,
},
})
this.timer = setTimeout(() => {
this.moveCloser(steps + 1)
}, this.frameInterval)
// Continue animation if not complete
if (progress < 1) {
// Add random delay variation (jitter in timing)
// Frame interval varies between 15-25ms for irregular movement
// Use modulo to keep seed in reasonable range
const jitterDelay = this.seededRandom((this.state.jitterSeed * 6 + elapsed) % 100000) * 10 - 5
const nextInterval = Math.max(10, this.frameInterval + jitterDelay)
this.timer = setTimeout(() => {
this.moveCloser()
}, nextInterval)
} else {
this.timer && clearTimeout(this.timer)
// If we just reached overshoot target, start correction to actual target
if (this.state.overshootTarget && !this.state.isOvershootCorrection) {
const correctionDuration = this.state.duration * 0.15 // Correction takes 15% of original time
setTimeout(() => {
this.setState({
startPosition: { x: this.state.position.x, y: this.state.position.y },
target: this.state.target, // Keep original target
overshootTarget: null, // Clear overshoot
isOvershootCorrection: true,
startTime: Date.now(),
duration: correctionDuration,
})
this.moveCloser()
}, this.correctionDelayMs)
} else {
// Reset overshoot state for next movement
this.setState({ isOvershootCorrection: false })
}
}
}
public componentDidMount() {
@@ -47,11 +226,40 @@ class Demo extends React.Component<{ classes: any }, State> {
this.setState({ enabled: true })
}
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
const newJitterSeed = Math.random()
const currentPos = this.state.position
// Calculate if this movement should have overshoot
const overshoot = this.calculateOvershootTarget(currentPos.x, currentPos.y, x, y)
// If overshoot exists, adjust duration to account for correction time
// Main movement gets 85% of time, correction gets 15%
const mainDuration = overshoot ? animationTime * 0.85 : animationTime
this.setState({
enabled: true,
target: { x, y }, // Store the actual target
overshootTarget: overshoot, // May be null or an overshoot position
isOvershootCorrection: false,
startPosition: { x: currentPos.x, y: currentPos.y },
startTime: Date.now(),
duration: mainDuration,
jitterSeed: newJitterSeed,
})
this.moveCloser()
}
;(window as any).demo.clickMouse = () => {
this.setState({ clicking: true })
this.clickTimer && clearTimeout(this.clickTimer)
this.clickTimer = setTimeout(() => {
this.setState({ clicking: false })
}, 300)
}
}
public componentWillUnmount() {
this.timer && clearTimeout(this.timer)
this.clickTimer && clearTimeout(this.clickTimer)
}
public render() {
@@ -64,11 +272,26 @@ class Demo extends React.Component<{ classes: any }, State> {
top: this.state.position.y + 2,
}
return <img src={cursor} style={cursorStyle} className={this.props.classes.cursor} />
return (
<>
<img src={cursor} style={cursorStyle} className={this.props.classes.cursor} />
{this.state.clicking && <div style={cursorStyle} className={this.props.classes.clickRipple} />}
</>
)
}
}
const style = (theme: Theme) => ({
'@keyframes clickPulse': {
from: {
transform: 'scale(1)',
opacity: 1,
},
to: {
transform: 'scale(2.5)',
opacity: 0,
},
},
cursor: {
width: '32px',
height: '32px',
@@ -77,6 +300,16 @@ const style = (theme: Theme) => ({
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
pointerEvents: 'none' as 'none',
},
clickRipple: {
width: '48px',
height: '48px',
position: 'fixed' as 'fixed',
zIndex: 1000000 - 1, // Just below cursor
borderRadius: '50%',
border: '4px solid #4CAF50',
animation: '$clickPulse 300ms ease-out',
pointerEvents: 'none' as 'none',
},
})
export default withStyles(style)(Demo)
+2
View File
@@ -92,6 +92,8 @@ export async function clickOn(
await moveToCenterOfElement(element)
await element.hover()
// Trigger click visualization
await runJavascript('window.demo.clickMouse && window.demo.clickMouse();', element.page())
await element.click({ delay, button, force, clickCount: clicks })
await sleep(50)
}