Files
iptvnator/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts
T
72727a5dfa feat(dashboard): detail-first Continue Watching cards with quick actions (#1469)
* feat(dashboard): detail-first continue watching cards with quick actions (#1441)

Continue Watching cards now open the detail page on click like movie
cards; resuming the saved episode, marking it watched, and removing the
entry from history move into a per-card ⋮ menu. Series details land on
the earliest season with unwatched episodes (or the latest once all are
watched) instead of always season 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): address review findings and season auto-select regressions

- A session's own watched toggles no longer re-resolve the selected
  season when the positions map first fills — marking season 1 watched
  used to jump the view to season 2 (CI regression in the web and
  Electron season-watched-toggle E2Es).
- The all-watched season fallback skips loaded-but-empty seasons and
  picks the latest season that has episodes (Greptile P1).
- Mark as Watched uses the strict failure-propagating save boundary
  (Codex P2), and both card mutations surface persistence failures via
  a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all
  19 languages (Greptile P2).
- Season E2Es now assert the intended post-reload behavior: the fresh
  mount lands on the earliest unwatched season while season 1 keeps its
  watched state behind its tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:06:46 +02:00

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
clickGridListCardByTitle,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
launchElectronApp,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForXtreamImportToFinish,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamSeriesFixture } from './portal-mock-fixtures';
// ---------------------------------------------------------------------------
// Season-level watched toggle on the Electron serial details page
//
// The season header exposes a bulk toggle carrying `data-test-id` (this
// suite's testIdAttribute, so getByTestId matches it), while the per-episode
// toggles carry `data-testid` and need raw attribute locators. In Electron
// the toggle routes every unwatched episode of the selected season through
// the DB_SAVE_PLAYBACK_POSITIONS_BATCH IPC (preload → main → database worker
// → one SQLite transaction) and the unmark path through
// DB_CLEAR_PLAYBACK_POSITIONS_BATCH. The runtime bridge has no per-episode
// fallback for these calls (`PlaybackPositionRuntimeBridgeService` throws
// when the batch methods are missing), so the rows asserted below via
// `dbGetAllPlaybackPositions` prove the batch IPC executed end to end.
// Durability is proven with a full app relaunch: the renderer and the main
// process restart, so the watched state can only come from SQLite on disk.
// ---------------------------------------------------------------------------
// The default `user1:pass1` mock scenario generates 3 seasons × 8 episodes
// per series (season/episode numbers starting at 1), matching the PWA spec.
const seasonCount = 3;
const seasonEpisodeCount = 8;
const expectedEpisodeNumbers = [1, 2, 3, 4, 5, 6, 7, 8];
const portalName = 'Season Watched Toggle';
const watchedEpisodeToggleSelector =
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched';
type EpisodePositionRow = {
episodeNumber: number | null;
positionSeconds: number;
seasonNumber: number | null;
};
/** Reads the persisted episode positions straight from SQLite over IPC. */
async function readEpisodePositions(
page: Page,
playlistId: string
): Promise<EpisodePositionRow[]> {
return page.evaluate(async (id) => {
const positions =
(await window.electron?.dbGetAllPlaybackPositions?.(id)) ?? [];
return positions
.filter((position) => position.contentType === 'episode')
.map((position) => ({
episodeNumber: position.episodeNumber ?? null,
positionSeconds: position.positionSeconds,
seasonNumber: position.seasonNumber ?? null,
}));
}, playlistId);
}
function extractPlaylistId(page: Page): string {
const match = new URL(page.url()).pathname.match(
/\/workspace\/xtreams\/([^/]+)\//
);
if (!match) {
throw new Error(`Expected an Xtream route, got ${page.url()}`);
}
return match[1];
}
/**
* Opens the given category's series grid and clicks into a series detail.
* Without `seriesTitle` it clicks the first card (the grid sorts by date, so
* fixture order is not grid order) and returns the clicked title so the
* post-restart navigation can target the exact same series.
*/
async function openSeriesDetail(
page: Page,
categoryName: string,
seriesTitle?: string
): Promise<string> {
await openWorkspaceSection(page, 'Series');
await clickCategoryByNameExact(page, categoryName);
let clickedTitle: string;
if (seriesTitle === undefined) {
clickedTitle = await clickFirstGridListCard(page);
} else {
await clickGridListCardByTitle(page, seriesTitle);
clickedTitle = seriesTitle;
}
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
return clickedTitle;
}
test.describe('Electron Season Watched Toggle', () => {
test('@xtream @persistence @electron marks a season watched via the batch IPC, survives an app restart, and clears again', async ({
dataDir,
request,
}) => {
// Full app relaunch mid-test: startup alone can eat half the default
// 60s budget on a real Electron app.
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const seriesFixture = await fetchXtreamSeriesFixture(request, {
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, { name: portalName });
await waitForXtreamImportToFinish(app.mainWindow);
const seriesTitle = await openSeriesDetail(
app.mainWindow,
seriesFixture.categoryName
);
const playlistId = extractPlaylistId(app.mainWindow);
// Season 1 auto-selects fully unwatched: the toggle offers to mark
// all 8 episodes and neither the DOM nor SQLite knows any watched
// episode yet.
const seasonToggle = app.mainWindow.getByTestId(
'toggle-season-watched'
);
await expect(seasonToggle).toBeVisible({ timeout: 20_000 });
await expect(seasonToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
const episodeCards = app.mainWindow.locator('.episode-card');
const watchedCards = app.mainWindow.locator(
'.episode-card--watched'
);
await expect(episodeCards).toHaveCount(seasonEpisodeCount, {
timeout: 10_000,
});
await expect(watchedCards).toHaveCount(0);
expect(
await readEpisodePositions(app.mainWindow, playlistId)
).toEqual([]);
await seasonToggle.click();
// The batch save flips the button, marks every card, and fills
// the per-episode toggles; the untouched second season stays
// unmarked while the selected season's tab shows the check.
await expect(seasonToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
);
await expect(watchedCards).toHaveCount(seasonEpisodeCount);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(seasonEpisodeCount);
const seasonTabs = app.mainWindow.locator('.season-tabs__pill');
await expect(seasonTabs).toHaveCount(seasonCount);
await expect(
seasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
await expect(
seasonTabs.nth(1).locator('.season-tabs__done')
).toHaveCount(0);
// The single batch transaction landed 8 full-progress episode
// rows for season 1 in SQLite.
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toHaveLength(seasonEpisodeCount);
const savedRows = await readEpisodePositions(
app.mainWindow,
playlistId
);
expect(
savedRows
.map((row) => row.episodeNumber)
.sort((left, right) => (left ?? 0) - (right ?? 0))
).toEqual(expectedEpisodeNumbers);
expect(
savedRows.every(
(row) => row.seasonNumber === 1 && row.positionSeconds > 0
)
).toBe(true);
// Relaunch the whole app: the watched state must be re-read from
// the SQLite database file, not from any renderer memory.
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openSeriesDetail(
app.mainWindow,
seriesFixture.categoryName,
seriesTitle
);
const restartedToggle = app.mainWindow.getByTestId(
'toggle-season-watched'
);
const restartedWatchedCards = app.mainWindow.locator(
'.episode-card--watched'
);
// With season 1 completed, the fresh mount auto-selects the
// earliest season with unwatched episodes (issue #1441): season 2
// opens unwatched while season 1's tab keeps the check.
await expect(restartedToggle).toBeVisible({ timeout: 20_000 });
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`
);
await expect(restartedWatchedCards).toHaveCount(0, {
timeout: 10_000,
});
const restartedSeasonTabs =
app.mainWindow.locator('.season-tabs__pill');
await expect(
restartedSeasonTabs.first().locator('.season-tabs__done')
).toBeVisible();
// Season 1 itself was re-read from SQLite fully watched.
await restartedSeasonTabs.first().click();
await expect(restartedToggle).toContainText(
'Mark season as unwatched',
{ timeout: 15_000 }
);
await expect(restartedWatchedCards).toHaveCount(
seasonEpisodeCount,
{ timeout: 10_000 }
);
// Unmark: the clear batch removes every row again, in the DOM and
// in SQLite.
await restartedToggle.click();
await expect(restartedToggle).toContainText(
`Mark season as watched (${seasonEpisodeCount})`,
{ timeout: 15_000 }
);
await expect(restartedWatchedCards).toHaveCount(0);
await expect(
app.mainWindow.locator(watchedEpisodeToggleSelector)
).toHaveCount(0);
await expect(
app.mainWindow.locator('.season-tabs__done')
).toHaveCount(0);
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toEqual([]);
// Series scope: the header ⋮ menu routes EVERY season through
// the same batch IPC — one action, 24 full-progress rows across
// seasons 1–3 (no second restart: durability of the channel is
// already proven above).
const seriesMenuTrigger =
app.mainWindow.getByTestId('series-watch-menu');
await expect(seriesMenuTrigger).toBeVisible();
await seriesMenuTrigger.click();
const seriesToggle = app.mainWindow.getByTestId(
'toggle-series-watched'
);
await expect(seriesToggle).toBeVisible();
await expect(seriesToggle).toContainText(
`Mark series as watched (${seasonCount * seasonEpisodeCount})`
);
await seriesToggle.click();
await expect(
app.mainWindow.locator('.season-tabs__done')
).toHaveCount(seasonCount, { timeout: 15_000 });
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toHaveLength(seasonCount * seasonEpisodeCount);
const seriesRows = await readEpisodePositions(
app.mainWindow,
playlistId
);
expect(
new Set(seriesRows.map((row) => row.seasonNumber))
).toEqual(new Set([1, 2, 3]));
expect(
seriesRows.every((row) => row.positionSeconds > 0)
).toBe(true);
// Unwatch-all clears all 24 rows again through the clear batch.
await seriesMenuTrigger.click();
await expect(seriesToggle).toContainText(
'Mark series as unwatched'
);
await seriesToggle.click();
await expect(
app.mainWindow.locator('.season-tabs__done')
).toHaveCount(0, { timeout: 15_000 });
await expect
.poll(() => readEpisodePositions(app.mainWindow, playlistId), {
timeout: 20_000,
})
.toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});