diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index f2477df..e7ab95d 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -1353,6 +1353,129 @@ test('a download by hand fills its bar and names its file, and only on its own s } }); +test('Download latest queues the newest, a schedule\'s bars choose how many, v opens an item, and Unsubscribe leaves no feed open', async ({ page, context }) => { + // #406. The server's default downloads the newest one on its own; the rest wait. + const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/latest.xml' } })).json(); + const here = async () => (await (await page.request.get(`/api/feeds/${encodeURIComponent(feed)}/entries`)).json() as ListPage) + .entries.filter(e => e.enclosures.some(x => x.path)).map(e => e.title).sort(); + try { + await expect.poll(here, { timeout: 20_000 }).toEqual(['Latest 1']); + await page.reload(); + await page.locator('#feedlist .feed', { hasText: 'Latest Show' }).click(); + await expect(page.locator('.ep', { hasText: 'Latest 4' })).toBeVisible({ timeout: 20_000 }); + + // The settings' strip of the newest items: tapping the third lights three. + await page.locator('#content .acts [data-a="settings"]').click(); + const bars = page.locator('#sepwin i'); + await expect(bars).toHaveCount(4); + await bars.nth(2).dispatchEvent('pointerdown', { pointerId: 1, clientX: (await bars.nth(2).boundingBox())!.x + 1 }); + await expect(page.locator('[name=swin][value=newest]')).toBeChecked(); + await expect(page.locator('#smax')).toHaveValue('3'); + await expect(page.locator('#sepwin i.on')).toHaveCount(3); + await page.keyboard.press('Escape'); + + await page.locator('#content .acts [data-a="dl"]').click(); + await page.locator('#dcount').fill('3'); + await page.locator('#dgo').click(); + await expect(page.locator('.toast', { hasText: 'Queued 3 items' })).toBeVisible(); + await expect.poll(here, { timeout: 20_000 }).toEqual(['Latest 1', 'Latest 2', 'Latest 3', 'Latest 4']); + + // v opens the item's own page. + await page.locator('.ep', { hasText: 'Latest 4' }).click(); + await page.locator('#detail').click({ position: { x: 1, y: 1 } }); + const opened = context.waitForEvent('page'); + await page.keyboard.press('v'); + expect((await opened).url()).toBe('http://127.0.0.1:8792/latest-4.html'); + + await page.locator('#content .acts [data-a="rm"]').click(); + await page.locator('#rgo').click(); + await expect(page.locator('.toast', { hasText: 'Unsubscribed' })).toBeVisible(); + await expect(page.locator('#feedlist .feed', { hasText: 'Latest Show' })).toHaveCount(0); + await expect(page.locator('#content h2', { hasText: 'Latest Show' })).toHaveCount(0); + } finally { + await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`); + } +}); + +test('the feed list\'s keys: Shift J and K between feeds, the arrows on a folder, r to refresh', async ({ page }) => { + // #406. + const at = () => page.evaluate(() => S.feed); + await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click(); + const rows = await page.locator('#feedlist [data-id]').evaluateAll(r => r.map(x => (x as HTMLElement).dataset.id)); + const i = rows.indexOf(await at() ?? ''); + await page.locator('#content').click({ position: { x: 1, y: 1 } }); + await page.keyboard.press('Shift+J'); + await expect.poll(at).toBe(rows[i + 1]); + await page.keyboard.press('Shift+K'); + await expect.poll(at).toBe(rows[i]); + + // A folder opens with Right and closes with Left, from the keyboard alone. + const folder = page.locator('#feedlist .group').first(); + const chev = folder.locator('.chev'); + const open = await chev.getAttribute('aria-expanded') === 'true'; + await folder.focus(); + await page.keyboard.press(open ? 'ArrowLeft' : 'ArrowRight'); + await expect(page.locator('#feedlist .group').first().locator('.chev')).toHaveAttribute('aria-expanded', String(!open)); + await page.locator('#feedlist .group').first().focus(); + await page.keyboard.press(open ? 'ArrowRight' : 'ArrowLeft'); + await expect(page.locator('#feedlist .group').first().locator('.chev')).toHaveAttribute('aria-expanded', String(open)); + + // r takes the lists again; v opens the item's own page. + await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click(); + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await page.locator('.ep').first().click(); + await page.locator('#detail').click({ position: { x: 1, y: 1 } }); + const again = page.waitForRequest(r => new URL(r.url()).pathname === '/api/feeds'); + await page.keyboard.press('r'); + await again; +}); + +test('the player keeps its speed and volume, marks an item played to its end read, and reports a stall', async ({ page }) => { + // #406. + // Hidden until something plays, so set as the page's own handlers see a change. + await page.locator('#rate').evaluate((r: HTMLSelectElement) => { r.value = '1.5'; r.dispatchEvent(new Event('change')); }); + await page.locator('#vol').evaluate((v: HTMLInputElement) => { v.value = '40'; v.dispatchEvent(new Event('input')); }); + await page.reload(); + await expect(page.locator('#rate')).toHaveValue('1.5'); + await expect(page.locator('#vol')).toHaveValue('40'); + expect(await page.evaluate(() => [audio.playbackRate, audio.volume])).toEqual([1.5, 0.4]); + + await page.locator('.feed', { hasText: 'Test Show' }).click(); + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + const row = page.locator('.ep', { has: page.locator('.kind.here') }).first(); + await expect(row).toBeVisible({ timeout: 20_000 }); + await row.click(); + // A player with no file behind it, as the play buttons' test has, events and all. + await page.evaluate(() => { + let paused = true; + Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true }); + audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); }; + audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); }; + }); + await page.locator('#files [data-a="play"]').click(); + await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true); + const key = await page.evaluate(() => [player.feed!, player.guid!]); + const read = async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(key[0])}/${encodeURIComponent(key[1])}`)).json()).read; + + // Started, then held up for more than half a second: the server's log hears how long. + await page.evaluate(() => audio.dispatchEvent(new Event('playing'))); + const stall = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'stall'); + await page.evaluate(async () => { + audio.dispatchEvent(new Event('waiting')); + await new Promise(r => setTimeout(r, 600)); + audio.dispatchEvent(new Event('playing')); + }); + expect((await stall).postDataJSON().ms).toBeGreaterThanOrEqual(500); + + // Marked unread while it plays, and played to its end: read again. + await page.locator('#detail [data-a="read"]').click(); + await expect.poll(read).toBe(false); + await page.evaluate(() => audio.dispatchEvent(new Event('ended'))); + await expect.poll(read).toBe(true); + await page.locator('#pclose').click(); + await page.evaluate(() => { localStorage.removeItem('ipx.rate'); localStorage.removeItem('ipx.vol'); }); +}); + test('a deleted file looks as if it was never downloaded', async ({ page }) => { // Other people subscribe to Picture Blog by now, so both prompts come; take them. page.on('dialog', d => d.accept()); @@ -1635,6 +1758,61 @@ test('the admin page sets up the directory, and a feed marked private leaves it if (paid) expect(paid.private_by_address).toBe(true); }); +test('a show\'s page in the Directory: More and Less, why it fails, and Subscribe from there', async ({ browser }) => { + // #405. Someone who subscribes to little, so a listed feed opens its Directory page. + const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` }; + try { execFileSync(setup.IPX_BIN, ['user', 'add', 'piper'], { input: 'piperpassword', env }); } + catch (e) { if (!said(e).includes('already exists')) throw e; } + const ctx = await browser.newContext(); + const piper = await ctx.newPage(); + try { + await piper.goto('/login'); + await piper.locator('#name').fill('piper'); + await piper.locator('#pw').fill('piperpassword'); + await piper.locator('button[type=submit]').click(); + await expect(piper.locator('#feedlist')).toBeVisible(); + const listed = async () => (await (await piper.request.get('/api/directory')).json() as DirFeed[]).find(p => p.title === 'Grouped Show'); + await expect.poll(listed, { timeout: 20_000 }).toBeTruthy(); + const show = (await listed())!; + // The page draws what the directory says of a feed; here, a long description and a failure. + await piper.route(url => url.pathname === `/api/directory/${show.id}`, r => r.fulfill({ json: { + description: 'A show about many things. '.repeat(60), failing: 'The feed has answered HTTP 404 Not Found since Monday.', + items: [{ guid: 'g', title: 'Latest one', link: null, description: 'What it was about.', published: null, duration: null }], + can_admin: false, + } })); + + await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); + const tiles = piper.locator('#dtiles .tile'); + await piper.locator('#epSearch').fill('no show is called this'); + await expect(piper.locator('#directory h2')).toHaveText('Results for “no show is called this”'); + await expect(piper.locator('#directory .sub').first()).toHaveText('No feed here has that in its name.'); + await expect(tiles).toHaveCount(0); + + await piper.locator('#epSearch').fill('grouped'); + await tiles.filter({ hasText: 'Grouped Show' }).locator('.txt').click(); + await expect(piper.locator('.show h2')).toHaveText('Grouped Show'); + await expect(piper.locator('#dshow .lep', { hasText: 'Latest one' })).toBeVisible(); + const more = piper.locator('#dabout button'); + await expect(more).toHaveText('More'); + await more.click(); + await expect(more).toHaveText('Less'); + await expect(more).toHaveAttribute('aria-expanded', 'true'); + await expect(piper.locator('#dabout')).toHaveClass(/open/); + await more.click(); + await expect(more).toHaveText('More'); + await expect(piper.locator('#dfail')).toHaveText('The feed has answered HTTP 404 Not Found since Monday.'); + // Not an admin of the directory: neither of its buttons. + await expect(piper.locator('.show [data-a="drefresh"], .show [data-a="dremove"]')).toHaveCount(0); + + await piper.locator('.show [data-a="sub"]').click(); + await expect(piper.locator('.toast', { hasText: 'Subscribed to Grouped Show' })).toBeVisible(); + await expect(piper.locator('#feedlist .feed.sel', { hasText: 'Grouped Show' })).toBeVisible({ timeout: 20_000 }); + } finally { + await piper.request.delete('/api/feeds/grouped-show'); + await ctx.close(); + } +}); + test('an admin removes a feed from the directory, and unblocks it (#206)', async ({ page, browser }) => { const all = () => page.evaluate((): Promise => api('/api/directory')); const listed = async () => (await all()).map(p => p.title); diff --git a/tests/ui/fixtures/serve.ts b/tests/ui/fixtures/serve.ts index d85d841..559472b 100644 --- a/tests/ui/fixtures/serve.ts +++ b/tests/ui/fixtures/serve.ts @@ -52,6 +52,19 @@ http.createServer((req, res) => { Live Show${item('live-1', 'Slow episode', 'live-slow.mp3')}${item('live-2', 'Broken episode', 'live-broken.mp3')}`); return; } + // Four episodes, newest first, for Download latest and a schedule's window (#406). + if (req.url === '/latest.xml') { + const item = (n: number) => `latest-${n}Latest ${n}http://127.0.0.1:${port}/latest-${n}.html${new Date(Date.UTC(2026, 0, 10 - n)).toUTCString()}` + + ``; + res.writeHead(200, { 'content-type': 'application/xml' }).end(` + Latest Show${[1, 2, 3, 4].map(item).join('')}`); + return; + } + if (/^\/latest-\d\.mp3$/.test(req.url ?? '')) { + const body = fs.readFileSync(path.join(dir, 'ep1.mp3')); + res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length }).end(body); + return; + } if (req.url === '/live-broken.mp3') { res.writeHead(500).end('broken'); return; } if (req.url === '/live-slow.mp3') { const body = fs.readFileSync(path.join(dir, 'ep1.mp3'));