mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
This repo could never sync. There was NO merge-base with tamagui — 372 commits
here against 15,791 there, unrelated histories — because it began as a
copy-paste rebrand with a flattened layout. Every "sync" since was a hand
re-application that drifted, which is why the tree sat on 7.3.1 while upstream
shipped fixes we then rediscovered by hand.
This commit's PARENT IS TAMAGUI. A future sync is `git merge tama/main` then
`node scripts/rebrand.mjs` — a real merge, because the history is now shared.
174 packages: 166 upstream, our 8 restored (chrome, shell, telemetry, recipes,
get, font-geist-{sans,mono}, @hanzo/booker), bento-get gained. 170/170 build.
Our patches to files UPSTREAM ALSO OWNS are carried, which the package-level
delta missed and which was where the value actually was:
- useMedia's server snapshot (`initState ?? getMedia()`). Upstream still
returns undefined there, so every Tamagui Dialog kills a prerender — the
exact crash hanzo.app spent a day on.
- data-slot on web host nodes, absent upstream entirely.
Both applied ONTO upstream's newer files (it rewrote useMedia +150/-63 and
createComponent +160/-55), marked `PATCH (ours, carried across the transplant)`.
Seven traps are encoded in scripts/rebrand.mjs, each found by a gate:
skip itself and lockfiles; special-case the double-branded names in BOTH scoped
and bare form; map path strings, not just paths; drop a level for the flattened
group; check disk rather than a stale index.
Rust build artifacts are NOT committed. An earlier draft of this commit swept
apps/desktop/src-tauri/target/debug in via `git add -A` — 3,859 files including
a 272MB .a, over GitHub's 100MB cap, so the push could never have landed. They
are gitignored now.
Co-Authored-By: Hanzo Dev <[email protected]>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { setupPage } from './test-utils'
|
|
|
|
/**
|
|
* ANIMATION BEHAVIOR TESTS
|
|
*
|
|
* Tests animation behavior across all configured drivers.
|
|
* Driver is determined by the playwright project (css, native, reanimated, motion).
|
|
* All tests verify start, intermediate, and end states.
|
|
* Uses scenario-36 (1000ms timing) for reliable intermediate capture.
|
|
*/
|
|
|
|
const TOLERANCE = 0.05
|
|
|
|
async function getOpacity(page: Page, testId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
const el = document.querySelector(`[data-testid="${id}"]`)
|
|
if (!el) return -1
|
|
return Number.parseFloat(getComputedStyle(el).opacity)
|
|
}, testId)
|
|
}
|
|
|
|
async function getScale(page: Page, testId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
const el = document.querySelector(`[data-testid="${id}"]`)
|
|
if (!el) return -1
|
|
const transform = getComputedStyle(el).transform
|
|
if (transform === 'none') return 1
|
|
const match = transform.match(/matrix\(([^,]+),/)
|
|
return match ? Number.parseFloat(match[1]) : 1
|
|
}, testId)
|
|
}
|
|
|
|
async function getTranslateX(page: Page, testId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
const el = document.querySelector(`[data-testid="${id}"]`)
|
|
if (!el) return -1
|
|
const transform = getComputedStyle(el).transform
|
|
if (transform === 'none') return 0
|
|
const match = transform.match(/matrix\([^,]+,[^,]+,[^,]+,[^,]+,([^,]+),/)
|
|
return match ? Number.parseFloat(match[1]) : 0
|
|
}, testId)
|
|
}
|
|
|
|
async function elementExists(page: Page, testId: string): Promise<boolean> {
|
|
return page.evaluate((id) => !!document.querySelector(`[data-testid="${id}"]`), testId)
|
|
}
|
|
|
|
function isIntermediate(
|
|
value: number,
|
|
start: number,
|
|
end: number,
|
|
tolerance = TOLERANCE
|
|
): boolean {
|
|
const notAtStart = Math.abs(value - start) > tolerance
|
|
const notAtEnd = Math.abs(value - end) > tolerance
|
|
const min = Math.min(start, end)
|
|
const max = Math.max(start, end)
|
|
const inRange = value >= min - tolerance && value <= max + tolerance
|
|
return notAtStart && notAtEnd && inRange
|
|
}
|
|
|
|
test.describe('Animation Behavior', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Skip native driver - it has issues with data-testid attributes on web
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
test.skip(driver === 'native', 'native driver has element detection issues on web')
|
|
|
|
await setupPage(page, {
|
|
name: 'AnimationComprehensiveCase',
|
|
type: 'useCase',
|
|
})
|
|
// CI can be slow - allow more time for initial render
|
|
await page.waitForTimeout(1000)
|
|
})
|
|
|
|
test('timing animation has start, intermediate, and end states', async ({ page }) => {
|
|
const START = 1,
|
|
END = 0.2
|
|
|
|
const startOpacity = await getOpacity(page, 'scenario-36-target')
|
|
expect(startOpacity, 'Start').toBeCloseTo(START, 1)
|
|
|
|
// poll via rAF for reliable intermediate capture across CI environments
|
|
const samples: number[] = await page.evaluate(() => {
|
|
return new Promise<number[]>((resolve) => {
|
|
const el = document.querySelector('[data-testid="scenario-36-target"]')!
|
|
const vals: number[] = []
|
|
const start = performance.now()
|
|
function tick() {
|
|
vals.push(Number(getComputedStyle(el).opacity))
|
|
if (performance.now() - start < 800) requestAnimationFrame(tick)
|
|
else resolve(vals)
|
|
}
|
|
;(
|
|
document.querySelector('[data-testid="scenario-36-trigger"]') as HTMLElement
|
|
).click()
|
|
requestAnimationFrame(tick)
|
|
})
|
|
})
|
|
|
|
const endOpacity = samples[samples.length - 1]
|
|
expect(endOpacity, 'End').toBeCloseTo(END, 1)
|
|
|
|
// verify real interpolation: multiple frames between start and end
|
|
const intermediates = samples.filter(
|
|
(v) => Math.abs(v - START) > TOLERANCE && Math.abs(v - END) > TOLERANCE
|
|
)
|
|
expect(
|
|
intermediates.length,
|
|
`Should have multiple intermediate opacity frames (got ${intermediates.length})`
|
|
).toBeGreaterThanOrEqual(2)
|
|
|
|
// verify animation doesn't complete instantly
|
|
const earlyEnd = samples.slice(0, 3).every((v) => Math.abs(v - END) < TOLERANCE)
|
|
expect(earlyEnd, 'Opacity animation should not complete in first 3 frames').toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
test('scale timing animation has intermediate values', async ({ page }) => {
|
|
const START = 1,
|
|
END = 1.5
|
|
|
|
const startScale = await getScale(page, 'scenario-36-target')
|
|
expect(startScale, 'Start').toBeCloseTo(START, 1)
|
|
|
|
// poll via rAF for reliable intermediate capture across CI environments
|
|
const samples: number[] = await page.evaluate(() => {
|
|
return new Promise<number[]>((resolve) => {
|
|
const el = document.querySelector('[data-testid="scenario-36-target"]')!
|
|
const vals: number[] = []
|
|
const start = performance.now()
|
|
function tick() {
|
|
const t = getComputedStyle(el).transform
|
|
const m = t.match(/matrix\(([^,]+),/)
|
|
vals.push(m ? Number.parseFloat(m[1]) : 1)
|
|
if (performance.now() - start < 800) requestAnimationFrame(tick)
|
|
else resolve(vals)
|
|
}
|
|
;(
|
|
document.querySelector('[data-testid="scenario-36-trigger"]') as HTMLElement
|
|
).click()
|
|
requestAnimationFrame(tick)
|
|
})
|
|
})
|
|
|
|
const endScale = samples[samples.length - 1]
|
|
expect(endScale, 'End').toBeCloseTo(END, 1)
|
|
|
|
// verify real interpolation: multiple frames between start and end
|
|
const intermediates = samples.filter(
|
|
(v) => Math.abs(v - START) > TOLERANCE && Math.abs(v - END) > TOLERANCE
|
|
)
|
|
expect(
|
|
intermediates.length,
|
|
`Should have multiple intermediate scale frames (got ${intermediates.length})`
|
|
).toBeGreaterThanOrEqual(2)
|
|
|
|
// verify animation doesn't complete instantly
|
|
const earlyEnd = samples.slice(0, 3).every((v) => Math.abs(v - END) < TOLERANCE)
|
|
expect(earlyEnd, 'Scale animation should not complete in first 3 frames').toBe(false)
|
|
})
|
|
|
|
test('animation with delay completes correctly', async ({ page }) => {
|
|
const START = 1,
|
|
END = 0.3
|
|
|
|
const startOpacity = await getOpacity(page, 'scenario-20-target')
|
|
expect(startOpacity, 'Start').toBeCloseTo(START, 1)
|
|
|
|
await page.getByTestId('scenario-20-trigger').click()
|
|
await page.waitForTimeout(2500)
|
|
const endOpacity = await getOpacity(page, 'scenario-20-target')
|
|
|
|
expect(endOpacity, 'End').toBeCloseTo(END, 1)
|
|
})
|
|
|
|
test('multi-property animation completes correctly', async ({ page }) => {
|
|
const OPACITY_END = 0.5,
|
|
SCALE_END = 1.3
|
|
|
|
await page.getByTestId('scenario-28-trigger').click()
|
|
await page.waitForTimeout(3000)
|
|
|
|
const endOpacity = await getOpacity(page, 'scenario-28-target')
|
|
const endScale = await getScale(page, 'scenario-28-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 0)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 0)
|
|
})
|
|
|
|
test('per-property configs complete correctly', async ({ page }) => {
|
|
const OPACITY_END = 0.3,
|
|
SCALE_END = 1.5
|
|
|
|
await page.getByTestId('scenario-31-trigger').click()
|
|
await page.waitForTimeout(3000)
|
|
|
|
const endOpacity = await getOpacity(page, 'scenario-31-target')
|
|
const endScale = await getScale(page, 'scenario-31-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 0)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 0)
|
|
})
|
|
|
|
test('enterStyle animates on mount', async ({ page }) => {
|
|
const trigger = page.getByTestId('scenario-21-trigger')
|
|
await trigger.click() // Hide
|
|
await page.waitForTimeout(1000)
|
|
|
|
expect(await elementExists(page, 'scenario-21-target'), 'Hidden').toBe(false)
|
|
|
|
await trigger.click() // Show
|
|
await page.waitForTimeout(2000)
|
|
|
|
expect(await elementExists(page, 'scenario-21-target'), 'Shown').toBe(true)
|
|
expect(await getOpacity(page, 'scenario-21-target'), 'End opacity').toBeCloseTo(1, 0)
|
|
expect(await getScale(page, 'scenario-21-target'), 'End scale').toBeCloseTo(1, 0)
|
|
})
|
|
|
|
test('exitStyle has intermediate values during exit animation', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const driver = (testInfo.project?.metadata as any)?.animationDriver
|
|
const START_OPACITY = 1,
|
|
END_OPACITY = 0
|
|
|
|
// Element should be visible initially
|
|
expect(await elementExists(page, 'scenario-22-target'), 'Initially visible').toBe(
|
|
true
|
|
)
|
|
expect(await getOpacity(page, 'scenario-22-target'), 'Start opacity').toBeCloseTo(
|
|
START_OPACITY,
|
|
1
|
|
)
|
|
|
|
// Click to trigger exit animation
|
|
await page.getByTestId('scenario-22-trigger').click()
|
|
|
|
// CSS bouncy uses a visible overshoot curve; spring bouncy takes longer.
|
|
// for CSS, check at 100ms; for spring, check at 150ms
|
|
const checkTime = driver === 'css' ? 100 : 150
|
|
await page.waitForTimeout(checkTime)
|
|
|
|
const exists = await elementExists(page, 'scenario-22-target')
|
|
if (exists) {
|
|
const midOpacity = await getOpacity(page, 'scenario-22-target')
|
|
// Mid values should be intermediate (not at start, not at end)
|
|
expect(
|
|
isIntermediate(midOpacity, START_OPACITY, END_OPACITY) ||
|
|
midOpacity < START_OPACITY,
|
|
`Mid opacity (${midOpacity.toFixed(2)}) should be animating`
|
|
).toBe(true)
|
|
}
|
|
|
|
// Wait for animation to complete (spring animations can be slow)
|
|
await page.waitForTimeout(2500)
|
|
|
|
// Element should be gone after exit animation completes
|
|
expect(await elementExists(page, 'scenario-22-target'), 'Hidden after exit').toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
test('interruption redirects to new target', async ({ page }) => {
|
|
const START = 0,
|
|
FINAL = 100
|
|
|
|
expect(await getTranslateX(page, 'scenario-25-target'), 'Start').toBeCloseTo(START, 0)
|
|
|
|
await page.getByTestId('scenario-25-trigger').click()
|
|
await page.waitForTimeout(3000)
|
|
|
|
expect(await getTranslateX(page, 'scenario-25-target'), 'End').toBeCloseTo(FINAL, 0)
|
|
})
|
|
|
|
test('complex multi-property completes correctly', async ({ page }) => {
|
|
const OPACITY_END = 0.7,
|
|
SCALE_END = 1.2
|
|
|
|
await page.getByTestId('scenario-34-trigger').click()
|
|
await page.waitForTimeout(4000)
|
|
|
|
const endOpacity = await getOpacity(page, 'scenario-34-target')
|
|
const endScale = await getScale(page, 'scenario-34-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 0)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 0)
|
|
})
|
|
|
|
test('enterStyle with scaleX animates from 0 to 1', async ({ page }) => {
|
|
const END_SCALE_X = 1
|
|
const END_OPACITY = 1
|
|
|
|
// Element should not exist initially
|
|
expect(await elementExists(page, 'scenario-37-target'), 'Initially hidden').toBe(
|
|
false
|
|
)
|
|
|
|
// Click to show element
|
|
await page.getByTestId('scenario-37-trigger').click()
|
|
|
|
// Wait a moment for the element to appear and start animating
|
|
await page.waitForTimeout(100)
|
|
|
|
// Element should exist now
|
|
expect(
|
|
await elementExists(page, 'scenario-37-target'),
|
|
'Should exist after click'
|
|
).toBe(true)
|
|
|
|
// Get scaleX value (first value in matrix transform)
|
|
const getScaleX = async () => {
|
|
return page.evaluate(() => {
|
|
const el = document.querySelector('[data-testid="scenario-37-target"]')
|
|
if (!el) return -1
|
|
const transform = getComputedStyle(el).transform
|
|
if (transform === 'none') return 1
|
|
// matrix(a, b, c, d, tx, ty) - scaleX is in the 'a' position
|
|
const match = transform.match(/matrix\(([^,]+),/)
|
|
return match ? Number.parseFloat(match[1]) : 1
|
|
})
|
|
}
|
|
|
|
// Wait for animation to complete (lazy animation takes a while)
|
|
await page.waitForTimeout(2000)
|
|
|
|
const endScaleX = await getScaleX()
|
|
const endOpacity = await getOpacity(page, 'scenario-37-target')
|
|
|
|
expect(endScaleX, 'End scaleX').toBeCloseTo(END_SCALE_X, 1)
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(END_OPACITY, 1)
|
|
})
|
|
|
|
test('per-property config with transform: unlisted properties still animate', async ({
|
|
page,
|
|
}) => {
|
|
// this tests the "animationClamped" pattern fix
|
|
// transition={['quick', { opacity: '200ms', backgroundColor: '200ms' }]}
|
|
// scale and y are NOT in the per-property config but should still animate
|
|
const OPACITY_END = 0.5
|
|
const SCALE_END = 1.3
|
|
|
|
// scroll to the element first (it's at the bottom of the page)
|
|
await page.getByTestId('scenario-38-trigger').scrollIntoViewIfNeeded()
|
|
|
|
// initial state
|
|
const initialOpacity = await getOpacity(page, 'scenario-38-target')
|
|
const initialScale = await getScale(page, 'scenario-38-target')
|
|
expect(initialOpacity, 'Initial opacity').toBeCloseTo(1, 1)
|
|
expect(initialScale, 'Initial scale').toBeCloseTo(1, 1)
|
|
|
|
// trigger animation
|
|
await page.getByTestId('scenario-38-trigger').click()
|
|
await page.waitForTimeout(1500)
|
|
|
|
// both should reach end state - the key test is that scale animates
|
|
// even though it wasn't listed in the per-property config
|
|
const endOpacity = await getOpacity(page, 'scenario-38-target')
|
|
const endScale = await getScale(page, 'scenario-38-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 1)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 1)
|
|
})
|
|
|
|
test('object format per-property config: unlisted properties still animate', async ({
|
|
page,
|
|
}) => {
|
|
// same as above but using object format: { opacity: '200ms', default: 'quick' }
|
|
const OPACITY_END = 0.5
|
|
const SCALE_END = 1.3
|
|
|
|
const initialScale = await getScale(page, 'scenario-39-target')
|
|
expect(initialScale, 'Initial scale').toBeCloseTo(1, 1)
|
|
|
|
await page.getByTestId('scenario-39-trigger').click()
|
|
await page.waitForTimeout(2500)
|
|
|
|
const endOpacity = await getOpacity(page, 'scenario-39-target')
|
|
const endScale = await getScale(page, 'scenario-39-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 1)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 1)
|
|
})
|
|
|
|
test('object format WITHOUT default: only specified properties animate', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
// this test is CSS-driver specific - other drivers may handle transitions differently
|
|
const driver = (testInfo.project?.metadata as any)?.animationDriver
|
|
if (driver !== 'css') {
|
|
test.skip()
|
|
return
|
|
}
|
|
|
|
// transition={{ opacity: '200ms' }} - NO default key
|
|
// opacity should animate, scale should snap instantly
|
|
const OPACITY_END = 0.5
|
|
const SCALE_END = 1.3
|
|
|
|
const initialScale = await getScale(page, 'scenario-40-target')
|
|
expect(initialScale, 'Initial scale').toBeCloseTo(1, 1)
|
|
|
|
await page.getByTestId('scenario-40-trigger').click()
|
|
|
|
// check immediately - scale should snap to end value (no transition)
|
|
await page.waitForTimeout(50)
|
|
const immediateScale = await getScale(page, 'scenario-40-target')
|
|
expect(immediateScale, 'Scale should snap immediately').toBeCloseTo(SCALE_END, 1)
|
|
|
|
// wait for opacity animation to complete (500ms animation + buffer)
|
|
await page.waitForTimeout(800)
|
|
const endOpacity = await getOpacity(page, 'scenario-40-target')
|
|
expect(endOpacity, 'Opacity should animate to end').toBeCloseTo(OPACITY_END, 1)
|
|
})
|
|
|
|
test('per-property config with delay: both delay and per-property work together', async ({
|
|
page,
|
|
}) => {
|
|
// transition={['quick', { delay: 300, opacity: '500ms' }]}
|
|
// 300ms delay, then opacity=500ms, scale=quick
|
|
const OPACITY_START = 1,
|
|
OPACITY_END = 0.5
|
|
const SCALE_END = 1.3
|
|
|
|
const initialOpacity = await getOpacity(page, 'scenario-41-target')
|
|
expect(initialOpacity, 'Initial opacity').toBeCloseTo(OPACITY_START, 1)
|
|
|
|
await page.getByTestId('scenario-41-trigger').click()
|
|
|
|
// at 150ms (before delay ends), values should still be at start
|
|
await page.waitForTimeout(150)
|
|
const duringDelay = await getOpacity(page, 'scenario-41-target')
|
|
expect(duringDelay, 'During delay, opacity should be near start').toBeCloseTo(
|
|
OPACITY_START,
|
|
0
|
|
)
|
|
|
|
// wait for delay + animations to complete (300ms delay + 500ms opacity + buffer)
|
|
await page.waitForTimeout(1000)
|
|
|
|
const endOpacity = await getOpacity(page, 'scenario-41-target')
|
|
const endScale = await getScale(page, 'scenario-41-target')
|
|
|
|
expect(endOpacity, 'End opacity').toBeCloseTo(OPACITY_END, 1)
|
|
expect(endScale, 'End scale').toBeCloseTo(SCALE_END, 1)
|
|
})
|
|
|
|
test('animateOnly with exitStyle has intermediate values during exit animation', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const driver = (testInfo.project?.metadata as any)?.animationDriver
|
|
const START_OPACITY = 1,
|
|
END_OPACITY = 0
|
|
|
|
// scroll to the element first
|
|
await page.getByTestId('scenario-48-trigger').scrollIntoViewIfNeeded()
|
|
await page.waitForTimeout(200)
|
|
|
|
// element should be visible initially
|
|
expect(await elementExists(page, 'scenario-48-target'), 'Initially visible').toBe(
|
|
true
|
|
)
|
|
expect(await getOpacity(page, 'scenario-48-target'), 'Start opacity').toBeCloseTo(
|
|
START_OPACITY,
|
|
1
|
|
)
|
|
|
|
// click to trigger exit animation
|
|
await page.getByTestId('scenario-48-trigger').click()
|
|
|
|
// 500ms animation - check at 250ms for reliable intermediate capture
|
|
await page.waitForTimeout(250)
|
|
|
|
const exists = await elementExists(page, 'scenario-48-target')
|
|
if (exists) {
|
|
const midOpacity = await getOpacity(page, 'scenario-48-target')
|
|
// mid values should be intermediate (not at start, not at end)
|
|
expect(
|
|
isIntermediate(midOpacity, START_OPACITY, END_OPACITY) ||
|
|
midOpacity < START_OPACITY,
|
|
`Mid opacity (${midOpacity.toFixed(2)}) should be animating`
|
|
).toBe(true)
|
|
}
|
|
|
|
// wait for animation to complete
|
|
await page.waitForTimeout(800)
|
|
|
|
// element should be gone after exit animation completes
|
|
expect(await elementExists(page, 'scenario-48-target'), 'Hidden after exit').toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
test('animateOnly with enterStyle and exitStyle animates correctly', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const driver = (testInfo.project?.metadata as any)?.animationDriver
|
|
const END_OPACITY = 1
|
|
|
|
// scroll to the element first
|
|
await page.getByTestId('scenario-49-trigger').scrollIntoViewIfNeeded()
|
|
|
|
// element should be visible initially (after enterStyle animation completes)
|
|
expect(await elementExists(page, 'scenario-49-target'), 'Initially visible').toBe(
|
|
true
|
|
)
|
|
|
|
// wait for enter animation to complete if it's still running
|
|
await page.waitForTimeout(800)
|
|
|
|
// should be at final state (opacity: 1, scale: 1)
|
|
expect(
|
|
await getOpacity(page, 'scenario-49-target'),
|
|
'Opacity after enter'
|
|
).toBeCloseTo(END_OPACITY, 1)
|
|
|
|
// click to trigger exit animation
|
|
await page.getByTestId('scenario-49-trigger').click()
|
|
|
|
// check at midpoint (250ms into 500ms animation)
|
|
await page.waitForTimeout(250)
|
|
|
|
const exists = await elementExists(page, 'scenario-49-target')
|
|
if (exists) {
|
|
const midOpacity = await getOpacity(page, 'scenario-49-target')
|
|
// mid values should be animating (not at start 1, heading toward 0)
|
|
expect(
|
|
midOpacity < END_OPACITY,
|
|
`Mid opacity (${midOpacity.toFixed(2)}) should be animating toward 0`
|
|
).toBe(true)
|
|
}
|
|
|
|
// wait for animation to complete
|
|
await page.waitForTimeout(800)
|
|
|
|
// element should be gone after exit animation completes
|
|
expect(await elementExists(page, 'scenario-49-target'), 'Hidden after exit').toBe(
|
|
false
|
|
)
|
|
})
|
|
})
|