const { test, expect } = require('@playwright/test'); const { TOKEN } = require('./global-setup'); // The token sets a cookie, so every test starts by presenting it once. test.beforeEach(async ({ page }) => { await page.goto(`/?token=${TOKEN}`); await expect(page.locator('#feedlist')).toBeVisible(); }); test('the page loads and lists the configured feeds', async ({ page }) => { // Regression: a ReferenceError in the script left the shell rendered and the sidebar // empty, with every handler below the error dead. Server-side checks all passed. // Four top-level feeds in the fixture config; the OPML's children are inside a closed folder. await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 }); await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible(); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.reload(); await expect(page.locator('.feed').first()).toBeVisible(); expect(errors, 'the page script must not throw at load').toEqual([]); }); test('the script is its own file, cached until a deploy changes it', async ({ page }) => { const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js'); const doc = await page.reload(); const r = await js; // The page is checked every visit, so it always names the current script... expect(doc.headers()['cache-control']).toBe('no-cache'); // ...by a hash of its contents, which is why the script itself can be kept for a year. expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/); expect(r.headers()['cache-control']).toContain('immutable'); expect(r.headers()['content-type']).toContain('javascript'); expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0); // The sign-in page's script loads before signing in. const login = await page.request.get('/login.js', { headers: { cookie: '' } }); expect(login.status()).toBe(200); }); test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => { const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]); const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor); await page.locator('#prefs').click(); await expect(page.locator('#stheme')).toHaveValue((await root())[0]); await page.locator('#stheme').selectOption('modern'); await page.locator('#smode').selectOption('auto'); // Auto follows the system, live, with no reload. await page.emulateMedia({ colorScheme: 'light' }); await expect.poll(root).toEqual(['modern', 'light']); await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg await page.emulateMedia({ colorScheme: 'dark' }); await expect.poll(root).toEqual(['modern', 'dark']); await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg // Dracula, then its light half, Alucard. await page.locator('#stheme').selectOption('dracula'); await page.locator('#smode').selectOption('dark'); await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36 await page.locator('#smode').selectOption('light'); await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB // Classic and Paper come one way only, so there is nothing to choose. await page.locator('#stheme').selectOption('paper'); await expect(page.locator('#smode')).toBeHidden(); await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE // The save that says Classic: the ones before it may still be answering. const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH' && r.request().postDataJSON().theme === 'classic'); await page.locator('#stheme').selectOption('classic'); await expect(page.locator('#smode')).toBeHidden(); await saved; // kept on the account, not the browser await page.reload(); await expect.poll(root).toEqual(['classic', 'light']); // The 2004 Mac app set its type in Lucida Grande. expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande'); expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0); // Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one. await page.locator('#prefs').click(); await page.locator('#stheme').selectOption('nordic'); await expect(page.locator('#smode')).toHaveValue('light'); await page.locator('#smode').selectOption('dark'); await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0 }); test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => { await page.locator('#prefs').click(); const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'); await page.locator('#stheme').selectOption('flatremix'); expect((await saved).status()).toBe(204); const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'); await page.locator('#smode').selectOption('light'); await saved2; // Another browser: nothing in its localStorage, and the page still arrives in the theme, // written onto by the server rather than set once the script has run. const other = await browser.newContext(); const p2 = await other.newPage(); const res = await p2.goto(`/?token=${TOKEN}`); expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light'); expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode])) .toEqual(['flatremix', 'light']); await other.close(); }); test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => { // A new account, made by the proxy header on first sight, so it has no theme of its own yet. const who = `theme-${Date.now()}@example.com`; const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } }); // From before light and dark: ipx.theme alone, 'light' meaning Modern, light. await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); }); const page = await ctx.newPage(); const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'); await page.goto('/'); expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode])) .toEqual(['modern', 'light']); expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' }); await ctx.close(); // Anywhere else now, it comes from the account. const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } }); const p2 = await fresh.newPage(); const res = await p2.goto('/'); expect(await res.text()).toContain('data-theme=modern data-choice=light'); await fresh.close(); }); test('settings opens and saves the global schedule', async ({ page }) => { await page.locator('#prefs').click(); await expect(page.locator('#modal.on')).toBeVisible(); await expect(page.locator('#gnum')).toBeVisible(); await page.locator('#gnum').fill('4'); await page.locator('#gunit').selectOption('h'); await page.locator('#gsave').click(); await expect(page.locator('#modal.on')).toBeHidden(); // It must survive a reload, i.e. actually reach the config. await page.locator('#prefs').click(); await expect(page.locator('#gnum')).toHaveValue('4'); await expect(page.locator('#gunit')).toHaveValue('h'); }); test('episodes show with their metadata, and the text opens below', async ({ page }) => { await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('First Episode')).toBeVisible(); // Newest first, so target the episode by name rather than by position. const first = page.locator('.ep', { hasText: 'First Episode' }); await expect(first).toContainText('S1E1'); await expect(first).toContainText('30:30'); // itunes:duration 1830 await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00'); // Selecting an item shows its text in the pane below, not inline in the row. await first.click(); await expect(first).toHaveClass(/sel/); await expect(page.locator('#detail')).toContainText('Show notes for the first one'); await expect(page.locator('#detail .dt')).toHaveText('First Episode'); }); test('the three panes are there and the item text lands in the bottom one', async ({ page }) => { await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('#list')).toBeVisible(); await expect(page.locator('#grab')).toBeVisible(); // the draggable divider await expect(page.locator('#detail')).toContainText('Pick an item'); await page.locator('.ep', { hasText: 'First Episode' }).click(); await expect(page.locator('#detail .dt')).toHaveText('First Episode'); // The enclosure goes to the Files pane beside the list, as the original's did. await expect(page.locator('#files')).toBeVisible(); await expect(page.locator('#files .encbox')).toHaveCount(1); // Only the downloaded one gets a player, and max_new_per_check is 1, so find it by // its chip rather than assuming which episode the daemon happened to fetch. const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first(); await downloaded.click(); await expect(page.locator('#files [data-a="play"]')).toBeVisible(); await expect(page.locator('#files .encbox [title="Save to this computer"]')).toBeVisible(); // Selecting another item replaces the pane rather than stacking. await page.locator('.ep', { hasText: 'First Episode' }).click(); await expect(page.locator('#detail .dt')).toHaveText('First Episode'); await expect(page.locator('#files [data-a="play"]')).toHaveCount(0); }); test('a downloaded file that is not audio gets no player', async ({ page }) => { // Regression: anything with a file got an