jellyfin-tools/jellyfin-year-review/web/tests/review.spec.js
jellyfin-year-review/web/tests/review.spec.jsRaw
1import { test, expect } from '@playwright/test';
2const offline = process.env.REEL_E2E_AUTH_MODE === 'offline';
3
4test('private review: login, navigation, years, responsive layout, logout', async ({ page }) => {
5 const errors = [];
6 const externalRequests = [];
7 page.on('request', request => { if (new URL(request.url()).origin !== 'http://127.0.0.1:18180') externalRequests.push(request.url()); });
8 page.on('pageerror', e => errors.push(e.message));
9 await page.goto('/review/2025');
10 await expect(page.getByRole('heading', { name: 'Your year, replayed.' })).toBeVisible();
11 await expect(page.locator('header, footer, .login-intro')).toHaveCount(0);
12 if (offline) await expect(page.getByText(/checked locally against a private Jellyfin account snapshot/)).toBeVisible();
13 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
14 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
15 await page.getByRole('button', { name: 'Roll the credits' }).click();
16 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
17 await page.getByRole('button', { name: 'View my recap' }).click();
18 await expect(page.getByText('hours watched', { exact: true })).toBeVisible();
19 await page.getByRole('button', { name: '3. Movies watched', exact: true }).click();
20 await expect(page.getByRole('heading', { name: 'The Last Lighthouse' }).first()).toBeVisible();
21 const poster = page.getByRole('img', { name: 'Poster for The Last Lighthouse' }).first();
22 await expect(poster).toBeVisible();
23 await expect(poster).toHaveAttribute('src', /^\/art\/[a-f0-9]{64}\.jpg$/);
24 await expect.poll(() => poster.evaluate(image => image.naturalWidth)).toBeGreaterThan(0);
25 await page.locator('main').click({ position: { x: 5, y: 5 } });
26 await page.keyboard.press('ArrowRight');
27 await expect(page.getByRole('heading', { name: 'Orbit Station' }).first()).toBeVisible();
28 await page.getByLabel('Review year').selectOption('2024');
29 await expect(page).toHaveURL(/2024$/);
30 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
31 await page.reload();
32 await expect(page.getByLabel('Review year')).toHaveValue('2024');
33 for (const label of [/Viewing by hour$/, /Your longest viewing session$/, /Your viewing profile$/, /Your year in summary$/]) {
34 await page.getByRole('button', { name: label, exact: true }).click();
35 await expect(page.locator('.story')).toBeVisible();
36 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
37 }
38 await page.screenshot({ path: 'test-results/review-mobile.png', fullPage: true });
39 await page.setViewportSize({ width: 1440, height: 1000 });
40 await page.screenshot({ path: 'test-results/review-desktop.png', fullPage: true });
41 await page.getByRole('button', { name: 'Sign out' }).click();
42 await expect(page.getByLabel('Jellyfin username')).toBeVisible();
43 await expect(page.locator('header, footer, .login-intro')).toHaveCount(0);
44 expect((await page.request.get('/api/review?year=2025')).status()).toBe(401);
45 expect(errors).toEqual([]);
46 expect(externalRequests).toEqual([]);
47});
48
49
50test('unavailable cards are skipped and chapter navigation updates when changing years', async ({ page }) => {
51 await page.route('**/api/review?year=*', async route => {
52 const response = await route.fetch();
53 const review = await response.json();
54 for (const availability of Object.values(review.availability)) {
55 availability.available = review.year !== 2025;
56 }
57 await route.fulfill({ response, json: review });
58 });
59 await page.goto('/review/2025');
60 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
61 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
62 await page.getByRole('button', { name: 'Roll the credits' }).click();
63 const chapters = page.getByRole('navigation', { name: 'Review chapters' });
64 await expect(chapters.getByRole('button')).toHaveCount(10);
65 await expect(chapters.getByRole('button', { name: /Your watch time|Unfinished movies|Your longest viewing session|Your viewing profile|It's not a competition\.\.\. but this is your watch-time ranking/ })).toHaveCount(0);
66 await page.getByRole('button', { name: 'View my recap' }).click();
67 await expect(page.getByRole('heading', { name: 'Movies watched' })).toBeVisible();
68 await page.locator('main').click({ position: { x: 5, y: 5 } });
69 await page.keyboard.press('ArrowRight');
70 await expect(page.getByRole('heading', { name: 'TV shows watched' })).toBeVisible();
71 await page.getByRole('button', { name: 'Next chapter' }).click();
72 await expect(page.getByRole('heading', { name: "Shows you've watched are from platforms..." })).toBeVisible();
73 await page.getByRole('button', { name: 'Next chapter' }).click();
74 await expect(page.getByRole('heading', { name: 'Your top genres' })).toBeVisible();
75 await page.locator('main').click({ position: { x: 5, y: 5 } });
76 await page.keyboard.press('End');
77 await expect(page.getByRole('heading', { name: 'Your year in summary' })).toBeVisible();
78 await expect(page.locator('.story-bottom')).toContainText('10 / 10');
79 await expect(page.getByRole('button', { name: 'Next chapter' })).toBeDisabled();
80 await expect(page.locator('.stat-grid')).not.toContainText('Your viewing profile');
81 await page.getByLabel('Review year').selectOption('2024');
82 await expect(chapters.getByRole('button')).toHaveCount(15);
83 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
84 await chapters.getByRole('button', { name: '5. Unfinished movies', exact: true }).click();
85 await expect(page.getByRole('heading', { name: 'Unfinished movies' })).toBeVisible();
86 await page.getByLabel('Review year').selectOption('2025');
87 await expect(chapters.getByRole('button')).toHaveCount(10);
88 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
89});
90
91test('complete movie and show timelines include every title and support older exports', async ({ page }) => {
92 let mode = 'timeline';
93 await page.route('**/api/review?year=*', async route => {
94 const response = await route.fetch();
95 const review = await response.json();
96 for (const [field, title] of [['movies', 'Movie'], ['shows', 'Show']]) {
97 review[field] = mode === 'empty' ? [] : Array.from({ length: 7 }, (_, i) => ({
98 id: `${field}-${i}`, title: `${title} ${i + 1}`, seconds: 3600, records: 1,
99 ...(i === 0 && field === 'shows' ? { poster: review.shows[0]?.poster, posterSource: 'https://www.tvmaze.com/shows/42' } : {}),
100 ...(mode === 'legacy' ? {} : { months: i === 0 ? [1, 3] : i === 6 ? [12] : [1] })
101 }));
102 }
103 await route.fulfill({ response, json: review });
104 });
105 await page.goto('/review/2025');
106 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
107 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
108 await page.getByRole('button', { name: 'Roll the credits' }).click();
109 for (const [label, title] of [['Movies watched', 'Movie'], ['TV shows watched', 'Show']]) {
110 await page.getByRole('button', { name: new RegExp(`${label}$`) }).click();
111 await expect(page.locator('.timeline-month > h3')).toHaveText(['January 2025', 'March 2025', 'December 2025']);
112 await expect(page.getByRole('region', { name: 'January 2025', exact: true }).getByRole('listitem')).toHaveCount(6);
113 await expect(page.getByRole('region', { name: 'March 2025', exact: true }).getByRole('heading', { name: `${title} 1`, exact: true })).toBeVisible();
114 await expect(page.getByRole('region', { name: 'December 2025', exact: true }).getByRole('heading', { name: `${title} 7` })).toBeVisible();
115 await expect(page.locator('.timeline-titles li')).toHaveCount(8);
116 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
117 }
118 await expect(page.getByRole('link', { name: 'TVmaze', exact: true })).toHaveCount(2);
119 await expect(page.getByRole('link', { name: 'TVmaze', exact: true }).first()).toHaveAttribute('href', 'https://www.tvmaze.com/shows/42');
120 mode = 'legacy';
121 await page.reload();
122 await page.getByRole('button', { name: /Movies watched$/ }).click();
123 await expect(page.getByRole('region', { name: 'Viewing dates unavailable' }).getByRole('listitem')).toHaveCount(7);
124 mode = 'empty';
125 await page.reload();
126 await page.getByRole('button', { name: /Movies watched$/ }).click();
127 await expect(page.getByText('No movies watched in 2025.')).toBeVisible();
128 await expect(page.locator('.viewing-timeline')).toHaveCount(0);
129});
130
131test('ranking celebrates on entry, clears on exit, and respects reduced motion', async ({ page }) => {
132 await page.route('**/api/review?year=*', async route => {
133 const response = await route.fetch();
134 const review = await response.json();
135 review.ranking = { rank: 3, cohort: 6, percentile: 40 };
136 review.availability.ranking.available = true;
137 await route.fulfill({ response, json: review });
138 });
139 await page.emulateMedia({ reducedMotion: 'no-preference' });
140 await page.goto('/review/2025');
141 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
142 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
143 await page.getByRole('button', { name: 'Roll the credits' }).click();
144 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
145 const confetti = page.locator('.confetti');
146 await expect(confetti).toHaveCount(0);
147 await page.getByRole('button', { name: /It's not a competition\.\.\. but this is your watch-time ranking$/ }).click();
148 await expect(confetti).toBeVisible();
149 await expect(confetti).toHaveAttribute('aria-hidden', 'true');
150 expect(await confetti.evaluate(element => {
151 const animations = element.getAnimations({ subtree: true });
152 if (!animations.length) return false;
153 for (const animation of animations) animation.finish();
154 return [...element.children].every(piece => piece.getBoundingClientRect().top > innerHeight);
155 })).toBeTruthy();
156 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
157 await page.getByRole('button', { name: 'Next chapter' }).click();
158 await expect(confetti).toHaveCount(0);
159 await page.getByRole('button', { name: 'Previous chapter' }).click();
160 await expect(confetti).toBeVisible();
161 expect(await confetti.evaluate(element => element.getAnimations({ subtree: true }).some(animation => animation.playState === 'running'))).toBeTruthy();
162 await page.emulateMedia({ reducedMotion: 'reduce' });
163 await expect(confetti).toBeHidden();
164 await expect(page.getByRole('heading', { name: "It's not a competition... but this is your watch-time ranking" })).toBeVisible();
165});
166
167test('anonymous ranking highlights your position and handles ties and edge positions', async ({ page }) => {
168 let ranking = { rank: 3, cohort: 6, percentile: 40 };
169 await page.route('**/api/review?year=*', async route => {
170 const response = await route.fetch();
171 const review = await response.json();
172 review.ranking = ranking;
173 review.availability.ranking.available = true;
174 await route.fulfill({ response, json: review });
175 });
176 await page.goto('/review/2025');
177 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
178 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
179 await page.getByRole('button', { name: 'Roll the credits' }).click();
180 const chart = page.locator('.ranking-position');
181 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
182 for (const sample of [
183 { rank: 3, cohort: 6, percentile: 40, above: 2, tied: 1, below: 2 },
184 { rank: 1, cohort: 6, percentile: 100, above: 0, tied: 0, below: 5 },
185 { rank: 6, cohort: 6, percentile: 0, above: 5, tied: 0, below: 0 },
186 { rank: 1, cohort: 6, percentile: 0, above: 0, tied: 5, below: 0 },
187 { rank: 1, cohort: 1, percentile: 0, above: 0, tied: 0, below: 0 }
188 ]) {
189 ranking = { rank: sample.rank, cohort: sample.cohort, percentile: sample.percentile };
190 await page.reload();
191 await page.getByRole('button', { name: /It's not a competition\.\.\. but this is your watch-time ranking$/ }).click();
192 await expect(chart.getByRole('listitem')).toHaveCount(sample.cohort);
193 await expect(chart.getByRole('listitem', { name: `You: rank ${sample.rank} of ${sample.cohort}`, exact: true })).toHaveCount(1);
194 await expect(chart.locator('.above')).toHaveCount(sample.above);
195 await expect(chart.locator('.tied')).toHaveCount(sample.tied);
196 await expect(chart.locator('.below')).toHaveCount(sample.below);
197 await expect(chart).not.toContainText(offline ? 'Alice' : 'Fixture Viewer');
198 await expect(chart.locator('a')).toHaveCount(0);
199 if (sample.rank === 3) {
200 await chart.screenshot({ path: 'test-results/ranking-mobile.png' });
201 await expect(page.getByText('Your total watch time compared with other eligible viewers.', { exact: true })).toHaveCount(0);
202 const title = page.getByRole('heading', { name: "It's not a competition... but this is your watch-time ranking" });
203 await expect(title.locator('span')).toHaveText(["It's not a competition...", 'but this is your watch-time ranking']);
204 const headingSizes = await title.locator('span').evaluateAll(elements => elements.map(element => parseFloat(getComputedStyle(element).fontSize)));
205 expect(headingSizes[1]).toBeLessThan(headingSizes[0]);
206 expect(headingSizes[1]).toBeGreaterThan(headingSizes[0] * .8);
207 const highlights = page.locator('.ranking-highlights');
208 await expect(highlights.locator('.ranking-value')).toHaveText(['#3', '12 h']);
209 for (const width of [390, 1440, 320]) {
210 await page.setViewportSize({ width, height: 1000 });
211 const metrics = await highlights.locator('.big-stat').evaluateAll(elements => elements.map(element => {
212 const box = element.getBoundingClientRect();
213 const value = element.querySelector('.ranking-value');
214 return { x: box.x, y: box.y, fontSize: parseFloat(getComputedStyle(value).fontSize) };
215 }));
216 expect(metrics[1].x).toBeGreaterThan(metrics[0].x);
217 expect(metrics[1].y).toBe(metrics[0].y);
218 const secondarySize = await page.locator('.ranking-secondary b').evaluate(element => parseFloat(getComputedStyle(element).fontSize));
219 expect(metrics[1].fontSize).toBeGreaterThan(secondarySize);
220 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
221 await page.locator('.story').screenshot({ path: `test-results/ranking-highlights-${width}.png` });
222 }
223 await page.setViewportSize({ width: 390, height: 844 });
224 }
225 if (sample.cohort === 1) await expect(chart).toContainText('You are the only eligible viewer.');
226 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
227 }
228});
229
230test('platform card shows only TV show counts and handles missing data', async ({ page }) => {
231 let mode = 'populated';
232 await page.route('**/api/review?year=*', async route => {
233 const response = await route.fetch();
234 const review = await response.json();
235 if (mode === 'populated' || mode === 'old-label') review.platforms = [
236 { name: 'Netflix', movies: 3, shows: 2 },
237 { name: 'HBO', movies: 0, shows: 1 },
238 { name: 'Apple TV+', movies: 1, shows: 0 },
239 { name: 'Other', movies: 20, shows: 1 }
240 ];
241 else delete review.platforms;
242 if (mode === 'old-label') {
243 review.platforms.pop();
244 review.platforms.unshift({ name: 'Unknown platform', movies: 20, shows: 1 });
245 }
246 if (mode === 'empty') review.records = 0;
247 await route.fulfill({ response, json: review });
248 });
249 await page.goto('/review/2025');
250 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
251 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
252 await page.getByRole('button', { name: 'Roll the credits' }).click();
253 await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
254 const table = page.getByRole('table', { name: 'TV shows watched by platform' });
255 await expect(table.getByRole('columnheader')).toHaveText(['Platform', 'TV shows']);
256 for (const [name, shows] of [['Netflix', '2'], ['HBO', '1'], ['Apple TV+', '0'], ['Other', '1']]) {
257 const row = table.getByRole('row').filter({ has: page.getByRole('rowheader', { name, exact: true }) });
258 await expect(row.getByRole('cell')).toHaveText([shows]);
259 }
260 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
261 await expect(table.getByRole('rowheader').last()).toHaveText('Other');
262 mode = 'old-label';
263 await page.reload();
264 await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
265 await expect(table.getByRole('rowheader')).toHaveText(['Netflix', 'HBO', 'Apple TV+', 'Other']);
266 mode = 'legacy';
267 await page.reload();
268 await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
269 await expect(page.getByText('Platform data is unavailable for this review. Regenerate the year to include it.')).toBeVisible();
270 mode = 'empty';
271 await page.reload();
272 await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
273 await expect(page.getByText('No TV shows watched this year.')).toBeVisible();
274});
275
276test('card swipes follow navigation direction and respect reduced motion', async ({ page }) => {
277 await page.emulateMedia({ reducedMotion: 'no-preference' });
278 await page.goto('/review/2025');
279 await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
280 await page.getByLabel('Password', { exact: true }).fill('fixture-only');
281 await page.getByRole('button', { name: 'Roll the credits' }).click();
282 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
283
284 // Sample the start of each animation so direction checks do not depend on timing.
285 async function entryOffset() {
286 return page.locator('.story-body').evaluate(element => {
287 const animation = element.getAnimations()[0];
288 if (!animation) throw new Error('Expected a card swipe animation');
289 animation.pause();
290 animation.currentTime = 0;
291 const offset = new DOMMatrixReadOnly(getComputedStyle(element).transform).m41;
292 animation.finish();
293 return offset;
294 });
295 }
296 async function swipe(dx, dy = 0) {
297 await page.locator('.story').evaluate((element, { dx, dy }) => {
298 element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch', clientX: 180, clientY: 250 }));
299 window.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'touch', clientX: 180 + dx, clientY: 250 + dy }));
300 }, { dx, dy });
301 }
302
303 await page.getByRole('button', { name: 'Next chapter' }).click();
304 await expect(page.getByRole('heading', { name: 'Your watch time', exact: true })).toBeVisible();
305 expect(await entryOffset()).toBeGreaterThan(0);
306 await page.getByRole('button', { name: 'Previous chapter' }).click();
307 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
308 expect(await entryOffset()).toBeLessThan(0);
309
310 await swipe(-120);
311 await expect(page.getByRole('heading', { name: 'Your watch time', exact: true })).toBeVisible();
312 expect(await entryOffset()).toBeGreaterThan(0);
313 await swipe(120);
314 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
315 expect(await entryOffset()).toBeLessThan(0);
316 await swipe(-20, 120);
317 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
318
319 await page.locator('main').click({ position: { x: 5, y: 5 } });
320 await page.keyboard.press('ArrowRight');
321 await expect(page.getByRole('heading', { name: 'Your watch time', exact: true })).toBeVisible();
322 expect(await entryOffset()).toBeGreaterThan(0);
323 await page.getByRole('button', { name: /Your year in summary$/ }).click();
324 await expect(page.getByRole('heading', { name: 'Your year in summary' })).toBeVisible();
325 expect(await entryOffset()).toBeGreaterThan(0);
326 await page.getByRole('button', { name: 'View recap again ↺' }).click();
327 await expect(page.getByRole('heading', { name: 'Your viewing recap' })).toBeVisible();
328 expect(await entryOffset()).toBeLessThan(0);
329 await swipe(120);
330 await expect(page.getByRole('button', { name: 'Previous chapter' })).toBeDisabled();
331 expect(await page.locator('.story-body').evaluate(element => element.getAnimations().some(animation => animation.playState === 'running'))).toBe(false);
332
333 await page.emulateMedia({ reducedMotion: 'reduce' });
334 await page.getByRole('button', { name: 'Next chapter' }).click();
335 await expect(page.getByRole('heading', { name: 'Your watch time', exact: true })).toBeVisible();
336 expect(await page.locator('.story-body').evaluate(element => element.getAnimations().length)).toBe(0);
337 expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
338});