Skip to content

Commit 9ed7634

Browse files
committed
add playwright-based e2e tests with visual regression testing, add a snapshot test of the 03_graphs example for starters. Update tsconfig to ignore compiling TS files in e2e/, playwright handles those directly
1 parent 55ac163 commit 9ed7634

10 files changed

Lines changed: 177 additions & 36 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,34 @@ jobs:
1414
node-version: [lts/*]
1515

1616
steps:
17-
- uses: actions/checkout@v2
17+
- uses: actions/checkout@v4
1818
- name: Use Node.js ${{ matrix.node-version }}
1919
uses: actions/setup-node@v4
2020
with:
2121
node-version: ${{ matrix.node-version }}
22+
cache: npm
2223
- run: npm clean-install
24+
- run: npx playwright install --with-deps chromium
25+
26+
# Uncomment this if for some reason you suspect CI (Linux) build differes from local, then you can inspect output.
27+
# - name: Upload dist artifact
28+
# uses: actions/upload-artifact@v4
29+
# with:
30+
# name: dist
31+
# path: dist/
32+
2333
- run: npm test
34+
35+
- name: Upload Playwright rest results
36+
uses: actions/upload-artifact@v4
37+
if: always()
38+
with:
39+
name: playwright-test-results
40+
path: test-results/
41+
42+
- name: Upload Playwright e2e snapshots
43+
uses: actions/upload-artifact@v4
44+
if: always()
45+
with:
46+
name: playwright-e2e-snapshots
47+
path: e2e/**/*-snapshots/

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
build/npm*
66
node_modules
77
npm-debug.log
8+
test-results/

‎e2e/graphs.spec.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {test, expect} from '@playwright/test'
2+
3+
test('03_graphs.html — easing curve thumbnails', async ({page}) => {
4+
await page.goto('/examples/03_graphs.html', {waitUntil: 'load'})
5+
6+
// Wait for the 2000ms tween animations to finish drawing all curves.
7+
await page.waitForTimeout(2500)
8+
9+
// The grid of canvas thumbnails should now show complete easing curves.
10+
await expect(page).toHaveScreenshot('03_graphs.webp', {
11+
fullPage: true,
12+
maxDiffPixelRatio: 0.01,
13+
})
14+
})
77.8 KB
Loading

‎e2e/playwright.config.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {defineConfig} from '@playwright/test'
2+
3+
export default defineConfig({
4+
testDir: '.',
5+
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}',
6+
webServer: {
7+
command: 'python3 -m http.server 3333',
8+
cwd: process.cwd(),
9+
port: 3333,
10+
reuseExistingServer: true,
11+
},
12+
use: {
13+
baseURL: 'http://localhost:3333',
14+
viewport: {width: 1280, height: 1030},
15+
},
16+
})

‎examples/20_timeline.html‎

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ <h2>20 _ timeline</h2>
1919
<p>Sequencing with Timeline: sequential by default, parallel via offsets and labels.</p>
2020
</div>
2121
<div style="position: absolute; top: 100px; left: 400px">
22-
<div id="target1" data-x="0" data-rotation="0" class="box">sequential</div>
23-
<div id="target2" data-x="0" data-rotation="0" class="box">parallel</div>
24-
<div id="target3" data-x="0" data-rotation="0" class="box">labels + nesting</div>
22+
<div id="target1" data-y="0" data-rotation="0" class="box">sequential</div>
23+
<div id="target2" data-y="0" data-rotation="0" class="box">parallel</div>
24+
<div id="target3" data-y="0" data-rotation="0" class="box">labels + nesting</div>
2525
</div>
2626

2727
<script type="module">
@@ -35,43 +35,38 @@ <h2>20 _ timeline</h2>
3535
function init() {
3636
// 1. Sequential: add() appends after the last child (replaces chain).
3737
const target1 = document.getElementById('target1')
38-
const seq = new Timeline()
39-
.add(new Tween(target1.dataset).to({x: 200}, 800).easing(Easing.Quadratic.InOut))
40-
.add(new Tween(target1.dataset).to({x: 0}, 800).easing(Easing.Quadratic.InOut))
41-
.onUpdate(function () {
42-
updateBox(target1, target1.dataset)
43-
})
44-
seq.start()
45-
timelines.push(seq)
38+
const sequence = new Timeline()
39+
.add(new Tween(target1.dataset).to({y: 200}, 800).easing(Easing.Quadratic.InOut))
40+
.add(new Tween(target1.dataset).to({y: 0}, 800).easing(Easing.Quadratic.InOut))
41+
.onUpdate(() => updateBox(target1, target1.dataset))
42+
sequence.start()
43+
timelines.push(sequence)
4644

4745
// 2. Parallel: explicit offset 0 starts children together
4846
// (two tweens, two different properties of the same box).
4947
const target2 = document.getElementById('target2')
50-
const par = new Timeline()
51-
.add(new Tween(target2.dataset).to({x: 200}, 1000).easing(Easing.Sinusoidal.InOut), 0)
48+
const parallel = new Timeline()
49+
.add(new Tween(target2.dataset).to({y: 200}, 1000).easing(Easing.Sinusoidal.InOut), 0)
5250
.add(new Tween(target2.dataset).to({rotation: 360}, 1000).easing(Easing.Sinusoidal.InOut), 0)
53-
.onUpdate(function () {
54-
updateBox(target2, target2.dataset)
55-
})
56-
par.start()
57-
timelines.push(par)
51+
.add(new Tween(target2.dataset).to({y: 0}, 1000).easing(Easing.Sinusoidal.InOut))
52+
.onUpdate(() => updateBox(target2, target2.dataset))
53+
parallel.start()
54+
timelines.push(parallel)
5855

5956
// 3. Labels and nesting: an inner timeline inside an outer one.
6057
// The spin starts 200ms after the 'moved' label, leaving a gap.
6158
const target3 = document.getElementById('target3')
6259
const inner = new Timeline()
63-
.add(new Tween(target3.dataset).to({x: 200}, 600).easing(Easing.Quadratic.Out), 0)
60+
.add(new Tween(target3.dataset).to({y: 200}, 600).easing(Easing.Quadratic.Out), 0)
6461
.addLabel('moved', 600)
6562
.add(new Tween(target3.dataset).to({rotation: 180}, 600).easing(Easing.Quadratic.InOut), {
6663
at: 'moved',
6764
offset: 200,
6865
})
6966
.add(new Tween(target3.dataset).to({rotation: 0}, 600).easing(Easing.Quadratic.InOut))
70-
.add(new Tween(target3.dataset).to({x: 0}, 600).easing(Easing.Quadratic.InOut))
67+
.add(new Tween(target3.dataset).to({y: 0}, 600).easing(Easing.Quadratic.InOut))
7168

72-
const outer = new Timeline().add(inner, 0).onUpdate(function () {
73-
updateBox(target3, target3.dataset)
74-
})
69+
const outer = new Timeline().add(inner, 0).onUpdate(() => updateBox(target3, target3.dataset))
7570
outer.start()
7671
timelines.push(outer)
7772
}
@@ -86,7 +81,7 @@ <h2>20 _ timeline</h2>
8681

8782
function updateBox(box, params) {
8883
const s = box.style,
89-
transform = 'translateX(' + Math.floor(params.x) + 'px) rotate(' + Math.floor(params.rotation) + 'deg)'
84+
transform = 'translateY(' + Math.floor(params.y) + 'px) rotate(' + Math.floor(params.rotation) + 'deg)'
9085
s.webkitTransform = transform
9186
s.mozTransform = transform
9287
s.transform = transform

‎package-lock.json‎

Lines changed: 70 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,17 @@
3131
"tween",
3232
"interpolation"
3333
],
34-
"dependencies": {},
3534
"scripts": {
3635
"dev": "(npm run tsc-watch -- --preserveWatchOutput & p1=$!; npm run rollup-build -- --watch & p2=$!; wait $p1 $p2)",
3736
"build": "rimraf dist .tmp && node scripts/write-version.js && npm run tsc && npm run rollup-build",
3837
"rollup-build": "rollup -c ./rollup.config.js",
3938
"tsc": "tsc",
4039
"tsc-watch": "tsc --watch",
4140
"examples": "npx serve .",
42-
"test": "npm run build && npm run test-lint && npm run test-unit",
41+
"test": "npm run build && npm run test-lint && npm run test-unit && npm run test-e2e",
4342
"test-unit": "nodeunit test/unit/nodeunitheadless.cjs",
43+
"test-e2e": "playwright test e2e/ --config=e2e/playwright.config.ts",
44+
"test-e2e-update-snapshots": "playwright test e2e/ --config=e2e/playwright.config.ts --update-snapshots",
4445
"test-lint": "npm run prettier -- --check",
4546
"lint": "npm run prettier -- --write",
4647
"prettier": "prettier .",
@@ -53,6 +54,7 @@
5354
},
5455
"author": "tween.js contributors (https://github.com/tweenjs/tween.js/graphs/contributors)",
5556
"devDependencies": {
57+
"@playwright/test": "^1.63.0",
5658
"nodeunit": "^0.11.3",
5759
"prettier": "^3.0.0",
5860
"rimraf": "^3.0.0",

‎src/Timeline.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class Timeline {
122122
private _isPlaying = false
123123
private _isPaused = false
124124
private _pauseStart = 0
125+
private _lastUpdateTime = -Infinity
125126

126127
private _onStartCallback?: (timeline: Timeline) => void
127128
private _onStartCallbackFired = false
@@ -512,6 +513,7 @@ export class Timeline {
512513
this._isPaused = false
513514
this._onStartCallbackFired = false
514515
this._startTime = time
516+
this._lastUpdateTime = -Infinity
515517

516518
for (const entry of this._entries) {
517519
if (entry.node.isPlaying()) entry.node.stop()
@@ -594,23 +596,39 @@ export class Timeline {
594596

595597
const effectiveLocal = Math.min(timelineLocal, this._duration)
596598

599+
// Detect scrub-back vs forward play: only eagerly restart/clamp
600+
// late-offset children when the playhead moved backward. On
601+
// forward playback (including auto-restart), children start
602+
// lazily so same-property tweens chain correctly.
603+
const scrubbingBack = time < this._lastUpdateTime
604+
this._lastUpdateTime = time
605+
597606
for (const entry of this._entries) {
598607
const child = entry.node
599608
if (!entry.started) {
600-
// First start must wait until due; a never-started child left
601-
// behind stays untouched. (Later re-starts are harmless and
602-
// handled below, since Tween keeps its captured setup.)
609+
// First start must wait until due; a never-started child
610+
// left behind stays untouched.
603611
if (effectiveLocal < entry.offset) continue
604612
child.start(entry.offset)
605613
entry.started = true
606614
} else if (!child.isPlaying() && effectiveLocal < entry.offset + child.getTotalDuration()) {
607-
// Re-enter when the playhead is inside the child's range after
608-
// scrubbing back.
609-
child.start(entry.offset)
615+
// On scrub-back, eagerly re-enter children past the playhead
616+
// so they snap to their start values. On forward playback
617+
// only re-enter when the playhead has reached the child.
618+
if (scrubbingBack || effectiveLocal >= entry.offset) {
619+
child.start(entry.offset)
620+
}
621+
}
622+
// On scrub-back, clamp late children to their offset so they
623+
// output start values. On forward playback let the child
624+
// start lazily (no output until playhead reaches offset).
625+
if (scrubbingBack && effectiveLocal < entry.offset) {
626+
child.update(entry.offset)
627+
} else if (!scrubbingBack && effectiveLocal < entry.offset && !child.isPlaying()) {
628+
// Forward play: child hasn't been reached yet, skip.
629+
} else {
630+
child.update(effectiveLocal)
610631
}
611-
// Clamp the lower end so reversed/scrubbed playheads snap the
612-
// child to its start value instead of freezing on stale values.
613-
child.update(effectiveLocal < entry.offset ? entry.offset : effectiveLocal)
614632
}
615633

616634
if (!isFinite(this._duration)) {

‎tsconfig.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@
1414
"ignoreDeprecations": "5.0",
1515
"importsNotUsedAsValues": "error"
1616
},
17+
"include": ["src"], // we don't need to compile e2e/ (playwright handles .ts files there), only src/
1718
"exclude": ["node_modules", "examples"]
1819
}

0 commit comments

Comments
 (0)