arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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) await chart.screenshot({ path: 'test-results/ranking-mobile.png' });
200    if (sample.cohort === 1) await expect(chart).toContainText('You are the only eligible viewer.');
201    expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
202  }
203});
204
205test('platform card shows only TV show counts and handles missing data', async ({ page }) => {
206  let mode = 'populated';
207  await page.route('**/api/review?year=*', async route => {
208    const response = await route.fetch();
209    const review = await response.json();
210    if (mode === 'populated' || mode === 'old-label') review.platforms = [
211      { name: 'Netflix', movies: 3, shows: 2 },
212      { name: 'HBO', movies: 0, shows: 1 },
213      { name: 'Apple TV+', movies: 1, shows: 0 },
214      { name: 'Other', movies: 20, shows: 1 }
215    ];
216    else delete review.platforms;
217    if (mode === 'old-label') {
218      review.platforms.pop();
219      review.platforms.unshift({ name: 'Unknown platform', movies: 20, shows: 1 });
220    }
221    if (mode === 'empty') review.records = 0;
222    await route.fulfill({ response, json: review });
223  });
224  await page.goto('/review/2025');
225  await page.getByLabel('Jellyfin username').fill(offline ? 'Alice' : 'fixture');
226  await page.getByLabel('Password', { exact: true }).fill('fixture-only');
227  await page.getByRole('button', { name: 'Roll the credits' }).click();
228  await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
229  const table = page.getByRole('table', { name: 'TV shows watched by platform' });
230  await expect(table.getByRole('columnheader')).toHaveText(['Platform', 'TV shows']);
231  for (const [name, shows] of [['Netflix', '2'], ['HBO', '1'], ['Apple TV+', '0'], ['Other', '1']]) {
232    const row = table.getByRole('row').filter({ has: page.getByRole('rowheader', { name, exact: true }) });
233    await expect(row.getByRole('cell')).toHaveText([shows]);
234  }
235  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
236  await expect(table.getByRole('rowheader').last()).toHaveText('Other');
237  mode = 'old-label';
238  await page.reload();
239  await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
240  await expect(table.getByRole('rowheader')).toHaveText(['Netflix', 'HBO', 'Apple TV+', 'Other']);
241  mode = 'legacy';
242  await page.reload();
243  await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
244  await expect(page.getByText('Platform data is unavailable for this review. Regenerate the year to include it.')).toBeVisible();
245  mode = 'empty';
246  await page.reload();
247  await page.getByRole('button', { name: /Shows you've watched are from platforms\.\.\.$/ }).click();
248  await expect(page.getByText('No TV shows watched this year.')).toBeVisible();
249});