Files
gui/apps/kitchen-sink/tests/ExitCompletion.animated.test.tsx
zeekayandHanzo Dev 69c6e860d7 transplant onto tamagui v2.6.0, brand as a re-runnable transform
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]>
2026-07-30 18:25:06 -07:00

426 lines
15 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { setupPage } from './test-utils'
/**
* EXIT COMPLETION INVARIANT TESTS
*
* Tests that sendExitComplete is called exactly once per exit cycle,
* at the correct time (after animations complete), and handles edge cases
* like rapid toggling, interruptions, and zero-duration animations.
*
* These tests catch bugs in exit completion tracking across all animation drivers.
*/
interface ExitTrackingData {
counts: Record<string, number>
times: Record<string, number[]>
}
async function getExitTrackingData(page: Page): Promise<ExitTrackingData> {
return page.evaluate(() => ({
counts: (window as any).__exitCompletionCounts || {},
times: (window as any).__exitCompletionTimes || {},
}))
}
async function resetExitTracking(page: Page): Promise<void> {
await page.evaluate(() => {
;(window as any).__resetExitTracking?.()
})
}
async function waitForExitComplete(
page: Page,
scenarioId: string,
timeout = 2000
): Promise<number> {
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const data = await getExitTrackingData(page)
if (data.counts[scenarioId] && data.counts[scenarioId] > 0) {
return data.counts[scenarioId]
}
await page.waitForTimeout(50)
}
// return count even if zero (timed out)
const data = await getExitTrackingData(page)
return data.counts[scenarioId] || 0
}
async function expectStableCompletionCount(
page: Page,
scenarioId: string,
expectedCount: number,
settleMs = 300
): Promise<void> {
await page.waitForTimeout(settleMs)
const data = await getExitTrackingData(page)
expect(
data.counts[scenarioId] || 0,
`${scenarioId} should remain at ${expectedCount} completion(s)`
).toBe(expectedCount)
}
async function elementExists(page: Page, testId: string): Promise<boolean> {
return page.evaluate((id) => !!document.querySelector(`[data-testid="${id}"]`), testId)
}
test.beforeEach(async ({ page }) => {
await setupPage(page, { name: 'ExitCompletionCase', type: 'useCase' })
await page.waitForTimeout(500) // let initial render settle
await resetExitTracking(page)
})
test.describe('Basic Exit Completion', () => {
test('scenario 01: basic exit completes exactly once', async ({ page }) => {
await page.getByTestId('exit-01-trigger').click()
// wait for exit to complete
const count = await waitForExitComplete(page, '01-basic-exit')
expect(count, 'Exit should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '01-basic-exit', 1, 400)
expect(await elementExists(page, 'exit-01-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 02: zero duration exit completes exactly once', async ({ page }) => {
await page.getByTestId('exit-02-trigger').click()
// zero duration should still complete once
const count = await waitForExitComplete(page, '02-zero-duration', 500)
expect(count, 'Zero duration exit should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '02-zero-duration', 1, 200)
expect(await elementExists(page, 'exit-02-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 03: very short duration (30ms) completes exactly once', async ({
page,
}) => {
await page.getByTestId('exit-03-trigger').click()
const count = await waitForExitComplete(page, '03-short-duration', 2000)
expect(count, 'Short duration exit should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '03-short-duration', 1, 400)
expect(await elementExists(page, 'exit-03-target'), 'Element should be gone').toBe(
false
)
})
})
test.describe('Duplicate Completion Guards', () => {
test('scenario 04: rapid toggle does not cause duplicate completions', async ({
page,
}) => {
// this test catches finding #4/#5: duplicate completions on re-renders
await page.getByTestId('exit-04-trigger').click()
// wait for the sequence to complete (off -> on -> off)
// CI can be slow, give plenty of time for animations
await page.waitForTimeout(1500)
const data = await getExitTrackingData(page)
const count = data.counts['04-rapid-toggle'] || 0
// first exit is interrupted; only final exit should complete
expect(count, 'Rapid toggle should complete exactly once for the final exit').toBe(1)
expect(await elementExists(page, 'exit-04-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 05: re-renders during exit do not cause duplicate completions', async ({
page,
}) => {
// this test catches finding #4: flush restart causing duplicate completions
await page.getByTestId('exit-05-trigger').click()
// wait for exit + re-renders to complete
// CI can be slow, give plenty of time
await page.waitForTimeout(1500)
const data = await getExitTrackingData(page)
const count = data.counts['05-rerender-during-exit'] || 0
expect(count, 'Re-renders during exit should not cause duplicate completions').toBe(1)
expect(await elementExists(page, 'exit-05-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 06: multiple children exiting fires completion once', async ({
page,
}) => {
await page.getByTestId('exit-06-trigger').click()
// wait for all 3 children to exit
await page.waitForTimeout(800)
const data = await getExitTrackingData(page)
const count = data.counts['06-multiple-children'] || 0
// AnimatePresence should fire onExitComplete once after ALL children exit
expect(count, 'Multiple children should trigger one completion').toBe(1)
expect(await elementExists(page, 'exit-06-target-1'), 'Child 1 should be gone').toBe(
false
)
expect(await elementExists(page, 'exit-06-target-2'), 'Child 2 should be gone').toBe(
false
)
expect(await elementExists(page, 'exit-06-target-3'), 'Child 3 should be gone').toBe(
false
)
})
})
test.describe('Timing Validation', () => {
test('scenario 07: long animation (500ms) completes after animation finishes', async ({
page,
}) => {
await page.getByTestId('exit-07-trigger').click()
const startTime = Date.now()
await waitForExitComplete(page, '07-long-animation', 2000)
const elapsed = Date.now() - startTime
const data = await getExitTrackingData(page)
const completionTime = data.times['07-long-animation']?.[0] || 0
// completion should happen after ~500ms, not immediately
// allow some tolerance for CI variance
expect(
completionTime,
'Exit should complete after animation (>350ms)'
).toBeGreaterThan(350)
expect(data.counts['07-long-animation'], 'Should complete exactly once').toBe(1)
})
test('scenario 08: interrupted exit completes at most once', async ({ page }) => {
// this test catches finding #2: canceled animations counted as complete
await page.getByTestId('exit-08-trigger').click()
// wait for the full sequence: exit -> interrupt -> exit again
await page.waitForTimeout(1200)
const data = await getExitTrackingData(page)
const count = data.counts['08-interrupted'] || 0
// the interrupted exit should not count as complete:
// only the final successful exit should complete
expect(count, 'Interrupted exit should complete exactly once').toBe(1)
const completionTime = data.times['08-interrupted']?.[0] || 0
expect(
completionTime,
'Interrupted scenario should not complete early (final completion should be late)'
).toBeGreaterThan(350)
// final state should be hidden
const status = await page.getByTestId('exit-08-status').textContent()
expect(status).toBe('hidden')
})
test('scenario 09: stress test with many cancel/restart cycles', async ({ page }) => {
await page.getByTestId('exit-09-trigger').click()
// wait for the stress test sequence to complete
await page.waitForTimeout(1500)
const data = await getExitTrackingData(page)
const count = data.counts['09-cancel-restart'] || 0
// all interim exits are interrupted; only the final exit should complete
expect(count, 'Stress test should complete exactly once').toBe(1)
// final state should be hidden
const status = await page.getByTestId('exit-09-status').textContent()
expect(status).toBe('hidden')
})
})
test.describe('Per-Property Exit', () => {
test('scenario 10: per-property exit waits for longest animation', async ({
page,
}, testInfo) => {
// motion driver can't differentiate transform sub-keys (scale becomes transform)
// so per-property timing for scale vs opacity doesn't work
const driver = (testInfo.project?.metadata as any)?.animationDriver
if (driver === 'motion') {
test.skip()
return
}
// this test catches finding #1: emitter-driven keys not tracked
// opacity=100ms, scale=500ms - should wait for scale
await page.getByTestId('exit-10-trigger').click()
await waitForExitComplete(page, '10-per-property', 2000)
const data = await getExitTrackingData(page)
const completionTime = data.times['10-per-property']?.[0] || 0
// should wait for the slow scale animation (500ms), not just opacity (100ms)
// allow tolerance for CI
expect(
completionTime,
'Per-property exit should wait for longest animation (>350ms)'
).toBeGreaterThan(350)
expect(data.counts['10-per-property'], 'Should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '10-per-property', 1, 350)
})
test('scenario 11: mixed duration exit completes after slowest property', async ({
page,
}, testInfo) => {
// motion driver can't differentiate transform sub-keys
const driver = (testInfo.project?.metadata as any)?.animationDriver
if (driver === 'motion') {
test.skip()
return
}
// opacity=100ms, scale/y=400ms
await page.getByTestId('exit-11-trigger').click()
await waitForExitComplete(page, '11-mixed-duration', 2000)
const data = await getExitTrackingData(page)
const completionTime = data.times['11-mixed-duration']?.[0] || 0
// should wait for 400ms animations, not 100ms opacity
expect(
completionTime,
'Mixed duration should wait for slowest property (>300ms)'
).toBeGreaterThan(300)
expect(data.counts['11-mixed-duration'], 'Should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '11-mixed-duration', 1, 350)
})
})
test.describe('Element removal timing', () => {
test('element should exist during exit animation', async ({ page }) => {
// native driver has timing issues on web - animations complete too quickly
const driver = (test.info().project?.metadata as any)?.animationDriver
test.skip(driver === 'native', 'native driver has animation timing issues on web')
// verify element stays in DOM during exit animation
await page.getByTestId('exit-07-trigger').click()
// immediately after triggering, element should still exist
await page.waitForTimeout(50)
expect(
await elementExists(page, 'exit-07-target'),
'Element should exist during exit animation'
).toBe(true)
// at 200ms into 500ms animation, should still exist
await page.waitForTimeout(150)
expect(
await elementExists(page, 'exit-07-target'),
'Element should still exist at 200ms into 500ms animation'
).toBe(true)
// after animation completes, should be gone
await page.waitForTimeout(500)
expect(
await elementExists(page, 'exit-07-target'),
'Element should be gone after animation completes'
).toBe(false)
})
})
test.describe('AnimateOnly & Transform Sub-Keys', () => {
test('scenario 51: animateOnly excludes scale from pending set', async ({
page,
}, testInfo) => {
// this test uses CSS-style duration strings which only CSS driver supports
const driver = (testInfo.project?.metadata as any)?.animationDriver
if (driver !== 'css') {
test.skip()
return
}
// animateOnly=['opacity'] with scale=500ms, opacity=100ms
// should complete based on opacity timing (~100ms), not scale
await page.getByTestId('exit-51-trigger').click()
await waitForExitComplete(page, '51-animateonly-exclusion', 2000)
const data = await getExitTrackingData(page)
const completionTime = data.times['51-animateonly-exclusion']?.[0] || 0
// should complete after opacity (100ms) finishes, not wait for excluded scale (500ms)
// allow tolerance but should NOT wait 400-500ms
expect(
completionTime,
'AnimateOnly should exclude scale - complete around opacity time (not 400+ms)'
).toBeLessThan(400)
expect(data.counts['51-animateonly-exclusion'], 'Should complete exactly once').toBe(
1
)
await expectStableCompletionCount(page, '51-animateonly-exclusion', 1, 350)
expect(await elementExists(page, 'exit-51-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 53: transform sub-keys with different durations wait for longest', async ({
page,
}, testInfo) => {
// this test uses CSS-style duration strings ('100ms', '500ms') which only CSS driver supports
const driver = (testInfo.project?.metadata as any)?.animationDriver
if (driver !== 'css') {
test.skip()
return
}
// scale=100ms, y=500ms - should wait for y
await page.getByTestId('exit-53-trigger').click()
await waitForExitComplete(page, '53-transform-subkeys', 2000)
const data = await getExitTrackingData(page)
const completionTime = data.times['53-transform-subkeys']?.[0] || 0
// should wait for the slower y animation (500ms), not just scale (100ms)
expect(
completionTime,
'Transform sub-keys should wait for longest (y=500ms, should be >350ms)'
).toBeGreaterThan(350)
expect(data.counts['53-transform-subkeys'], 'Should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '53-transform-subkeys', 1, 350)
expect(await elementExists(page, 'exit-53-target'), 'Element should be gone').toBe(
false
)
})
test('scenario 55: zero animatable props completes immediately', async ({ page }) => {
// animateOnly=[] means nothing should animate, immediate completion
await page.getByTestId('exit-55-trigger').click()
const startTime = Date.now()
await waitForExitComplete(page, '55-zero-animatable', 1000)
const elapsed = Date.now() - startTime
const data = await getExitTrackingData(page)
const completionTime = data.times['55-zero-animatable']?.[0] || 0
// should complete almost immediately since no animations
// 300ms threshold to account for CI overhead (actual should be <10ms)
expect(
completionTime,
'Zero animatable props should complete immediately (<300ms)'
).toBeLessThan(300)
expect(data.counts['55-zero-animatable'], 'Should complete exactly once').toBe(1)
await expectStableCompletionCount(page, '55-zero-animatable', 1, 200)
expect(await elementExists(page, 'exit-55-target'), 'Element should be gone').toBe(
false
)
})
})