From c79d4d47a3b67b3e0631a0b577f3e1bf730338d7 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 20:27:48 -0400 Subject: [PATCH] test: cover jobs in Settings, and unpick two shared selectors The spec worth having is not that the tab is gone -- that is one line of a table -- but that nothing became unreachable when it went. #24 promises that no action is ever unreachable at any supported size, and deleting a destination is exactly the change that quietly breaks it. Two existing selectors had to give. `config-section .header` is ambiguous the moment a section holds a job, because `job-details-drawer` carries that class too -- so `settings-reach.spec.ts` locates a disclosure by role and name instead. And `page-header`'s and `offline-icons`'s view lists lose an entry each. Closes #27 --- e2e/specs/jobs-in-settings.spec.ts | 124 ++++++++++++++++++ e2e/specs/layout-overflow.spec.ts | 7 +- e2e/specs/offline-icons.spec.ts | 2 +- e2e/specs/page-header.spec.ts | 2 - e2e/specs/settings-reach.spec.ts | 11 +- frontend/test/components/chrome.test.ts | 1 - .../test/components/keyboard-reach.test.ts | 2 +- frontend/test/components/smoke.test.ts | 4 +- .../test/components/view-lifecycle.test.ts | 2 - .../test/components/view-visibility.test.ts | 6 +- 10 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 e2e/specs/jobs-in-settings.spec.ts diff --git a/e2e/specs/jobs-in-settings.spec.ts b/e2e/specs/jobs-in-settings.spec.ts new file mode 100644 index 0000000..dafd552 --- /dev/null +++ b/e2e/specs/jobs-in-settings.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, waitForEvent } from '../support/fixtures.js'; + +/** + * The Jobs tab folded into the places the work is started (#27). + * + * The assertion worth making is not that the tab is gone — that is one + * line of a table — but that **nothing became unreachable when it + * went**. Scanning is the case that mattered: the per-library controls + * lived only on that page, and the tab's own comment says they had been + * moved there out of Settings in the first place. + * + * `#24` wrote down one sentence covering all three size bands: *no + * action is ever unreachable at any supported size*. Deleting a + * destination is exactly the change that can quietly break it. + */ +type Page = import('@playwright/test').Page; + +const section = (page: Page, heading: string) => + page.locator(`config-page config-section[heading="${heading}"]`); + +async function openSettings(page: Page, heading: string): Promise { + await page.getByTestId('nav-settings').click(); + + // The section's own disclosure, by role rather than by `.header`: + // an open Libraries section also contains `job-details-drawer`, + // whose own header matches that class and makes it ambiguous. + const header = section(page, heading) + .getByRole('button', { name: heading }) + .first(); + + await expect(header).toBeVisible(); + + if ((await header.getAttribute('aria-expanded')) === 'false') { + await header.click(); + } + + await expect(header).toHaveAttribute('aria-expanded', 'true'); +} + +test.describe('background jobs live where the work is started', () => { + test('the Jobs destination is gone', async ({ app }) => { + await expect(app.getByTestId('nav-jobs')).toHaveCount(0); + + // And it is not offered as a launch page either, which is the copy + // of the destination list that is easiest to forget. + await openSettings(app, 'General'); + + const options = await section(app, 'General') + .locator('select') + .first() + .locator('option') + .allTextContents(); + + expect(options).not.toContain('Jobs'); + }); + + /** + * Scanning is startable from Settings → Libraries, and the job that + * results is visible there with its controls. One assertion covers + * both halves, because a Scan All that started nothing would leave + * the panel empty and read exactly like a panel that does not work. + */ + test('a scan is started and watched in Settings', async ({ app }) => { + await openSettings(app, 'Libraries'); + + const libraries = section(app, 'Libraries'); + + await libraries.getByRole('button', { name: 'Scan All' }).click(); + + await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 }); + + const panel = libraries.locator('job-panel'); + + await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 }); + + // The generic affordances are the point of the panel: the tier + // list and the progress rings the other surfaces already had + // cannot open a log. + await expect( + panel.getByRole('button', { name: /^Details/ }), + ).toBeVisible(); + }); + + /** A finished job dismisses from where it is shown. */ + test('a finished scan can be dismissed in place', async ({ app }) => { + await openSettings(app, 'Libraries'); + + const panel = section(app, 'Libraries').locator('job-panel'); + const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first(); + + await expect(dismiss).toBeVisible({ timeout: 10_000 }); + await dismiss.click(); + + await expect(panel.locator('job-row')).toHaveCount(0); + }); + + /** + * Full rescan is destructive and asks first. It is asserted at the + * dialog rather than through it — running one against the seeded app + * would delete the library the rest of the suite reads. + */ + test('Full Rescan asks before it does anything', async ({ app }) => { + await openSettings(app, 'Libraries'); + + await section(app, 'Libraries') + .getByRole('button', { name: 'Full Rescan' }) + .click(); + + const dialog = app.getByRole('dialog', { name: 'Full rescan' }); + + await expect(dialog).toBeVisible(); + + // The message is read off the *host*, not the dialog: a wa-dialog + // keeps its slotted content in the host's shadow root, so + // `toContainText` on the dialog itself sees only Web Awesome's + // chrome. + await expect(app.locator('confirm-dialog')).toContainText( + 'deletes all library data', + ); + + await app.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + }); +}); diff --git a/e2e/specs/layout-overflow.spec.ts b/e2e/specs/layout-overflow.spec.ts index da0a296..c5b6401 100644 --- a/e2e/specs/layout-overflow.spec.ts +++ b/e2e/specs/layout-overflow.spec.ts @@ -93,9 +93,10 @@ test.describe('the app fits in its own window', () => { ) .toBe(true); - // Settings and Jobs are the two that were unreachable: they are - // last in the nav, and the pane used to clip rather than scroll. - for (const view of ['jobs', 'settings'] as const) { + // Settings is the one that was unreachable: it is last in the nav, + // and the pane used to clip rather than scroll. (Jobs was the other + // half of this until #27 folded it into Settings.) + for (const view of ['explore', 'settings'] as const) { const item = app.getByTestId(`nav-${view}`); await item.scrollIntoViewIfNeeded(); diff --git a/e2e/specs/offline-icons.spec.ts b/e2e/specs/offline-icons.spec.ts index cf882ff..be81f2f 100644 --- a/e2e/specs/offline-icons.spec.ts +++ b/e2e/specs/offline-icons.spec.ts @@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5; const VIEWS = [ 'home', 'tracks', 'albums', 'artists', 'genres', 'playlists', - 'explore', 'downloads', 'jobs', 'settings', + 'explore', 'downloads', 'autotag', 'settings', ]; type IconState = { name: string; hasSvg: boolean }; diff --git a/e2e/specs/page-header.spec.ts b/e2e/specs/page-header.spec.ts index 71ce42a..42de153 100644 --- a/e2e/specs/page-header.spec.ts +++ b/e2e/specs/page-header.spec.ts @@ -21,7 +21,6 @@ const VIEWS: [string, string, boolean][] = [ ['tracks', 'Tracks', true], ['explore', 'Explore', false], ['downloads', 'Downloads', false], - ['jobs', 'Background jobs', false], ]; /** The header lives in the view's shadow root, inside its own. */ @@ -53,7 +52,6 @@ const TAGS: Record = { tracks: 'track-list', explore: 'explore-view', downloads: 'downloads-view', - jobs: 'jobs-view', }; test.describe('every primary view says what it is', () => { diff --git a/e2e/specs/settings-reach.spec.ts b/e2e/specs/settings-reach.spec.ts index 1ddddea..37d7700 100644 --- a/e2e/specs/settings-reach.spec.ts +++ b/e2e/specs/settings-reach.spec.ts @@ -18,16 +18,19 @@ test.describe('Settings is reachable without a mouse', () => { }) => { await app.getByTestId('nav-settings').click(); - const headers = app.locator('config-page config-section .header'); + // Per *section*, not per `.header`: a section holding a + // `job-panel` (#27) also contains `job-details-drawer`, whose own + // header carries that class and is not a disclosure. + const sections = app.locator('config-page config-section'); - await expect(headers.first()).toBeVisible(); + await expect(sections.first()).toBeVisible(); - const count = await headers.count(); + const count = await sections.count(); expect(count).toBeGreaterThan(4); for (let i = 0; i < count; i++) { - const header = headers.nth(i); + const header = sections.nth(i).locator('.header').first(); expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON'); expect(['true', 'false']).toContain( diff --git a/frontend/test/components/chrome.test.ts b/frontend/test/components/chrome.test.ts index 971793e..5cb7290 100644 --- a/frontend/test/components/chrome.test.ts +++ b/frontend/test/components/chrome.test.ts @@ -63,7 +63,6 @@ describe('', () => { 'nav-explore', 'nav-downloads', 'nav-autotag', - 'nav-jobs', 'nav-settings', ]); }); diff --git a/frontend/test/components/keyboard-reach.test.ts b/frontend/test/components/keyboard-reach.test.ts index c89177e..1aabdd0 100644 --- a/frontend/test/components/keyboard-reach.test.ts +++ b/frontend/test/components/keyboard-reach.test.ts @@ -49,7 +49,7 @@ describe(' is reachable', () => { const items = shadowAll(el, 'li button'); - expect(items).toHaveLength(11); + expect(items).toHaveLength(10); expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true); }); diff --git a/frontend/test/components/smoke.test.ts b/frontend/test/components/smoke.test.ts index c9125b7..e4c3f16 100644 --- a/frontend/test/components/smoke.test.ts +++ b/frontend/test/components/smoke.test.ts @@ -40,7 +40,7 @@ import '@components/jobs/job-details-drawer'; import '@components/jobs/job-indicator'; import '@components/jobs/job-log-view'; import '@components/jobs/job-row'; -import '@components/jobs/jobs-view'; +import '@components/jobs/job-panel'; import '@components/library-filter/library-filter'; import '@components/library-status-indicator/library-status-indicator'; import '@components/now-playing/now-playing'; @@ -87,8 +87,8 @@ const TAGS = [ 'job-details-drawer', 'job-indicator', 'job-log-view', + 'job-panel', 'job-row', - 'jobs-view', 'library-filter', 'library-status-indicator', 'now-playing', diff --git a/frontend/test/components/view-lifecycle.test.ts b/frontend/test/components/view-lifecycle.test.ts index 6351f85..14b3ed8 100644 --- a/frontend/test/components/view-lifecycle.test.ts +++ b/frontend/test/components/view-lifecycle.test.ts @@ -27,7 +27,6 @@ import '@components/explore-view/explore-view'; import '@components/home-view/home-view'; import '@components/downloads-view/downloads-view'; -import '@components/jobs/jobs-view'; import '@components/playlist-view/playlist-view'; import { fixture } from '@test/support/render'; import { stub, flush } from '@test/support/harness'; @@ -223,7 +222,6 @@ const CACHED_VIEWS = [ 'artists-view', 'genres-view', 'downloads-view', - 'jobs-view', 'playlist-view', 'explore-view', 'home-view', diff --git a/frontend/test/components/view-visibility.test.ts b/frontend/test/components/view-visibility.test.ts index d05d2cc..7a00c40 100644 --- a/frontend/test/components/view-visibility.test.ts +++ b/frontend/test/components/view-visibility.test.ts @@ -72,7 +72,6 @@ const ALL_VISIBLE = { explore: true, downloads: true, autotag: true, - jobs: true, settings: true, }; @@ -96,7 +95,6 @@ describe('view visibility', () => { 'explore', 'downloads', 'autotag', - 'jobs', 'settings', ]); }); @@ -104,11 +102,11 @@ describe('view visibility', () => { it('drops the ones the user switched off', async () => { const el = await fixture('app-sidebar'); - await setViews({ ...ALL_VISIBLE, autotag: false, jobs: false }); + await setViews({ ...ALL_VISIBLE, autotag: false, explore: false }); await el.updateComplete; expect(navIDs(el)).not.toContain('autotag'); - expect(navIDs(el)).not.toContain('jobs'); + expect(navIDs(el)).not.toContain('explore'); expect(navIDs(el)).toContain('settings'); });