fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher

PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes):

- CI: category-management.e2e refreshed the active playlist through the
  old switcher trigger's inline refresh button, which the redesign
  replaced with the switcher row menu (⋯ → Refresh playlist). The helper
  now goes through that menu; it failed on all three OS shards.
- A desktop Xtream delete's completed event cleared its progress while
  PlaylistsService still cleaned up (parental lock, stored state), so the
  already-deleted source was actionable again on Sources and back in the
  switcher until the store dropped it. A terminal event now only ends
  Cancel; the row stays busy until the delete settles and the removal
  service clears it.
- A running delete (from the switcher, say) disappeared from Sources when
  the page's saved filters excluded it, hiding its only progress and
  Cancel. Running deletes stay listed whatever the filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 21:00:05 +02:00
1 parent a48d0b913f
commit dac79af54f
5 files changed
+78 -12

No files matched your search

@@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise<void> {
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
// Refresh for the active source lives in its switcher row menu.
await page.getByTestId('playlist-switcher-trigger').click();
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page
.getByRole('menuitem', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
@@ -214,6 +214,15 @@ describe('RecentPlaylistsComponent', () => {
expect(removal.remove).toHaveBeenCalledWith(M3U, FILE);
});
it('keeps a running delete on screen whatever the filters', () => {
(
removal.progress as { isDeleting: (id: string) => boolean }
).isDeleting = (id) => id === 'xtream';
viewState.setType('m3u');
fixture.detectChanges();
expect(ids()).toEqual(['m3u', 'xtream', 'file']);
});
it('applies the type and status filters together', () => {
viewState.setType('m3u');
expect(ids()).toEqual(['m3u', 'file']);
@@ -122,7 +122,7 @@ export class RecentPlaylistsComponent {
};
const options = this.sortOptions();
const filter = this.viewState.filter();
const filtered = filterSources(
const matching = filterSources(
this.playlists(),
this.statusService.supportsStatusFilter
? filter
@@ -130,6 +130,14 @@ export class RecentPlaylistsComponent {
this.searchQueryInput(),
bucketOf
);
// A running delete keeps its row, whatever the filters: its
// progress and Cancel live there (one started from the switcher
// has nowhere else to show).
const filtered = this.playlists().filter(
(playlist) =>
matching.includes(playlist) ||
this.removal.progress.isDeleting(playlist._id)
);
const sorted = this.sortService.sortPlaylists(
filtered,
options,
@@ -77,18 +77,28 @@ export class SourceDeleteProgressService {
return this.cancelled.delete(id);
}
/**
* Follows a running delete. A terminal event ends what Cancel can stop,
* but the row stays busy: the delete still cleans up (parental lock,
* stored state) before its source leaves the store, so the caller clears
* it once the whole delete has settled.
*/
track(id: string, event: DbOperationEvent): void {
if (['completed', 'cancelled', 'error'].includes(event.status)) {
this.clear(id);
return;
}
const terminal = ['completed', 'cancelled', 'error'].includes(
event.status
);
this.operations.update((current) =>
new Map(current).set(id, {
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
})
new Map(current).set(
id,
terminal
? { ...current.get(id), operationId: undefined }
: {
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
}
)
);
}
@@ -336,6 +336,38 @@ describe('SourceRemovalService', () => {
expect(toasts).toHaveLength(1);
});
it('keeps a deleted row busy until the whole delete has settled', async () => {
let finish!: (deleted: boolean) => void;
deletePlaylist.mockImplementation(
(
_playlist: PlaylistMeta,
options: { onEvent: (event: object) => void }
) => {
options.onEvent({
operation: 'delete-playlist',
operationId: 'op-2',
status: 'completed',
});
return new Promise<boolean>((resolve) => (finish = resolve));
}
);
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
expireUndo();
await flush();
// The database is done, its cleanup is not: no Cancel, still busy.
expect(service.progress.isDeleting('x')).toBe(true);
expect(service.progress.canCancel('x')).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
finish(true);
await flush();
expect(dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({ playlistId: 'x' })
);
expect(service.progress.isDeleting('x')).toBe(false);
});
it('brings the source back and says so when the delete fails', async () => {
deletePlaylist.mockResolvedValue(false);
service.remove(playlist('a'));