mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
4750 lines
260 KiB
TypeScript
4750 lines
260 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||
|
||
declare global {
|
||
interface Window {
|
||
__allstarrMetrics: { cls: number; lcp: number; navigation: number };
|
||
__emitAllstarrUpdate?: () => void;
|
||
}
|
||
}
|
||
|
||
const viewports = [
|
||
{ width: 320, height: 844 },
|
||
{ width: 390, height: 844 },
|
||
{ width: 430, height: 932 },
|
||
{ width: 667, height: 375 },
|
||
{ width: 820, height: 760 },
|
||
{ width: 1280, height: 800 },
|
||
];
|
||
|
||
async function expectContainedMobileGeometry(page: Page) {
|
||
const audit = await page.evaluate(() => {
|
||
const visible = (element: HTMLElement) => {
|
||
const style = getComputedStyle(element);
|
||
const rect = element.getBoundingClientRect();
|
||
return style.display !== "none" && style.visibility !== "hidden" &&
|
||
rect.width > 0 && rect.height > 0;
|
||
};
|
||
const identify = (element: HTMLElement) => {
|
||
const role = element.getAttribute("role");
|
||
const classes = [...element.classList].slice(0, 3).join(".");
|
||
const text = element.textContent?.replace(/\s+/g, " ").trim().slice(0, 36);
|
||
return `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}${classes ? `.${classes}` : ""}${role ? `[role=${role}]` : ""}${text ? ` “${text}”` : ""}`;
|
||
};
|
||
const hasLocalScrollOwner = (element: HTMLElement) => {
|
||
let parent = element.parentElement;
|
||
while (parent && parent !== document.body) {
|
||
const style = getComputedStyle(parent);
|
||
if (/(auto|scroll)/.test(style.overflowX) && parent.scrollWidth > parent.clientWidth + 1)
|
||
return true;
|
||
parent = parent.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const viewportEscapes: string[] = [];
|
||
for (const element of document.querySelectorAll<HTMLElement>("body *")) {
|
||
if (!visible(element)) continue;
|
||
const rect = element.getBoundingClientRect();
|
||
if (rect.left >= -1 && rect.right <= window.innerWidth + 1) continue;
|
||
if (hasLocalScrollOwner(element)) continue;
|
||
viewportEscapes.push(`${identify(element)} [${Math.round(rect.left)}, ${Math.round(rect.right)}]`);
|
||
if (viewportEscapes.length === 12) break;
|
||
}
|
||
|
||
const groupEscapes: string[] = [];
|
||
const groups = document.querySelectorAll<HTMLElement>(
|
||
".mobile-navigation, .dialog-actions, .panel-heading-actions, .sources-heading-actions, .playlist-toolbar-actions, [role=tablist]",
|
||
);
|
||
for (const group of groups) {
|
||
if (!visible(group)) continue;
|
||
const groupRect = group.getBoundingClientRect();
|
||
const scrollable = /(auto|scroll)/.test(getComputedStyle(group).overflowX) &&
|
||
group.scrollWidth > group.clientWidth + 1;
|
||
const controls = group.querySelectorAll<HTMLElement>("a, button, [role=tab]");
|
||
for (const control of controls) {
|
||
if (!visible(control) || control.closest(".mobile-navigation, .dialog-actions, .panel-heading-actions, .sources-heading-actions, .playlist-toolbar-actions, [role=tablist]") !== group)
|
||
continue;
|
||
if (scrollable && control.getAttribute("aria-selected") !== "true") continue;
|
||
const rect = control.getBoundingClientRect();
|
||
if (rect.left < groupRect.left - 1 || rect.right > groupRect.right + 1)
|
||
groupEscapes.push(
|
||
`${identify(control)} [${Math.round(rect.left)}, ${Math.round(rect.right)}] outside ${identify(group)} ` +
|
||
`[${Math.round(groupRect.left)}, ${Math.round(groupRect.right)}], scroll ${Math.round(group.scrollLeft)}/${group.scrollWidth - group.clientWidth}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const undersizedTargets: string[] = [];
|
||
const targets = new Set(document.querySelectorAll<HTMLElement>(
|
||
"button, [role=button], [role=tab], summary, [data-slot=button], .mobile-navigation a",
|
||
));
|
||
for (const target of targets) {
|
||
if (!visible(target) || target.matches(":disabled")) continue;
|
||
const rect = target.getBoundingClientRect();
|
||
if (rect.width < 43 || rect.height < 43)
|
||
undersizedTargets.push(`${identify(target)} ${Math.round(rect.width)}×${Math.round(rect.height)}`);
|
||
if (undersizedTargets.length === 12) break;
|
||
}
|
||
const navReserveErrors: string[] = [];
|
||
const mobileNav = document.querySelector<HTMLElement>(".sidebar");
|
||
const workspace = document.querySelector<HTMLElement>(".workspace");
|
||
if (mobileNav && workspace && visible(mobileNav)) {
|
||
const navHeight = mobileNav.getBoundingClientRect().height;
|
||
const reserved = Number.parseFloat(getComputedStyle(workspace).paddingBottom);
|
||
if (reserved + 1 < navHeight)
|
||
navReserveErrors.push(`workspace reserves ${Math.round(reserved)}px for a ${Math.round(navHeight)}px navigation bar`);
|
||
}
|
||
return { viewportEscapes, groupEscapes, undersizedTargets, navReserveErrors };
|
||
});
|
||
expect(audit.viewportEscapes, "visible descendants must stay in the viewport or a local horizontal scroller").toEqual([]);
|
||
expect(audit.groupEscapes, "controls must stay inside their navigation/action outline").toEqual([]);
|
||
expect(audit.undersizedTargets, "touch controls must provide a 44px target").toEqual([]);
|
||
expect(audit.navReserveErrors, "fixed mobile navigation must have matching content clearance").toEqual([]);
|
||
}
|
||
|
||
const schema = {
|
||
activeBackend: "Jellyfin",
|
||
providers: [
|
||
{ id: "jellyfin", name: "Jellyfin", categories: ["streaming"] },
|
||
{
|
||
id: "lumen-audio", name: "Lumen Audio", categories: ["metadata", "streaming"],
|
||
accountSettings: [
|
||
{ key: "token", label: "Access token", type: "password", sensitive: true, required: true },
|
||
{ key: "region", label: "Region", type: "select", options: ["us", "ca"], defaultValueJson: '"us"' },
|
||
],
|
||
},
|
||
{
|
||
id: "spotify", name: "Spotify", categories: ["playlists"],
|
||
accountSettings: [
|
||
{ key: "sessionCookie", label: "Spotify session cookie", type: "password", sensitive: true, required: true },
|
||
],
|
||
},
|
||
{
|
||
id: "audiomuse-ai", name: "AudioMuse-AI", categories: ["intelligence"],
|
||
accountSettings: [
|
||
{ key: "baseUrl", label: "AudioMuse-AI server URL", type: "url", required: true },
|
||
{ key: "apiToken", label: "AudioMuse-AI access token", type: "password", sensitive: true, required: false },
|
||
{ key: "server", label: "AudioMuse-AI music server", type: "text", required: false },
|
||
],
|
||
},
|
||
{ id: "listenbrainz", name: "ListenBrainz", categories: ["scrobbling"] },
|
||
{
|
||
id: "apple-download", name: "Apple Music – GAMDL", categories: ["metadata", "streaming", "download"],
|
||
connectionKind: "operator_managed",
|
||
configSchema: [
|
||
{ key: "APPLE_DOWNLOAD_URL", label: "External provider URL", type: "url", valuePath: "appleDownload.baseUrl" },
|
||
],
|
||
},
|
||
],
|
||
configSections: [
|
||
{
|
||
id: "general", label: "General", fields: [
|
||
{ key: "AUDIO_QUALITY", label: "Audio quality", type: "audio-quality", valuePath: "audio.quality" },
|
||
{ key: "MATCHING_LOCAL_PREFERENCE_PERCENT", label: "Local match window", type: "number", valuePath: "matching.localPreferencePercent", min: 0, max: 20, helpText: "A local candidate wins when it is no more than this many confidence points behind the strongest result. Default: 7%." },
|
||
{ key: "STORAGE_MODE", label: "Storage mode", type: "select", valuePath: "library.storageMode", options: ["Permanent", "Cache"] },
|
||
{ key: "PublicUrl", label: "Public URL", type: "text", valuePath: "deployment.url", ownership: "deployment", readOnly: true },
|
||
],
|
||
},
|
||
{
|
||
id: "cache", label: "Cache", fields: [
|
||
{ key: "CACHE_DURATION_HOURS", label: "Track cache hours", type: "number", valuePath: "library.cacheDurationHours", min: 1 },
|
||
{ key: "CACHE_SEARCH_RESULTS_MINUTES", label: "Search results minutes", type: "number", valuePath: "cache.searchResultsMinutes", min: 1, max: 1440 },
|
||
{ key: "CACHE_TRANSCODE_MINUTES", label: "Transcode cache minutes", type: "number", valuePath: "cache.transcodeCacheMinutes", min: 1 },
|
||
{ key: "CACHE_MEDIA_MAXIMUM_MEGABYTES", label: "Media cache total MiB", type: "number", valuePath: "cache.mediaMaximumMegabytes", ownership: "deployment", readOnly: true },
|
||
],
|
||
},
|
||
],
|
||
priorityGroups: [{
|
||
id: "streaming", label: "Playback", envKey: "StreamingOrder", providers: ["lumen-audio", "future-audio"],
|
||
pinnedProvider: { id: "jellyfin", name: "Jellyfin", icon: "server", reason: "Local media server" },
|
||
}],
|
||
};
|
||
|
||
const responses: Record<string, unknown> = {
|
||
"/api/admin/auth/me": {
|
||
authenticated: true, backend: "Jellyfin",
|
||
user: {
|
||
id: "user", name: "Tester", isAdministrator: true,
|
||
avatarUrl: "/api/admin/auth/me/avatar?user=user",
|
||
},
|
||
},
|
||
"/api/admin/onboarding/status": {
|
||
completed: true, setupOpen: false, shouldRedirectToSetup: false,
|
||
schemaVersion: "onboarding-v1", completedSteps: ["backend-identity"],
|
||
completionSource: "setup-guide", completedAt: "2026-01-01", revision: 2,
|
||
recoveryNotices: [],
|
||
migration: { available: true, completed: false, firstRun: true },
|
||
},
|
||
"/api/admin/onboarding/complete": {
|
||
completed: true, setupOpen: false, shouldRedirectToSetup: false,
|
||
schemaVersion: "onboarding-v1", completedSteps: ["backend-identity"],
|
||
completionSource: "setup-guide", completedAt: "2026-01-01", revision: 2,
|
||
recoveryNotices: [],
|
||
migration: { available: true, completed: false, firstRun: true },
|
||
},
|
||
"/api/admin/onboarding/reopen": {
|
||
completed: false, setupOpen: true, shouldRedirectToSetup: false,
|
||
schemaVersion: "onboarding-v1", completedSteps: ["backend-identity"],
|
||
completionSource: "administrator", reopenedAt: "2026-01-01", revision: 3,
|
||
recoveryNotices: [],
|
||
migration: { available: true, completed: false, firstRun: true },
|
||
},
|
||
"/api/admin/ui/schema": schema,
|
||
"/api/admin/status": { version: "test", backendType: "Jellyfin" },
|
||
"/api/admin/playlists": { playlists: [], inventory: { managed: 0, unmanaged: 0 } },
|
||
"/api/admin/jobs?limit=100": {
|
||
jobs: [{
|
||
id: "11111111-1111-1111-1111-111111111111", type: "playlist.materialize",
|
||
state: "Running", attemptCount: 1, failureCount: 0, deferralCount: 0,
|
||
cancellationRequestedAt: null, lastErrorCode: null, lastErrorMessage: null,
|
||
updatedAt: "2026-01-01",
|
||
}],
|
||
progress: [{
|
||
id: "progress", jobId: "11111111-1111-1111-1111-111111111111",
|
||
action: "playlist.match", outcome: "running", createdAt: "2026-01-01",
|
||
detailsJson: JSON.stringify({
|
||
stage: "playlist.match", message: "Matching Test song", completed: 1, total: 2,
|
||
provider: "lumen-audio", playlist: "Test playlist", track: "Test song",
|
||
throughputPerSecond: 1.5,
|
||
}),
|
||
}],
|
||
},
|
||
"/api/admin/ui/activity?limit=8": {
|
||
items: [{
|
||
id: "activity", kind: "playlist", source: "lumen-audio",
|
||
label: "playlist check", state: "succeeded", detail: "66 ms",
|
||
occurredAt: "2026-01-01",
|
||
}],
|
||
hasMore: false,
|
||
},
|
||
"/api/admin/ui/provider-summaries": {
|
||
providers: [{
|
||
providerId: "lumen-audio", connectedAccountName: "Legacy .env import",
|
||
enabledAccountCount: 1, capabilityTotal: 2, healthyCapabilityCount: 2,
|
||
failedCapabilityCount: 0, lastCheckedAt: "2026-01-01",
|
||
}],
|
||
},
|
||
"/api/admin/ui/home": {
|
||
schema,
|
||
status: { version: "test", backendType: "Jellyfin", durableStorage: { readiness: "Ready" } },
|
||
stats: {
|
||
linkedPlaylists: 1, playableTracks: 1, unresolvedTracks: 1, activeJobs: 1,
|
||
completedListens: 12, currentWeekListens: 24, previousWeekListens: 20,
|
||
scrobbleDeliveries: 2, cacheTracks: 3, keptTracks: 4,
|
||
topArtist: { name: "Beyoncé", listens: 7 },
|
||
},
|
||
providerHealth: {
|
||
providers: [{
|
||
providerId: "lumen-audio", connectedAccountName: "Legacy .env import",
|
||
enabledAccountCount: 1, capabilityTotal: 2, healthyCapabilityCount: 2,
|
||
failedCapabilityCount: 0, lastCheckedAt: "2026-01-01",
|
||
}],
|
||
},
|
||
activity: {
|
||
items: [{
|
||
id: "activity", kind: "playlist", source: "lumen-audio",
|
||
label: "playlist check", state: "succeeded", detail: "66 ms",
|
||
occurredAt: "2026-01-01",
|
||
}],
|
||
hasMore: false,
|
||
},
|
||
},
|
||
"/api/admin/ui/now-playing": {
|
||
items: [{
|
||
deviceId: "device-1", userId: "user-1", userName: "Tester",
|
||
avatarUrl: null, client: "Feishin", device: "Desktop",
|
||
itemId: "ext-lumen-audio-song-1", title: "Rocket", artist: "Beyoncé",
|
||
album: "Act II", providerId: "lumen-audio", artworkUrl: null,
|
||
catalogProviderId: "deezer", sourceConfirmed: true, cached: false,
|
||
positionSeconds: 30, durationSeconds: 120, progress: 0.25,
|
||
lastActivity: "2026-01-01", scrobbleThresholdSeconds: 60,
|
||
scrobbleEligible: false, scrobbleDeliveries: [], scrobbled: false,
|
||
}],
|
||
},
|
||
"/api/admin/playlist-links": {
|
||
playlistLinks: [{
|
||
id: "playlist-link", enabled: true, name: "Test playlist",
|
||
sourceProviderId: "lumen-audio", sourcePlaylistId: "source-list",
|
||
providerAccountId: "lumen", libraryScopeId: "music",
|
||
targetProtocol: "jellyfin", targetBackendInstanceId: "main",
|
||
targetCredentialReferenceId: "33333333-3333-3333-3333-333333333333",
|
||
targetPlaylistId: "jellyfin-playlist", mode: "hybrid", projectionMode: "resolved",
|
||
materializationMode: "reconcile", importMode: "linked", trackRetention: "onDemand",
|
||
mirrorStaleEntries: false,
|
||
preserveManualEntries: true, syncName: true, syncDescription: true, syncArtwork: true,
|
||
ruleVersion: "playlist-rules-v1", policyVersion: "playlist-policy-v1",
|
||
revision: 1, artworkUrl: "/missing-playlist-art",
|
||
trackCount: 2,
|
||
matchedCount: 0, unmatchedCount: 1, playableCount: 1, materializedCount: 0,
|
||
routeCoverage: [{ providerId: "lumen-audio", count: 1 }],
|
||
metrics: { total: 2, matched: 0, unresolved: 1, review: 1, rejected: 0, playable: 1, materialized: 0 },
|
||
}],
|
||
},
|
||
"/api/admin/playlist-sources": {
|
||
accounts: [
|
||
{ id: "qobuz", providerId: "qobuz", displayName: "Qobuz", accessLabel: "Personal account" },
|
||
{ id: "spotify", providerId: "spotify", displayName: "Spotify", ownerDisplayName: "Tester", accessLabel: "Personal account" },
|
||
{ id: "lumen", providerId: "lumen-audio", displayName: "Lumen", accessLabel: "Personal account" },
|
||
{ id: "subsonic", providerId: "subsonic", displayName: "Subsonic", accessLabel: "Library-shared account" },
|
||
{ id: "jellyfin", providerId: "jellyfin", displayName: "Jellyfin", accessLabel: "Library-shared account" },
|
||
],
|
||
blockedAccounts: [],
|
||
providers: [
|
||
{ id: "jellyfin", displayName: "Jellyfin" },
|
||
{ id: "subsonic", displayName: "Subsonic" },
|
||
{ id: "lumen-audio", displayName: "Lumen Audio" },
|
||
{ id: "qobuz", displayName: "Qobuz" },
|
||
{ id: "spotify", displayName: "Spotify" },
|
||
],
|
||
},
|
||
"/api/admin/media-targets": {
|
||
targets: [{
|
||
id: "22222222-2222-2222-2222-222222222222", protocol: "jellyfin", backendInstanceId: "main",
|
||
libraryScopeId: "music", displayName: "Jellyfin Music",
|
||
credentialReferenceId: "33333333-3333-3333-3333-333333333333",
|
||
}],
|
||
},
|
||
"/api/admin/provider-accounts": {
|
||
managementMode: "Hybrid",
|
||
audienceUsers: [
|
||
{ id: "user", displayName: "Tester" },
|
||
{ id: "listener", displayName: "Listener" },
|
||
],
|
||
accounts: [{
|
||
id: "account", providerId: "lumen-audio", displayName: "Lumen account",
|
||
sourceDisplayName: "Lumen Audio", scope: "User", enabled: true, revision: 1,
|
||
ownerUserId: "user", ownerDisplayName: "Tester", createdByUserId: "user",
|
||
creatorDisplayName: "Tester",
|
||
configuration: { region: "ca" }, configuredFields: ["token", "region"],
|
||
secret: { configured: true, revoked: false }, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
}],
|
||
},
|
||
"/api/admin/providers/status": [
|
||
{
|
||
provider: "lumen-audio", providerAccountId: "account", providerAccountName: "Lumen account",
|
||
capability: "metadata", accountScope: "user", supported: true, enabled: true,
|
||
configuration: "configured", health: "healthy", ready: true, canAttempt: true, canTest: true,
|
||
},
|
||
{
|
||
provider: "lumen-audio", providerAccountId: "account", providerAccountName: "Lumen account",
|
||
capability: "streaming", accountScope: "user", supported: true, enabled: true,
|
||
configuration: "configured", health: "degraded", ready: false, canAttempt: true, canTest: true,
|
||
reasonCode: "probe_failed",
|
||
},
|
||
],
|
||
"/api/admin/provider-diagnostics/deep-stream/latest": { measurements: [] },
|
||
"/api/admin/apple-download/status": {
|
||
state: "ready", ready: true, staged: true, daemon_running: true,
|
||
wrapper_healthy: true, logged_in: true, login_state: "authenticated", api_version: "2",
|
||
},
|
||
"/api/admin/config": {
|
||
deployment: { url: "https://music.example.test" },
|
||
audio: { quality: "BestAvailable" },
|
||
matching: { localPreferencePercent: 7 },
|
||
appleDownload: { baseUrl: "http://apple-download.test" },
|
||
library: { storageMode: "Cache", cacheDurationHours: 24 },
|
||
cache: { searchResultsMinutes: 1, mediaMaximumMegabytes: 512, transcodeCacheMinutes: 60 },
|
||
providers: { streamingOrder: "lumen-audio" },
|
||
},
|
||
"/api/admin/storage": { storage: { provider: "PostgreSQL", readiness: "Ready" }, backups: [] },
|
||
"/api/admin/cache": {
|
||
database: { entryCount: 2, payloadBytes: 2048, hitRatio: .75, hits: 3, misses: 1, writes: 2, evictions: 1 },
|
||
hot: { entryCount: 1, payloadBytes: 1024, maximumBytes: 16_777_216, hitRatio: .75, hits: 3, misses: 1, writes: 1, evictions: 0 },
|
||
media: { entryCount: 1, payloadBytes: 4096, maximumBytes: 536_870_912, maximumEntryBytes: 16_777_216, hitRatio: .5, hits: 1, misses: 1, writes: 1, evictions: 1 },
|
||
categories: [{
|
||
category: "Artwork", owner: "media-assets", storageTier: "Media", enabled: true,
|
||
entryCount: 1, payloadBytes: 4096, freshSeconds: 86_400, staleSeconds: 3600,
|
||
maximumBytes: 536_870_912, maximumEntries: 10_000,
|
||
warmingRule: "VisibleOrSelected", invalidationTrigger: "resource-or-artwork-revision",
|
||
}],
|
||
activity: { coalescedRequests: 4, staleServes: 2, upstreamBytesAvoided: 8192 },
|
||
artworkLimits: { maximumEntryBytes: 16_777_216, maximumDecodedPixels: 16_000_000 },
|
||
extensionStorage: { activeExtensions: 1, entryCount: 2, payloadBytes: 512, maximumBytes: 4_194_304 },
|
||
capturedAt: "2026-01-01",
|
||
},
|
||
"/api/admin/cache/maintenance/preview": {
|
||
metadata: { scannedEntries: 2, scanLimitReached: false, expiredEntries: 1, unknownOwnerEntries: 0, disabledCategoryEntries: 0, noExpiryEntries: 0, staleAuthorizationScopeEntries: 0, supersededEntries: 0, overQuotaEntries: 0, reclaimableBytes: 1024 },
|
||
media: { scannedFiles: 2, scanLimitReached: false, temporaryFiles: 0, malformedMetadataFiles: 0, orphanedMetadataFiles: 0, orphanedPayloadFiles: 0, expiredEntries: 0, noExpiryEntries: 0, overQuotaEntries: 0, reclaimableBytes: 0, cleanupIntervalSeconds: 900, lastCleanupAt: "2026-01-01", lastCleanupDeletedEntries: 1 },
|
||
unreferencedArtworkPayloads: 1, unreferencedArtworkBytes: 4096,
|
||
artworkReferenceScanLimitReached: false,
|
||
},
|
||
"/api/admin/config/migration/status": {
|
||
available: true, completed: false, sourcePresent: false, firstRun: true,
|
||
},
|
||
"/api/admin/preview-selective-state": {
|
||
canImport: true,
|
||
dependencies: ["Settings", "Accounts"],
|
||
conflicts: [],
|
||
report: {
|
||
includedCategories: ["Accounts", "Playlists"],
|
||
excludedCategories: [],
|
||
totalRows: 3,
|
||
rowsByEntry: { "provider-accounts": 1, "playlist-links": 2 },
|
||
},
|
||
},
|
||
"/api/admin/import-selective-state": {
|
||
success: true,
|
||
message: "Selective state imported.",
|
||
report: {
|
||
includedCategories: ["Accounts", "Playlists"],
|
||
excludedCategories: [],
|
||
totalRows: 3,
|
||
rowsByEntry: { "provider-accounts": 1, "playlist-links": 2 },
|
||
},
|
||
},
|
||
"/api/admin/extensions/packages": [{
|
||
id: "package", extensionId: "lumen-audio", displayName: "Lumen Audio", version: "1.0.0",
|
||
lifecycle: "active", state: "active", active: true, installed: true,
|
||
permissionReviewRequired: false, capabilities: ["metadata", "streaming"], revision: 1,
|
||
}],
|
||
"/api/admin/extensions/registries": [],
|
||
"/api/admin/extensions/store": { items: [], errors: [] },
|
||
"/api/admin/extensions/logs?limit=100": [],
|
||
"/api/admin/intelligence": {
|
||
state: "configured",
|
||
scope: { protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music" },
|
||
policy: { enabled: true, retentionDays: 30, revision: 1, targetCredentialReferenceId: "33333333-3333-3333-3333-333333333333", targetCredentialConfigured: true },
|
||
availableSignalTypes: [{ id: "play", label: "Play", enabled: true }],
|
||
providers: [{
|
||
id: "lumen-audio", label: "Lumen Audio", description: "Private similarity source.",
|
||
enabled: true, available: true, state: "ready",
|
||
}, {
|
||
id: "audiomuse-ai", label: "AudioMuse-AI", description: "Explore this library by sound.",
|
||
enabled: true, available: true, state: "ready",
|
||
}],
|
||
listeningServices: [
|
||
{ id: "lastfm", label: "Last.fm", configured: true, latestState: "delivered", requiresReauthentication: false },
|
||
{ id: "listenbrainz", label: "ListenBrainz", configured: false, latestState: null, requiresReauthentication: false },
|
||
],
|
||
songDetails: { pending: 2, resolved: 8, unresolved: 0, failed: 0 },
|
||
actions: {
|
||
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "running",
|
||
latestJobId: "job-1", attemptCount: 1, failureCount: 0, maxAttempts: 5,
|
||
canCancel: true, progress: {
|
||
stage: "recommendation.provider", message: "Searching Lumen Audio.",
|
||
completed: 1, total: 2, provider: "lumen-audio", track: "Future Song",
|
||
},
|
||
},
|
||
candidates: [{
|
||
id: "candidate-1", trackKey: "track-1", title: "Future Song", artist: "Artist",
|
||
album: "Album", score: .91, source: "lumen-audio", providerId: "lumen-audio",
|
||
sourceRevision: "fixture:1", revision: 1, explanations: [{
|
||
code: "similar", weight: .9, explanation: "Similar to recent listening.",
|
||
}], exclusions: [], feedback: null,
|
||
}, {
|
||
id: "candidate-2", trackKey: "track-2", title: "Second Song", artist: "Another Artist",
|
||
album: "Second Album", score: .84, source: "audiomuse-ai", providerId: "audiomuse-ai",
|
||
sourceRevision: "fixture:1", revision: 1, explanations: [{
|
||
code: "similar", weight: .8, explanation: "A nearby sound in this library.",
|
||
}], exclusions: [], feedback: null,
|
||
}],
|
||
generatedSets: [{
|
||
id: "set-1", name: "Morning discovery", trackCount: 25, state: "succeeded", materialized: true,
|
||
}],
|
||
schedules: [{
|
||
id: "schedule-1", cronExpression: "0 8 * * 1", timeZoneId: "America/New_York",
|
||
overlapPolicy: "skip", misfirePolicy: "runOnce", enabled: true,
|
||
nextRunAt: "2026-01-05T13:00:00Z", revision: 1, name: "Monday discoveries", limit: 25,
|
||
}],
|
||
visualization: [{ key: "plays", label: "Plays", value: .7 }],
|
||
},
|
||
"/api/admin/intelligence/listening-apps": {
|
||
items: [{ id: "66666666-6666-6666-6666-666666666666", relayExternally: false, createdAt: "2026-01-01T00:00:00Z" }],
|
||
},
|
||
"/api/admin/intelligence/history/overview": {
|
||
period: { from: "2025-12-01T00:00:00Z", to: "2026-01-01T00:00:00Z", timeZoneId: "UTC" },
|
||
allTime: { completedListens: 42, distinctTracks: 20, distinctArtists: 8, listeningTimeMilliseconds: 7_200_000, firstListen: "2025-01-01T00:00:00Z" },
|
||
selected: { completedListens: 12, distinctTracks: 9, distinctArtists: 5, listeningTimeMilliseconds: 3_660_000, firstListen: "2025-12-01T00:00:00Z" },
|
||
breakdowns: {
|
||
sources: [{ dimension: "source", value: "playback", listenCount: 8, durationMilliseconds: 2_400_000 }, { dimension: "source", value: "import", listenCount: 4, durationMilliseconds: 1_260_000 }],
|
||
providers: [{ dimension: "provider", value: "jellyfin", listenCount: 7, durationMilliseconds: 2_100_000 }, { dimension: "provider", value: "deezer", listenCount: 5, durationMilliseconds: 1_560_000 }],
|
||
clients: [{ dimension: "client", value: "Jellyfin Web", listenCount: 7, durationMilliseconds: 2_100_000 }, { dimension: "client", value: "Koito", listenCount: 5, durationMilliseconds: 1_560_000 }],
|
||
},
|
||
currentStreakDays: 3, longestStreakDays: 9, nowPlaying: null, recent: [],
|
||
},
|
||
"/api/admin/intelligence/history/activity": {
|
||
period: { from: "2025-12-01T00:00:00Z", to: "2026-01-01T00:00:00Z", timeZoneId: "UTC" },
|
||
currentStreakDays: 3, longestStreakDays: 9,
|
||
buckets: Array.from({ length: 31 }, (_, index) => ({
|
||
date: `2025-12-${String(index + 1).padStart(2, "0")}`,
|
||
count: index + 1,
|
||
durationMilliseconds: (index + 1) * 180_000,
|
||
})),
|
||
},
|
||
"/api/admin/intelligence/history/top/artist": {
|
||
kind: "artist", period: {}, items: [{ artist: "The Comets", listenCount: 7, listeningTimeMilliseconds: 1_260_000, lastListenedAt: "2025-12-31T18:00:00Z" }],
|
||
},
|
||
"/api/admin/intelligence/history/top/album": {
|
||
kind: "album", period: {}, items: [{ album: "Night Drive", artist: "The Comets", listenCount: 5, listeningTimeMilliseconds: 900_000, lastListenedAt: "2025-12-31T18:00:00Z" }],
|
||
},
|
||
"/api/admin/intelligence/history/top/track": {
|
||
kind: "track", period: {}, items: [{ title: "Moon Song", artist: "The Comets", album: "Night Drive", listenCount: 4, listeningTimeMilliseconds: 720_000, lastListenedAt: "2025-12-31T18:00:00Z" }],
|
||
},
|
||
"/api/admin/intelligence/history": {
|
||
period: { from: "2025-12-01T00:00:00Z", to: "2026-01-01T00:00:00Z", timeZoneId: "UTC" },
|
||
items: [{
|
||
id: "44444444-4444-4444-4444-444444444444", title: "Moon Song", artist: "The Comets", album: "Night Drive",
|
||
listenedAt: "2025-12-31T18:00:00Z", durationMilliseconds: 180_000, client: "ios",
|
||
source: "import", provider: "spotify", state: "completed", enrichmentState: "notrequested",
|
||
artworkUrl: "/api/admin/downloads/artwork/ext-spotify-song-track-a", targetStatuses: [], revision: 2,
|
||
}],
|
||
nextCursor: null,
|
||
},
|
||
"/api/admin/intelligence/history/44444444-4444-4444-4444-444444444444": {
|
||
item: {
|
||
id: "44444444-4444-4444-4444-444444444444", title: "Moon Song", artist: "The Comets", album: "Night Drive",
|
||
listenedAt: "2025-12-31T18:00:00Z", durationMilliseconds: 180_000, client: "ios",
|
||
source: "import", provider: "spotify", state: "completed", enrichmentState: "notrequested",
|
||
artworkUrl: "/api/admin/downloads/artwork/ext-spotify-song-track-a", targetStatuses: [], revision: 2,
|
||
},
|
||
identity: { albumArtist: "The Comets", musicBrainzEnrichmentConfidence: null },
|
||
provenance: { source: "import", client: "ios", device: null, provider: "spotify", imported: true },
|
||
},
|
||
"/api/admin/intelligence/history/imports": { items: [] },
|
||
};
|
||
|
||
function routeRelease() {
|
||
let release!: () => void;
|
||
const promise = new Promise<void>((resolve) => { release = resolve; });
|
||
return { promise, release };
|
||
}
|
||
|
||
async function mockApi(page: Page, options: {
|
||
releasePath?: string;
|
||
release?: Promise<void>;
|
||
fail?: string[];
|
||
audioMuseUnavailable?: boolean;
|
||
audioMuseDisabledAccount?: boolean;
|
||
} = {}) {
|
||
await page.route("**/fonts/**", (route) => route.fulfill({ status: 204 }));
|
||
await page.route("**/images/providers/**", (route) => route.fulfill({
|
||
status: 200,
|
||
contentType: "image/svg+xml",
|
||
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>',
|
||
}));
|
||
await page.route("**/api/admin/**", async (route) => {
|
||
const url = new URL(route.request().url());
|
||
if (url.pathname === "/api/admin/downloads/artwork/ext-spotify-song-track-a") {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "image/svg+xml",
|
||
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path fill="#4285f4" d="M0 0h1v1H0z"/></svg>',
|
||
});
|
||
return;
|
||
}
|
||
if (url.pathname === options.releasePath) await options.release;
|
||
if (options.fail?.includes(url.pathname)) {
|
||
await route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Fixture unavailable" }),
|
||
});
|
||
return;
|
||
}
|
||
let body = responses[`${url.pathname}${url.search}`] ?? responses[url.pathname];
|
||
if (url.pathname === "/api/admin/provider-accounts" && route.request().method() === "GET" &&
|
||
options.audioMuseDisabledAccount) {
|
||
const accountResponse = structuredClone(body) as typeof responses["/api/admin/provider-accounts"];
|
||
body = {
|
||
...(accountResponse as Record<string, unknown>),
|
||
accounts: [
|
||
...((accountResponse as { accounts: Array<Record<string, unknown>> }).accounts ?? []),
|
||
{
|
||
id: "audio-account", providerId: "audiomuse-ai", displayName: "My AudioMuse-AI connection",
|
||
sourceDisplayName: "AudioMuse-AI", scope: "User", enabled: false, revision: 2,
|
||
ownerUserId: "user", ownerDisplayName: "Tester", createdByUserId: "user",
|
||
creatorDisplayName: "Tester", configuration: {}, configuredFields: [],
|
||
secret: { configured: false, revoked: false },
|
||
createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (url.pathname === "/api/admin/intelligence" && options.audioMuseUnavailable) {
|
||
const intelligence = structuredClone(body) as typeof responses["/api/admin/intelligence"];
|
||
body = {
|
||
...(intelligence as Record<string, unknown>),
|
||
providers: ((intelligence as { providers: Array<Record<string, unknown>> }).providers ?? []).map((provider) =>
|
||
provider.id === "audiomuse-ai" ? { ...provider, available: false, state: "unconfigured" } : provider),
|
||
};
|
||
}
|
||
if (url.pathname === "/api/admin/ui/activity" && url.searchParams.get("limit") !== "8")
|
||
body = { items: [], hasMore: false };
|
||
if (url.pathname === "/api/admin/downloads")
|
||
body = {
|
||
storage: url.searchParams.get("storage"),
|
||
files: [{
|
||
path: "/managed/song.flac", storage: url.searchParams.get("storage"), artist: "Artist",
|
||
album: "Album", title: "Test song", fileName: "song.flac", size: 1_024_000,
|
||
sizeFormatted: "1000 KiB", lastModified: "2026-01-01", codec: "FLAC",
|
||
bitrateKbps: 900, sampleRateHz: 44_100, bitDepth: 16, channels: 2,
|
||
durationMilliseconds: 180_000, quality: "Lossless", provider: "lumen-audio",
|
||
externalId: "track-1", artworkUrl: "/missing-download-art",
|
||
lastAccessedAt: "2026-01-02", expiresAt: "2026-01-03",
|
||
publicationState: "Indexed", referenceCount: 0, removable: true,
|
||
}],
|
||
totalSize: 1_024_000, totalSizeFormatted: "1000 KiB", count: 1,
|
||
managedCount: 1, diagnosticCount: 0,
|
||
};
|
||
if (url.pathname === "/api/admin/track-matches")
|
||
body = {
|
||
matches: [{
|
||
externalSnapshotId: "snapshot", providerId: "lumen-audio", libraryScopeId: "library",
|
||
state: "suggested", decisionSource: "automatic", confidence: 0.82, threshold: 0.9,
|
||
title: "Test song - Remix", searchQuery: "Test song", artist: "Artist", album: "Album", isrc: "US-AAA-26-00001",
|
||
durationMilliseconds: 180_000,
|
||
providerIdentities: [], reasons: ["title_match"], warnings: [], candidates: [{
|
||
libraryTrackId: "local-track", backendItemId: "backend-track", title: "Test song",
|
||
artist: "Artist", album: "Album", candidateIsrc: "US-AAA-26-00001",
|
||
sourceIsrc: "US-AAA-26-00001", normalizedSourceTitle: "test song remix",
|
||
normalizedCandidateTitle: "test song", artistOverlap: 1, albumEvidence: 1,
|
||
durationDeltaMilliseconds: 250,
|
||
providerTrackIds: { "lumen-audio": "provider-track", "apple-download": "apple-track" },
|
||
confidence: 0.82, durationMilliseconds: 180_000, components: { title: 1 },
|
||
}, {
|
||
libraryTrackId: "metadata-only", isLocal: false, title: "MusicBrainz album",
|
||
artist: "Metadata only", providerTrackIds: { musicbrainzalbum: "release-id" },
|
||
confidence: 0.99, durationMilliseconds: 180_000,
|
||
}, ...(url.searchParams.get("search") === "Test song" ? [{
|
||
libraryTrackId: "jellyfin-track", backendItemId: "jellyfin-track", isLocal: true,
|
||
title: "Test song", artist: "Artist", album: "Album",
|
||
confidence: 0.82, durationMilliseconds: 180_000,
|
||
components: { title: 1, priorityWindow: 0.07 },
|
||
}] : [])],
|
||
}],
|
||
stats: { total: 1, matched: 0, accepted: 0, unresolved: 0, suggested: 1, review: 1, rejected: 0, attention: 1 },
|
||
pagination: { page: 1, pageSize: 50, total: 1, totalPages: 1 },
|
||
};
|
||
if (url.pathname === "/api/admin/track-matches/snapshot/resolve" && route.request().method() === "POST")
|
||
body = { success: true };
|
||
if (url.pathname === "/api/admin/track-matches/targets/local")
|
||
body = {
|
||
tracks: [{
|
||
id: "jellyfin-kiss-me-more", backendItemId: "jellyfin-kiss-me-more",
|
||
title: "Kiss Me More", artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||
durationMilliseconds: 208_000, confidence: 0.91,
|
||
components: { priorityWindow: 0.07 },
|
||
}],
|
||
};
|
||
if (url.pathname === "/api/admin/track-matches/targets/provider")
|
||
body = {
|
||
tracks: [{
|
||
id: "provider-kiss-me-more", externalId: "provider-kiss-me-more",
|
||
externalProvider: "lumen-audio", title: "Kiss Me More",
|
||
artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||
durationMilliseconds: 208_000, confidence: 0.94,
|
||
}],
|
||
providers: ["lumen-audio", "qobuz"],
|
||
};
|
||
if (url.pathname === "/api/admin/playlist-links/playlist-link" && route.request().method() === "GET") {
|
||
const projectionMode = url.searchParams.get("projectionMode") ?? "resolved";
|
||
body = {
|
||
id: "playlist-link", snapshotId: "playlist-snapshot", snapshotVersion: 1,
|
||
latestSourceSnapshotVersion: 1, hasNewerSourceGeneration: false,
|
||
name: "Test playlist", sourceProviderId: "lumen-audio", projectionMode,
|
||
targetProtocol: "jellyfin", targetPlaylistId: "jellyfin-playlist",
|
||
artworkUrl: "/missing-playlist-art",
|
||
retrievedAt: "2026-01-01", completedAt: "2026-01-01", trackCount: 2,
|
||
localCount: 0, externalCount: 1, unresolvedCount: 1, durationMs: 180_000,
|
||
matchedCount: 0, reviewCount: 1, rejectedCount: 0, playableCount: 1,
|
||
routeCoverage: [{ providerId: "lumen-audio", count: 1 }],
|
||
unknownDurationCount: 0,
|
||
clientProjection: {
|
||
protocolId: "allstarr-vpl-playlist-link", projectionMode, trackCount: 1,
|
||
tracks: [{
|
||
position: 1, sourcePosition: 0,
|
||
itemId: projectionMode === "target" ? "target-track" : "client-track",
|
||
title: projectionMode === "resolved" ? "Test song" : `${projectionMode} track`, artists: ["Artist"], album: "Album",
|
||
durationMs: 180_000, routeKind: "local",
|
||
}],
|
||
},
|
||
tracks: [{
|
||
sourcePosition: 0, position: 1, externalSnapshotId: "snapshot", title: "Test song",
|
||
artists: ["Artist"], album: "Album", isrc: "US-AAA-26-00001",
|
||
artworkUrl: "/missing-track-art",
|
||
durationMs: 180_000, routeKind: "external", routeProviderId: "lumen-audio",
|
||
matchState: "suggested", targetEligible: false,
|
||
outcomeCode: "skipped_external_only_for_backend", targetStatus: "wrongbackend",
|
||
providerRoutes: [{ providerId: "lumen-audio", externalId: "provider-track", pinned: false }],
|
||
}],
|
||
};
|
||
}
|
||
if (url.pathname.includes("/api/admin/playlist-sources/") && url.pathname.endsWith("/playlists"))
|
||
body = {
|
||
items: [{
|
||
id: url.searchParams.has("cursor") ? "playlist-2" : "playlist",
|
||
providerId: url.pathname.split("/")[4],
|
||
name: url.searchParams.has("cursor") ? "Second Mix" : "Source Mix",
|
||
owner: "Tester", trackCount: 24, artworkUrl: "/missing-source-playlist-art",
|
||
}],
|
||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||
};
|
||
if (url.pathname.includes("/api/admin/media-targets/") && url.pathname.endsWith("/playlists"))
|
||
body = {
|
||
items: [{
|
||
id: "jellyfin-playlist", name: "Road trip", description: "Existing Jellyfin playlist",
|
||
trackCount: 18, artworkUrl: "/missing-target-playlist-art", writable: true,
|
||
}],
|
||
nextCursor: null,
|
||
};
|
||
if (url.pathname === "/api/admin/playlist-links" && route.request().method() === "POST")
|
||
body = { id: "new-playlist" };
|
||
if (url.pathname === "/api/admin/playlist-links/playlist-link" && route.request().method() === "PUT")
|
||
body = {
|
||
...(responses["/api/admin/playlist-links"] as { playlistLinks: Record<string, unknown>[] }).playlistLinks[0],
|
||
...route.request().postDataJSON(),
|
||
revision: 2,
|
||
};
|
||
if (url.pathname.endsWith("/schedules") && url.pathname !== "/api/admin/intelligence/schedules" && route.request().method() === "POST")
|
||
body = {
|
||
id: "schedule", cronExpression: "0 3 * * *", timeZoneId: "America/New_York",
|
||
overlapPolicy: "skip", misfirePolicy: "runOnce", enabled: true, revision: 1,
|
||
};
|
||
if (url.pathname.endsWith("/refresh") && route.request().method() === "POST")
|
||
body = { snapshot: { snapshotId: "playlist-snapshot" }, preview: {} };
|
||
if (url.pathname === "/api/admin/playlist-links/rematch/preview" && route.request().method() === "GET")
|
||
body = {
|
||
confirmationId: "a".repeat(64), playlistCount: 1, libraryCount: 1, totalRows: 2,
|
||
localRows: 0, exactProviderRows: 1, genericExternalRows: 0, unresolvedRows: 1,
|
||
confirmedManualRows: 0, staleRevisionRows: 1, conflictingRows: 0,
|
||
rowsToRematch: 1, uniqueTracksToRematch: 1, canApply: true,
|
||
};
|
||
if (url.pathname === "/api/admin/playlist-links/rematch/apply" && route.request().method() === "POST")
|
||
body = { jobId: "11111111-1111-1111-1111-111111111111", created: true };
|
||
if (url.pathname === "/api/admin/track-matches/rematch-all/preview" && route.request().method() === "GET")
|
||
body = {
|
||
confirmationId: "b".repeat(64), algorithmVersion: "priority-windows-v16",
|
||
totalTracks: 120, resolvedTracks: 80, reviewTracks: 15, unresolvedTracks: 25,
|
||
protectedManualTracks: 5, automaticDecisionsToReplace: 100,
|
||
tracksToRematch: 115, canApply: true,
|
||
};
|
||
if (url.pathname === "/api/admin/track-matches/rematch-all/apply" && route.request().method() === "POST")
|
||
body = {
|
||
jobId: "33333333-3333-3333-3333-333333333333", created: true,
|
||
algorithmVersion: "priority-windows-v16", trackCount: 115,
|
||
};
|
||
if (url.pathname.endsWith("/run") && route.request().method() === "POST")
|
||
body = { jobId: "11111111-1111-1111-1111-111111111111", created: true };
|
||
if (url.pathname.endsWith("/source-update/preview") && route.request().method() === "GET")
|
||
body = {
|
||
providerId: "spotify",
|
||
providerName: "Spotify",
|
||
sourcePlaylistName: "Road trip source",
|
||
backendPlaylistName: "Road trip",
|
||
backendProtocol: "jellyfin",
|
||
sourceVersion: "a1b2c3d4e5f6",
|
||
expectedRevision: 1,
|
||
confirmationId: "a".repeat(64),
|
||
currentCount: 4,
|
||
includedCount: 4,
|
||
skippedCount: 1,
|
||
addedCount: 1,
|
||
removedCount: 1,
|
||
movedCount: 1,
|
||
duplicateCount: 1,
|
||
canApply: true,
|
||
message: "Allstarr will update Road trip source in Spotify after you confirm.",
|
||
changes: [
|
||
{ kind: "add", toPosition: 4, title: "New song", artist: "New artist" },
|
||
{ kind: "move", fromPosition: 3, toPosition: 1, title: "First song", artist: "Artist" },
|
||
{ kind: "remove", fromPosition: 2, title: "Old song", artist: "Old artist" },
|
||
],
|
||
skipped: [{ position: 5, title: "Local only", artist: "Artist", reason: "This song has no confirmed match." }],
|
||
unshownChangeCount: 0,
|
||
unshownSkippedCount: 0,
|
||
};
|
||
if (url.pathname.endsWith("/source-update/apply") && route.request().method() === "POST")
|
||
body = { jobId: "22222222-2222-2222-2222-222222222222", created: true };
|
||
if (url.pathname.endsWith("/cancel") && route.request().method() === "POST")
|
||
body = { jobId: "11111111-1111-1111-1111-111111111111", state: "CancellationRequested" };
|
||
if (url.pathname.endsWith("/audience") && route.request().method() === "PUT") {
|
||
const input = route.request().postDataJSON();
|
||
const account = (responses["/api/admin/provider-accounts"] as { accounts: Record<string, unknown>[] }).accounts[0];
|
||
body = { ...account, scope: input.scope, ownerUserId: input.ownerUserId, revision: 2 };
|
||
}
|
||
if (url.pathname === "/api/admin/provider-accounts/audio-account/secret" &&
|
||
route.request().method() === "PUT")
|
||
body = { accountId: "audio-account", revision: 3 };
|
||
if (url.pathname === "/api/admin/provider-accounts/audio-account" &&
|
||
route.request().method() === "PATCH")
|
||
body = { enabled: true, revision: 4 };
|
||
if (url.pathname === "/api/admin/providers/test/audiomuse-ai" &&
|
||
route.request().method() === "POST")
|
||
body = { healthy: true };
|
||
if (url.pathname === "/api/admin/config/migration/preview")
|
||
body = {
|
||
previewToken: "preview-token", revision: "revision", expiresAt: "2026-01-01",
|
||
canApply: true, importedSettingCount: 1, providerAccountCount: 1, manualCount: 0,
|
||
backendIdentityCount: 1, playlistLinkCount: 1, scheduleCount: 1,
|
||
items: [{
|
||
key: "CACHE_LYRICS_DAYS", sourceLine: 1, action: "import_if_absent",
|
||
reason: "Import into tenant-scoped durable runtime settings.", sensitive: false,
|
||
valuePreview: "21",
|
||
}],
|
||
conflicts: [], warnings: ["Imported accounts remain disabled until reviewed."],
|
||
};
|
||
if (url.pathname === "/api/admin/config/migration/apply")
|
||
body = { success: true, alreadyApplied: false };
|
||
if (url.pathname === "/api/admin/config/migration/reset")
|
||
body = {};
|
||
if (url.pathname.startsWith("/api/admin/cache/categories/") && route.request().method() === "DELETE")
|
||
body = { category: decodeURIComponent(url.pathname.split("/").at(-1) ?? ""), deleted: 1 };
|
||
if (url.pathname.match(/^\/api\/admin\/cache\/(metadata|media|all)$/) && route.request().method() === "DELETE")
|
||
body = { deleted: 1 };
|
||
if (url.pathname === "/api/admin/cache/maintenance" && route.request().method() === "POST")
|
||
body = { deleted: 1 };
|
||
if (url.pathname === "/api/admin/intelligence/runs")
|
||
body = { runId: "run-2", jobId: "job-2" };
|
||
if (url.pathname === "/api/admin/intelligence/generated-sets")
|
||
body = { id: "set-2" };
|
||
if (url.pathname.includes("/api/admin/intelligence/candidates/") && url.pathname.endsWith("/feedback"))
|
||
body = { revision: 1 };
|
||
if (url.pathname === "/api/admin/intelligence/policy")
|
||
body = { revision: 2 };
|
||
if (url.pathname === "/api/admin/intelligence/data")
|
||
body = {};
|
||
if (url.pathname === "/api/admin/intelligence/listening-apps" && route.request().method() === "POST")
|
||
body = { id: "77777777-7777-7777-7777-777777777777", token: `als_${"7".repeat(32)}_${"8".repeat(64)}`, relayExternally: false, createdAt: "2026-01-02T00:00:00Z" };
|
||
if (url.pathname.startsWith("/api/admin/intelligence/listening-apps/") && route.request().method() === "DELETE")
|
||
body = {};
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/analysis")
|
||
body = { jobId: "sound-scan-1", state: "completed", completed: 200, total: 200 };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/similar")
|
||
body = { tracks: [{ trackId: "track-3", title: "Nearby Song", artist: "Sound Artist", album: "Sound Album", score: .92, explanation: "AudioMuse found a song with a similar sound." }] };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/path")
|
||
body = { tracks: [
|
||
{ trackId: "track-1", title: "Future Song", artist: "Future Artist", score: 1 },
|
||
{ trackId: "track-8", title: "Bridge Song", artist: "Sound Artist", score: .8 },
|
||
{ trackId: "track-2", title: "Second Song", artist: "Future Artist", score: 1 },
|
||
], totalDistance: .4 };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/blend")
|
||
body = { tracks: [{ trackId: "track-9", title: "Blend Song", artist: "Sound Artist", score: .89 }] };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/fingerprint")
|
||
body = { tracks: [{ trackId: "track-5", title: "Taste Song", artist: "Sound Artist", score: .9, explanation: "AudioMuse matched what you played most." }], periodDays: 90, completedListens: 42, seedCount: 8 };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/generated-sets")
|
||
body = { id: "sound-playlist-1", state: "creating" };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/search") {
|
||
const mode = route.request().postDataJSON().mode;
|
||
body = { mode, tracks: [{ trackId: "track-4", title: mode === "lyrics" ? "Rain Words" : "Quiet Light", artist: "Sound Artist", score: .88, explanation: "AudioMuse matched this song to your description." }] };
|
||
}
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/clusters")
|
||
body = url.searchParams.get("cursor")
|
||
? { clusters: [{ id: "bright", name: "Bright and quick", tracks: [{ trackId: "track-6", title: "Day Song", artist: "Sound Artist", score: .86 }] }], nextCursor: null }
|
||
: { clusters: [{ id: "soft", name: "Soft and warm", tracks: [{ trackId: "track-4", title: "Quiet Light", artist: "Sound Artist", score: .88 }] }], nextCursor: "1" };
|
||
if (url.pathname === "/api/admin/intelligence/audiomuse/map")
|
||
body = url.searchParams.get("cursor")
|
||
? { items: [{ trackId: "track-7", title: "Far Song", artist: "Sound Artist", score: .8, x: -.4, y: .1 }], projection: "fixture", nextCursor: null, isPartial: false, snapshotVersion: "map-1" }
|
||
: { items: [{ trackId: "track-4", title: "Quiet Light", artist: "Sound Artist", score: .88, x: .2, y: .6, clusterId: "soft" }], projection: "fixture", nextCursor: "next", isPartial: false, snapshotVersion: "map-1" };
|
||
if (url.pathname === "/api/admin/intelligence/history/44444444-4444-4444-4444-444444444444" && route.request().method() === "PUT")
|
||
body = { id: "44444444-4444-4444-4444-444444444444", revision: 3 };
|
||
if (url.pathname === "/api/admin/intelligence/history/44444444-4444-4444-4444-444444444444" && route.request().method() === "DELETE")
|
||
body = {};
|
||
if (url.pathname === "/api/admin/intelligence/history/imports/preview") {
|
||
const secondFile = route.request().postData()?.includes("ListenBrainz.jsonl") ?? false;
|
||
body = {
|
||
importId: secondFile ? "66666666-6666-6666-6666-666666666666" : "55555555-5555-5555-5555-555555555555", revision: "preview-revision",
|
||
displayFileName: secondFile ? "ListenBrainz.jsonl" : "Streaming_History.json", sizeBytes: 1024, expiresAt: "2026-01-02T00:00:00Z",
|
||
state: "previewed", outboundReplay: false,
|
||
preview: {
|
||
format: "spotify-extended-history", fileRows: 15, musicRows: 14, completed: 12, partial: 1,
|
||
skipped: 1, episodes: 1, nonTrack: 0, malformed: 0, duplicateInFile: 1, duplicateExisting: 2,
|
||
newRows: 9, resolvedNewRows: 7, unresolvedNewRows: 2, rowsWithoutProviderIdentity: 0,
|
||
sourceUserCount: 1, estimatedMusicBrainzLookups: 2, earliest: "2025-01-01T00:00:00Z",
|
||
latest: "2025-12-31T00:00:00Z", reasonCounts: {},
|
||
},
|
||
};
|
||
}
|
||
if (url.pathname === "/api/admin/intelligence/history/imports/55555555-5555-5555-5555-555555555555")
|
||
body = { importId: "55555555-5555-5555-5555-555555555555", revision: "done-revision", state: "completed", importedRows: 9, duplicateRows: 3, resolvedRows: 7, unresolvedRows: 2, outboundReplay: false };
|
||
if (url.pathname.match(/^\/api\/admin\/intelligence\/history\/imports\/[^/]+\/(apply|resume|cancel)$/))
|
||
body = { importId: "55555555-5555-5555-5555-555555555555", revision: "done-revision", state: "completed", importedRows: 9, duplicateRows: 3, resolvedRows: 7, unresolvedRows: 2, outboundReplay: false };
|
||
if (url.pathname === "/api/admin/intelligence/schedules" && route.request().method() === "POST")
|
||
body = { id: "schedule-2", ...route.request().postDataJSON(), revision: 1, nextRunAt: "2026-01-02T13:00:00Z" };
|
||
if (url.pathname.startsWith("/api/admin/intelligence/schedules/") && route.request().method() === "PUT")
|
||
body = { id: url.pathname.split("/").at(-1), ...route.request().postDataJSON(), revision: 2, nextRunAt: "2026-01-02T13:00:00Z" };
|
||
if (url.pathname.startsWith("/api/admin/intelligence/schedules/") && route.request().method() === "DELETE")
|
||
body = {};
|
||
await route.fulfill({
|
||
status: body === undefined ? 404 : 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify(body ?? { error: `Missing fixture: ${url.pathname}` }),
|
||
});
|
||
});
|
||
}
|
||
|
||
const routes = [
|
||
["#/", "Home"],
|
||
["#/library", "Library"],
|
||
["#/library/playlists", "Library"],
|
||
["#/library/mappings", "Library"],
|
||
["#/library/cached", "Library"],
|
||
["#/library/kept", "Library"],
|
||
["#/activity", "Activity"],
|
||
["#/intelligence", "Intelligence"],
|
||
["#/integrations/services", "Integrations"],
|
||
["#/integrations/accounts", "Integrations"],
|
||
["#/integrations/extensions", "Integrations"],
|
||
["#/integrations/routing", "Integrations"],
|
||
["#/settings/general", "Settings"],
|
||
] as const;
|
||
|
||
const stateRoutes = [
|
||
["#/", "Home", "Loading Home", "/api/admin/ui/home", ["/api/admin/ui/home", "/api/admin/ui/now-playing"]],
|
||
["#/library/playlists", "Library", "Loading playlists", "/api/admin/playlist-links", ["/api/admin/playlist-links"]],
|
||
["#/library/mappings", "Library", "Loading match review", "/api/admin/track-matches", ["/api/admin/track-matches"]],
|
||
["#/library/cached", "Library", "Loading Cached tracks", "/api/admin/downloads", ["/api/admin/downloads"]],
|
||
["#/activity", "Activity", "Loading Event log", "/api/admin/ui/activity", ["/api/admin/ui/activity"]],
|
||
["#/integrations/services", "Integrations", "Loading Services", "/api/admin/provider-accounts", ["/api/admin/ui/schema", "/api/admin/provider-accounts"]],
|
||
["#/settings/general", "Settings", "Loading Settings", "/api/admin/ui/schema", ["/api/admin/ui/schema"]],
|
||
] as const;
|
||
|
||
for (const viewport of viewports) {
|
||
test.describe(`${viewport.width}x${viewport.height}`, () => {
|
||
test.use({ viewport, colorScheme: process.env.ALLSTARR_SCREENSHOT_THEME === "dark" ? "dark" : "light" });
|
||
|
||
for (const [route, heading] of routes) {
|
||
test(`${route} has no document overflow`, async ({ page }) => {
|
||
const errors: string[] = [];
|
||
page.on("pageerror", (error) => errors.push(error.message));
|
||
const screenshotTheme = process.env.ALLSTARR_SCREENSHOT_THEME;
|
||
if (screenshotTheme === "light" || screenshotTheme === "dark") {
|
||
await page.addInitScript((theme) => localStorage.setItem("allstarr.theme", theme), screenshotTheme);
|
||
}
|
||
await mockApi(page);
|
||
await page.goto(route);
|
||
const pageHeading = page.getByRole("heading", { name: heading, level: 1 });
|
||
await expect(pageHeading).toBeVisible();
|
||
if (route === "#/integrations/extensions") {
|
||
await expect(page.getByText("Extension manager", { exact: true })).toBeVisible();
|
||
}
|
||
await page.evaluate(() => new Promise<void>((resolve) =>
|
||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||
));
|
||
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||
expect((await pageHeading.boundingBox())?.y).toBeGreaterThanOrEqual(0);
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
if (viewport.width <= 760) await expectContainedMobileGeometry(page);
|
||
if (route === "#/activity") {
|
||
if (viewport.width <= 650) {
|
||
await expect(page.getByText("Filters", { exact: true })).toBeVisible();
|
||
await expect(page.getByLabel("Search")).toBeHidden();
|
||
} else {
|
||
await expect(page.getByLabel("Search")).toBeVisible();
|
||
}
|
||
}
|
||
expect(errors).toEqual([]);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR) {
|
||
const name = route.replace(/^#\/?/, "").replaceAll(/[^a-z0-9]+/gi, "-") || "home";
|
||
await page.screenshot({
|
||
path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${screenshotTheme ?? "system"}-${viewport.width}-${name}.png`,
|
||
fullPage: true,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
test("Playlist projection dialogs remain usable", async ({ page }, testInfo) => {
|
||
await mockApi(page);
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const details = page.getByRole("dialog", { name: "Test playlist" });
|
||
await expect(details).not.toContainText(/native playlist|hybrid|materialized|write-back|projection mode|backend/i);
|
||
await details.getByRole("tab", { name: "Jellyfin playlist", exact: true }).click();
|
||
await expect(details.getByText("target track", { exact: true })).toBeVisible();
|
||
await expect(details).toHaveCSS("overflow", "hidden");
|
||
await expect(details.locator(".track-scroll")).toHaveCSS("overflow", "auto");
|
||
await expect.poll(() => details.locator(".track-scroll").evaluate((scroll) =>
|
||
scroll.clientHeight - scroll.querySelector("table")!.scrollHeight)).toBeLessThan(48);
|
||
const headerContentClearsSwitcher = () => details.evaluate((dialog) => {
|
||
const header = dialog.querySelector(".playlist-hero");
|
||
const switcher = dialog.querySelector(".playlist-view-switcher");
|
||
if (!header || !switcher) return false;
|
||
const switcherTop = switcher.getBoundingClientRect().top;
|
||
return Array.from(header.children).every((child) => child.getBoundingClientRect().bottom <= switcherTop + 1);
|
||
});
|
||
await expect.poll(headerContentClearsSwitcher).toBe(true);
|
||
if (viewport.width === 1280) {
|
||
await page.setViewportSize({ width: 1168, height: 497 });
|
||
await expect.poll(headerContentClearsSwitcher).toBe(true);
|
||
await page.setViewportSize(viewport);
|
||
}
|
||
const screenshotDirectory = process.env.ALLSTARR_SCREENSHOT_DIR;
|
||
const detailScreenshot = await page.screenshot({
|
||
path: screenshotDirectory ? `${screenshotDirectory}/playlist-${viewport.width}-details.png` : undefined,
|
||
});
|
||
await testInfo.attach("playlist-details", {
|
||
body: detailScreenshot,
|
||
contentType: "image/png",
|
||
});
|
||
|
||
await details.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Edit settings" }).click();
|
||
const settings = page.getByRole("dialog", { name: "Edit playlist settings" });
|
||
await expect(settings).not.toContainText(/native playlist|hybrid|materialized|write-back|projection mode|backend/i);
|
||
await expect(settings.locator(".playlist-settings-form")).toHaveCSS("overflow-y", "auto");
|
||
await expect(settings.getByRole("button", { name: "Save settings" })).toBeInViewport();
|
||
const settingsScreenshot = await page.screenshot({
|
||
path: screenshotDirectory ? `${screenshotDirectory}/playlist-${viewport.width}-settings.png` : undefined,
|
||
});
|
||
await testInfo.attach("playlist-settings", {
|
||
body: settingsScreenshot,
|
||
contentType: "image/png",
|
||
});
|
||
});
|
||
|
||
for (const [route, heading, loadingLabel, releasePath, failures] of stateRoutes) {
|
||
test(`${route} exposes loading and error recovery`, async ({ page, context }) => {
|
||
const delayed = routeRelease();
|
||
await mockApi(page, { releasePath, release: delayed.promise });
|
||
await page.goto(route);
|
||
await expect(page.getByLabel(loadingLabel)).toBeVisible();
|
||
delayed.release();
|
||
await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible();
|
||
|
||
const errorPage = await context.newPage();
|
||
await mockApi(errorPage, { fail: [...failures] });
|
||
await errorPage.goto(route);
|
||
await expect(errorPage.getByRole("alert")).toBeVisible();
|
||
await expect(errorPage.getByRole("button", { name: "Try again" })).toBeInViewport();
|
||
});
|
||
}
|
||
|
||
test("Settings dialogs remain usable", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/settings/general");
|
||
await expect(page.getByText("Public URL", { exact: true }).locator("..").getByText("Deployment-owned")).toBeVisible();
|
||
await page.getByLabel("Color theme").click();
|
||
await expect(page.getByRole("option", { name: "Dark" })).toBeVisible();
|
||
await page.getByRole("option", { name: "Dark" }).click();
|
||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||
await page.keyboard.press("Escape");
|
||
await page.goto("#/settings/accounts");
|
||
await expect(page.getByRole("heading", { name: "Integrations", level: 1 })).toBeVisible();
|
||
await expect(page.getByRole("tab", { name: "Accounts" })).toHaveAttribute("aria-selected", "true");
|
||
await page.goto("#/sources?source=lumen-audio§ion=configuration");
|
||
await expect(page.getByRole("heading", { name: "Integrations", level: 1 })).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByRole("tab", { name: "Services" })).toHaveAttribute("aria-selected", "true");
|
||
await page.getByRole("button", { name: "Connect another account" }).click();
|
||
const sourceDialog = page.getByRole("dialog", { name: "Connect a Source" });
|
||
await expect(sourceDialog.getByRole("button", { name: "Source", exact: true })).toContainText("Lumen Audio");
|
||
await expect(sourceDialog.getByLabel("Access token")).toHaveValue("");
|
||
await sourceDialog.getByRole("button", { name: "Cancel" }).click();
|
||
await page.goto("#/settings/routing");
|
||
await expect(page.getByRole("tab", { name: "Routing" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(page.getByText("Local · fixed")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Save playback and matching" })).toBeDisabled();
|
||
await expect(page.getByRole("button", { name: "Move Jellyfin up" })).toHaveCount(0);
|
||
await expect(page.locator(".provider-art").first()).toHaveCSS("border-top-width", "0px");
|
||
const savePlayback = page.getByRole("button", { name: "Save Playback", exact: true });
|
||
await expect(savePlayback).toBeDisabled();
|
||
const routes = page.locator(".routing-group li[draggable=true]");
|
||
await routes.nth(0).dragTo(routes.nth(1));
|
||
await expect(routes.nth(0)).toContainText("Future Audio");
|
||
await expect(savePlayback).toBeEnabled();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
await page.goto("#/settings/extensions");
|
||
await expect(page.getByRole("tab", { name: "Extensions" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(page.locator(".extension-row .badge")).toContainText(["Metadata", "Streaming"]);
|
||
await expect(page.locator(".segmented-tab-count").first()).toHaveCSS("border-style", "solid");
|
||
await expect(page.getByRole("tab", { name: /Available/ })).toHaveCSS("border-right-width", "0px");
|
||
await page.getByRole("tab", { name: /Available/ }).click();
|
||
await expect(page.getByText("Available packages", { exact: true })).toBeVisible();
|
||
await page.getByRole("tab", { name: /Registries/ }).click();
|
||
await expect(page.getByText("Add registry", { exact: true })).toBeVisible();
|
||
await page.getByRole("tab", { name: "Activity" }).click();
|
||
await expect(page.getByText("Extension activity", { exact: true })).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
await page.getByRole("button", { name: "Install extension" }).click();
|
||
await expect(page.getByRole("dialog", { name: "Install extension" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Verify package" })).toBeInViewport();
|
||
await page.getByRole("button", { name: "Close installer" }).click();
|
||
await page.goto("#/settings/maintenance");
|
||
await expect(page.getByText("Selective state transfer")).toBeVisible();
|
||
await expect(page.getByText("128 MiB max")).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
await page.getByRole("button", { name: "Purge all cache" }).click();
|
||
const dialog = page.getByRole("alertdialog", { name: "Purge the application cache?" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByRole("button", { name: "Purge cache" })).toBeInViewport();
|
||
});
|
||
|
||
test("Intelligence results remain usable", async ({ page, context }) => {
|
||
const delayed = routeRelease();
|
||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||
await mockApi(page, { releasePath: "/api/admin/intelligence", release: delayed.promise });
|
||
const initialHistoryOverview = page.waitForRequest((request) =>
|
||
new URL(request.url()).pathname === "/api/admin/intelligence/history/overview");
|
||
await page.goto("#/intelligence");
|
||
const loadingStatus = page.getByRole("status", { name: "Loading Intelligence" });
|
||
await expect(loadingStatus).toBeVisible();
|
||
expect(await loadingStatus.locator(".skeleton-panel").first().evaluate((element) =>
|
||
getComputedStyle(element, "::after").animationName)).toBe("none");
|
||
delayed.release();
|
||
const initialHistoryUrl = new URL((await initialHistoryOverview).url());
|
||
expect(initialHistoryUrl.searchParams.has("from")).toBe(false);
|
||
expect(initialHistoryUrl.searchParams.has("to")).toBe(false);
|
||
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(page.getByRole("tab", { name: "All time" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(page.locator(".scope-value")).toContainText("Music");
|
||
await expect(page.locator(".scope-value")).toContainText("Jellyfin · Jellyfin Music");
|
||
const themeButton = page.getByRole("button", { name: /Theme:/ });
|
||
await themeButton.click();
|
||
await expect(page.locator("html")).not.toHaveClass(/dark/);
|
||
await themeButton.click();
|
||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||
await themeButton.click();
|
||
await expect(page.getByLabel("Server connection")).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: "Open library" })).toHaveCount(0);
|
||
if (viewport.width <= 650) {
|
||
const sectionTabs = page.getByRole("navigation", { name: "Intelligence sections" }).locator(".segmented-tabs");
|
||
await expect(sectionTabs).toHaveCSS("overflow-x", "auto");
|
||
await expect(sectionTabs).toHaveCSS("scrollbar-width", "none");
|
||
await expect(sectionTabs.getByRole("tab", { name: "Playlists", exact: true })).toHaveCount(1);
|
||
expect(Math.min(...await sectionTabs.getByRole("tab").evaluateAll((tabs) => tabs.map((tab) => tab.clientWidth)))).toBeGreaterThanOrEqual(72);
|
||
}
|
||
const recap = page.locator(".recap-card");
|
||
await expect(recap).toContainText("12 times across 9 songs and 5 artists");
|
||
await expect(recap).toContainText("Your most-played song was Moon Song by The Comets");
|
||
await expect(recap).toContainText("Your first recorded listen was");
|
||
await expect(page.getByRole("heading", { name: "Recently played", level: 3 })).toBeVisible();
|
||
const historyList = page.locator(".history-list");
|
||
await expect(historyList.getByText("Moon Song", { exact: true })).toBeVisible();
|
||
await expect(historyList).not.toContainText(/not\s?requested/i);
|
||
await expect(historyList.locator(".track-art img")).toBeVisible();
|
||
const historyRow = historyList.getByRole("button").first();
|
||
expect((await historyRow.boundingBox())!.height).toBeLessThan(viewport.width <= 760 ? 112 : 72);
|
||
if (viewport.width > 760) await expect(page.locator(".history-column-head")).toBeVisible();
|
||
else await expect(page.locator(".history-column-head")).toBeHidden();
|
||
expect(await page.locator(".history-list").evaluate((element) => element.tagName)).toBe("UL");
|
||
const activityCard = page.locator(".activity-card");
|
||
const dailyHeatmap = activityCard.getByRole("slider", { name: "Daily listening activity" });
|
||
await expect(dailyHeatmap).toHaveAttribute("aria-valuemax", "30");
|
||
await dailyHeatmap.focus();
|
||
await page.keyboard.press("Home");
|
||
await expect(dailyHeatmap).toHaveAttribute("aria-valuenow", "0");
|
||
await expect(activityCard.getByRole("region", { name: "Selected listening activity" })).toContainText("December 1, 2025");
|
||
await page.keyboard.press("End");
|
||
await expect(dailyHeatmap).toHaveAttribute("aria-valuenow", "30");
|
||
const activityDetail = activityCard.getByRole("region", { name: "Selected listening activity" });
|
||
await expect(activityDetail).toContainText("December 31, 2025");
|
||
await expect(activityDetail).toContainText("31");
|
||
await expect(activityDetail).toContainText("1 hr 33 min");
|
||
await expect(activityDetail).toContainText("3:00");
|
||
|
||
const firstHeatmapCell = activityCard.locator('[data-heatmap-index="0"]');
|
||
const dragHeatmapCell = activityCard.locator('[data-heatmap-index="20"]');
|
||
const firstCellBox = await firstHeatmapCell.boundingBox();
|
||
const dragCellBox = await dragHeatmapCell.boundingBox();
|
||
expect(firstCellBox).not.toBeNull();
|
||
expect(dragCellBox).not.toBeNull();
|
||
const center = (box: NonNullable<typeof firstCellBox>) => ({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||
await page.mouse.move(center(firstCellBox!).x, center(firstCellBox!).y);
|
||
await page.mouse.down();
|
||
await page.mouse.move(center(dragCellBox!).x, center(dragCellBox!).y, { steps: 2 });
|
||
await expect(dailyHeatmap).toHaveAttribute("aria-valuenow", "20");
|
||
await page.mouse.up();
|
||
|
||
const dispatchTouchPointer = async (type: "pointerdown" | "pointermove" | "pointerup", point: { x: number; y: number }) =>
|
||
page.evaluate(({ type, point }) => {
|
||
const slider = document.querySelector<HTMLElement>('.activity-card [role="slider"]');
|
||
if (!slider) throw new Error("Listening activity heatmap slider is missing.");
|
||
const event = new PointerEvent(type, {
|
||
bubbles: true, cancelable: true, pointerId: 23, pointerType: "touch",
|
||
clientX: point.x, clientY: point.y, button: 0, buttons: type === "pointerup" ? 0 : 1,
|
||
});
|
||
if (type === "pointerdown") {
|
||
const originalCapture = Element.prototype.setPointerCapture;
|
||
let captured = false;
|
||
Element.prototype.setPointerCapture = () => { captured = true; };
|
||
try { slider.dispatchEvent(event); } finally { Element.prototype.setPointerCapture = originalCapture; }
|
||
return captured;
|
||
} else {
|
||
slider.dispatchEvent(event);
|
||
return false;
|
||
}
|
||
}, { type, point });
|
||
expect(await dispatchTouchPointer("pointerdown", center(firstCellBox!))).toBe(true);
|
||
await dispatchTouchPointer("pointermove", center(dragCellBox!));
|
||
await expect(dailyHeatmap).toHaveAttribute("aria-valuenow", "20");
|
||
await dispatchTouchPointer("pointerup", center(dragCellBox!));
|
||
|
||
await activityCard.getByRole("tab", { name: "Monthly" }).click();
|
||
const monthlyHeatmap = activityCard.getByRole("slider", { name: "Monthly listening activity" });
|
||
await expect(monthlyHeatmap).toHaveAttribute("aria-valuemax", "0");
|
||
await expect(activityCard.getByRole("region", { name: "Selected listening activity" })).toContainText("December 2025");
|
||
await activityCard.getByRole("tab", { name: "Daily" }).click();
|
||
await expect(page.locator(".breakdown-card").filter({ hasText: "Sources" })).toContainText("playback");
|
||
await expect(page.locator(".breakdown-card").filter({ hasText: "Providers" })).toContainText("deezer");
|
||
await expect(page.locator(".breakdown-card").filter({ hasText: "Listening apps" })).toContainText("Koito");
|
||
const topTabs = page.getByRole("navigation", { name: "Top listening category" });
|
||
await topTabs.getByRole("tab", { name: "Artists" }).focus();
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(topTabs.getByRole("tab", { name: "Albums" })).toHaveAttribute("aria-selected", "true");
|
||
await page.keyboard.press("End");
|
||
await expect(topTabs.getByRole("tab", { name: "Songs" })).toHaveAttribute("aria-selected", "true");
|
||
if (viewport.width <= 650)
|
||
expect(Math.min(...await topTabs.getByRole("tab").evaluateAll((tabs) => tabs.map((tab) => tab.clientHeight)))).toBeGreaterThanOrEqual(44);
|
||
await page.getByRole("tab", { name: "Playlists", exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/intelligence\?section=playlists$/);
|
||
const intelligencePlaylists = page.locator(".intelligence-playlists");
|
||
await expect(intelligencePlaylists.getByRole("heading", { name: "Recommendation playlists", level: 3 })).toBeVisible();
|
||
await expect(intelligencePlaylists.getByText("Morning discovery", { exact: true })).toBeVisible();
|
||
await expect(intelligencePlaylists).toContainText("injects it into this user’s Jellyfin library");
|
||
await expect(intelligencePlaylists.getByLabel("Playlist name")).toHaveValue("Your recommendations");
|
||
await expect(intelligencePlaylists.getByRole("button", { name: "Create in Jellyfin" })).toBeVisible();
|
||
await page.getByRole("tab", { name: "Discover" }).click();
|
||
await expect(page).toHaveURL(/#\/intelligence\?section=discover$/);
|
||
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Searching Lumen Audio.")).toBeVisible();
|
||
await expect(page.getByRole("progressbar", { name: "Recommendation refresh progress" })).toHaveAttribute("aria-valuenow", "1");
|
||
await page.getByRole("button", { name: "Cancel refresh" }).scrollIntoViewIfNeeded();
|
||
await expect(page.getByRole("button", { name: "Cancel refresh" })).toBeInViewport();
|
||
await page.getByRole("button", { name: "Refresh recommendations" }).scrollIntoViewIfNeeded();
|
||
await expect(page.getByRole("button", { name: "Refresh recommendations" })).toBeInViewport();
|
||
const soundDiscovery = page.locator(".sound-discovery");
|
||
await expect(soundDiscovery).toContainText("Allstarr will not create or change a Jellyfin playlist");
|
||
await expect(soundDiscovery).not.toContainText(/native playlist|hybrid|materialized|write-back/i);
|
||
const firstScan = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/analysis"));
|
||
await soundDiscovery.getByRole("button", { name: "Scan library sounds" }).click();
|
||
expect((await firstScan).postDataJSON()).toMatchObject({ rebuild: false });
|
||
await expect(soundDiscovery.getByRole("progressbar", { name: "Library sound scan progress" })).toHaveAttribute("aria-valuenow", "200");
|
||
const rebuildScan = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/analysis"));
|
||
await soundDiscovery.getByRole("button", { name: "Scan library again" }).click();
|
||
expect((await rebuildScan).postDataJSON()).toMatchObject({ rebuild: true });
|
||
const similarRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/similar"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await similarRequest).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music", seedTrackIds: ["track-1"],
|
||
});
|
||
await expect(soundDiscovery.getByText("Nearby Song", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("How to explore").click();
|
||
await page.getByRole("option", { name: "Connect two songs" }).click();
|
||
const pathRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/path"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await pathRequest).postDataJSON()).toMatchObject({ startTrackId: "track-1", endTrackId: "track-2" });
|
||
await expect(soundDiscovery.getByText("Bridge Song", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("How to explore").click();
|
||
await page.getByRole("option", { name: "Include one sound and avoid another" }).click();
|
||
const blendRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/blend"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await blendRequest).postDataJSON()).toMatchObject({ includeTrackIds: ["track-1"], avoidTrackIds: ["track-2"] });
|
||
await expect(soundDiscovery.getByText("Blend Song", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("How to explore").click();
|
||
await page.getByRole("option", { name: "Describe what you want" }).click();
|
||
await soundDiscovery.getByLabel("Describe a sound").fill("warm and quiet");
|
||
const textRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/search"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await textRequest).postDataJSON()).toMatchObject({ query: "warm and quiet", mode: "text" });
|
||
await expect(soundDiscovery.getByText("Quiet Light", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("What to match").click();
|
||
await page.getByRole("option", { name: "Song lyrics" }).click();
|
||
await soundDiscovery.getByLabel("Words to find").fill("city lights in the rain");
|
||
const lyricsRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/search"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await lyricsRequest).postDataJSON()).toMatchObject({ query: "city lights in the rain", mode: "lyrics" });
|
||
await expect(soundDiscovery.getByText("Rain Words", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("How to explore").click();
|
||
await page.getByRole("option", { name: "Use what I played most" }).click();
|
||
const fingerprintRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/fingerprint"));
|
||
await soundDiscovery.getByRole("button", { name: "Find songs" }).click();
|
||
expect((await fingerprintRequest).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music", periodDays: 90,
|
||
});
|
||
await expect(soundDiscovery.getByText("Taste Song", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("Playlist name").fill("Evening sounds");
|
||
await soundDiscovery.getByRole("button", { name: "Create Jellyfin playlist" }).click();
|
||
const createDialog = page.getByRole("alertdialog", { name: "Create Evening sounds in Jellyfin?" });
|
||
await expect(createDialog).toContainText("Allstarr will create Evening sounds in Jellyfin with 1 song.");
|
||
await createDialog.getByRole("button", { name: "Do not create playlist" }).click();
|
||
await expect(createDialog).toBeHidden();
|
||
await soundDiscovery.getByRole("button", { name: "Create Jellyfin playlist" }).click();
|
||
const createRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/audiomuse/generated-sets"));
|
||
await createDialog.getByRole("button", { name: "Create Jellyfin playlist" }).click();
|
||
expect((await createRequest).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music",
|
||
name: "Evening sounds", trackIds: ["track-5"],
|
||
});
|
||
await expect(soundDiscovery.getByText("Allstarr is creating Evening sounds in Jellyfin.", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByLabel("How to explore").click();
|
||
await page.getByRole("option", { name: "Browse the whole library by sound" }).click();
|
||
await soundDiscovery.getByRole("button", { name: "Group similar songs" }).click();
|
||
await expect(soundDiscovery.getByText("Soft and warm", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByRole("button", { name: "Show more groups" }).click();
|
||
await expect(soundDiscovery.getByText("Bright and quick", { exact: true })).toBeVisible();
|
||
await soundDiscovery.getByRole("button", { name: "List the sound map" }).click();
|
||
await expect(soundDiscovery.getByText("Quiet Light", { exact: true })).toBeVisible();
|
||
const nextMap = page.waitForRequest((request) => request.url().includes("/api/admin/intelligence/audiomuse/map") && new URL(request.url()).searchParams.get("cursor") === "next");
|
||
await soundDiscovery.getByRole("button", { name: "Show more songs" }).click();
|
||
await nextMap;
|
||
await expect(soundDiscovery.getByText("Far Song", { exact: true })).toBeVisible();
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-recommendations.png`, fullPage: true });
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
await page.getByRole("tab", { name: "History", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Listening history", level: 3, exact: true })).toBeVisible();
|
||
await expect(page.locator(".history-list").getByText("Moon Song", { exact: true })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Import listening history" })).toHaveCount(0);
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-history.png`, fullPage: true });
|
||
await page.getByRole("tab", { name: "Import", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Import listening history" })).toBeVisible();
|
||
await expect(page.getByText(/upload the JSON files from your Extended Streaming History download/)).toBeVisible();
|
||
const automationTab = page.getByRole("tab", { name: "Automation", exact: true });
|
||
await expect(automationTab).toHaveCount(1);
|
||
await automationTab.scrollIntoViewIfNeeded();
|
||
await expect(automationTab).toBeInViewport();
|
||
await expect(page.getByText("Imported listens older than 30 days are removed automatically.", { exact: true })).toBeVisible();
|
||
await expect(page.locator(".history-list")).toHaveCount(0);
|
||
const historyUpload = page.locator(".upload-zone");
|
||
await historyUpload.scrollIntoViewIfNeeded();
|
||
await expect(historyUpload).toBeInViewport();
|
||
await expect(page.getByLabel("History export files")).toHaveAttribute("multiple", "");
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-imports.png`, fullPage: true });
|
||
await page.getByRole("tab", { name: "Automation" }).click();
|
||
await expect(page.getByText("Monday discoveries", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Allstarr keeps private listening history and uses it for recommendations.", { exact: true })).toBeVisible();
|
||
const automaticHistory = page.getByRole("checkbox", { name: /Save my listening automatically/ });
|
||
await expect(automaticHistory).toBeChecked();
|
||
await expect(automaticHistory.locator("..")).toHaveClass(/selected/);
|
||
expect((await automaticHistory.locator("..").boundingBox())?.height ?? 999).toBeLessThan(140);
|
||
await expect(automaticHistory).toHaveAttribute("data-slot", "checkbox");
|
||
await expect(page.getByText("Keep listening history for", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Recommendation actions", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Recommendation sources", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Connected services Allstarr may use to find candidates. This does not import history or change source accounts.", { exact: true })).toBeVisible();
|
||
const audioMuseSetup = page.locator(".provider-setup").filter({ hasText: "AudioMuse-AI connection" });
|
||
await expect(audioMuseSetup).toContainText("Connect your self-hosted AudioMuse-AI server here");
|
||
await audioMuseSetup.getByRole("button", { name: "Connect AudioMuse-AI" }).click();
|
||
const audioMuseDialog = page.getByRole("dialog", { name: "Connect a Source" });
|
||
await expect(audioMuseDialog.getByLabel("AudioMuse-AI server URL")).toBeVisible();
|
||
await expect(audioMuseDialog.getByLabel("AudioMuse-AI access token")).toBeVisible();
|
||
await audioMuseDialog.getByRole("button", { name: "Close source connection dialog" }).click();
|
||
await expect(page.locator(".settings-stack")).not.toContainText(/\bsignals?\b/i);
|
||
await expect(page.getByText("Allstarr sent the latest completed listen to Last.fm.", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Allstarr will not send listens to ListenBrainz.", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Allstarr is checking 2 saved listens for more song details.", { exact: true })).toBeVisible();
|
||
const intelligenceSource = page.locator(".provider-choices label").filter({ hasText: "Private similarity source." });
|
||
await expect(intelligenceSource.locator('[data-slot="badge"]')).toHaveText("Ready");
|
||
await expect(page.getByText("Where generated playlists are created", { exact: true })).toBeVisible();
|
||
expect(await page.locator(".status-list").evaluate((element) => element.tagName)).toBe("UL");
|
||
expect(await page.locator(".schedule-list").evaluate((element) => element.tagName)).toBe("UL");
|
||
const listeningApps = page.locator(".listening-apps-card");
|
||
await expect(listeningApps).toContainText("Allstarr will save listens from this key to music on Jellyfin.");
|
||
await expect(listeningApps).toContainText("Allstarr will not send these listens to another service.");
|
||
expect(await listeningApps.locator(".key-list").evaluate((element) => element.tagName)).toBe("UL");
|
||
await expect(listeningApps).not.toContainText(/native playlist|hybrid|materialized|write-back|projection|backend/i);
|
||
const keyRequest = page.waitForRequest((request) => request.url().endsWith("/api/admin/intelligence/listening-apps") && request.method() === "POST");
|
||
await listeningApps.getByRole("button", { name: "Create private key" }).click();
|
||
expect((await keyRequest).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music", sendToConnectedServices: false,
|
||
});
|
||
await expect(listeningApps.getByLabel("New listening app private key")).toHaveValue(/^als_/);
|
||
await page.getByRole("button", { name: "Turn off and clear" }).click();
|
||
const clearDialog = page.getByRole("alertdialog", { name: "Clear private listening data for this library?" });
|
||
await expect(clearDialog).toContainText("it will not change Jellyfin playlists or connected Last.fm or ListenBrainz accounts.");
|
||
await clearDialog.getByRole("button", { name: "Keep my data" }).click();
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-settings.png`, fullPage: true });
|
||
|
||
const errorPage = await context.newPage();
|
||
await mockApi(errorPage, { fail: ["/api/admin/intelligence"] });
|
||
await errorPage.goto("#/intelligence");
|
||
await expect(errorPage.getByRole("alert")).toContainText("Fixture unavailable");
|
||
await expect(errorPage.getByRole("button", { name: "Try again" })).toBeInViewport();
|
||
});
|
||
|
||
test("Source dialogs remain usable", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/sources");
|
||
await page.getByRole("button", { name: "Connect Source" }).click();
|
||
const connect = page.getByRole("dialog", { name: "Connect a Source" });
|
||
await expect(connect).toBeVisible();
|
||
await connect.getByRole("button", { name: "Source", exact: true }).click();
|
||
await page.getByRole("option", { name: "ListenBrainz" }).click();
|
||
await expect(connect.getByLabel("ListenBrainz or Koito token")).toBeVisible();
|
||
await expect(connect.getByLabel("Where to send listens (optional)")).toBeVisible();
|
||
await expect(connect.getByText("Leave blank to send listens to ListenBrainz. To use Koito, paste its HTTPS listening address.")).toBeVisible();
|
||
await expect(connect.getByRole("button", { name: "Save and test" })).toBeInViewport();
|
||
await page.getByRole("button", { name: "Close source connection dialog" }).click();
|
||
await page.goto("#/integrations/services?source=audiomuse-ai§ion=configuration");
|
||
const intelligenceLink = page.getByRole("link", { name: "Configure in Intelligence" });
|
||
await expect(intelligenceLink).toHaveAttribute("href", "#/intelligence?section=automation");
|
||
await expect(page.getByRole("dialog")).toContainText("AudioMuse-AI powers sound-based discovery");
|
||
await page.goto("#/integrations/accounts");
|
||
await page.getByRole("button", { name: /Lumen Audio Account details stored/ }).click();
|
||
await page.getByRole("tab", { name: "Access" }).click();
|
||
await page.getByRole("button", { name: "Edit access" }).click();
|
||
const access = page.locator(".access-dialog");
|
||
await expect(access).toBeVisible();
|
||
await access.getByRole("radio", { name: "One user" }).check();
|
||
await access.getByRole("button", { name: "Allstarr user" }).click();
|
||
await page.getByRole("option", { name: "Listener" }).click();
|
||
await access.getByRole("button", { name: "Save access" }).click();
|
||
await expect(access).toBeHidden();
|
||
|
||
await page.getByRole("button", { name: /Lumen Audio Account details stored/ }).click();
|
||
await page.getByRole("tab", { name: "Access" }).click();
|
||
await page.getByRole("button", { name: "Edit access" }).click();
|
||
await page.getByRole("radio", { name: "One library" }).check();
|
||
await expect(page.getByText("Only requests in the selected media library may use this account.")).toBeVisible();
|
||
await page.getByLabel("Library ID").fill("music");
|
||
await page.getByRole("button", { name: "Save access" }).click();
|
||
const libraryShare = page.getByRole("alertdialog", {
|
||
name: "Share this connection with a library?",
|
||
});
|
||
await expect(libraryShare).toBeVisible();
|
||
await libraryShare.getByRole("button", { name: "Keep current access" }).click();
|
||
await page.getByRole("radio", { name: "Global" }).check();
|
||
await page.getByRole("button", { name: "Save access" }).click();
|
||
await expect(page.getByRole("alertdialog", { name: "Share this connection with everyone?" })).toBeVisible();
|
||
});
|
||
|
||
test("A listener can choose Private or explicitly confirm Global when connecting Spotify", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/auth/me", (route) => route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
authenticated: true,
|
||
backend: "Jellyfin",
|
||
user: { id: "listener", name: "Listener", isAdministrator: false },
|
||
}),
|
||
}));
|
||
|
||
await page.goto("#/integrations/accounts?source=spotify&connect=1");
|
||
const dialog = page.getByRole("dialog", { name: "Connect a Source" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByRole("button", { name: "Source", exact: true })).toContainText("Spotify");
|
||
const audience = dialog.getByRole("button", { name: "Who can use it?" });
|
||
await expect(audience).toContainText("Private");
|
||
await audience.click();
|
||
await expect(page.getByRole("option", { name: "Private", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("option", { name: "One library" })).toHaveCount(0);
|
||
await page.getByRole("option", { name: "Global", exact: true }).click();
|
||
const consent = dialog.getByRole("checkbox", { name: "I agree to share provider access with every Allstarr user." });
|
||
await expect(consent).toBeVisible();
|
||
await expect(consent).not.toBeChecked();
|
||
await expect(dialog).toContainText("personal playlists and scrobbling");
|
||
await consent.check();
|
||
await expect(consent).toBeChecked();
|
||
await audience.click();
|
||
await page.getByRole("option", { name: "Private", exact: true }).click();
|
||
await expect(consent).toHaveCount(0);
|
||
const writes: Record<string, unknown>[] = [];
|
||
await page.route("**/api/admin/provider-accounts", async (route) => {
|
||
if (route.request().method() !== "POST") return route.fallback();
|
||
writes.push(route.request().postDataJSON());
|
||
await route.fulfill({ status: 201, json: { id: "new-account", revision: 1, enabled: true } });
|
||
});
|
||
await audience.click();
|
||
await page.getByRole("option", { name: "Global", exact: true }).click();
|
||
await dialog.getByLabel("Spotify session cookie").fill("fixture-only-cookie");
|
||
await dialog.getByRole("button", { name: "Save connection" }).click();
|
||
expect(writes).toHaveLength(0);
|
||
await consent.check();
|
||
await dialog.getByRole("button", { name: "Save connection" }).click();
|
||
await expect(dialog).toBeHidden();
|
||
expect(writes).toHaveLength(1);
|
||
expect(writes[0]).toMatchObject({ providerId: "spotify", scope: "Global", secret: { sessionCookie: "fixture-only-cookie" } });
|
||
});
|
||
|
||
test("A listener controls sharing of their own account and can make it Private again", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/auth/me", (route) => route.fulfill({ json: {
|
||
authenticated: true, backend: "Jellyfin",
|
||
user: { id: "listener", name: "Listener", isAdministrator: false },
|
||
} }));
|
||
const fixture = responses["/api/admin/provider-accounts"] as { accounts: Record<string, unknown>[] };
|
||
let account = { ...fixture.accounts[0], ownerUserId: "listener", createdByUserId: "listener", canChangeAudience: true, scope: "User", revision: 1 };
|
||
const writes: Record<string, unknown>[] = [];
|
||
await page.route("**/api/admin/provider-accounts", (route) => route.fulfill({ json: {
|
||
managementMode: "Hybrid", audienceUsers: [], accounts: [account],
|
||
} }));
|
||
await page.route("**/api/admin/provider-accounts/account/audience", async (route) => {
|
||
const input = route.request().postDataJSON();
|
||
writes.push(input);
|
||
account = { ...account, ...input, revision: account.revision + 1 };
|
||
await route.fulfill({ json: account });
|
||
});
|
||
const editor = page.locator(".access-dialog");
|
||
async function editAccess() {
|
||
await page.getByRole("button", { name: /Lumen Audio Account details stored/ }).click();
|
||
await page.getByRole("tab", { name: "Access" }).click();
|
||
await page.getByRole("button", { name: "Edit access" }).click();
|
||
await expect(editor).toBeVisible();
|
||
}
|
||
await page.goto("#/integrations/accounts");
|
||
await editAccess();
|
||
await expect(editor.getByRole("radio", { name: "Private" })).toBeChecked();
|
||
await expect(editor.getByRole("radio", { name: "One user" })).toHaveCount(0);
|
||
await expect(editor.getByRole("radio", { name: "One library" })).toHaveCount(0);
|
||
await editor.getByRole("radio", { name: "Global" }).check();
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/sharing-${viewport.width}.png` });
|
||
await editor.getByRole("button", { name: "Save access" }).click();
|
||
const confirmation = page.getByRole("alertdialog", { name: "Share this connection with everyone?" });
|
||
await expect(confirmation).toBeVisible();
|
||
expect(writes).toHaveLength(0);
|
||
await confirmation.getByRole("button", { name: "Share with everyone" }).click();
|
||
await expect(editor).toBeHidden();
|
||
expect(writes[0]).toMatchObject({ scope: "Global", expectedRevision: 1 });
|
||
await editAccess();
|
||
await expect(editor.getByRole("radio", { name: "Global" })).toBeChecked();
|
||
await editor.getByRole("radio", { name: "Private" }).check();
|
||
await editor.getByRole("button", { name: "Save access" }).click();
|
||
await expect(editor).toBeHidden();
|
||
expect(writes[1]).toMatchObject({ scope: "User", ownerUserId: "listener", expectedRevision: 2 });
|
||
});
|
||
|
||
test("AudioMuse-AI configuration stays in Intelligence before the connection is ready", async ({ page }) => {
|
||
await mockApi(page, { audioMuseUnavailable: true });
|
||
await page.goto("#/intelligence?section=automation");
|
||
|
||
const setup = page.locator(".provider-setup").filter({ hasText: "AudioMuse-AI connection" });
|
||
await expect(setup).toContainText("Connect your self-hosted AudioMuse-AI server here");
|
||
await expect(setup.getByRole("link", { name: /extension/i })).toHaveCount(0);
|
||
await setup.getByRole("button", { name: "Connect AudioMuse-AI" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Connect a Source" });
|
||
await expect(dialog.getByLabel("AudioMuse-AI server URL")).toBeVisible();
|
||
await expect(dialog.getByLabel("AudioMuse-AI access token")).toBeVisible();
|
||
await expect(dialog.getByLabel("AudioMuse-AI music server")).toBeVisible();
|
||
});
|
||
|
||
test("unfinished AudioMuse-AI setup is recoverable and does not look unhealthy", async ({ page }) => {
|
||
await mockApi(page, { audioMuseUnavailable: true, audioMuseDisabledAccount: true });
|
||
await page.goto("#/intelligence?section=automation");
|
||
|
||
const source = page.locator(".provider-choices label").filter({ hasText: "AudioMuse-AI" });
|
||
await expect(source.getByText("AudioMuse-AI", { exact: true })).toBeVisible();
|
||
await expect(source.locator('[data-slot="badge"]')).toHaveText("Not connected");
|
||
const setup = page.locator(".provider-setup").filter({ hasText: "AudioMuse-AI connection" });
|
||
await expect(setup).toContainText("The previous setup did not finish.");
|
||
await setup.getByRole("button", { name: "Finish AudioMuse-AI setup" }).click();
|
||
|
||
const dialog = page.getByRole("dialog", { name: "Configure AudioMuse-AI" });
|
||
await dialog.getByLabel("AudioMuse-AI server URL").fill("http://audiomuse.test");
|
||
const secretRequest = page.waitForRequest((request) =>
|
||
request.url().endsWith("/api/admin/provider-accounts/audio-account/secret") && request.method() === "PUT");
|
||
const enableRequest = page.waitForRequest((request) =>
|
||
request.url().endsWith("/api/admin/provider-accounts/audio-account") && request.method() === "PATCH");
|
||
await dialog.getByRole("button", { name: "Save and test" }).click();
|
||
await secretRequest;
|
||
expect((await enableRequest).postDataJSON()).toMatchObject({ enabled: true, expectedRevision: 3 });
|
||
});
|
||
|
||
test("Match and removal dialogs remain usable", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/library/mappings");
|
||
await page.getByRole("button", { name: "Interactive search" }).click();
|
||
await expect(page.getByRole("dialog", { name: "Test song" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Reject candidate" })).toBeInViewport();
|
||
await page.getByRole("button", { name: "Reject candidate" }).click();
|
||
const reject = page.getByRole("alertdialog", { name: "Reject this candidate?" });
|
||
await expect(reject).toBeVisible();
|
||
await expect(reject.getByRole("button", { name: "Reject candidate" })).toBeInViewport();
|
||
await reject.getByRole("button", { name: "Cancel" }).click();
|
||
await expect(reject).toBeHidden();
|
||
await page.getByRole("button", { name: "Close match dialog" }).click();
|
||
await expect(page.getByRole("dialog", { name: "Test song" })).toBeHidden();
|
||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).pointerEvents)).toBe("auto");
|
||
|
||
await page.goto("#/library/cached");
|
||
const removeButton = page.getByRole("button", { name: "Remove", exact: true });
|
||
await removeButton.click();
|
||
const removal = page.getByRole("alertdialog", { name: "Remove this track?" });
|
||
await expect(removal).toBeVisible();
|
||
await expect(removal.getByRole("button", { name: "Remove track" })).toBeInViewport();
|
||
});
|
||
|
||
test("keyboard dialogs honor reduced motion", async ({ page }) => {
|
||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||
await mockApi(page);
|
||
await page.goto("#/settings/extensions");
|
||
await expect(page.getByRole("tab", { name: "Extensions" })).toHaveAttribute("aria-selected", "true");
|
||
await page.getByRole("button", { name: "Install extension" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Install extension" });
|
||
await expect(dialog).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(dialog).toBeHidden();
|
||
});
|
||
});
|
||
}
|
||
|
||
test("Intelligence history imports, corrections, and schedules use the selected scope", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
const overviewRequest = page.waitForRequest((request) => request.url().includes("/api/admin/intelligence/history/overview"));
|
||
await page.goto("#/intelligence?section=history");
|
||
await expect(page.getByRole("tab", { name: "History", exact: true })).toHaveAttribute("aria-selected", "true");
|
||
expect(new URL((await overviewRequest).url()).searchParams.get("backendInstanceId")).toBe("main");
|
||
|
||
await page.locator(".history-list").getByRole("button").click();
|
||
const detail = page.getByRole("dialog", { name: "Edit listen" });
|
||
await expect(detail).toBeVisible();
|
||
await expect(detail.getByText("MusicBrainz details", { exact: true })).toBeVisible();
|
||
await expect(detail.getByText("No lookup queued", { exact: true })).toBeVisible();
|
||
await detail.getByLabel("Song").fill("Moon Song (live)");
|
||
const correction = page.waitForRequest((request) => request.method() === "PUT" &&
|
||
request.url().includes("/api/admin/intelligence/history/44444444-4444-4444-4444-444444444444"));
|
||
await detail.getByRole("button", { name: "Save changes" }).click();
|
||
expect((await correction).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music",
|
||
title: "Moon Song (live)", expectedRevision: 2,
|
||
});
|
||
await detail.getByRole("button", { name: "Close listen details" }).click();
|
||
|
||
await page.getByRole("tab", { name: "Import", exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/intelligence\?section=imports$/);
|
||
const previews: string[] = [];
|
||
page.on("request", (request) => {
|
||
if (request.method() === "POST" && request.url().endsWith("/api/admin/intelligence/history/imports/preview")) previews.push(request.url());
|
||
});
|
||
await expect(page.getByLabel("History export files")).toHaveAttribute("multiple", "");
|
||
await page.getByLabel("History export files").setInputFiles([
|
||
{ name: "Streaming_History.json", mimeType: "application/json", buffer: Buffer.from("[]") },
|
||
{ name: "ListenBrainz.jsonl", mimeType: "text/plain", buffer: Buffer.from("{}") },
|
||
]);
|
||
await expect.poll(() => previews.length).toBe(2);
|
||
await expect(page.getByText("9", { exact: true }).first()).toBeVisible();
|
||
await expect(page.getByText("does not send them to Last.fm or ListenBrainz.", { exact: false }).first()).toBeVisible();
|
||
await expect(page.getByRole("status").filter({ hasText: "Streaming_History.json" })).toContainText("previewed");
|
||
await expect(page.getByRole("status").filter({ hasText: "ListenBrainz.jsonl" })).toContainText("previewed");
|
||
const applies: Array<{ url: string; body: unknown }> = [];
|
||
page.on("request", (request) => {
|
||
if (request.method() === "POST" && request.url().match(/\/api\/admin\/intelligence\/history\/imports\/[^/]+\/apply$/))
|
||
applies.push({ url: request.url(), body: request.postDataJSON() });
|
||
});
|
||
await expect(page.getByRole("checkbox", { name: "Include Streaming_History.json" })).toBeChecked();
|
||
await expect(page.getByRole("checkbox", { name: "Include ListenBrainz.jsonl" })).toBeChecked();
|
||
await page.getByRole("button", { name: "Add all 2 ready files" }).click();
|
||
await expect.poll(() => applies.length).toBe(2);
|
||
expect(applies.map((request) => request.url)).toEqual(expect.arrayContaining([
|
||
expect.stringContaining("/55555555-5555-5555-5555-555555555555/apply"),
|
||
expect.stringContaining("/66666666-6666-6666-6666-666666666666/apply"),
|
||
]));
|
||
for (const request of applies) expect(request.body).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music", revision: "preview-revision",
|
||
});
|
||
|
||
await page.getByRole("tab", { name: "Automation" }).click();
|
||
await expect(page.getByRole("button", { name: "Generated playlist destination" })).toContainText("Jellyfin Music");
|
||
await page.getByRole("button", { name: "New schedule" }).click();
|
||
await page.getByLabel("Playlist name").fill("Friday discoveries");
|
||
const cadence = page.getByRole("button", { name: "When to create it" });
|
||
await cadence.click();
|
||
await page.getByRole("option", { name: "Every Friday at 8:00 AM" }).click();
|
||
await expect(cadence).toContainText("Every Friday at 8:00 AM");
|
||
const schedule = page.waitForRequest((request) => request.method() === "POST" &&
|
||
request.url().endsWith("/api/admin/intelligence/schedules"));
|
||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||
expect((await schedule).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music",
|
||
name: "Friday discoveries", cronExpression: "0 8 * * 5", limit: 25,
|
||
});
|
||
await expect(page.getByText("Monday discoveries", { exact: true })).toBeVisible();
|
||
await page.getByRole("button", { name: "Remove" }).click();
|
||
const removal = page.getByRole("alertdialog", { name: "Remove this automatic playlist schedule?" });
|
||
const remove = page.waitForRequest((request) => request.method() === "DELETE" &&
|
||
request.url().endsWith("/api/admin/intelligence/schedules/schedule-1"));
|
||
await removal.getByRole("button", { name: "Remove schedule" }).click();
|
||
await remove;
|
||
});
|
||
|
||
test("Intelligence keeps completed imports visible and explains retention", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
const longImportFile = `${"StreamingHistoryAudio".repeat(7)}.json`;
|
||
await page.route("**/api/admin/intelligence/history/imports?*", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ items: [{
|
||
importId: "77777777-7777-7777-7777-777777777777",
|
||
revision: "saved-revision",
|
||
displayFileName: longImportFile,
|
||
sizeBytes: 4096,
|
||
expiresAt: "2026-08-20T00:00:00Z",
|
||
state: "completed",
|
||
importedRows: 18_240,
|
||
duplicateRows: 0,
|
||
resolvedRows: 12_000,
|
||
unresolvedRows: 6_240,
|
||
outboundReplay: false,
|
||
preview: {
|
||
format: "spotify-extended-history", fileRows: 18_240, musicRows: 18_240,
|
||
completed: 18_240, partial: 0, skipped: 0, episodes: 0, nonTrack: 0, malformed: 0,
|
||
duplicateInFile: 0, duplicateExisting: 0, newRows: 18_240, resolvedNewRows: 12_000,
|
||
unresolvedNewRows: 6_240, rowsWithoutProviderIdentity: 0, sourceUserCount: 1,
|
||
estimatedMusicBrainzLookups: 6_240, earliest: "2025-01-01T00:00:00Z",
|
||
latest: "2020-12-31T00:00:00Z", reasonCounts: {}, outsideRetentionRows: 0,
|
||
},
|
||
}] }),
|
||
}));
|
||
await page.route("**/api/admin/intelligence/history/imports/77777777-7777-7777-7777-777777777777", (route) => {
|
||
if (route.request().method() !== "DELETE") return route.fallback();
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ removedImport: true, removedListens: 18_240 }),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/intelligence?section=imports");
|
||
await expect(page.getByText(longImportFile, { exact: true })).toBeVisible();
|
||
await expect(page.getByText("18,240 added", { exact: false })).toBeVisible();
|
||
await expect(page.getByRole("checkbox", { name: `Include ${longImportFile}` })).toHaveCount(0);
|
||
await expect.poll(() => page.locator(".import-result > header").evaluate((element) =>
|
||
element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||
await expect(page.getByText(/no longer appear in Overview or History/)).toBeVisible();
|
||
await page.getByRole("button", { name: "Undo import" }).click();
|
||
const dialog = page.getByRole("alertdialog", { name: "Undo this history import?" });
|
||
await expect(dialog).toContainText("18,240 were added");
|
||
const removal = page.waitForRequest((request) => request.method() === "DELETE" &&
|
||
request.url().endsWith("/api/admin/intelligence/history/imports/77777777-7777-7777-7777-777777777777"));
|
||
await dialog.getByRole("button", { name: "Undo import" }).click();
|
||
expect((await removal).postDataJSON()).toMatchObject({
|
||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music",
|
||
revision: "saved-revision", confirmed: true,
|
||
});
|
||
await expect(page.getByText(longImportFile, { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test("Intelligence empty states explain how to get results", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/**", async (route) => {
|
||
const path = new URL(route.request().url()).pathname;
|
||
let body: unknown;
|
||
if (path === "/api/admin/intelligence")
|
||
body = { ...(responses[path] as Record<string, unknown>), candidates: [], visualization: [], generatedSets: [], providers: [] };
|
||
else if (path === "/api/admin/intelligence/history/overview") {
|
||
const empty = { completedListens: 0, distinctTracks: 0, distinctArtists: 0, listeningTimeMilliseconds: 0, firstListen: null };
|
||
body = { ...(responses[path] as Record<string, unknown>), allTime: empty, selected: empty, currentStreakDays: 0, longestStreakDays: 0 };
|
||
} else if (path === "/api/admin/intelligence/history/activity")
|
||
body = { ...(responses[path] as Record<string, unknown>), buckets: [], currentStreakDays: 0, longestStreakDays: 0 };
|
||
else if (path.startsWith("/api/admin/intelligence/history/top/"))
|
||
body = { ...(responses[path] as Record<string, unknown>), items: [] };
|
||
else if (path === "/api/admin/intelligence/history")
|
||
body = { ...(responses[path] as Record<string, unknown>), items: [], nextCursor: null };
|
||
else {
|
||
await route.fallback();
|
||
return;
|
||
}
|
||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
|
||
});
|
||
|
||
await page.goto("#/intelligence?section=unknown");
|
||
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(page.getByRole("heading", { name: "Listening overview" })).toBeVisible();
|
||
await page.getByRole("tab", { name: "Discover" }).click();
|
||
const sourceLink = page.getByRole("link", { name: "Connect or configure a source" });
|
||
await expect(sourceLink).toBeVisible();
|
||
expect((await sourceLink.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||
await expect(page.getByText("Turn on automatic history in Automation, then play music or import a history file.")).toBeVisible();
|
||
const settingsLink = page.getByRole("link", { name: "Automation", exact: true });
|
||
expect((await settingsLink.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||
|
||
await page.getByRole("tab", { name: "Overview" }).click();
|
||
await expect(page.getByText("No completed listens were recorded for this period. Play music or import a history file to build this recap.")).toBeVisible();
|
||
await expect(page.getByText("No activity in this period. Play music or import a history file to begin.")).toBeVisible();
|
||
await expect(page.getByText("Nothing to rank yet. Play music or import a history file to begin.")).toBeVisible();
|
||
|
||
await page.getByRole("tab", { name: "History", exact: true }).click();
|
||
await expect(page.getByText("No completed listens yet")).toBeVisible();
|
||
await expect(page.getByText("Turn on automatic history in Automation, then play music or import a history file.")).toBeVisible();
|
||
});
|
||
|
||
test("All-time activity exposes imported years instead of clipping them to recent playback", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/intelligence/history/activity?*", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
period: { from: "0001-01-01T00:00:00Z", to: "2026-08-27T00:00:00Z", timeZoneId: "UTC" },
|
||
currentStreakDays: 1,
|
||
longestStreakDays: 28,
|
||
buckets: [
|
||
{ date: "2024-01-15", count: 120, importedCount: 120, playbackCount: 0, durationMilliseconds: 21_600_000 },
|
||
{ date: "2024-02-01", count: 60, importedCount: 60, playbackCount: 0, durationMilliseconds: 10_800_000 },
|
||
{ date: "2025-08-18", count: 10, importedCount: 10, playbackCount: 0, durationMilliseconds: 1_800_000 },
|
||
{ date: "2026-08-26", count: 1, importedCount: 0, playbackCount: 1, durationMilliseconds: 180_000 },
|
||
],
|
||
}),
|
||
}));
|
||
|
||
await page.goto("#/intelligence");
|
||
const activityCard = page.locator(".activity-card");
|
||
const years = activityCard.getByRole("navigation", { name: "Activity year" });
|
||
await expect(years.getByRole("tab", { name: "2024" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(activityCard.locator(".heatmap-range")).toContainText("180 imported · 0 playback");
|
||
await expect(activityCard.locator('[data-heatmap-date="2024-01-15"]')).not.toHaveAttribute("data-intensity", "0");
|
||
await expect(activityCard.getByRole("slider", { name: "Daily listening activity" })).toHaveAttribute("aria-valuemax", "365");
|
||
|
||
await years.getByRole("tab", { name: "2026" }).click();
|
||
await expect(activityCard.locator(".heatmap-range")).toContainText("0 imported · 1 playback");
|
||
await expect(activityCard.locator('[data-heatmap-date="2026-08-26"]')).not.toHaveAttribute("data-intensity", "0");
|
||
await activityCard.getByRole("tab", { name: "Monthly" }).click();
|
||
await expect(activityCard.getByRole("slider", { name: "Monthly listening activity" })).toHaveAttribute("aria-valuemax", "7");
|
||
await expectContainedMobileGeometry(page);
|
||
});
|
||
|
||
test("Subsonic Intelligence requests explicit scoped background access before indexing", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
const credentialReferenceId = "44444444-4444-4444-4444-444444444444";
|
||
let indexed = false;
|
||
await page.route("**/api/admin/media-targets", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ targets: [{
|
||
id: "subsonic-target", protocol: "subsonic", backendInstanceId: "primary",
|
||
libraryScopeId: indexed ? "music" : null, displayName: "Listener",
|
||
credentialReferenceId: indexed ? credentialReferenceId : null,
|
||
}] }),
|
||
}));
|
||
await page.route("**/api/admin/playlist-links/backend-credentials", (route) => route.fulfill({
|
||
status: 201,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ referenceId: credentialReferenceId, activeVersion: 1, updatedAt: "2026-08-26T18:00:00Z" }),
|
||
}));
|
||
await page.route("**/api/admin/library-index/enqueue", async (route) => {
|
||
indexed = true;
|
||
await route.fulfill({
|
||
status: 202,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ jobId: "index-job", created: true, generation: 1 }),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/intelligence");
|
||
await expect(page.getByText("Connect background access", { exact: true })).toBeVisible();
|
||
await page.getByRole("button", { name: "Connect access" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Connect Subsonic for background features" });
|
||
await expect(dialog).toContainText("Only your Allstarr account can use this credential.");
|
||
await expect(dialog).toContainText("never included in job payloads or logs");
|
||
await dialog.getByLabel("Subsonic username").fill("listener");
|
||
await dialog.getByLabel("Subsonic password").fill("test-password");
|
||
const credentialRequest = page.waitForRequest((request) =>
|
||
request.method() === "POST" && request.url().endsWith("/api/admin/playlist-links/backend-credentials"));
|
||
const indexRequest = page.waitForRequest((request) =>
|
||
request.method() === "POST" && request.url().endsWith("/api/admin/library-index/enqueue"));
|
||
await dialog.getByRole("button", { name: "Connect and index" }).click();
|
||
expect((await credentialRequest).postDataJSON()).toEqual({
|
||
targetProtocol: "subsonic", backendInstanceId: "primary", username: "listener", password: "test-password",
|
||
});
|
||
expect((await indexRequest).postDataJSON()).toMatchObject({
|
||
libraryScopeId: "music", credentialReferenceId, pageSize: 200,
|
||
});
|
||
await expect(dialog).toBeHidden();
|
||
await expect(page.locator(".library-setup-feedback")).toContainText("indexed and ready for Intelligence");
|
||
await expect(page.locator(".scope-value")).toContainText("Subsonic · Listener");
|
||
});
|
||
|
||
test("Intelligence partial and section error states stay announced", async ({ page }) => {
|
||
await page.setViewportSize({ width: 768, height: 1024 });
|
||
const historyRelease = routeRelease();
|
||
let failHistory = false;
|
||
await mockApi(page, {
|
||
releasePath: "/api/admin/intelligence/history/activity",
|
||
release: historyRelease.promise,
|
||
});
|
||
await page.route("**/api/admin/**", async (route) => {
|
||
const url = new URL(route.request().url());
|
||
if (url.pathname === "/api/admin/intelligence") {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...(responses[url.pathname] as Record<string, unknown>),
|
||
state: "degraded",
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
if ((failHistory && url.pathname === "/api/admin/intelligence/history/activity") ||
|
||
(url.pathname === "/api/admin/intelligence/history/imports/preview" && route.request().method() === "POST") ||
|
||
(url.pathname === "/api/admin/intelligence/schedules" && route.request().method() === "POST")) {
|
||
await route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Fixture unavailable" }),
|
||
});
|
||
return;
|
||
}
|
||
await route.fallback();
|
||
});
|
||
|
||
await page.goto("#/intelligence?section=history");
|
||
await expect(page.getByRole("status", { name: "Loading listening history" })).toBeVisible();
|
||
historyRelease.release();
|
||
await expect(page.locator(".history-list").getByText("Moon Song", { exact: true })).toBeVisible();
|
||
await expect(page.locator(".degraded-banner")).toContainText("Existing results remain available.");
|
||
|
||
await page.getByRole("tab", { name: "Discover" }).click();
|
||
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
|
||
failHistory = true;
|
||
await page.getByRole("tab", { name: "History", exact: true }).click();
|
||
await expect(page.locator(".history-workspace").getByRole("alert")).toContainText("Fixture unavailable");
|
||
|
||
await page.getByRole("tab", { name: "Import", exact: true }).click();
|
||
await page.getByLabel("History export files").setInputFiles({
|
||
name: "Streaming_History.json", mimeType: "application/json", buffer: Buffer.from("[]"),
|
||
});
|
||
const queue = page.getByRole("list", { name: "Selected history exports" });
|
||
await expect(queue).toHaveAttribute("aria-live", "polite");
|
||
await expect(queue).toContainText("Fixture unavailable");
|
||
|
||
await page.getByRole("tab", { name: "Automation" }).click();
|
||
await page.getByRole("button", { name: "New schedule" }).click();
|
||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||
await expect(page.locator(".intelligence-schedules").getByRole("alert")).toContainText("Fixture unavailable");
|
||
|
||
const clear = page.getByRole("button", { name: "Turn off and clear" });
|
||
await clear.click();
|
||
const dialog = page.getByRole("alertdialog", { name: "Clear private listening data for this library?" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true);
|
||
await page.keyboard.press("Escape");
|
||
await expect(dialog).toBeHidden();
|
||
await expect(clear).toBeFocused();
|
||
|
||
const scrollOwners = await page.locator(".intelligence-view").evaluate((element) => {
|
||
const nested = [];
|
||
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||
const overflow = getComputedStyle(parent).overflowY;
|
||
if (/(auto|scroll)/.test(overflow) && parent.scrollHeight > parent.clientHeight) nested.push(parent.className);
|
||
}
|
||
return {
|
||
nested,
|
||
document: document.scrollingElement!.scrollHeight > document.scrollingElement!.clientHeight,
|
||
};
|
||
});
|
||
expect(scrollOwners).toEqual({ nested: [], document: true });
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
});
|
||
|
||
for (const theme of ["light", "dark"] as const) {
|
||
for (const state of ["streaming", "cached", "unknown"] as const) {
|
||
test(`Home playback source labels remain honest and contained — ${theme} ${state}`, async ({ page }) => {
|
||
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
const fixture = structuredClone(responses["/api/admin/ui/now-playing"]) as { items: Record<string, unknown>[] };
|
||
fixture.items[0].sourceConfirmed = state !== "unknown";
|
||
fixture.items[0].cached = state === "cached";
|
||
await page.route("**/api/admin/ui/now-playing", (route) => route.fulfill({ json: fixture }));
|
||
await page.goto("#/");
|
||
const playing = page.getByRole("region", { name: "Now playing" });
|
||
await expect(playing).toContainText(state === "unknown" ? "Catalog source · playback unconfirmed" :
|
||
state === "cached" ? "Cached from" : "Playing from");
|
||
await expectContainedMobileGeometry(page);
|
||
await page.screenshot({ path: `test-results/playback-source-${theme}-${state}.png`, fullPage: true });
|
||
await page.setViewportSize({ width: 1280, height: 900 });
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
|
||
expect(await playing.evaluate((panel) => panel.scrollWidth <= panel.clientWidth)).toBe(true);
|
||
});
|
||
}
|
||
}
|
||
|
||
test("Home stays inside runtime and request budgets", async ({ page }) => {
|
||
const requests: string[] = [];
|
||
page.on("request", (request) => requests.push(new URL(request.url()).pathname));
|
||
await page.addInitScript(() => {
|
||
const metrics = { cls: 0, lcp: 0, navigation: 0 };
|
||
Object.assign(window, { __allstarrMetrics: metrics });
|
||
new PerformanceObserver((list) => {
|
||
metrics.lcp = Math.max(metrics.lcp, ...list.getEntries().map((entry) => entry.startTime));
|
||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||
new PerformanceObserver((list) => {
|
||
for (const entry of list.getEntries()) {
|
||
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
||
metrics.cls += (entry as PerformanceEntry & { value: number }).value;
|
||
}
|
||
}
|
||
}).observe({ type: "layout-shift", buffered: true });
|
||
});
|
||
await mockApi(page);
|
||
await page.goto("#/");
|
||
await expect(page.getByLabel("Loading Home")).toBeHidden();
|
||
await expect(page.getByText("Linked playlists", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Legacy .env import")).toHaveCount(0);
|
||
await expect(page.locator(".provider-line strong", { hasText: "Lumen Audio" })).toBeVisible();
|
||
await expect(page.getByText("Playlist Check", { exact: true })).toBeVisible();
|
||
await expect(page.locator(".provider-line .provider-mark")).toBeVisible();
|
||
await expect(page.locator(".activity-line .activity-artwork svg")).toBeVisible();
|
||
const nowPlaying = page.getByRole("region", { name: "Now playing" });
|
||
await expect(nowPlaying).toContainText("Rocket");
|
||
await expect(nowPlaying.getByText("Tester", { exact: true })).toBeVisible();
|
||
await expect(nowPlaying.getByText("Feishin · Desktop", { exact: true })).toBeVisible();
|
||
await expect(nowPlaying.locator(".scrobble-state")).toContainText("Listening");
|
||
|
||
const apiRequests = requests.filter((path) => path.startsWith("/api/admin/"));
|
||
expect(apiRequests.length).toBeLessThanOrEqual(14);
|
||
|
||
await page.evaluate(() => {
|
||
const metrics = window.__allstarrMetrics;
|
||
const start = { value: 0 };
|
||
document.querySelector('a[href="#/library/playlists"]')?.addEventListener("click", () => {
|
||
start.value = performance.now();
|
||
}, { once: true });
|
||
new MutationObserver((_, observer) => {
|
||
if (document.querySelector("h1")?.textContent === "Library") {
|
||
metrics.navigation = performance.now() - start.value;
|
||
observer.disconnect();
|
||
}
|
||
}).observe(document.body, { childList: true, subtree: true, characterData: true });
|
||
});
|
||
await page.getByRole("link", { name: "Library" }).first().click();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
window.__allstarrMetrics.navigation,
|
||
)).toBeGreaterThan(0);
|
||
|
||
const metrics = await page.evaluate(() => window.__allstarrMetrics);
|
||
expect(metrics.lcp).toBeGreaterThan(0);
|
||
expect(metrics.lcp).toBeLessThanOrEqual(2_500);
|
||
expect(metrics.cls).toBeLessThanOrEqual(0.1);
|
||
expect(metrics.navigation).toBeLessThanOrEqual(100);
|
||
});
|
||
|
||
test("Shared visual tokens meet typography, geometry, and contrast floors", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/");
|
||
const contract = await page.evaluate(() => {
|
||
const root = getComputedStyle(document.documentElement);
|
||
const eyebrowSample = document.createElement("p");
|
||
eyebrowSample.className = "eyebrow";
|
||
document.body.append(eyebrowSample);
|
||
const eyebrow = getComputedStyle(eyebrowSample);
|
||
const resolveColor = (value: string) => {
|
||
const sample = document.createElement("span");
|
||
sample.style.color = value;
|
||
document.body.append(sample);
|
||
const color = getComputedStyle(sample).color;
|
||
sample.remove();
|
||
return color;
|
||
};
|
||
const channels = (value: string) =>
|
||
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
|
||
const luminance = (value: string) => {
|
||
const [red, green, blue] = channels(value).map((channel) => {
|
||
const normalized = channel / 255;
|
||
return normalized <= 0.04045
|
||
? normalized / 12.92
|
||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||
});
|
||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||
};
|
||
const contrast = (foreground: string, background: string) => {
|
||
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
|
||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||
};
|
||
const canvas = resolveColor("var(--color-canvas)");
|
||
const result = {
|
||
spacing: ["--space-1", "--space-2", "--space-3", "--space-4"].map((token) =>
|
||
Number.parseFloat(root.getPropertyValue(token)) * 16),
|
||
controls: ["--control-sm", "--control-md", "--control-lg"].map((token) =>
|
||
Number.parseFloat(root.getPropertyValue(token)) * 16),
|
||
icons: ["--icon-sm", "--icon-md", "--icon-lg", "--icon-xl"].map((token) =>
|
||
Number.parseFloat(root.getPropertyValue(token)) * 16),
|
||
radii: ["--radius-sm", "--radius-md", "--radius-lg"].map((token) =>
|
||
Number.parseFloat(root.getPropertyValue(token)) * 16),
|
||
dataGeometry: ["--data-row-height", "--data-artwork-size"].map((token) =>
|
||
Number.parseFloat(root.getPropertyValue(token)) * 16),
|
||
fonts: [Number.parseFloat(root.getPropertyValue("--text-sm")) * 16, Number.parseFloat(eyebrow.fontSize)],
|
||
contrast: {
|
||
body: contrast(getComputedStyle(document.body).color, canvas),
|
||
metadata: contrast(eyebrow.color, canvas),
|
||
primary: contrast(resolveColor("var(--color-on-signal)"), resolveColor("var(--color-signal)")),
|
||
focus: contrast(resolveColor("var(--focus-ring)"), canvas),
|
||
},
|
||
};
|
||
eyebrowSample.remove();
|
||
return result;
|
||
});
|
||
expect(contract.spacing).toEqual([4, 8, 12, 16]);
|
||
expect(contract.controls).toEqual([36, 44, 48]);
|
||
expect(contract.icons).toEqual([16, 18, 20, 24]);
|
||
expect(contract.radii).toEqual([8, 12, 16]);
|
||
expect(contract.dataGeometry).toEqual([70.4, 44]);
|
||
expect(contract.fonts[0]).toBeGreaterThanOrEqual(14);
|
||
expect(contract.fonts[1]).toBeGreaterThanOrEqual(12);
|
||
expect(contract.contrast.body).toBeGreaterThanOrEqual(4.5);
|
||
expect(contract.contrast.metadata).toBeGreaterThanOrEqual(4.5);
|
||
expect(contract.contrast.primary).toBeGreaterThanOrEqual(4.5);
|
||
expect(contract.contrast.focus).toBeGreaterThanOrEqual(3);
|
||
});
|
||
|
||
test("Slim sidebar centers navigation and profile controls", async ({ page }) => {
|
||
await page.setViewportSize({ width: 835, height: 762 });
|
||
await mockApi(page);
|
||
await page.goto("#/");
|
||
const sidebar = await page.locator(".sidebar").boundingBox();
|
||
const home = await page.locator(".desktop-navigation").getByRole("link", { name: "Home", exact: true }).boundingBox();
|
||
const sourcesIcon = await page.locator(".desktop-navigation").getByRole("link", { name: "Integrations" }).locator("svg").boundingBox();
|
||
const profile = await page.getByRole("link", { name: "Settings for Tester" }).boundingBox();
|
||
expect(sidebar && home && sourcesIcon && profile).toBeTruthy();
|
||
const center = (box: NonNullable<typeof sidebar>) => box.x + box.width / 2;
|
||
expect(Math.abs(center(home!) - center(sidebar!))).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(center(sourcesIcon!) - center(sidebar!))).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(center(profile!) - center(sidebar!))).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
test("Shared selects and settings tabs animate without remounting", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/settings/general");
|
||
const selectedNav = page.locator(".desktop-navigation")
|
||
.getByRole("link", { name: "Settings", exact: true });
|
||
const selectedNavBackground = await selectedNav.evaluate((element) =>
|
||
getComputedStyle(element).backgroundColor);
|
||
await selectedNav.hover();
|
||
await expect(selectedNav).toHaveCSS("background-color", selectedNavBackground);
|
||
await page.getByLabel("Color theme").click();
|
||
await expect(page.locator(".select-content")).toHaveCSS("animation-name", "dropdown-in");
|
||
await page.keyboard.press("Escape");
|
||
await page.goto("#/integrations/services");
|
||
const tabs = page.locator(".settings-tabs");
|
||
await tabs.evaluate((element) => element.setAttribute("data-instance", "stable"));
|
||
const routing = page.getByRole("tab", { name: "Routing" });
|
||
await routing.hover();
|
||
await expect(routing).toHaveCSS("border-top-left-radius", "8px");
|
||
await routing.click();
|
||
await expect(tabs).toHaveAttribute("data-instance", "stable");
|
||
expect(await routing.evaluate((element) => getComputedStyle(element, "::after").height)).toBe("3px");
|
||
expect(await routing.evaluate((element) => getComputedStyle(element, "::after").backgroundColor))
|
||
.toBe(await page.evaluate(() => {
|
||
const sample = document.createElement("span");
|
||
sample.style.color = "var(--color-signal)";
|
||
document.body.append(sample);
|
||
const value = getComputedStyle(sample).color;
|
||
sample.remove();
|
||
return value;
|
||
}));
|
||
});
|
||
|
||
test("Legacy Library links open their current shared views", async ({ page }) => {
|
||
await mockApi(page);
|
||
for (const route of ["#/library", "#/library/link", "#/library/injected", "#/library/external"]) {
|
||
await page.goto(route);
|
||
await expect(page.getByText("Imported playlists", { exact: true })).toBeVisible();
|
||
await expect(page.getByRole("tab", { name: "Playlists" })).toHaveAttribute("aria-selected", "true");
|
||
}
|
||
for (const route of ["#/library/missing", "#/library/migration"]) {
|
||
await page.goto(route);
|
||
await expect(page.getByRole("tab", { name: "Mappings" })).toHaveAttribute("aria-selected", "true");
|
||
}
|
||
});
|
||
|
||
test("Legacy integration links open their canonical tabs", async ({ page }) => {
|
||
await mockApi(page);
|
||
for (const [route, tab] of [
|
||
["#/sources", "Services"],
|
||
["#/settings/accounts", "Accounts"],
|
||
["#/settings/extensions", "Extensions"],
|
||
["#/settings/routing", "Routing"],
|
||
] as const) {
|
||
await page.goto(route);
|
||
await expect(page.getByRole("heading", { name: "Integrations", level: 1 })).toBeVisible();
|
||
await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true");
|
||
}
|
||
});
|
||
|
||
test("extension updates explain access changes on mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
const basePackage = {
|
||
extensionId: "lumen-audio", displayName: "Lumen Audio", lifecycle: "disabled",
|
||
active: false, installed: true, permissionReviewRequired: false, capabilities: ["metadata"],
|
||
revision: 1,
|
||
};
|
||
await page.route("**/api/admin/extensions/packages", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([
|
||
{ ...basePackage, id: "old", version: "1.0.0", state: "disabled", stagedAt: "2026-01-01" },
|
||
{
|
||
...basePackage, id: "new", version: "2.0.0", state: "reviewRequired",
|
||
previousPackageId: "old", permissionReviewRequired: true,
|
||
capabilities: ["metadata", "streaming"], stagedAt: "2026-02-01",
|
||
},
|
||
]),
|
||
}));
|
||
const permission = (id: string, kind: string, value: string) => ({
|
||
id, permissionKind: kind, permissionValue: value, required: true, decision: "pending",
|
||
});
|
||
await page.route("**/packages/old/permissions", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([permission("old-network", "network", "https://api.example.test/"), permission("old-cache", "cache", "metadata")]),
|
||
}));
|
||
await page.route("**/packages/new/permissions", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([permission("new-network", "network", "https://api.example.test/"), permission("new-secret", "secret", "accountToken")]),
|
||
}));
|
||
await page.route("**/packages/new/review", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...basePackage, id: "new", version: "2.0.0", state: "staged",
|
||
previousPackageId: "old", revision: 2,
|
||
}),
|
||
}));
|
||
let activationRequests = 0;
|
||
let releaseActivation = () => {};
|
||
const activationResponse = new Promise<void>((resolve) => {
|
||
releaseActivation = resolve;
|
||
});
|
||
await page.route("**/packages/new/activate", async (route) => {
|
||
activationRequests++;
|
||
if (activationRequests === 1) {
|
||
await activationResponse;
|
||
return route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Runtime failed to start." }),
|
||
});
|
||
}
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...basePackage, id: "new", version: "2.0.0", state: "active",
|
||
active: true, previousPackageId: "old", revision: 3,
|
||
}),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/settings/extensions");
|
||
await page.getByRole("button", { name: "Review permissions" }).click();
|
||
let review = page.getByRole("dialog", { name: "Review permissions" });
|
||
await expect(review).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(review).toBeHidden();
|
||
await page.getByRole("button", { name: "Review permissions" }).click();
|
||
review = page.getByRole("dialog", { name: "Review permissions" });
|
||
await expect(review.getByText("Update 1.0.0 → 2.0.0. Capability and permission changes are shown below.")).toBeVisible();
|
||
await expect(review.getByText("New access", { exact: false })).toBeVisible();
|
||
await expect(review.getByText("Removed access", { exact: false })).toBeVisible();
|
||
await expect(review.getByRole("button", { name: "Save review" })).toBeInViewport();
|
||
await review.getByRole("button", { name: "Allow all" }).click();
|
||
await review.getByRole("checkbox").check();
|
||
await review.getByRole("button", { name: "Save review" }).click();
|
||
const activation = page.getByRole("alertdialog", { name: "Activate Lumen Audio?" });
|
||
await expect(activation).toBeVisible();
|
||
expect(activationRequests).toBe(0);
|
||
await activation.getByRole("button", { name: "Activate extension" }).click();
|
||
await expect(activation.getByRole("button", { name: "Activating…" })).toBeDisabled();
|
||
await expect.poll(() => activationRequests).toBe(1);
|
||
releaseActivation();
|
||
await expect(activation.getByRole("alert")).toHaveText("Runtime failed to start.");
|
||
await activation.getByRole("button", { name: "Activate extension" }).click();
|
||
await expect(activation).toBeHidden();
|
||
expect(activationRequests).toBe(2);
|
||
});
|
||
|
||
test("extension updates stay beside the shared management menu", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/extensions/packages", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([{
|
||
id: "old", extensionId: "lumen-audio", displayName: "Lumen Audio",
|
||
version: "1.0.0", lifecycle: "active", state: "active", active: true,
|
||
installed: true, permissionReviewRequired: false, hasPermissions: true, capabilities: ["metadata"],
|
||
previousPackageId: "previous", registryId: "registry", stagedAt: "2026-01-01", revision: 1,
|
||
}]),
|
||
}));
|
||
await page.route("**/api/admin/extensions/registries", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([{
|
||
id: "registry", name: "Community", registryUrl: "https://example.test/registry.json",
|
||
enabled: true, revision: 1,
|
||
}]),
|
||
}));
|
||
await page.route("**/api/admin/extensions/store", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
items: [{
|
||
id: "lumen-audio", displayName: "Lumen Audio", version: "2.0.0",
|
||
downloadUrl: "https://example.test/lumen.zip", sha256: "a".repeat(64),
|
||
registryId: null, types: ["metadata", "streaming"],
|
||
}],
|
||
errors: [],
|
||
}),
|
||
}));
|
||
let reviewRequests = 0;
|
||
await page.route("**/api/admin/extensions/packages/old/permissions/revoke", (route) => {
|
||
reviewRequests += 1;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
id: "old", extensionId: "lumen-audio", displayName: "Lumen Audio",
|
||
version: "1.0.0", lifecycle: "reviewrequired", state: "reviewrequired",
|
||
active: false, installed: false, permissionReviewRequired: true, hasPermissions: true,
|
||
capabilities: ["metadata"], registryId: "registry", revision: 2,
|
||
}),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/extensions/packages/old/permissions", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify([{
|
||
id: "permission", permissionKind: "network", permissionValue: "https://example.test/",
|
||
required: true, decision: "pending",
|
||
}]),
|
||
}));
|
||
|
||
await page.goto("#/settings/extensions");
|
||
const actions = page.locator(".extension-actions");
|
||
await expect(actions.getByRole("button", { name: "Update" })).toBeInViewport();
|
||
await actions.getByRole("button", { name: "Manage extension" }).click();
|
||
await expect(page.getByRole("menuitem", { name: "Disable" })).toBeVisible();
|
||
await expect(page.getByRole("menuitem", { name: "Rollback" })).toBeVisible();
|
||
await expect(page.getByRole("menuitem", { name: "Review access" })).toBeVisible();
|
||
await page.getByRole("menuitem", { name: "Review access" }).click();
|
||
const access = page.getByRole("alertdialog", { name: "Review access for Lumen Audio?" });
|
||
await expect(access).toContainText("runtime will stop");
|
||
await access.getByRole("button", { name: "Stop and review" }).click();
|
||
await expect.poll(() => reviewRequests).toBe(1);
|
||
const review = page.getByRole("dialog", { name: "Review permissions" });
|
||
await expect(review).toBeVisible();
|
||
await review.getByRole("button", { name: "Close review" }).click();
|
||
await actions.getByRole("button", { name: "Manage extension" }).click();
|
||
await expect(page.getByRole("menuitem", { name: "Uninstall" })).toBeVisible();
|
||
await page.getByRole("menuitem", { name: "Uninstall" }).click();
|
||
const uninstall = page.getByRole("alertdialog", { name: "Uninstall Lumen Audio?" });
|
||
await expect(uninstall).toBeVisible();
|
||
await uninstall.getByRole("button", { name: "Cancel" }).click();
|
||
await page.getByRole("tab", { name: /Registries/ }).click();
|
||
await expect(page.getByText("1 installed package version must be removed first.")).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Remove" })).toBeDisabled();
|
||
});
|
||
|
||
test("failed extension registry validation preserves the form", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/extensions/registries", (route) => {
|
||
if (route.request().method() !== "POST") return route.fallback();
|
||
return route.fulfill({
|
||
status: 400,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Registry could not be reached." }),
|
||
});
|
||
});
|
||
await page.goto("#/integrations/extensions");
|
||
await page.getByRole("tab", { name: /Registries/ }).click();
|
||
const name = page.getByLabel("Name");
|
||
const url = page.getByLabel("Registry JSON URL");
|
||
await name.fill("My catalog");
|
||
await url.fill("https://example.test/registry.json");
|
||
await page.getByRole("button", { name: "Validate and add" }).click();
|
||
await expect(page.getByText("Registry could not be reached.")).toBeVisible();
|
||
await expect(name).toHaveValue("My catalog");
|
||
await expect(url).toHaveValue("https://example.test/registry.json");
|
||
});
|
||
|
||
test("extension catalogs explain why an unsigned package cannot be installed", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/extensions/store", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
items: [{
|
||
id: "unsigned", displayName: "Unsigned catalog entry", version: "1.0.0",
|
||
downloadUrl: "https://example.test/unsigned.zip", sha256: "", registryId: "community",
|
||
types: ["metadata"],
|
||
}],
|
||
errors: [],
|
||
}),
|
||
}));
|
||
await page.goto("#/integrations/extensions");
|
||
await page.getByRole("tab", { name: /Available/ }).click();
|
||
|
||
const unavailable = page.getByRole("button", { name: "Cannot install Unsigned catalog entry: checksum unavailable" });
|
||
await expect(unavailable).toBeDisabled();
|
||
await expect(unavailable).toHaveText("Checksum unavailable");
|
||
await expect(unavailable).toHaveAttribute("title", /required for a safe install/);
|
||
});
|
||
|
||
test("Import playlist separates source, client view, destination, updates, and storage on mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const sources = routeRelease();
|
||
await mockApi(page, { releasePath: "/api/admin/playlist-sources", release: sources.promise });
|
||
await page.goto("#/library/playlists");
|
||
const add = page.getByRole("button", { name: "Import playlist" });
|
||
await add.click();
|
||
const dialog = page.getByRole("dialog", { name: "Import a playlist" });
|
||
await expect(dialog).toBeVisible();
|
||
const dialogBox = await dialog.boundingBox();
|
||
expect(dialogBox).toBeTruthy();
|
||
expect(dialogBox!.x).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(dialogBox!.width - 390)).toBeLessThanOrEqual(1);
|
||
expect(dialogBox!.height).toBeLessThanOrEqual(844 * 0.92 + 1);
|
||
await expect(dialog.getByText("Loading playlist sources…")).toBeVisible();
|
||
sources.release();
|
||
await expect(dialog.locator(".playlist-source-groups legend")).toHaveCount(5);
|
||
await expect(dialog.getByRole("button", { name: "Source", exact: true })).toBeDisabled();
|
||
await expect(dialog.getByRole("button", { name: "What listeners see", exact: true })).toBeDisabled();
|
||
const stepTops = await dialog.locator(".playlist-add-steps button").evaluateAll((buttons) =>
|
||
buttons.map((button) => Math.round(button.getBoundingClientRect().top)));
|
||
expect(new Set(stepTops).size).toBe(2);
|
||
expect(stepTops.filter((top) => top === stepTops[0])).toHaveLength(2);
|
||
await page.keyboard.press("Escape");
|
||
await expect(dialog).toBeHidden();
|
||
await expect(add).toBeFocused();
|
||
await add.click();
|
||
const sourceGroups = dialog.locator(".playlist-source-groups legend");
|
||
await expect(sourceGroups).toHaveCount(5);
|
||
expect(await sourceGroups.allTextContents()).toEqual([
|
||
"Jellyfin", "Subsonic", "Spotify", "Lumen Audio", "Qobuz",
|
||
]);
|
||
await dialog.getByRole("radio", { name: /Spotify/ }).check();
|
||
await expect(dialog.locator(".playlist-art > span")).toBeVisible();
|
||
await dialog.getByRole("button", { name: "Load more" }).click();
|
||
await expect(dialog.getByRole("radio", { name: /Second Mix/ })).toBeVisible();
|
||
await dialog.getByRole("radio", { name: /Source Mix/ }).check();
|
||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||
await expect(dialog.getByRole("button", { name: "Back to Source" })).toBeEnabled();
|
||
await expect(dialog.getByRole("button", { name: "What listeners see", exact: true })).toHaveAttribute("aria-current", "step");
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/playlist-390-listener-choice.png` });
|
||
await dialog.getByRole("radio", { name: /Every song from Spotify Keep the songs/ }).check();
|
||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||
await expect.poll(() => dialog.locator(".playlist-add-body").evaluate((body) => body.scrollTop)).toBe(0);
|
||
await dialog.getByRole("radio", { name: /Show through Allstarr and add songs to.*Allstarr will show/ }).check();
|
||
await expect(dialog.getByRole("radio", { name: /Road trip/ })).toBeVisible();
|
||
await dialog.getByRole("radio", { name: /Road trip/ }).check();
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR) {
|
||
await dialog.locator(".playlist-add-body").evaluate((body) => body.scrollTo({ top: 0 }));
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/playlist-390-appearance-choice.png` });
|
||
}
|
||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||
await dialog.getByRole("button", { name: "Source updates" }).click();
|
||
await expect(page.getByRole("option", { name: "Import once" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(dialog.getByRole("button", { name: "Import playlist" })).toBeVisible();
|
||
await page.getByRole("option", { name: "Daily at 3:00 AM" }).click();
|
||
await expect(dialog.getByRole("button", { name: "Import and link" })).toBeVisible();
|
||
await dialog.getByRole("radio", { name: /Keep every song/ }).check();
|
||
await expect(dialog.getByRole("button", { name: "Import and link" })).toBeInViewport();
|
||
await expect(dialog.locator(".playlist-add-body")).toHaveCSS("overflow-y", "auto");
|
||
const create = page.waitForRequest((request) =>
|
||
request.method() === "POST" && request.url().endsWith("/api/admin/playlist-links"));
|
||
const scheduled = page.waitForRequest((request) =>
|
||
request.method() === "POST" && request.url().endsWith("/schedules"));
|
||
await dialog.getByRole("button", { name: "Import and link" }).click();
|
||
const input = (await create).postDataJSON();
|
||
expect(input).toMatchObject({
|
||
libraryScopeId: "music",
|
||
targetPlaylistId: "jellyfin-playlist",
|
||
sourcePlaylistId: "playlist",
|
||
mode: "hybrid",
|
||
projectionMode: "source",
|
||
importMode: "linked",
|
||
trackRetention: "keepAll",
|
||
});
|
||
expect((await scheduled).postDataJSON().cronExpression).toBe("0 3 * * *");
|
||
await expect(dialog).toBeHidden();
|
||
});
|
||
|
||
test("Playlist views and revisioned settings stay keyboard-safe", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await mockApi(page);
|
||
let listRevision = 1;
|
||
await page.route("**/api/admin/playlist-links", (route) => {
|
||
if (route.request().method() !== "GET") return route.fallback();
|
||
const response = responses["/api/admin/playlist-links"] as { playlistLinks: Record<string, unknown>[] };
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ playlistLinks: response.playlistLinks.map((item) => ({ ...item, revision: listRevision })) }),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/media-targets", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ targets: [
|
||
{
|
||
id: "wrong-credential", protocol: "jellyfin", backendInstanceId: "main",
|
||
libraryScopeId: "music", credentialReferenceId: "wrong", displayName: "Wrong credential",
|
||
},
|
||
...(responses["/api/admin/media-targets"] as { targets: Record<string, unknown>[] }).targets,
|
||
] }),
|
||
}));
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const details = page.getByRole("dialog", { name: "Test playlist" });
|
||
await expect(details).toBeVisible();
|
||
const resolved = details.getByRole("tab", { name: "Jellyfin when available", exact: true });
|
||
await resolved.focus();
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(details.getByRole("tab", { name: "Every song from Lumen Audio", exact: true })).toHaveAttribute("aria-selected", "true");
|
||
await expect(details.getByText("source track", { exact: true })).toBeVisible();
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(details.getByRole("tab", { name: "Jellyfin playlist", exact: true })).toHaveAttribute("aria-selected", "true");
|
||
await expect(details.getByText("target track", { exact: true })).toBeVisible();
|
||
await expect(details).toHaveCSS("overflow", "hidden");
|
||
await expect(details.locator(".track-scroll")).toHaveCSS("overflow", "auto");
|
||
|
||
const exactTarget = page.waitForRequest((request) =>
|
||
request.url().includes("/api/admin/media-targets/22222222-2222-2222-2222-222222222222/playlists"));
|
||
await details.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Edit settings" }).click();
|
||
let settings = page.getByRole("dialog", { name: "Edit playlist settings" });
|
||
await exactTarget;
|
||
await settings.getByRole("radio", { name: /Every song from Lumen Audio Keep the songs/ }).check();
|
||
await settings.getByRole("radio", { name: /Show only through Allstarr Allstarr will show/ }).check();
|
||
const update = page.waitForRequest((request) =>
|
||
request.method() === "PUT" && request.url().endsWith("/api/admin/playlist-links/playlist-link"));
|
||
await settings.getByRole("button", { name: "Save settings" }).click();
|
||
expect((await update).postDataJSON()).toMatchObject({
|
||
expectedRevision: 1,
|
||
mode: "virtual",
|
||
projectionMode: "source",
|
||
targetPlaylistId: null,
|
||
});
|
||
await expect(settings).toBeHidden();
|
||
|
||
await page.route("**/api/admin/playlist-links/playlist-link", async (route) => {
|
||
if (route.request().method() === "PUT") {
|
||
await route.fulfill({
|
||
status: 409,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "The resource changed before this update" }),
|
||
});
|
||
return;
|
||
}
|
||
await route.fallback();
|
||
});
|
||
await details.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Edit settings" }).click();
|
||
settings = page.getByRole("dialog", { name: "Edit playlist settings" });
|
||
await settings.getByRole("button", { name: "Save settings" }).click();
|
||
await expect(settings.getByRole("alert")).toContainText("resource changed");
|
||
listRevision = 2;
|
||
await page.locator(".playlist-toolbar-actions").getByRole("button", { name: "Refresh playlists" })
|
||
.evaluate((button) => (button as HTMLButtonElement).click());
|
||
await expect(settings.getByRole("alert")).toContainText("changed while you were editing");
|
||
await expect(settings.getByRole("button", { name: "Save settings" })).toBeDisabled();
|
||
await page.keyboard.press("Escape");
|
||
await expect(settings).toBeHidden();
|
||
await expect(details).toBeVisible();
|
||
|
||
await page.route("**/api/admin/playlist-links/playlist-link?projectionMode=source", (route) => route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Projection fixture unavailable" }),
|
||
}), { times: 1 });
|
||
await details.getByRole("tab", { name: "Lumen Audio" }).click();
|
||
const projectionError = details.getByRole("alert");
|
||
await expect(projectionError).toContainText("Projection fixture unavailable");
|
||
await projectionError.getByRole("button", { name: "Try again" }).click();
|
||
await expect(details.getByText("source track", { exact: true })).toBeVisible();
|
||
});
|
||
|
||
for (const viewport of [
|
||
{ width: 390, height: 844 },
|
||
{ width: 820, height: 900 },
|
||
{ width: 1280, height: 800 },
|
||
]) {
|
||
test(`Provider playlist update names both playlists and requires the checked confirmation at ${viewport.width}px`, async ({ page }) => {
|
||
await page.setViewportSize(viewport);
|
||
await mockApi(page);
|
||
const response = responses["/api/admin/playlist-links"] as { playlistLinks: Record<string, unknown>[] };
|
||
await page.route("**/api/admin/playlist-links", (route) => {
|
||
if (route.request().method() !== "GET") return route.fallback();
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
playlistLinks: response.playlistLinks.map((item) => ({
|
||
...item,
|
||
sourceProviderId: "spotify",
|
||
sourceUpdateAvailable: true,
|
||
})),
|
||
}),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const details = page.getByRole("dialog", { name: "Test playlist" });
|
||
await details.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Preview changes to Spotify" }).click();
|
||
|
||
const preview = page.getByRole("dialog", { name: "Update Spotify?" });
|
||
await expect(preview).toBeVisible();
|
||
await expect(preview).toContainText("Allstarr will update “Road trip source” in Spotify to match “Road trip” in Jellyfin.");
|
||
await expect(preview).toContainText("Allstarr will not change “Road trip” in Jellyfin.");
|
||
await expect(preview).toContainText("If either changes before this runs, nothing will be updated.");
|
||
await expect(preview.getByRole("definition")).toHaveCount(4);
|
||
await expect(preview.getByText("Local only")).toBeVisible();
|
||
await expect(preview).not.toContainText(/write-back|hybrid|native playlist/i);
|
||
await expect(preview.locator(".playlist-source-update-body")).toHaveCSS("overflow", "auto");
|
||
await expect(preview.getByRole("button", { name: "Update Spotify" })).toBeInViewport();
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/playlist-${viewport.width}-update-spotify.png` });
|
||
|
||
const request = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/playlist-links/playlist-link/source-update/apply"));
|
||
await preview.getByRole("button", { name: "Update Spotify" }).click();
|
||
expect((await request).postDataJSON()).toEqual({
|
||
expectedRevision: 1,
|
||
confirmationId: "a".repeat(64),
|
||
});
|
||
await expect(preview).toBeHidden();
|
||
await expect(details.getByText("Spotify update queued.")).toBeVisible();
|
||
});
|
||
}
|
||
|
||
test("Full rematch replaces automatic decisions in durable batches and protects manual choices", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/library/mappings");
|
||
|
||
await page.getByRole("button", { name: "Rematch all" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Rematch every automatic decision" });
|
||
await expect(dialog).toBeVisible();
|
||
const dialogBox = await dialog.boundingBox();
|
||
expect(dialogBox).toBeTruthy();
|
||
expect(dialogBox!.x).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(dialogBox!.width - 390)).toBeLessThanOrEqual(1);
|
||
expect(dialogBox!.height).toBeLessThanOrEqual(844 * 0.92 + 1);
|
||
await expect(dialog).toContainText("115 tracks will be reprocessed");
|
||
await expect(dialog).toContainText("25 tracks at a time");
|
||
await expect(dialog.getByText("Protected manual").locator("..").getByRole("definition")).toHaveText("5");
|
||
const apply = dialog.getByRole("button", { name: "Rematch 115 tracks" });
|
||
await expect(apply).toBeDisabled();
|
||
await dialog.getByRole("checkbox").check();
|
||
await expect(apply).toBeEnabled();
|
||
await expect(apply).toBeInViewport();
|
||
const request = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/track-matches/rematch-all/apply"));
|
||
await apply.click();
|
||
expect((await request).postDataJSON()).toEqual({ confirmationId: "b".repeat(64) });
|
||
await expect(dialog).toBeHidden();
|
||
await expect(page.getByText("Rematching 115 tracks in durable batches.")).toBeVisible();
|
||
});
|
||
|
||
test("Manual and Rejected tabs expose explicit rematch and delete controls", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/track-matches?*", (route) => {
|
||
const state = new URL(route.request().url()).searchParams.get("state");
|
||
const authority = state === "manual_rejections"
|
||
? {
|
||
id: "rejection", kind: "rejection", revision: 2, authoritySnapshotId: "snapshot",
|
||
reason: "Wrong recording", createdAt: "2026-01-01T00:00:00Z", effective: true,
|
||
}
|
||
: {
|
||
id: "authority", kind: "local_match", revision: 3, authoritySnapshotId: "snapshot",
|
||
reason: "Selected from the local library", createdAt: "2026-01-01T00:00:00Z", effective: true,
|
||
};
|
||
const visible = state === "manual_matches" || state === "manual_rejections";
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
matches: visible ? [{
|
||
externalSnapshotId: "snapshot", providerId: "lumen-audio", libraryScopeId: "music",
|
||
state: state === "manual_rejections" ? "rejected" : "pinned",
|
||
decisionSource: "manual_authority", confidence: 1, threshold: .9,
|
||
title: "Manual song", artist: "Artist", album: "Album", durationMilliseconds: 180_000,
|
||
manualAuthorities: [authority], providerIdentities: [], reasons: [], warnings: [], candidates: [],
|
||
localTrack: state === "manual_matches" ? {
|
||
id: "local", backendItemId: "local", title: "Manual song", artist: "Artist",
|
||
album: "Album", durationMilliseconds: 180_000,
|
||
} : null,
|
||
}] : [],
|
||
stats: {
|
||
total: 2, matched: 1, accepted: 1, unresolved: 0, suggested: 0, review: 0,
|
||
rejected: 1, attention: 0, manualMatches: 1, manualRejections: 1,
|
||
},
|
||
pagination: { page: 1, pageSize: 50, total: visible ? 1 : 0, totalPages: 1 },
|
||
}),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/track-matches/snapshot/manual-authorities/authority/rematch", (route) =>
|
||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ rematched: true, state: "accepted" }) }));
|
||
await page.route("**/api/admin/track-matches/snapshot/manual-authorities/rejection?*", (route) =>
|
||
route.fulfill({ status: 204, body: "" }));
|
||
await page.goto("#/library/mappings");
|
||
|
||
await page.getByRole("tab", { name: /^Manual 1$/ }).click();
|
||
const manualMatch = page.locator(".manual-authority-row");
|
||
await expect(manualMatch).toContainText("Manual Jellyfin match");
|
||
const rematch = page.waitForRequest((request) =>
|
||
request.method() === "POST" && request.url().endsWith("/manual-authorities/authority/rematch"));
|
||
await manualMatch.getByRole("button", { name: "Rematch" }).click();
|
||
const rematchDialog = page.getByRole("alertdialog", { name: "Release and rematch this decision?" });
|
||
await expect(rematchDialog).toContainText("current matching algorithm");
|
||
await rematchDialog.getByRole("button", { name: "Release and rematch" }).click();
|
||
expect((await rematch).postDataJSON()).toEqual({ kind: "local_match", expectedRevision: 3 });
|
||
|
||
await page.getByRole("tab", { name: /^Rejected 1$/ }).click();
|
||
const manualRejection = page.locator(".manual-authority-row");
|
||
await expect(manualRejection).toContainText("Manual rejection");
|
||
const deletion = page.waitForRequest((request) =>
|
||
request.method() === "DELETE" && request.url().includes("/manual-authorities/rejection?"));
|
||
await manualRejection.getByRole("button", { name: "Delete" }).click();
|
||
const deleteDialog = page.getByRole("alertdialog", { name: "Delete this manual decision?" });
|
||
await deleteDialog.getByRole("button", { name: "Delete manual decision" }).click();
|
||
const deleteUrl = new URL((await deletion).url());
|
||
expect(deleteUrl.searchParams.get("kind")).toBe("rejection");
|
||
expect(deleteUrl.searchParams.get("expectedRevision")).toBe("2");
|
||
});
|
||
|
||
test("Tentative mappings sort by confidence and deep links open review", async ({ page }) => {
|
||
await mockApi(page);
|
||
const delayedProvider = routeRelease();
|
||
let localSearches = 0;
|
||
await page.route("**/api/admin/track-matches/targets/local?*", (route) => {
|
||
localSearches += 1;
|
||
const hasLocalResult = new URL(route.request().url()).searchParams.get("query") === "Kiss Me More";
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
tracks: hasLocalResult ? [{
|
||
id: "jellyfin-kiss-me-more", backendItemId: "jellyfin-kiss-me-more",
|
||
title: "Kiss Me More", artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||
durationMilliseconds: 208_000, confidence: 0.91,
|
||
components: { priorityWindow: 0.07 },
|
||
}] : [],
|
||
}),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/track-matches/targets/provider?*", async (route) => {
|
||
await delayedProvider.promise;
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
tracks: [{
|
||
id: "provider-kiss-me-more", externalId: "provider-kiss-me-more",
|
||
externalProvider: "lumen-audio", title: "Kiss Me More",
|
||
artist: "Doja Cat feat. SZA", album: "Planet Her",
|
||
durationMilliseconds: 208_000, confidence: 0.94,
|
||
components: { priorityWindow: 0.05 },
|
||
}],
|
||
providers: ["lumen-audio", "qobuz"],
|
||
}),
|
||
});
|
||
});
|
||
await page.goto("#/library/mappings?search=Test%20song&review=snapshot");
|
||
const dialog = page.getByRole("dialog", { name: "Test song" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByLabel("Search local library and playable providers")).toHaveValue("Test song");
|
||
await expect(dialog.getByText("ISRC US-AAA-26-00001")).toHaveCount(2);
|
||
await expect(dialog.locator(".candidate-provider").filter({ hasText: "Apple Music – GAMDL" })).toBeVisible();
|
||
await expect(dialog.getByText("MusicBrainz album")).toHaveCount(0);
|
||
await expect(dialog.locator(".candidate-card .mapping-art > span").first()).toBeVisible();
|
||
await expect(
|
||
dialog.locator(".automatic-candidates .candidate-provider")
|
||
.filter({ hasText: "Jellyfin · 7% priority window" }),
|
||
).toBeVisible();
|
||
await expect(dialog.locator(".automatic-candidates .candidate-confidence").getByText("82%"))
|
||
.toHaveCount(2);
|
||
await dialog.getByLabel("Search local library and playable providers").fill("Kiss Me More");
|
||
await dialog.getByRole("button", { name: "Search", exact: true }).click();
|
||
await expect(dialog.getByRole("button", { name: "Searching…" })).toBeVisible();
|
||
await expect(dialog.locator(".provider-result-summary")).toHaveCount(0);
|
||
delayedProvider.release();
|
||
await expect.poll(() => localSearches).toBe(1);
|
||
await expect(dialog.getByRole("button", { name: /Jellyfin 1/ })).toBeVisible();
|
||
await expect(dialog.getByRole("button", { name: /Lumen Audio 1/ })).toBeVisible();
|
||
await expect(dialog.getByRole("button", { name: /Qobuz/ })).toHaveCount(0);
|
||
await expect(dialog.getByText("Planet Her")).toHaveCount(2);
|
||
await expect(dialog.locator(".target-results > button .target-score")).toHaveCount(2);
|
||
await expect(dialog.locator(".target-results").getByText("· 7% priority window")).toHaveCount(1);
|
||
await expect(dialog.locator(".target-results").getByText("· 5% priority window")).toHaveCount(1);
|
||
await expect(dialog.locator(".target-results")).toHaveCSS("overflow-y", "visible");
|
||
await expect(dialog.locator(":scope > footer")).toHaveCSS("position", "sticky");
|
||
await expect(
|
||
dialog.locator(".target-results > button").filter({ hasText: "Jellyfin" })
|
||
.locator(".target-score").getByText("91%"),
|
||
).toBeVisible();
|
||
await expect(
|
||
dialog.locator(".target-results > button").filter({ hasText: "Lumen Audio" })
|
||
.locator(".target-score").getByText("94%"),
|
||
).toBeVisible();
|
||
await expect(dialog.getByText("rank #1")).toBeVisible();
|
||
await dialog.locator(".candidate-card").first().getByText("Full scoring evidence").click();
|
||
await expect(dialog.locator(".candidate-card").first().getByText("Candidate ID")).toBeVisible();
|
||
await expect(dialog.locator(".candidate-card").first().getByText("Artist overlap")).toBeVisible();
|
||
await expect(dialog.locator(".candidate-card").first().getByText("Duration difference")).toBeVisible();
|
||
await expect(dialog.locator(".candidate-card").first().getByText("Apple Music – GAMDL track ID")).toBeVisible();
|
||
await dialog.locator(".candidate-card").last().getByText("Full scoring evidence").click();
|
||
await expect(dialog.locator(".candidate-card").last().getByRole("term")
|
||
.filter({ hasText: /priority window/i }))
|
||
.toBeVisible();
|
||
await dialog.getByLabel("Search local library and playable providers").fill("No local copy");
|
||
await dialog.getByRole("button", { name: "Search", exact: true }).click();
|
||
await expect.poll(() => localSearches).toBe(2);
|
||
await expect(dialog.getByRole("button", { name: /Jellyfin 0/ })).toBeVisible();
|
||
await expect(dialog.getByRole("button", { name: /Lumen Audio 1/ })).toBeVisible();
|
||
await dialog.getByRole("button", { name: "Close match dialog" }).click();
|
||
|
||
const request = page.waitForRequest((item) =>
|
||
item.url().includes("/api/admin/track-matches") &&
|
||
new URL(item.url()).searchParams.get("sort") === "confidence_desc");
|
||
await page.getByRole("button", { name: "Confidence", exact: true }).click();
|
||
await page.getByRole("option", { name: "Highest first" }).click();
|
||
await request;
|
||
const unresolved = page.waitForRequest((item) =>
|
||
item.url().includes("/api/admin/track-matches") &&
|
||
new URL(item.url()).searchParams.get("state") === "unresolved");
|
||
await page.getByRole("tab", { name: "Unresolved 0" }).click();
|
||
await unresolved;
|
||
const tentative = page.waitForRequest((item) =>
|
||
item.url().includes("/api/admin/track-matches") &&
|
||
new URL(item.url()).searchParams.get("state") === "suggested");
|
||
await page.getByRole("tab", { name: "Tentative 1" }).click();
|
||
await tentative;
|
||
await expect(page.getByRole("button", { name: "Accept" })).toBeVisible();
|
||
await page.getByRole("button", { name: "Accept" }).click();
|
||
await expect(page.locator(".mapping-row")).toHaveCount(0);
|
||
});
|
||
|
||
test("Mappings keep mobile tabs, evidence, and actions readable", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/library/mappings");
|
||
|
||
for (const label of ["Library sections", "Mapping views"]) {
|
||
const tabs = page.getByRole("navigation", { name: label }).getByRole("tab");
|
||
expect(await tabs.evaluateAll((items) => items.every((item) => item.scrollWidth <= item.clientWidth))).toBe(true);
|
||
}
|
||
|
||
const mappingTabs = page.getByRole("navigation", { name: "Mapping views" });
|
||
const geometry = await mappingTabs.getByRole("tab").evaluateAll((tabs) => tabs.map((tab) => {
|
||
const bounds = tab.getBoundingClientRect();
|
||
return { left: bounds.left, right: bounds.right };
|
||
}));
|
||
expect(geometry.every((tab, index) => index === 0 || geometry[index - 1].right <= tab.left)).toBe(true);
|
||
expect(await mappingTabs.getByRole("tablist").evaluate((rail) => rail.scrollWidth > rail.clientWidth)).toBe(true);
|
||
|
||
const row = page.locator(".mapping-row").first();
|
||
await row.scrollIntoViewIfNeeded();
|
||
await expect(row.getByRole("button", { name: "Accept" })).toBeInViewport();
|
||
await expect(row.getByRole("button", { name: "Interactive search" })).toBeInViewport();
|
||
await expect(row.locator(".mapping-evidence-summary")).toContainText("title match");
|
||
expect(await row.locator(".mapping-evidence-summary").evaluate((item) => item.scrollWidth <= item.clientWidth)).toBe(true);
|
||
|
||
const more = row.getByRole("button", { name: "More actions for Test song - Remix" });
|
||
await expect(more).toBeInViewport();
|
||
await more.click();
|
||
await expect(page.getByRole("menuitem", { name: "Rematch" })).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
});
|
||
|
||
test("Shared search fields reserve icon space", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
|
||
for (const [route, label] of [
|
||
["#/library/playlists", "Filter playlists"],
|
||
["#/library/mappings", "Search"],
|
||
["#/library/cached", "Filter cached tracks"],
|
||
["#/activity", "Search"],
|
||
] as const) {
|
||
await page.goto(route);
|
||
if (route === "#/activity") await page.getByText("Filters", { exact: true }).click();
|
||
const input = page.getByRole("searchbox", { name: label });
|
||
await expect(input).toBeVisible();
|
||
const spacing = await input.evaluate((element) => {
|
||
const inputBounds = element.getBoundingClientRect();
|
||
const iconBounds = element.previousElementSibling!.getBoundingClientRect();
|
||
return {
|
||
iconRight: iconBounds.right,
|
||
textStart: inputBounds.left + Number.parseFloat(getComputedStyle(element).paddingLeft),
|
||
};
|
||
});
|
||
expect(spacing.iconRight).toBeLessThanOrEqual(spacing.textStart);
|
||
}
|
||
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Import playlist" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Import a playlist" });
|
||
await dialog.getByRole("radio", { name: /Spotify/ }).check();
|
||
await expect(dialog.getByRole("searchbox", { name: "Find a source playlist" })).toBeVisible();
|
||
});
|
||
|
||
test("Event log groups matching work and preserves actionable history", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
let currentRequests = 0;
|
||
await page.route("**/api/admin/ui/activity?*", (route) => {
|
||
const url = new URL(route.request().url());
|
||
const older = url.searchParams.has("before");
|
||
if (!older) currentRequests += 1;
|
||
const current = ["First", "First", "Second"].map((playlistName, index) => ({
|
||
id: `match-${index}`, kind: "matching", source: "lumen-audio",
|
||
providerId: "lumen-audio", label: "Track matched", state: "accepted",
|
||
detail: `Song ${index}`, occurredAt: `2026-01-02T00:00:0${2 - index}Z`,
|
||
correlationId: "job-1", action: "track-match.evaluate", playlistName,
|
||
sourceTitle: `Song ${index}`, targetProviderId: "library",
|
||
targetTitle: `Local Song ${index}`, confidenceLabel: "96%",
|
||
sourceProviderTrackId: `provider-${index}`, backendItemId: `backend-${index}`,
|
||
artworkUrl: `/artwork-${index}.jpg`,
|
||
technicalDetails: { titleSimilarity: "0.98" },
|
||
}));
|
||
const items = older
|
||
? [{
|
||
id: "older", kind: "job", source: "system", label: "Playlist sync",
|
||
state: "succeeded", detail: "Generation 1", occurredAt: "2026-01-01T00:00:00Z",
|
||
}]
|
||
: currentRequests > 1
|
||
? [{
|
||
id: "live-event", kind: "job", source: "system", label: "New live event",
|
||
state: "succeeded", detail: "Streamed update", occurredAt: "2026-01-03T00:00:00Z",
|
||
}, ...current]
|
||
: current;
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
items,
|
||
hasMore: !older,
|
||
nextCursor: older ? null : "2026-01-02T00:00:00Z",
|
||
nextCursorId: older ? null : "match-2",
|
||
}),
|
||
});
|
||
});
|
||
await page.goto("#/activity");
|
||
|
||
await expect(page.getByText("Matched 3 tracks across 2 playlists")).toBeVisible();
|
||
await expect(page.locator(".event-log-group summary .event-kind-icon img")).toHaveCount(0);
|
||
await expect(page.locator(".event-log-group summary .event-kind-icon svg")).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/activity-populated-390.png`, fullPage: true });
|
||
await expect(page.getByLabel("Search")).toBeHidden();
|
||
await page.getByText("Filters", { exact: true }).click();
|
||
await expect(page.getByLabel("Search")).toBeVisible();
|
||
await page.getByLabel("Search").fill("missing event");
|
||
await expect(page.getByText("No events match these filters")).toBeVisible();
|
||
await page.getByRole("button", { name: "Reset filters" }).click();
|
||
|
||
await page.getByRole("button", { name: "Load earlier events" }).click();
|
||
await expect(page.getByText("4 events retained in this view")).toBeVisible();
|
||
const group = page.locator(".event-log-group").filter({ hasText: "Matched 3 tracks" });
|
||
await group.locator(":scope > summary").focus();
|
||
await page.keyboard.press("Enter");
|
||
await expect(group.locator(".event-child .event-art > span").first()).toBeVisible();
|
||
const technical = page.getByText("Technical details").first();
|
||
await expect(technical).toBeVisible();
|
||
await expect(page.getByText("Title Similarity").first()).toBeHidden();
|
||
await technical.click();
|
||
await expect(page.getByText("Media server item ID").first()).toBeVisible();
|
||
await expect(page.getByText("Title Similarity").first()).toBeVisible();
|
||
await page.getByRole("button", { name: "Refresh" }).click();
|
||
await expect(page.locator(".event-log-group.streaming")).toHaveCount(1);
|
||
const streamedGroup = page.locator(".event-log-group").filter({ hasText: "New Live Event" });
|
||
await expect(streamedGroup.locator(":scope > summary").getByText("New Live Event", { exact: true })).toBeVisible();
|
||
await expect(group).toHaveAttribute("open", "");
|
||
await group.getByRole("link", { name: "Open related view" }).first().click();
|
||
await expect(page).toHaveURL(/#\/library\/mappings\?search=Song%200$/);
|
||
});
|
||
|
||
test("Cached and Kept keep media facts and actions readable on mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/library/cached");
|
||
await expect(page.getByRole("button", { name: "Provider" })).toContainText("All providers");
|
||
const cached = page.locator(".download-row");
|
||
await expect(cached.locator(".download-art .provider-mark")).toBeVisible();
|
||
await expect(cached.getByText("FLAC · 900 kbps · 16-bit · 44.1 kHz · 2 ch")).toBeVisible();
|
||
const fileFacts = cached.locator(".download-file");
|
||
await expect(fileFacts).toHaveAttribute("aria-label", /Size 1000 KiB\. Updated /);
|
||
await expect(fileFacts.getByText("1000 KiB", { exact: true })).toBeVisible();
|
||
await expect(cached.getByRole("link", { name: "Download" })).toBeVisible();
|
||
await expect(cached.getByRole("button", { name: "Keep" })).toBeVisible();
|
||
await expect(cached.getByRole("button", { name: "Remove" })).toBeVisible();
|
||
await expect(cached.getByText(/Track cache/)).toBeVisible();
|
||
|
||
await page.goto("#/library/kept");
|
||
const kept = page.locator(".download-row");
|
||
await expect(kept.getByRole("button", { name: "Keep" })).toHaveCount(0);
|
||
const downloadWidth = (await kept.getByRole("link", { name: "Download" }).boundingBox())?.width ?? 0;
|
||
const removeWidth = (await kept.getByRole("button", { name: "Remove" }).boundingBox())?.width ?? 0;
|
||
expect(Math.abs(downloadWidth - removeWidth)).toBeLessThanOrEqual(1);
|
||
await kept.getByRole("button", { name: "Remove" }).click();
|
||
await expect(page.getByRole("alertdialog", { name: "Remove this track?" })).toBeVisible();
|
||
});
|
||
|
||
test("Cached owns track storage and retention controls", async ({ page }) => {
|
||
await mockApi(page);
|
||
let saved: Record<string, string> | null = null;
|
||
await page.route("**/api/admin/config", (route) => {
|
||
if (route.request().method() === "POST") {
|
||
saved = route.request().postDataJSON().updates;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ message: "Runtime configuration updated.", updatedKeys: Object.keys(saved ?? {}) }),
|
||
});
|
||
}
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify(responses["/api/admin/config"]),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/library/cached");
|
||
await expect(page.getByText("Track cache behavior")).toBeVisible();
|
||
await expect(page.getByText("Cache mode", { exact: true })).toBeVisible();
|
||
await page.getByText("Track cache behavior").click();
|
||
await expect(page.locator('input[name="CACHE_DURATION_HOURS"]')).toHaveValue("24");
|
||
await expect(page.locator('input[name="CACHE_TRANSCODE_MINUTES"]')).toHaveValue("60");
|
||
const saveCache = page.getByRole("button", { name: "Save track cache" });
|
||
await expect(saveCache).toBeDisabled();
|
||
await page.locator('input[name="CACHE_DURATION_HOURS"]').fill("48");
|
||
await expect(saveCache).toBeEnabled();
|
||
await saveCache.click();
|
||
await expect.poll(() => saved).toEqual({
|
||
STORAGE_MODE: "Cache",
|
||
CACHE_DURATION_HOURS: "48",
|
||
CACHE_TRANSCODE_MINUTES: "60",
|
||
});
|
||
await expect(page.getByText("Track cache settings saved.")).toBeVisible();
|
||
|
||
await page.goto("#/settings/general");
|
||
await expect(page.getByRole("button", { name: "Storage mode" })).toHaveCount(0);
|
||
await expect(page.getByText("Track cache hours")).toHaveCount(0);
|
||
await expect(page.getByText("Transcode cache minutes")).toHaveCount(0);
|
||
});
|
||
|
||
test("Settings fields share columns, control baselines, and a compact action rail", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
const screenshotTheme = process.env.ALLSTARR_SCREENSHOT_THEME;
|
||
if (screenshotTheme === "light" || screenshotTheme === "dark")
|
||
await page.addInitScript((theme) => localStorage.setItem("allstarr.theme", theme), screenshotTheme);
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/ui/schema", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...schema,
|
||
configSections: [{
|
||
id: "general",
|
||
label: "General",
|
||
fields: [
|
||
{
|
||
key: "BACKEND",
|
||
label: "Backend",
|
||
type: "text",
|
||
valuePath: "deployment.backend",
|
||
ownership: "deployment",
|
||
readOnly: true,
|
||
helpText: "Edit the deployment configuration and recreate the container to apply this value.",
|
||
},
|
||
{
|
||
key: "DOWNLOAD_MODE",
|
||
label: "Download mode",
|
||
type: "select",
|
||
valuePath: "downloads.mode",
|
||
options: ["Track", "Album"],
|
||
},
|
||
{
|
||
key: "EXPLICIT_FILTER",
|
||
label: "Explicit filter",
|
||
type: "select",
|
||
valuePath: "playback.explicitFilter",
|
||
options: ["All", "Clean", "Explicit"],
|
||
},
|
||
],
|
||
}],
|
||
}),
|
||
}));
|
||
await page.route("**/api/admin/config", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
deployment: { backend: "Jellyfin" },
|
||
downloads: { mode: "Track" },
|
||
playback: { explicitFilter: "All" },
|
||
}),
|
||
}));
|
||
|
||
await page.goto("#/settings/general");
|
||
const section = page.locator("details.settings-disclosure").filter({ hasText: "General" });
|
||
const fields = section.locator(".setting-field");
|
||
await expect(fields).toHaveCount(3);
|
||
|
||
const fieldBoxes = await fields.evaluateAll((items) => items.map((item) => {
|
||
const box = item.getBoundingClientRect();
|
||
return { x: box.x, y: box.y, width: box.width };
|
||
}));
|
||
expect(Math.max(...fieldBoxes.map((box) => box.y)) - Math.min(...fieldBoxes.map((box) => box.y))).toBeLessThanOrEqual(1);
|
||
expect(Math.max(...fieldBoxes.map((box) => box.width)) - Math.min(...fieldBoxes.map((box) => box.width))).toBeLessThanOrEqual(1);
|
||
|
||
const controls = section.locator(".setting-field :is(output, .select-trigger)");
|
||
const controlBoxes = await controls.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().y));
|
||
expect(Math.max(...controlBoxes) - Math.min(...controlBoxes)).toBeLessThanOrEqual(1);
|
||
expect((await section.boundingBox())?.height ?? Infinity).toBeLessThan(320);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/settings-aligned-fields.png`, fullPage: true });
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const mobileBoxes = await fields.evaluateAll((items) => items.map((item) => {
|
||
const box = item.getBoundingClientRect();
|
||
return { x: box.x, width: box.width };
|
||
}));
|
||
expect(Math.max(...mobileBoxes.map((box) => box.x)) - Math.min(...mobileBoxes.map((box) => box.x))).toBeLessThanOrEqual(1);
|
||
expect(Math.max(...mobileBoxes.map((box) => box.width)) - Math.min(...mobileBoxes.map((box) => box.width))).toBeLessThanOrEqual(1);
|
||
const saveButton = section.getByRole("button", { name: "Save General" });
|
||
const footer = section.locator(".settings-fields > footer");
|
||
await expect(saveButton).toBeDisabled();
|
||
expect(Math.abs(((await saveButton.boundingBox())?.width ?? 0) - ((await footer.boundingBox())?.width ?? 0))).toBeLessThanOrEqual(1);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/settings-aligned-fields-mobile.png`, fullPage: true });
|
||
await section.getByRole("button", { name: "Download mode" }).click();
|
||
await page.getByRole("option", { name: "Album" }).click();
|
||
await expect(saveButton).toBeEnabled();
|
||
});
|
||
|
||
test("Dashboard primary tracks stay geometrically aligned", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await mockApi(page);
|
||
|
||
const expectSameTrack = async (firstSelector: string, secondSelector: string) => {
|
||
const [first, second] = await Promise.all([
|
||
page.locator(firstSelector).first().boundingBox(),
|
||
page.locator(secondSelector).first().boundingBox(),
|
||
]);
|
||
expect(first).toBeTruthy();
|
||
expect(second).toBeTruthy();
|
||
expect(Math.abs(first!.x - second!.x)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(first!.width - second!.width)).toBeLessThanOrEqual(1);
|
||
};
|
||
|
||
await page.goto("#/");
|
||
const homeColumns = page.locator(".home-columns > .home-panel");
|
||
await expect(homeColumns).toHaveCount(2);
|
||
const homeBoxes = await homeColumns.evaluateAll((items) => items.map((item) => {
|
||
const box = item.getBoundingClientRect();
|
||
return { y: box.y, width: box.width };
|
||
}));
|
||
expect(Math.abs(homeBoxes[0].y - homeBoxes[1].y)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(homeBoxes[0].width - homeBoxes[1].width)).toBeLessThanOrEqual(1);
|
||
|
||
await page.goto("#/settings/general");
|
||
await expect(page.locator(".settings-workspace")).toBeVisible();
|
||
await expectSameTrack(".settings-workspace", ".settings-tabs");
|
||
|
||
await page.goto("#/library/playlists");
|
||
await expect(page.locator(".library-tabs")).toBeVisible();
|
||
await expectSameTrack(".workspace-header", ".library-tabs");
|
||
const playlistList = page.locator(".playlist-list");
|
||
await expect(playlistList).toBeVisible();
|
||
expect((await playlistList.boundingBox())?.height ?? Infinity).toBeLessThan(400);
|
||
|
||
await page.goto("#/integrations/routing");
|
||
const routingGroups = page.locator(".routing-groups");
|
||
const routingCards = routingGroups.locator(":scope > .routing-group");
|
||
await expect(routingCards).toHaveCount(1);
|
||
await expectSameTrack(".routing-groups", ".routing-groups > .routing-group");
|
||
|
||
await page.goto("#/intelligence");
|
||
await expect(page.locator(".intelligence-view")).toBeVisible();
|
||
await expectSameTrack(".intelligence-view", ".intelligence-tabs");
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto("#/");
|
||
await expect(homeColumns).toHaveCount(2);
|
||
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
||
const [lastPanel, mobileNav] = await Promise.all([
|
||
page.locator(".home-columns > .home-panel").last().boundingBox(),
|
||
page.locator(".sidebar").boundingBox(),
|
||
]);
|
||
expect(lastPanel).toBeTruthy();
|
||
expect(mobileNav).toBeTruthy();
|
||
expect(lastPanel!.y + lastPanel!.height).toBeLessThanOrEqual(mobileNav!.y + 1);
|
||
const workspaceBottomPadding = await page.locator(".workspace").evaluate((element) =>
|
||
Number.parseFloat(getComputedStyle(element).paddingBottom));
|
||
expect(workspaceBottomPadding).toBeGreaterThanOrEqual(mobileNav!.height);
|
||
});
|
||
|
||
test("Compact panel headings share one full-width action track", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
|
||
for (const { route, heading, actions } of [
|
||
{ route: "#/library/playlists", heading: ".playlist-toolbar", actions: ".playlist-toolbar-actions" },
|
||
{ route: "#/library/mappings", heading: ".mapping-heading", actions: ".mapping-heading-actions" },
|
||
{ route: "#/activity", heading: ".event-log-heading", actions: ':scope > [data-slot="button"]' },
|
||
{ route: "#/library/cached", heading: ".downloads-heading", actions: ".downloads-heading-actions" },
|
||
{ route: "#/integrations/services", heading: ".sources-heading", actions: ".sources-heading-actions" },
|
||
]) {
|
||
await page.goto(route);
|
||
const panelHeading = page.locator(heading);
|
||
await expect(panelHeading).toBeVisible();
|
||
const [copyBox, actionBox] = await Promise.all([
|
||
panelHeading.locator(":scope > :first-child").boundingBox(),
|
||
panelHeading.locator(actions).boundingBox(),
|
||
]);
|
||
expect(copyBox).toBeTruthy();
|
||
expect(actionBox).toBeTruthy();
|
||
expect(Math.abs(copyBox!.x - actionBox!.x)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(copyBox!.width - actionBox!.width)).toBeLessThanOrEqual(1);
|
||
}
|
||
|
||
await page.goto("#/library/playlists");
|
||
const playlistActionButtons = page.locator(".playlist-toolbar-actions [data-slot=button]");
|
||
await expect(playlistActionButtons).toHaveCount(3);
|
||
const actionButtons = await playlistActionButtons.evaluateAll((items) =>
|
||
items.map((item) => item.getBoundingClientRect().toJSON()));
|
||
expect(Math.abs(actionButtons[0].y - actionButtons[1].y)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(actionButtons[0].width - actionButtons[1].width)).toBeLessThanOrEqual(1);
|
||
expect(actionButtons[2].width).toBeGreaterThan(actionButtons[0].width * 1.9);
|
||
});
|
||
|
||
test("Compact count-bearing route tabs stay separated and scroll the active tab into view", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/integrations/extensions");
|
||
|
||
const tabs = page.locator(".extension-tabs");
|
||
await expect(tabs).toBeVisible();
|
||
const initial = await tabs.evaluate((element) => ({
|
||
clientWidth: element.clientWidth,
|
||
scrollWidth: element.scrollWidth,
|
||
right: element.getBoundingClientRect().right,
|
||
}));
|
||
expect(initial.right).toBeLessThanOrEqual(390);
|
||
expect(initial.scrollWidth).toBeGreaterThan(initial.clientWidth);
|
||
|
||
const activity = tabs.getByRole("tab", { name: /Activity/ });
|
||
await activity.click();
|
||
await expect(activity).toHaveAttribute("aria-selected", "true");
|
||
await expect.poll(() => tabs.evaluate((element) => {
|
||
const active = element.querySelector<HTMLElement>('[aria-selected="true"]');
|
||
if (!active) return false;
|
||
const container = element.getBoundingClientRect();
|
||
const item = active.getBoundingClientRect();
|
||
return item.left >= container.left - 1 && item.right <= container.right + 1;
|
||
})).toBe(true);
|
||
});
|
||
|
||
test("Narrow workspace controls and contextual tabs stay separated", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/intelligence");
|
||
|
||
const [heading, actions, liveState] = await Promise.all([
|
||
page.locator(".workspace-header h1").boundingBox(),
|
||
page.locator(".workspace-header > .flex").boundingBox(),
|
||
page.locator(".workspace-header .live-state").boundingBox(),
|
||
]);
|
||
expect(heading && actions && liveState).toBeTruthy();
|
||
expect(heading!.x + heading!.width).toBeLessThanOrEqual(actions!.x - 7);
|
||
expect(liveState!.width).toBeLessThanOrEqual(44);
|
||
await expect(page.locator(".workspace-header .live-state")).toHaveAttribute("aria-label", /Live updates/);
|
||
|
||
const periods = page.locator(".period-tabs");
|
||
const before = await periods.evaluate((element) => ({
|
||
clientWidth: element.clientWidth,
|
||
scrollWidth: element.scrollWidth,
|
||
overflowX: getComputedStyle(element).overflowX,
|
||
}));
|
||
expect(before.scrollWidth).toBeGreaterThan(before.clientWidth);
|
||
expect(before.overflowX).toBe("auto");
|
||
const custom = periods.getByRole("tab", { name: "Custom" });
|
||
await custom.click();
|
||
await expect(custom).toHaveAttribute("aria-selected", "true");
|
||
await expect.poll(() => periods.evaluate((element) => {
|
||
const active = element.querySelector<HTMLElement>('[aria-selected="true"]');
|
||
if (!active) return false;
|
||
const container = element.getBoundingClientRect();
|
||
const item = active.getBoundingClientRect();
|
||
return item.left >= container.left - 1 && item.right <= container.right + 1;
|
||
})).toBe(true);
|
||
});
|
||
|
||
test("Mobile primary navigation stays contained and moves secondary tasks into More", async ({ page }) => {
|
||
const screenshotTheme = process.env.ALLSTARR_SCREENSHOT_THEME;
|
||
if (screenshotTheme === "light" || screenshotTheme === "dark")
|
||
await page.addInitScript((theme) => localStorage.setItem("allstarr.theme", theme), screenshotTheme);
|
||
await mockApi(page);
|
||
|
||
for (const viewport of [
|
||
{ width: 320, height: 844 },
|
||
{ width: 390, height: 844 },
|
||
{ width: 430, height: 932 },
|
||
]) {
|
||
await page.setViewportSize(viewport);
|
||
await page.goto("#/activity");
|
||
|
||
const navigation = page.locator(".mobile-navigation");
|
||
await expect(navigation).toBeVisible();
|
||
await expect(page.locator(".desktop-navigation")).toBeHidden();
|
||
await expect(navigation.locator(":scope > :is(a, button)")).toHaveCount(4);
|
||
await expect(navigation.getByRole("link", { name: "Activity" })).toHaveAttribute("aria-current", "page");
|
||
await expect(navigation.getByRole("link", { name: "Insights" })).toHaveCount(0);
|
||
|
||
const geometry = await navigation.locator(":scope > :is(a, button)").evaluateAll((items) => {
|
||
const parent = items[0].parentElement!.getBoundingClientRect();
|
||
return items.map((item) => {
|
||
const rect = item.getBoundingClientRect();
|
||
const label = item.querySelector<HTMLElement>(".nav-label")!;
|
||
return {
|
||
contained: rect.left >= parent.left - 1 && rect.right <= parent.right + 1,
|
||
fillsTrack: item !== items.at(-1) || Math.abs(rect.right - parent.right) <= 1,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
labelContained: label.scrollWidth <= label.clientWidth,
|
||
};
|
||
});
|
||
});
|
||
expect(geometry.every(({ contained, fillsTrack, width, height, labelContained }) =>
|
||
contained && fillsTrack && Math.abs(width - geometry[0].width) <= 1 && height >= 44 && labelContained)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${screenshotTheme ?? "system"}-mobile-${viewport.width}-activity.png` });
|
||
|
||
const more = navigation.getByRole("button", { name: "More destinations" });
|
||
await more.click();
|
||
const sheet = page.getByRole("dialog", { name: "More" });
|
||
await expect(sheet).toBeVisible();
|
||
await expect(sheet.getByRole("link", { name: /Integrations/ })).toBeVisible();
|
||
await expect(sheet.getByRole("link", { name: /Settings/ })).toBeVisible();
|
||
await expect(sheet.getByRole("link", { name: /Intelligence|Insights/ })).toHaveCount(0);
|
||
expect(await sheet.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${screenshotTheme ?? "system"}-mobile-${viewport.width}-more.png` });
|
||
|
||
const actionRows = await sheet.locator(".mobile-menu-actions button").evaluateAll((buttons) =>
|
||
buttons.map((button) => Math.round(button.getBoundingClientRect().y)));
|
||
expect(new Set(actionRows).size).toBe(viewport.width <= 380 ? 2 : 1);
|
||
|
||
await sheet.getByRole("link", { name: /Settings/ }).click();
|
||
await expect(sheet).toBeHidden();
|
||
await expect(page).toHaveURL(/#\/settings$/);
|
||
await expect(more).toHaveAttribute("aria-pressed", "true");
|
||
}
|
||
});
|
||
|
||
test("Contextual dialog tabs scroll inside their panels at narrow widths", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
const screenshotTheme = process.env.ALLSTARR_SCREENSHOT_THEME;
|
||
if (screenshotTheme === "light" || screenshotTheme === "dark")
|
||
await page.addInitScript((theme) => localStorage.setItem("allstarr.theme", theme), screenshotTheme);
|
||
await mockApi(page);
|
||
|
||
const expectContainedActiveTab = async (tabs: Locator) => {
|
||
await expect(tabs).toHaveCSS("overflow-x", "auto");
|
||
const items = tabs.getByRole("tab");
|
||
const heights = await items.evaluateAll((elements) =>
|
||
elements.map((element) => element.getBoundingClientRect().height));
|
||
expect(heights.every((height) => height >= 44)).toBe(true);
|
||
await items.last().click();
|
||
await expect.poll(() => tabs.evaluate((element) => {
|
||
const active = element.querySelector<HTMLElement>('[aria-selected="true"]');
|
||
if (!active) return false;
|
||
const container = element.getBoundingClientRect();
|
||
const item = active.getBoundingClientRect();
|
||
return item.left >= container.left - 1 && item.right <= container.right + 1;
|
||
})).toBe(true);
|
||
};
|
||
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const playlist = page.getByRole("dialog", { name: "Test playlist" });
|
||
await expectContainedActiveTab(playlist.locator(".playlist-view-switcher .contextual-tabs"));
|
||
expect(await playlist.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${screenshotTheme ?? "system"}-mobile-320-playlist-tabs.png` });
|
||
await playlist.getByRole("button", { name: "Close playlist details" }).click();
|
||
|
||
await page.goto("#/integrations/services");
|
||
const lumen = page.locator(".sources-table tr").filter({ hasText: "Lumen Audio" });
|
||
await lumen.getByRole("button").first().click();
|
||
const source = page.getByRole("dialog", { name: "Lumen Audio", description: "Source capability and readiness" });
|
||
await expectContainedActiveTab(source.locator(".contextual-tabs"));
|
||
expect(await source.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/${screenshotTheme ?? "system"}-mobile-320-source-tabs.png` });
|
||
});
|
||
|
||
test("Compact dialogs share symmetric actions and readable progress", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
await mockApi(page);
|
||
|
||
await page.goto("#/library/playlists");
|
||
const rematchButton = page.getByRole("button", { name: "Review rematch" });
|
||
await expect(rematchButton).toBeVisible();
|
||
expect(await rematchButton.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true);
|
||
const toolbarActions = await page.locator(".playlist-toolbar-actions [data-slot=button]").evaluateAll((buttons) =>
|
||
buttons.map((button) => {
|
||
const rect = button.getBoundingClientRect();
|
||
return { contained: button.scrollWidth <= button.clientWidth, width: rect.width, y: Math.round(rect.y) };
|
||
}));
|
||
expect(new Set(toolbarActions.map(({ y }) => y)).size).toBe(3);
|
||
expect(toolbarActions.every(({ contained }) => contained)).toBe(true);
|
||
expect(Math.max(...toolbarActions.map(({ width }) => width)) -
|
||
Math.min(...toolbarActions.map(({ width }) => width))).toBeLessThanOrEqual(1);
|
||
await page.getByRole("button", { name: "Import playlist" }).click();
|
||
const playlistDialog = page.getByRole("dialog", { name: "Import a playlist" });
|
||
const steps = await playlistDialog.locator(".playlist-add-steps button").evaluateAll((buttons) =>
|
||
buttons.map((button) => {
|
||
const rect = button.getBoundingClientRect();
|
||
return { height: rect.height, width: rect.width, y: Math.round(rect.y) };
|
||
}),
|
||
);
|
||
expect(new Set(steps.map(({ y }) => y)).size).toBe(2);
|
||
expect(steps.every(({ height }) => height >= 44)).toBe(true);
|
||
expect(Math.max(...steps.map(({ width }) => width)) - Math.min(...steps.map(({ width }) => width))).toBeLessThanOrEqual(1);
|
||
const playlistActions = await playlistDialog.locator(":scope > .dialog-actions > *").evaluateAll((actions) =>
|
||
actions.map((action) => {
|
||
const rect = action.getBoundingClientRect();
|
||
return { width: rect.width, y: Math.round(rect.y) };
|
||
}),
|
||
);
|
||
expect(Math.abs(playlistActions[0].width - playlistActions[1].width)).toBeLessThanOrEqual(1);
|
||
expect(new Set(playlistActions.map(({ y }) => y)).size).toBe(2);
|
||
await playlistDialog.getByRole("button", { name: "Cancel" }).click();
|
||
|
||
await page.goto("#/integrations/services");
|
||
await expect(page.getByRole("heading", { name: "Services", level: 2 })).toBeVisible();
|
||
const serviceActions = await page.locator(".sources-heading-actions button").evaluateAll((buttons) =>
|
||
buttons.map((button) => {
|
||
const rect = button.getBoundingClientRect();
|
||
return { contained: button.scrollWidth <= button.clientWidth, width: rect.width, y: rect.y };
|
||
}),
|
||
);
|
||
expect(new Set(serviceActions.map(({ y }) => y)).size).toBe(2);
|
||
expect(serviceActions.every(({ contained, width }) => contained && width >= 200)).toBe(true);
|
||
await page.getByRole("button", { name: "Connect Source" }).click();
|
||
const sourceDialog = page.getByRole("dialog", { name: "Connect a Source" });
|
||
const sourceActions = await sourceDialog.locator("form > .dialog-actions > *").evaluateAll((actions) =>
|
||
actions.map((action) => {
|
||
const rect = action.getBoundingClientRect();
|
||
return { height: rect.height, width: rect.width, y: rect.y };
|
||
}),
|
||
);
|
||
expect(sourceActions).toHaveLength(2);
|
||
expect(sourceActions.every(({ height }) => height >= 44)).toBe(true);
|
||
expect(Math.abs(sourceActions[0].width - sourceActions[1].width)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(sourceActions[0].y - sourceActions[1].y)).toBeGreaterThanOrEqual(44);
|
||
expect(await sourceDialog.evaluate((dialog) => dialog.scrollWidth <= dialog.clientWidth)).toBe(true);
|
||
});
|
||
|
||
test("Sparse data surfaces use the shared compact empty state", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
await mockApi(page);
|
||
|
||
await page.goto("#/activity");
|
||
await expect(page.locator(".event-log-empty")).toBeVisible();
|
||
expect((await page.locator(".event-log-empty").boundingBox())?.height ?? Infinity).toBeLessThanOrEqual(200);
|
||
|
||
await page.route("**/api/admin/downloads**", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
storage: new URL(route.request().url()).searchParams.get("storage"),
|
||
files: [], totalSize: 0, totalSizeFormatted: "0 B", count: 0,
|
||
managedCount: 0, diagnosticCount: 0,
|
||
}),
|
||
}));
|
||
await page.goto("#/library/cached");
|
||
await expect(page.locator(".downloads-empty")).toBeVisible();
|
||
expect((await page.locator(".downloads-empty").boundingBox())?.height ?? Infinity).toBeLessThanOrEqual(200);
|
||
});
|
||
|
||
test("Operational tables adapt before the workspace needs horizontal scrolling", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.setViewportSize({ width: 820, height: 760 });
|
||
|
||
for (const [route, table] of [
|
||
["#/integrations/services", ".sources-table"],
|
||
["#/integrations/accounts", ".accounts-table"],
|
||
] as const) {
|
||
await page.goto(route);
|
||
const surface = page.locator(".operational-table-scroll");
|
||
await expect(page.locator(`${table} th`).nth(1)).toBeHidden();
|
||
await expect(page.locator(`${table} .operational-mobile-detail`).first()).toBeVisible();
|
||
await expect.poll(() => surface.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||
}
|
||
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await page.goto("#/integrations/services");
|
||
await expect(page.locator(".sources-table th").nth(1)).toBeVisible();
|
||
await expect(page.locator(".sources-table .operational-mobile-detail").first()).toBeHidden();
|
||
});
|
||
|
||
test("Intelligence cards, table columns, and compact actions stay aligned", async ({ page }) => {
|
||
await page.setViewportSize({ width: 820, height: 760 });
|
||
await mockApi(page);
|
||
await page.goto("#/intelligence");
|
||
|
||
for (const [selector, count] of [
|
||
[".history-stats > article", 5],
|
||
[".history-breakdowns > .breakdown-card", 3],
|
||
] as const) {
|
||
const cards = page.locator(selector);
|
||
await expect(cards).toHaveCount(count);
|
||
const boxes = await cards.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().toJSON()));
|
||
expect(Math.max(...boxes.map((box) => box.y)) - Math.min(...boxes.map((box) => box.y))).toBeLessThanOrEqual(1);
|
||
expect(Math.max(...boxes.map((box) => box.width)) - Math.min(...boxes.map((box) => box.width))).toBeLessThanOrEqual(1);
|
||
}
|
||
|
||
const tabHeights = await page.locator(".history-workspace .segmented-tabs [role=tab]")
|
||
.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
|
||
expect(tabHeights.every((height) => height >= 44)).toBe(true);
|
||
|
||
for (const width of [820, 1280]) {
|
||
await page.setViewportSize({ width, height: 760 });
|
||
const header = page.locator(".history-column-head > span");
|
||
const row = page.locator(".history-list > li > button").first();
|
||
const [sourceHeading, timeHeading, source, time] = await Promise.all([
|
||
header.nth(2).boundingBox(),
|
||
header.nth(3).boundingBox(),
|
||
row.locator(".history-route").boundingBox(),
|
||
row.locator(".history-time").boundingBox(),
|
||
]);
|
||
expect(sourceHeading && source).toBeTruthy();
|
||
expect(timeHeading && time).toBeTruthy();
|
||
expect(Math.abs(sourceHeading!.x - source!.x)).toBeLessThanOrEqual(5);
|
||
expect(Math.abs(timeHeading!.x - time!.x)).toBeLessThanOrEqual(5);
|
||
await expect.poll(() => page.locator(".history-list-card").evaluate((element) =>
|
||
element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||
}
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto("#/intelligence?section=discover");
|
||
const tools = page.locator(".heading-tools");
|
||
const scopeValue = tools.locator(":scope > .scope-value");
|
||
const runState = tools.locator(":scope > .badge");
|
||
const refreshAction = tools.locator(":scope > [data-slot=button]");
|
||
await expect(scopeValue).toBeVisible();
|
||
await expect(runState).toBeVisible();
|
||
await expect(refreshAction).toBeVisible();
|
||
const [scope, state, action] = await Promise.all([
|
||
scopeValue.boundingBox(),
|
||
runState.boundingBox(),
|
||
refreshAction.boundingBox(),
|
||
]);
|
||
expect(scope && state && action).toBeTruthy();
|
||
expect(Math.abs(scope!.x - state!.x)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(scope!.x + scope!.width - action!.x - action!.width)).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
test("Integrations keep primary actions visible and report scoped degradation", async ({ page, context }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
let appleState = {
|
||
state: "needs_login", ready: false, staged: true, daemon_running: true,
|
||
wrapper_healthy: true, logged_in: false, login_state: "logged_out", api_version: "2",
|
||
};
|
||
await page.route("**/api/admin/apple-download/status", (route) => route.fulfill({
|
||
contentType: "application/json", body: JSON.stringify(appleState),
|
||
}));
|
||
await page.route("**/api/admin/apple-download/login", (route) => {
|
||
appleState = { ...appleState, state: "needs_login", login_state: "awaiting_2fa" };
|
||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(appleState) });
|
||
});
|
||
await page.route("**/api/admin/apple-download/login/2fa", (route) => {
|
||
appleState = { ...appleState, state: "ready", ready: true, logged_in: true, login_state: "authenticated" };
|
||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(appleState) });
|
||
});
|
||
let appleCtsRequest: unknown;
|
||
await page.route("**/api/admin/provider-diagnostics/deep-stream", async (route) => {
|
||
appleCtsRequest = route.request().postDataJSON();
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ succeeded: true, providerId: "apple-download", clickToStreamMilliseconds: 42 }),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/ui/schema", (route) => route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...schema,
|
||
providers: [
|
||
...schema.providers,
|
||
{ id: "disabled-source", name: "Disabled Source", status: "disabled", categories: ["metadata"] },
|
||
],
|
||
}),
|
||
}));
|
||
await page.goto("#/sources");
|
||
const lumenSource = page.locator(".sources-table tr").filter({ hasText: "Lumen Audio" });
|
||
await expect(lumenSource.locator(".operational-mobile-detail dd").filter({ hasText: "Awaiting first CTS sample" })).toHaveText("Awaiting first CTS sample");
|
||
const tableGutters = await page.locator(".sources-panel").evaluate((panel) => ({
|
||
heading: Number.parseFloat(getComputedStyle(panel.querySelector(".sources-heading")!).paddingLeft),
|
||
cell: Number.parseFloat(getComputedStyle(panel.querySelector(".sources-table td")!).paddingLeft),
|
||
}));
|
||
expect(tableGutters.cell).toBe(tableGutters.heading);
|
||
await lumenSource.getByRole("button").first().click();
|
||
const sourceDetails = page.getByRole("dialog", { name: "Lumen Audio", description: "Source capability and readiness" });
|
||
await sourceDetails.getByRole("tab", { name: "Configuration" }).click();
|
||
await expect(sourceDetails.getByText("Access token")).toBeVisible();
|
||
await expect(sourceDetails.getByText("Stored", { exact: true })).toBeVisible();
|
||
await expect(sourceDetails.getByText("Region")).toBeVisible();
|
||
await expect(sourceDetails.getByText("ca", { exact: true })).toBeVisible();
|
||
await expect(sourceDetails.getByRole("button", { name: "Connect another account" })).toBeVisible();
|
||
await sourceDetails.getByRole("button", { name: "Edit configuration" }).click();
|
||
const sourceEditor = page.getByRole("dialog", { name: "Configure Lumen Audio" });
|
||
await expect(sourceEditor.getByRole("button", { name: "Region" })).toHaveText("ca");
|
||
await expect(sourceEditor.getByLabel("Access token")).toHaveValue("");
|
||
await page.keyboard.press("Escape");
|
||
const disabledSource = page.locator(".sources-table tr").filter({ hasText: "Disabled Source" });
|
||
const disabledStatus = disabledSource.locator(".operational-mobile-state");
|
||
await expect(disabledStatus).toHaveText("Disabled");
|
||
await expect(disabledStatus).toHaveCSS("border-top-width", "1px");
|
||
await expect(disabledStatus).toHaveCSS("border-top-style", "solid");
|
||
await page.getByRole("button", { name: /Apple Music – GAMDL/ }).click();
|
||
await page.getByRole("tab", { name: "Configuration" }).click();
|
||
await page.getByRole("button", { name: "Measure CTS" }).click();
|
||
await expect(page.getByText("Apple Music – GAMDL click-to-stream measured.")).toBeVisible();
|
||
expect(appleCtsRequest).toEqual({ providerId: "apple-download", quality: 0 });
|
||
await page.getByRole("button", { name: "Manage Apple Music – GAMDL" }).click();
|
||
const appleManager = page.getByRole("dialog", { name: "Apple Music – GAMDL" });
|
||
await appleManager.getByLabel("Apple ID").fill("tester@example.test");
|
||
await appleManager.getByLabel("Password").fill("password");
|
||
await appleManager.getByRole("button", { name: "Start login" }).click();
|
||
await appleManager.getByLabel("2FA code").fill("123456");
|
||
await appleManager.getByRole("button", { name: "Submit 2FA" }).click();
|
||
await expect(appleManager.getByText("Apple Music – GAMDL is ready")).toBeVisible();
|
||
await expect(appleManager.locator('.source-metrics [data-slot="badge"]')).toHaveCount(3);
|
||
const appleProgress = await appleManager.locator(".apple-setup-progress li").evaluateAll((items) =>
|
||
items.map((item) => {
|
||
const rect = item.getBoundingClientRect();
|
||
return { contained: item.scrollWidth <= item.clientWidth, width: rect.width, x: rect.x };
|
||
}),
|
||
);
|
||
expect(appleProgress.every(({ contained }) => contained)).toBe(true);
|
||
expect(Math.max(...appleProgress.map(({ width }) => width)) - Math.min(...appleProgress.map(({ width }) => width))).toBeLessThanOrEqual(1);
|
||
expect(Math.max(...appleProgress.map(({ x }) => x)) - Math.min(...appleProgress.map(({ x }) => x))).toBeLessThanOrEqual(1);
|
||
expect(await appleManager.evaluate((dialog) => dialog.scrollWidth <= dialog.clientWidth)).toBe(true);
|
||
const appleActionHeights = await appleManager.locator(":scope > .dialog-actions > *")
|
||
.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
|
||
expect(appleActionHeights.every((height) => height >= 44)).toBe(true);
|
||
await expect(appleManager.getByRole("link", { name: "Provider settings" }))
|
||
.toHaveAttribute("href", "#/integrations/services?source=apple-download§ion=configuration");
|
||
await page.keyboard.press("Escape");
|
||
await page.goto("#/integrations/accounts");
|
||
await page.getByRole("button", { name: /Lumen Audio Account details stored/ }).click();
|
||
const accountDetails = page.getByRole("dialog", { name: "Lumen Audio", description: "Lumen Audio account" });
|
||
await accountDetails.getByRole("tab", { name: "Configuration" }).click();
|
||
await expect(accountDetails.getByRole("button", { name: "Disable account" })).toBeVisible();
|
||
await expect(accountDetails.getByRole("button", { name: "Test connection" })).toBeVisible();
|
||
await expect(accountDetails.getByRole("button", { name: "Edit configuration" })).toBeVisible();
|
||
const metadataCapability = accountDetails.locator(".source-detail-capabilities > span").filter({ hasText: "Metadata" });
|
||
await expect(metadataCapability.locator('[data-slot="badge"]')).toHaveText("Ready");
|
||
const successColor = await page.evaluate(() => {
|
||
const sample = document.createElement("span");
|
||
sample.style.color = "var(--color-success)";
|
||
document.body.append(sample);
|
||
const value = getComputedStyle(sample).color;
|
||
sample.remove();
|
||
return value;
|
||
});
|
||
await expect(metadataCapability.locator('[data-slot="badge"]')).toHaveCSS("color", successColor);
|
||
await expect(metadataCapability.getByRole("button", { name: "Test" })).toHaveAttribute("data-slot", "button");
|
||
await accountDetails.getByRole("button", { name: "Close Source details" }).click();
|
||
|
||
const listener = await context.newPage();
|
||
await mockApi(listener);
|
||
await listener.route("**/api/admin/auth/me", (route) => route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
authenticated: true, backend: "Jellyfin",
|
||
user: { id: "listener", name: "Listener", isAdministrator: false },
|
||
}),
|
||
}));
|
||
await listener.route("**/api/admin/provider-accounts", (route) => route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "Account scope unavailable" }),
|
||
}));
|
||
await listener.goto("#/integrations/accounts");
|
||
await expect(listener.getByText("Source readiness may be stale.")).toBeVisible();
|
||
await expect(listener.getByText("Accounts are administrator-managed")).toBeVisible();
|
||
});
|
||
|
||
test("Settings loads only the active section owners", async ({ page }) => {
|
||
const requests: string[] = [];
|
||
page.on("request", (request) => requests.push(new URL(request.url()).pathname));
|
||
await mockApi(page);
|
||
await page.goto("#/settings/general");
|
||
await expect(page.getByRole("heading", { name: "General", level: 2 })).toBeVisible();
|
||
expect(requests).toContain("/api/admin/config");
|
||
expect(requests).not.toContain("/api/admin/provider-accounts");
|
||
expect(requests).not.toContain("/api/admin/storage");
|
||
expect(requests).not.toContain("/api/admin/cache");
|
||
|
||
requests.length = 0;
|
||
await page.getByRole("tab", { name: "Maintenance" }).click();
|
||
await expect(page.getByRole("heading", { name: "Maintenance", level: 2 })).toBeVisible();
|
||
await expect.poll(() => requests.includes("/api/admin/cache/maintenance/preview")).toBe(true);
|
||
await expect.poll(() => requests.includes("/api/admin/config/migration/status")).toBe(true);
|
||
expect(requests).toContain("/api/admin/storage");
|
||
expect(requests).toContain("/api/admin/cache");
|
||
expect(requests).not.toContain("/api/admin/config");
|
||
expect(requests).not.toContain("/api/admin/provider-accounts");
|
||
});
|
||
|
||
test("Audio quality supports keyboard changes, provider outcomes, save, and reload", async ({ page }) => {
|
||
await mockApi(page);
|
||
let quality = "BestAvailable";
|
||
let saved = "";
|
||
await page.route("**/api/admin/config", (route) => {
|
||
if (route.request().method() === "POST") {
|
||
saved = route.request().postDataJSON().updates.AUDIO_QUALITY;
|
||
quality = saved;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ message: "Runtime configuration updated.", updatedKeys: ["AUDIO_QUALITY"] }),
|
||
});
|
||
}
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...(responses["/api/admin/config"] as Record<string, unknown>),
|
||
audio: { quality },
|
||
}),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/integrations/routing");
|
||
const slider = page.getByRole("slider", { name: "Audio quality" });
|
||
await expect(slider).toHaveAttribute("aria-valuetext", /Best available/);
|
||
await slider.focus();
|
||
await page.keyboard.press("Home");
|
||
await expect(slider).toHaveAttribute("aria-valuetext", /Data saver/);
|
||
await page.keyboard.press("End");
|
||
await expect(slider).toHaveAttribute("aria-valuetext", /Best available/);
|
||
await page.keyboard.press("Home");
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(slider).toHaveAttribute("aria-valuetext", /High lossy/);
|
||
await expect(page.getByText(/Smaller files and streams with high sound quality/)).toBeVisible();
|
||
|
||
await page.getByText("Music source quality details", { exact: true }).click();
|
||
await expect(page.getByText("Apple Music: AAC 320 kbps")).toBeVisible();
|
||
await expect(page.getByText("Deezer: MP3 320 kbps")).toBeVisible();
|
||
await expect(page.getByLabel("Local match window")).toHaveValue("7");
|
||
await page.getByRole("button", { name: "Save playback and matching" }).click();
|
||
await expect.poll(() => saved).toBe("High");
|
||
await page.reload();
|
||
await expect(page.getByRole("slider", { name: "Audio quality" }))
|
||
.toHaveAttribute("aria-valuetext", /High lossy/);
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect(page.locator(".audio-quality-field")).toBeVisible();
|
||
expect(await page.locator(".audio-quality-field").evaluate((element) =>
|
||
element.scrollWidth <= element.clientWidth)).toBe(true);
|
||
});
|
||
|
||
test("Settings preserves dirty drafts during live refresh and scrolls mobile tabs", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
await page.addInitScript(() => {
|
||
class TestEventSource {
|
||
static instance: TestEventSource | undefined;
|
||
listeners = new Map<string, EventListener[]>();
|
||
|
||
constructor() {
|
||
TestEventSource.instance = this;
|
||
}
|
||
|
||
addEventListener(type: string, listener: EventListener) {
|
||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||
}
|
||
|
||
close() {}
|
||
}
|
||
|
||
Object.defineProperty(window, "EventSource", { value: TestEventSource });
|
||
window.__emitAllstarrUpdate = () => {
|
||
const event = {
|
||
data: JSON.stringify({ resource: "config", resourceId: "runtime", revision: 2 }),
|
||
lastEventId: "settings-2",
|
||
};
|
||
for (const listener of TestEventSource.instance?.listeners.get("update") ?? [])
|
||
listener(event as unknown as Event);
|
||
};
|
||
});
|
||
await mockApi(page);
|
||
let serverValue = 1;
|
||
await page.route("**/api/admin/config", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...(responses["/api/admin/config"] as Record<string, unknown>),
|
||
cache: { searchResultsMinutes: serverValue, mediaMaximumMegabytes: 512 },
|
||
}),
|
||
}));
|
||
await page.goto("#/settings/general");
|
||
const cache = page.locator(".settings-disclosure").filter({ hasText: "Cache" });
|
||
await cache.locator("summary").click();
|
||
const input = cache.getByLabel("Search results minutes");
|
||
await input.fill("27");
|
||
await input.evaluate((element) => element.setAttribute("data-instance", "draft"));
|
||
await input.focus();
|
||
serverValue = 9;
|
||
await page.evaluate(() => window.__emitAllstarrUpdate?.());
|
||
|
||
await expect(page.getByText("Your unsaved edits are preserved.")).toBeVisible();
|
||
await expect(cache).toHaveAttribute("open", "");
|
||
await expect(input).toHaveValue("27");
|
||
await expect(input).toHaveAttribute("data-instance", "draft");
|
||
await expect(input).toBeFocused();
|
||
await page.getByRole("button", { name: "Reload server values" }).click();
|
||
await expect(input).toHaveValue("9");
|
||
|
||
const tabs = page.locator(".settings-tabs");
|
||
const tabSizes = await tabs.getByRole("tab").evaluateAll((items) => items.map((item) => {
|
||
const box = item.getBoundingClientRect();
|
||
return { height: box.height, fontSize: Number.parseFloat(getComputedStyle(item).fontSize) };
|
||
}));
|
||
expect(tabSizes.every(({ height, fontSize }) => height >= 44 && fontSize >= 14)).toBe(true);
|
||
const generalTab = tabs.getByRole("tab", { name: "General" });
|
||
const selectedBackground = await generalTab.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||
const selectedUnderline = await generalTab.evaluate((element) => getComputedStyle(element, "::after").backgroundColor);
|
||
await generalTab.hover();
|
||
await expect.poll(() => generalTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||
.not.toBe(selectedBackground);
|
||
await tabs.getByRole("tab", { name: "Maintenance" }).click();
|
||
await expect(tabs.getByRole("tab", { name: "Maintenance" })).toHaveAttribute("aria-selected", "true");
|
||
const maintenanceTab = tabs.getByRole("tab", { name: "Maintenance" });
|
||
await maintenanceTab.hover();
|
||
await expect.poll(() => maintenanceTab.evaluate((element) => getComputedStyle(element, "::after").backgroundColor))
|
||
.toBe(selectedUnderline);
|
||
await expect.poll(() => tabs.evaluate((element) => {
|
||
const active = element.querySelector<HTMLElement>('[aria-selected="true"]');
|
||
if (!active) return false;
|
||
const container = element.getBoundingClientRect();
|
||
const item = active.getBoundingClientRect();
|
||
return item.left >= container.left - 1 && item.right <= container.right + 1;
|
||
})).toBe(true);
|
||
});
|
||
|
||
test("Maintenance previews, retries, and applies a legacy import on mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/settings/maintenance");
|
||
const card = page.locator(".maintenance-card").filter({ hasText: "Legacy v2 import" });
|
||
const legacyFile = {
|
||
name: "allstarr.env",
|
||
mimeType: "text/plain",
|
||
buffer: Buffer.from("CACHE_LYRICS_DAYS=21"),
|
||
};
|
||
|
||
await card.getByLabel("Legacy environment file").setInputFiles(legacyFile);
|
||
await card.getByRole("button", { name: "Preview import" }).click();
|
||
await expect(card.getByText("Imported accounts remain disabled until reviewed.")).toBeVisible();
|
||
await card.getByText("Review 1 parsed settings").click();
|
||
await expect(card.getByText("CACHE_LYRICS_DAYS")).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
await card.getByRole("button", { name: "Discard and retry" }).click();
|
||
await expect(card.getByRole("button", { name: "Preview import" })).toBeVisible();
|
||
|
||
await card.getByLabel("Legacy environment file").setInputFiles(legacyFile);
|
||
await card.getByRole("button", { name: "Preview import" }).click();
|
||
await card.getByRole("checkbox").check();
|
||
await card.getByRole("button", { name: "Import preview" }).click();
|
||
await expect(card.getByText("Legacy settings imported.")).toBeVisible();
|
||
});
|
||
|
||
test("Maintenance validates before selective import and reports applied rows", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const delayed = routeRelease();
|
||
await mockApi(page, { releasePath: "/api/admin/preview-selective-state", release: delayed.promise });
|
||
await page.goto("#/settings/maintenance");
|
||
const card = page.locator(".transfer-card");
|
||
const archive = {
|
||
name: "allstarr-state.zip",
|
||
mimeType: "application/zip",
|
||
buffer: Buffer.from("fixture"),
|
||
};
|
||
|
||
await expect(card.getByRole("button", { name: "Import validated archive" })).toHaveCount(0);
|
||
await card.locator('input[type="file"]').setInputFiles(archive);
|
||
await card.getByRole("button", { name: "Import behavior" }).click();
|
||
await page.getByRole("option", { name: "Merge compatible rows" }).click();
|
||
await card.getByRole("button", { name: "Validate archive" }).click();
|
||
await expect(card.getByLabel("preview in progress")).toBeVisible();
|
||
await card.getByRole("button", { name: "Cancel" }).click();
|
||
delayed.release();
|
||
await expect(card.getByText("State transfer cancelled.")).toBeVisible();
|
||
await card.getByRole("button", { name: "Validate archive" }).click();
|
||
await expect(card.getByRole("region", { name: "Selective transfer preview" })).toContainText("3 rows");
|
||
await expect(card.getByText("Dependencies: Settings, Accounts")).toBeVisible();
|
||
await card.getByRole("button", { name: "Import validated archive" }).click();
|
||
const dialog = page.getByRole("alertdialog", { name: "Import validated state?" });
|
||
await expect(dialog).toBeVisible();
|
||
await dialog.getByRole("button", { name: "Import archive" }).click();
|
||
const result = card.getByRole("region", { name: "Selective transfer result" });
|
||
await expect(result).toContainText("Import complete");
|
||
await expect(result).toContainText("provider-accounts");
|
||
await expect(result).toContainText("playlist-links");
|
||
await expect(dialog).toBeHidden();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
});
|
||
|
||
test("Maintenance reports cache budgets and confirms category purge", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const requests: string[] = [];
|
||
page.on("request", (request) => requests.push(new URL(request.url()).pathname));
|
||
await mockApi(page);
|
||
await page.goto("#/settings/general");
|
||
const cacheSettings = page.locator("details").filter({ hasText: "Cache" });
|
||
await cacheSettings.locator("summary").click();
|
||
await expect(cacheSettings.getByText(/hot RAM remains fixed at 16 MiB/)).toBeVisible();
|
||
await page.goto("#/settings/maintenance");
|
||
const card = page.locator(".cache-diagnostics-card");
|
||
|
||
await expect(card.getByText("Hot RAM hit / miss")).toBeVisible();
|
||
await expect(card.getByText("Upstream avoided")).toBeVisible();
|
||
await card.getByText("Limits and cleanup preview").click();
|
||
await expect(card.getByText("Stale account scopes")).toBeVisible();
|
||
await card.getByText("Category budgets").click();
|
||
await expect(card.getByText(/media-assets/)).toBeVisible();
|
||
await card.getByRole("button", { name: "Purge", exact: true }).click();
|
||
const dialog = page.getByRole("alertdialog", { name: "Purge Artwork cache?" });
|
||
await expect(dialog).toBeVisible();
|
||
await dialog.getByRole("button", { name: "Purge cache" }).click();
|
||
await expect.poll(() => requests).toContain("/api/admin/cache/categories/Artwork");
|
||
await expect(dialog).toBeHidden();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
});
|
||
|
||
test("Durable onboarding controls first setup and targeted recovery", async ({ page }) => {
|
||
await mockApi(page);
|
||
let completed = false;
|
||
let recovery = false;
|
||
let releaseStatus!: () => void;
|
||
const initialStatus = new Promise<void>((resolve) => { releaseStatus = resolve; });
|
||
let waitForInitialStatus = true;
|
||
await page.route("**/api/admin/onboarding/status", async (route) => {
|
||
if (waitForInitialStatus) {
|
||
waitForInitialStatus = false;
|
||
await initialStatus;
|
||
}
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
completed, setupOpen: false, shouldRedirectToSetup: !completed,
|
||
schemaVersion: "onboarding-v1",
|
||
completedSteps: completed ? ["backend-identity"] : [],
|
||
completionSource: completed ? "setup-guide" : "none",
|
||
completedAt: completed ? "2026-01-01" : null,
|
||
revision: completed ? 2 : 0,
|
||
recoveryNotices: recovery ? ["backend_identity_missing"] : [],
|
||
migration: { available: true, completed: false, firstRun: true },
|
||
}),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/onboarding/complete", (route) => {
|
||
completed = true;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
completed: true, setupOpen: false, shouldRedirectToSetup: false,
|
||
schemaVersion: "onboarding-v1", completedSteps: ["backend-identity"],
|
||
completionSource: "setup-guide", completedAt: "2026-01-01", revision: 2,
|
||
recoveryNotices: [],
|
||
migration: { available: true, completed: false, firstRun: true },
|
||
}),
|
||
});
|
||
});
|
||
|
||
const navigation = page.goto("#/");
|
||
await expect(page.getByRole("heading", { name: "Bringing your music universe online" })).toBeVisible();
|
||
await expect(page.getByRole("status")).toHaveText("Loading Allstarr. Preparing your music control center.");
|
||
await expect(page.locator("[role=progressbar]")).toHaveCount(0);
|
||
releaseStatus();
|
||
await navigation;
|
||
const setup = page.getByRole("dialog", { name: "Set up Allstarr" });
|
||
await expect(setup).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(setup).toBeVisible();
|
||
await setup.getByRole("button", { name: "Finish setup" }).click();
|
||
await expect(setup).toBeHidden();
|
||
|
||
await page.evaluate(() => localStorage.clear());
|
||
await page.reload();
|
||
await expect(setup).toBeHidden();
|
||
|
||
recovery = true;
|
||
await page.reload();
|
||
await expect(page.getByText("Media server connection needs attention.")).toBeVisible();
|
||
await expect(page.getByText(/review the media server connection/i)).toBeVisible();
|
||
await expect(setup).toBeHidden();
|
||
});
|
||
|
||
test("Signal boot exposes a retryable bootstrap failure", async ({ page }) => {
|
||
const failing = ["/api/admin/auth/me"];
|
||
await mockApi(page, { fail: failing });
|
||
await page.goto("#/");
|
||
|
||
await expect(page.getByRole("heading", { name: "Allstarr could not start." })).toBeVisible();
|
||
await expect(page.getByRole("alert")).toContainText("Fixture unavailable");
|
||
failing.length = 0;
|
||
await page.getByRole("button", { name: "Try again" }).click();
|
||
await expect(page.getByRole("heading", { name: "Home", level: 1 })).toBeVisible();
|
||
});
|
||
|
||
test("authentication operations expose pending state and recover from sign-out failure", async ({ page }) => {
|
||
await mockApi(page);
|
||
const login = routeRelease();
|
||
await page.route("**/api/admin/auth/me", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ authenticated: false, backend: "Jellyfin", user: null }),
|
||
}));
|
||
await page.route("**/api/admin/auth/login", async (route) => {
|
||
await login.promise;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify(responses["/api/admin/auth/me"]),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/auth/logout", (route) => route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "The media server did not confirm sign out." }),
|
||
}));
|
||
await page.goto("#/");
|
||
await page.getByLabel("Username").fill("tester");
|
||
await page.getByLabel("Password").fill("secret");
|
||
await page.getByRole("button", { name: "Sign in" }).click();
|
||
|
||
await expect(page.getByRole("button", { name: "Signing in…" })).toBeDisabled();
|
||
await expect(page.getByLabel("Username")).toBeDisabled();
|
||
await expect(page.getByLabel("Password")).toBeDisabled();
|
||
login.release();
|
||
await expect(page.getByRole("heading", { name: "Home", level: 1 })).toBeVisible();
|
||
|
||
await page.getByRole("button", { name: "Sign out" }).click();
|
||
const signOutError = page.getByRole("alert");
|
||
await expect(signOutError).toContainText("Sign out failed.");
|
||
await expect(signOutError).toContainText("The media server did not confirm sign out.");
|
||
await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeEnabled();
|
||
await page.getByRole("button", { name: "Dismiss sign out error" }).click();
|
||
await expect(signOutError).toBeHidden();
|
||
});
|
||
|
||
test("Administrators can reopen durable setup from Maintenance", async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 844 });
|
||
await mockApi(page);
|
||
await page.goto("#/settings/maintenance");
|
||
await page.getByRole("button", { name: "Open setup guide" }).click();
|
||
const setup = page.getByRole("dialog", { name: "Set up Allstarr" });
|
||
await expect(setup).toBeVisible();
|
||
await expect(setup.getByRole("button", { name: "Finish setup" })).toBeInViewport();
|
||
const dialogBox = await setup.boundingBox();
|
||
const checklistBox = await setup.locator(".setup-checklist").boundingBox();
|
||
expect(dialogBox).not.toBeNull();
|
||
expect(checklistBox).not.toBeNull();
|
||
expect((checklistBox?.x ?? 0) - (dialogBox?.x ?? 0)).toBeGreaterThanOrEqual(16);
|
||
const actionBoxes = await setup.locator(":scope > .dialog-actions button").evaluateAll((buttons) =>
|
||
buttons.map((button) => {
|
||
const rect = button.getBoundingClientRect();
|
||
return { width: rect.width, y: rect.y };
|
||
}),
|
||
);
|
||
expect(actionBoxes).toHaveLength(2);
|
||
expect(Math.abs(actionBoxes[0].width - actionBoxes[1].width)).toBeLessThanOrEqual(1);
|
||
expect(Math.abs(actionBoxes[0].y - actionBoxes[1].y)).toBeGreaterThanOrEqual(44);
|
||
await page.keyboard.press("Escape");
|
||
await expect(setup).toBeHidden();
|
||
});
|
||
|
||
test("Playlist details use a responsive dialog and track rows open mapping review", async ({ page }) => {
|
||
await page.setViewportSize({ width: 835, height: 762 });
|
||
await mockApi(page);
|
||
await page.goto("#/library/playlists");
|
||
await expect.poll(async () => (await page.locator(".playlist-list").boundingBox())?.height ?? Infinity).toBeLessThan(400);
|
||
await expect(page.locator(".desktop-navigation").getByRole("link", { name: "Library" }).locator("svg"))
|
||
.toBeVisible();
|
||
await expect(page.locator(".desktop-navigation").getByRole("link", { name: "Library" }).locator("use"))
|
||
.toHaveCount(0);
|
||
const refresh = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/playlist-links/playlist-link/refresh"));
|
||
await page.getByRole("button", { name: "Refresh playlists" }).click();
|
||
await refresh;
|
||
await expect(page.getByText(/1 playlists refreshed in/)).toBeVisible();
|
||
const coverage = page.locator(".playlist-row .coverage-bar > span[style]");
|
||
await expect(coverage.nth(0)).toHaveAttribute("style", /width:\s*50%/);
|
||
await expect(coverage.nth(1)).toHaveAttribute("style", /width:\s*50%; --route-color:\s*var\(--color-ink-muted\)/);
|
||
const [playlistRow, playlistBar] = await Promise.all([
|
||
page.locator(".playlist-row").boundingBox(),
|
||
page.locator(".playlist-row .coverage-bar").boundingBox(),
|
||
]);
|
||
expect(Math.abs((playlistRow!.y + playlistRow!.height) - (playlistBar!.y + playlistBar!.height)))
|
||
.toBeLessThanOrEqual(1);
|
||
expect(playlistBar!.height).toBeGreaterThanOrEqual(6);
|
||
const rematchPreview = page.waitForRequest((item) =>
|
||
item.method() === "GET" && item.url().endsWith("/api/admin/playlist-links/rematch/preview"));
|
||
await page.getByRole("button", { name: "Review rematch" }).click();
|
||
await rematchPreview;
|
||
const rematchDialog = page.getByRole("dialog", { name: "Review full rematch" });
|
||
await expect(rematchDialog.getByText("1 track needs review")).toBeVisible();
|
||
await expect(rematchDialog.getByText("Generic external").locator("..")).toContainText("0");
|
||
await rematchDialog.getByRole("checkbox", { name: /I reviewed these counts/ }).check();
|
||
const rematchApply = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/playlist-links/rematch/apply"));
|
||
await rematchDialog.getByRole("button", { name: "Rematch 1 track" }).click();
|
||
await rematchApply;
|
||
await expect(page.getByText("Controlled rematch queued.")).toBeVisible();
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect(page.locator(".playlist-row .coverage-bar")).toContainText("Lumen Audio: 1, Unresolved: 1");
|
||
await expect(page.locator(".playlist-row .playlist-art > span")).toBeVisible();
|
||
const openPlaylist = page.getByRole("button", { name: "Open Test playlist playlist details", exact: true });
|
||
await expect(openPlaylist).toBeVisible();
|
||
await expect(page.getByText("1 to review", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("1 unresolved", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("Not updated yet", { exact: true })).toBeVisible();
|
||
await expect(page.getByRole("group", { name: "0% confirmed, 1 of 2 playable" })).toBeVisible();
|
||
const metricsFit = await page.locator(".playlist-summary").evaluate((summary) => {
|
||
const parent = summary.getBoundingClientRect();
|
||
const children = [...summary.children].map((child) => child.getBoundingClientRect());
|
||
const inside = children.every((child) =>
|
||
child.left >= parent.left - 1 && child.right <= parent.right + 1 &&
|
||
child.top >= parent.top - 1 && child.bottom <= parent.bottom + 1);
|
||
const separate = children.every((item, index) => children.slice(index + 1).every((other) =>
|
||
item.right <= other.left || other.right <= item.left ||
|
||
item.bottom <= other.top || other.bottom <= item.top));
|
||
return inside && separate;
|
||
});
|
||
expect(metricsFit).toBe(true);
|
||
await openPlaylist.click();
|
||
const dialog = page.getByRole("dialog", { name: "Test playlist" });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.locator(".hero-art > span")).toBeVisible();
|
||
await expect(dialog.locator(".track-art > span")).toBeVisible();
|
||
await expect(dialog.getByRole("table", { name: "Test playlist tracks" })).toBeVisible();
|
||
await expect(dialog.locator(".coverage-bar")).toContainText("Lumen Audio: 1, Unresolved: 1");
|
||
await expect(dialog.getByText("Needs review", { exact: true })).toBeVisible();
|
||
await expect(dialog.getByText("No automatic updates", { exact: true })).toBeVisible();
|
||
await dialog.getByRole("button", { name: "Show 1 track needing review" }).click();
|
||
await expect(dialog.getByRole("button", { name: "Track route" })).toContainText("To review (1)");
|
||
const mobileReview = dialog.getByRole("button", { name: "Open mapping details for Test song" });
|
||
await expect.poll(async () => (await mobileReview.boundingBox())?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||
await mobileReview.click();
|
||
const mobileMatch = page.getByRole("dialog", { name: "Test song" });
|
||
await expect(mobileMatch).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(mobileMatch).toBeHidden();
|
||
await expect(dialog).toBeVisible();
|
||
await expect(mobileReview).toBeFocused();
|
||
await dialog.getByRole("button", { name: "Close playlist details" }).click();
|
||
await openPlaylist.click();
|
||
await expect(dialog.getByRole("button", { name: "Track route" })).toContainText("All routes");
|
||
await expect(dialog.getByRole("button", { name: "Actions" })).toBeInViewport();
|
||
await dialog.getByRole("button", { name: "Actions" }).click();
|
||
await expect(page.getByRole("menuitem", { name: "Update playlist now" })).toBeVisible();
|
||
await expect(page.getByRole("menuitem", { name: "Rematch" })).toBeVisible();
|
||
await expect(page.getByRole("menuitem", { name: "Refresh source" })).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await expect.poll(async () => (await dialog.boundingBox())?.width ?? 0).toBeGreaterThan(900);
|
||
await expect(dialog.getByText(/Snapshot v/)).toHaveCount(0);
|
||
await expect(dialog.getByText("Last target sync")).toHaveCount(0);
|
||
await expect(dialog.getByText("Current published snapshot")).toHaveCount(0);
|
||
await expect(dialog.getByRole("button", { name: /Operation details:/ })).toBeVisible();
|
||
await expect(dialog.getByRole("columnheader", { name: "Artist" })).toBeVisible();
|
||
await expect(dialog.getByRole("columnheader", { name: "Album" })).toBeVisible();
|
||
await dialog.getByRole("button", { name: /Choose track columns/ }).click();
|
||
await page.locator(".track-column-picker").getByLabel("Album", { exact: true }).uncheck();
|
||
await expect(dialog.getByRole("columnheader", { name: "Album" })).toHaveCount(0);
|
||
await page.keyboard.press("Escape");
|
||
const density = await dialog.evaluate((element) => {
|
||
const bounds = element.getBoundingClientRect();
|
||
const strip = element.querySelector(".playlist-meta-strip")!;
|
||
const trackScroll = element.querySelector(".track-scroll")!.getBoundingClientRect();
|
||
const table = element.querySelector(".track-data-table")!.getBoundingClientRect();
|
||
const centers = [...strip.children].map((child) => {
|
||
const childBounds = child.getBoundingClientRect();
|
||
return childBounds.top + childBounds.height / 2;
|
||
});
|
||
return {
|
||
dialogHeight: bounds.height,
|
||
blankTrackTail: trackScroll.height - table.height,
|
||
metadataCenterSpread: Math.max(...centers) - Math.min(...centers),
|
||
};
|
||
});
|
||
expect(density.dialogHeight).toBeLessThan(700);
|
||
expect(density.blankTrackTail).toBeLessThan(48);
|
||
expect(density.metadataCenterSpread).toBeLessThanOrEqual(1);
|
||
await dialog.getByRole("button", { name: "Technical details for Test song" }).click();
|
||
await expect(page).toHaveURL(/#\/library\/playlists$/);
|
||
const trackDetails = page.locator(".track-details-menu");
|
||
const reviewMatch = trackDetails.getByRole("button", { name: "Review match" });
|
||
await expect(reviewMatch).toBeVisible();
|
||
await expect.poll(() => trackDetails.evaluate((panel) => {
|
||
const bounds = panel.getBoundingClientRect();
|
||
return panel.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + 8));
|
||
})).toBe(true);
|
||
await reviewMatch.click();
|
||
await expect(trackDetails).toBeHidden();
|
||
const reviewedMatch = page.getByRole("dialog", { name: "Test song" });
|
||
await expect(reviewedMatch).toBeVisible();
|
||
await page.keyboard.press("Escape");
|
||
await expect(reviewedMatch).toBeHidden();
|
||
await expect(dialog.getByRole("button", { name: "Technical details for Test song" })).toBeFocused();
|
||
await dialog.getByRole("button", { name: "Open mapping details for Test song" }).focus();
|
||
await page.keyboard.press("Enter");
|
||
await expect(page).toHaveURL(/#\/library\/playlists$/);
|
||
await expect(page.getByRole("dialog", { name: "Test song" })).toBeVisible();
|
||
});
|
||
|
||
for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 800 }]) {
|
||
test(`Playlist sync preserves reading position at ${viewport.width}px`, async ({ page }) => {
|
||
await page.setViewportSize(viewport);
|
||
await mockApi(page);
|
||
const first = (responses["/api/admin/playlist-links"] as {
|
||
playlistLinks: Record<string, unknown>[];
|
||
}).playlistLinks[0];
|
||
await page.route("**/api/admin/playlist-links", async (route) => {
|
||
if (route.request().method() !== "GET") return route.fallback();
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
playlistLinks: [
|
||
{ ...first, trackCount: 60, playableCount: 60, unmatchedCount: 0 },
|
||
{ ...first, id: "playlist-link-two", name: "Second playlist", trackCount: 60, playableCount: 60, unmatchedCount: 0 },
|
||
],
|
||
}),
|
||
});
|
||
});
|
||
|
||
const detail = (id: string, name: string, projectionMode: "resolved" | "source" | "target") => ({
|
||
id,
|
||
snapshotId: `${id}-snapshot`,
|
||
snapshotVersion: 1,
|
||
latestSourceSnapshotVersion: 1,
|
||
hasNewerSourceGeneration: false,
|
||
name,
|
||
sourceProviderId: "lumen-audio",
|
||
projectionMode,
|
||
targetProtocol: "jellyfin",
|
||
retrievedAt: "2026-01-01",
|
||
completedAt: "2026-01-01",
|
||
trackCount: 60,
|
||
localCount: 60,
|
||
externalCount: 0,
|
||
unresolvedCount: 0,
|
||
durationMs: 10_800_000,
|
||
matchedCount: 60,
|
||
reviewCount: 0,
|
||
rejectedCount: 0,
|
||
playableCount: 60,
|
||
routeCoverage: [{ providerId: "jellyfin", count: 60 }],
|
||
unknownDurationCount: 0,
|
||
clientProjection: {
|
||
protocolId: `allstarr-vpl-${id}`,
|
||
projectionMode,
|
||
trackCount: 60,
|
||
tracks: Array.from({ length: 60 }, (_, index) => ({
|
||
position: index + 1,
|
||
sourcePosition: index,
|
||
itemId: `${id}-${projectionMode}-${index}`,
|
||
title: `Track ${index + 1}`,
|
||
artists: ["Artist"],
|
||
album: "Album",
|
||
durationMs: 180_000,
|
||
routeKind: "local",
|
||
})),
|
||
},
|
||
tracks: Array.from({ length: 60 }, (_, index) => ({
|
||
sourcePosition: index,
|
||
position: index + 1,
|
||
externalSnapshotId: `${id}-track-${index}`,
|
||
title: `Track ${index + 1}`,
|
||
artists: ["Artist"],
|
||
album: "Album",
|
||
durationMs: 180_000,
|
||
routeKind: "local",
|
||
routeProviderId: "jellyfin",
|
||
matchState: "accepted",
|
||
providerRoutes: [],
|
||
})),
|
||
});
|
||
let detailRequests = 0;
|
||
let releaseRefresh!: () => void;
|
||
const refreshHeld = new Promise<void>((resolve) => { releaseRefresh = resolve; });
|
||
await page.route("**/api/admin/playlist-links/playlist-link?*", async (route) => {
|
||
detailRequests++;
|
||
if (detailRequests === 2) await refreshHeld;
|
||
const mode = new URL(route.request().url()).searchParams.get("projectionMode") as "resolved" | "source" | "target";
|
||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(detail("playlist-link", "Test playlist", mode)) });
|
||
});
|
||
await page.route("**/api/admin/playlist-links/playlist-link-two?*", (route) => {
|
||
const mode = new URL(route.request().url()).searchParams.get("projectionMode") as "resolved" | "source" | "target";
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify(detail("playlist-link-two", "Second playlist", mode)),
|
||
});
|
||
});
|
||
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Test playlist" });
|
||
const scroll = dialog.locator(".track-scroll");
|
||
await scroll.evaluate((element) => {
|
||
element.setAttribute("data-instance", "reading-position");
|
||
element.scrollTop = 600;
|
||
});
|
||
const filter = dialog.getByLabel("Filter tracks");
|
||
await filter.fill("Track");
|
||
await filter.focus();
|
||
const before = await scroll.evaluate((element) => element.scrollTop);
|
||
await dialog.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Update playlist now" }).click();
|
||
await filter.focus();
|
||
await expect.poll(() => detailRequests).toBe(2);
|
||
|
||
await expect(dialog.getByText("Loading playlist tracks…")).toHaveCount(0);
|
||
await expect(scroll).toHaveAttribute("data-instance", "reading-position");
|
||
await expect(filter).toHaveValue("Track");
|
||
await expect(filter).toBeFocused();
|
||
expect(Math.abs(await scroll.evaluate((element) => element.scrollTop) - before)).toBeLessThanOrEqual(1);
|
||
releaseRefresh();
|
||
await expect(dialog.getByText("Playlist update queued.")).toBeVisible();
|
||
await expect(scroll).toHaveAttribute("data-instance", "reading-position");
|
||
expect(Math.abs(await scroll.evaluate((element) => element.scrollTop) - before)).toBeLessThanOrEqual(1);
|
||
|
||
await dialog.getByRole("button", { name: "Close playlist details" }).click();
|
||
await page.getByRole("button", { name: "Open Second playlist playlist details" }).click();
|
||
await expect(page.getByRole("dialog", { name: "Second playlist" }).locator(".track-scroll"))
|
||
.toHaveJSProperty("scrollTop", 0);
|
||
});
|
||
}
|
||
|
||
test("Playlist operations show durable progress and confirm cancellation", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await mockApi(page);
|
||
let cancelled = false;
|
||
let detailRequests = 0;
|
||
await page.route("**/api/admin/jobs?limit=100", (route) => {
|
||
const fixture = responses["/api/admin/jobs?limit=100"] as { jobs: Record<string, unknown>[]; progress: unknown[] };
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
...fixture,
|
||
jobs: fixture.jobs.map((job) => ({
|
||
...job,
|
||
state: cancelled ? "Succeeded" : "Deferred",
|
||
deferralCount: 1,
|
||
availableAt: "2099-01-01T12:00:00Z",
|
||
})),
|
||
}),
|
||
});
|
||
});
|
||
await page.route("**/api/admin/jobs/*/cancel", (route) => {
|
||
cancelled = true;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
jobId: "11111111-1111-1111-1111-111111111111",
|
||
state: "CancellationRequested",
|
||
}),
|
||
});
|
||
});
|
||
page.on("request", (request) => {
|
||
if (new URL(request.url()).pathname === "/api/admin/playlist-links/playlist-link")
|
||
detailRequests++;
|
||
});
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: /Test playlist/ }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Test playlist" });
|
||
const update = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/playlist-links/playlist-link/run") &&
|
||
item.postDataJSON().snapshotId === "playlist-snapshot");
|
||
await dialog.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Update playlist now" }).click();
|
||
await update;
|
||
await expect(dialog.getByText("Playlist update queued.")).toBeVisible();
|
||
await dialog.getByRole("button", { name: /Operation details:/ }).click();
|
||
const operation = page.locator(".operation-popover");
|
||
await expect(operation.getByText("Matching Test song", { exact: true }).first()).toBeVisible();
|
||
await expect(operation.getByText("1/2")).toBeVisible();
|
||
await expect(operation.getByText("Wait until")).toBeVisible();
|
||
await expect(operation.getByText("Deferrals")).toBeVisible();
|
||
await operation.getByRole("button", { name: "Cancel operation" }).click();
|
||
const confirmation = page.getByRole("alertdialog", { name: "Cancel this operation?" });
|
||
await expect(confirmation).toBeVisible();
|
||
const request = page.waitForRequest((item) =>
|
||
item.method() === "POST" && item.url().endsWith("/api/admin/jobs/11111111-1111-1111-1111-111111111111/cancel"));
|
||
await confirmation.getByRole("button", { name: "Cancel operation" }).click();
|
||
await request;
|
||
await expect.poll(() => detailRequests).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
test("Profile artwork is stable in full, slim, and mobile navigation", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.route("**/api/admin/auth/me/avatar?user=user", (route) => route.fulfill({
|
||
status: 200,
|
||
contentType: "image/gif",
|
||
body: Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64"),
|
||
}));
|
||
|
||
for (const width of [1280, 924, 390]) {
|
||
await page.setViewportSize({ width, height: 844 });
|
||
await page.goto("#/");
|
||
if (width === 390) await page.getByRole("button", { name: "More destinations" }).click();
|
||
const avatar = page.locator(width === 390 ? ".mobile-sheet-profile .avatar" : ".profile .avatar");
|
||
await expect(avatar).toBeVisible();
|
||
await expect(avatar.locator("img")).toBeVisible();
|
||
await expect.poll(async () => (await avatar.boundingBox())?.width ?? 0).toBe(36);
|
||
if (width === 924) {
|
||
await page.getByRole("button", { name: "Collapse sidebar" }).click();
|
||
await expect(page.locator(".app-shell")).toHaveClass(/slim/);
|
||
await expect.poll(async () =>
|
||
(await page.locator(".desktop-navigation").getByRole("link", { name: "Library" }).boundingBox())?.width ?? 0
|
||
).toBe(48);
|
||
await page.getByRole("button", { name: "Expand sidebar" }).click();
|
||
await expect(page.locator(".app-shell")).not.toHaveClass(/slim/);
|
||
}
|
||
if (width === 390) {
|
||
await expect(page.locator(".mobile-navigation").getByRole("link", { name: "Settings" })).toHaveCount(0);
|
||
await expect(page.getByRole("dialog", { name: "More" }).getByRole("link", { name: /Settings/ })).toBeVisible();
|
||
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.height ?? 0).toBeLessThan(80);
|
||
await page.getByRole("button", { name: "Close more destinations" }).click();
|
||
}
|
||
}
|
||
|
||
await page.route("**/api/admin/auth/me/avatar?user=user", (route) => route.fulfill({ status: 404 }));
|
||
await page.reload();
|
||
await page.getByRole("button", { name: "More destinations" }).click();
|
||
await expect(page.locator(".mobile-sheet-profile .avatar > span")).toHaveText("T");
|
||
await expect.poll(async () => (await page.locator(".mobile-sheet-profile .avatar").boundingBox())?.width ?? 0).toBe(36);
|
||
});
|
||
|
||
test("Segmented navigation and unified provider filters support keyboard use", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("tab", { name: "Playlists" }).focus();
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(page).toHaveURL(/#\/library\/mappings$/);
|
||
await expect(page.getByRole("tab", { name: "Mappings" })).toHaveAttribute("aria-selected", "true");
|
||
|
||
await page.getByRole("button", { name: "Interactive search" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "Test song" });
|
||
await dialog.getByLabel("Search local library and playable providers").fill("Kiss Me More");
|
||
await dialog.getByRole("button", { name: "Search", exact: true }).click();
|
||
await dialog.getByRole("button", { name: /Jellyfin 1/ }).focus();
|
||
await page.keyboard.press("Space");
|
||
await expect(dialog.getByRole("button", { name: /Jellyfin 1/ })).toHaveAttribute("aria-pressed", "true");
|
||
|
||
await page.goto("#/settings/extensions");
|
||
await page.getByRole("tab", { name: /Installed/ }).focus();
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(page.getByRole("tab", { name: /Available/ })).toHaveAttribute("aria-selected", "true");
|
||
});
|
||
|
||
test("Responsive boundaries preserve navigation and download identity", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/");
|
||
for (const width of [760, 761, 900, 901]) {
|
||
await page.setViewportSize({ width, height: 844 });
|
||
const navigation = page.locator(width <= 760 ? ".mobile-navigation" : ".desktop-navigation");
|
||
await expect(navigation.getByRole("link")).toHaveCount(width <= 760 ? 3 : 5);
|
||
await expect(navigation.locator(":scope > :is(a, button)")).toHaveCount(width <= 760 ? 4 : 5);
|
||
await expect(page.locator(".sidebar")).toHaveCSS("position", width <= 760 ? "fixed" : "sticky");
|
||
if (width === 900)
|
||
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.width ?? 0).toBe(80);
|
||
if (width === 901)
|
||
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.width ?? 0).toBe(248);
|
||
if (width === 760) {
|
||
const boxes = await navigation.locator(":scope > :is(a, button)").evaluateAll((links) =>
|
||
links.map((link) => link.getBoundingClientRect().height));
|
||
expect(boxes.every((height) => height >= 44)).toBe(true);
|
||
}
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
}
|
||
|
||
for (const width of [800, 801, 1050, 1051, 1100, 1101]) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await page.goto("#/library/cached");
|
||
const row = page.locator(".download-row");
|
||
await expect(row.locator(".download-provider strong", { hasText: "Lumen Audio" })).toBeVisible();
|
||
await expect(row.getByRole("link", { name: "Download" })).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
}
|
||
});
|
||
|
||
test("Playlist columns and nested dialogs retain interaction ownership", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await mockApi(page);
|
||
await page.goto("#/library/playlists");
|
||
const opener = page.getByRole("button", { name: "Open Test playlist playlist details" });
|
||
await opener.click();
|
||
const playlist = page.getByRole("dialog", { name: "Test playlist" });
|
||
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
|
||
await playlist.getByRole("button", { name: /Choose track columns/ }).click();
|
||
const columns = page.locator(".track-column-picker");
|
||
await columns.getByLabel("Artist", { exact: true }).uncheck();
|
||
await expect(playlist.getByRole("columnheader", { name: "Artist" })).toHaveCount(0);
|
||
await page.keyboard.press("Escape");
|
||
|
||
const review = playlist.getByRole("button", { name: "Open mapping details for Test song" });
|
||
await review.click();
|
||
const match = page.getByRole("dialog", { name: "Test song" });
|
||
await expect(match).toBeVisible();
|
||
const layers = await page.evaluate(() => ({
|
||
parent: Number.parseInt(getComputedStyle(document.querySelector(".playlist-detail-dialog")!).zIndex),
|
||
nestedOverlay: Number.parseInt(getComputedStyle(document.querySelector(".match-dialog-overlay")!).zIndex),
|
||
nested: Number.parseInt(getComputedStyle(document.querySelector(".match-dialog")!).zIndex),
|
||
}));
|
||
expect(layers.parent).toBeLessThan(layers.nestedOverlay);
|
||
expect(layers.nestedOverlay).toBeLessThan(layers.nested);
|
||
await page.keyboard.press("Escape");
|
||
await expect(match).toBeHidden();
|
||
await expect(playlist).toBeVisible();
|
||
await expect(review).toBeFocused();
|
||
await playlist.getByRole("button", { name: "Close playlist details" }).click();
|
||
await expect(opener).toBeFocused();
|
||
});
|
||
|
||
test("Sidebar uses an integrated expander and deterministic slim breakpoint", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await page.goto("#/");
|
||
const shell = page.locator(".app-shell");
|
||
const expander = page.getByRole("button", { name: "Collapse sidebar" });
|
||
await expect(expander).toBeVisible();
|
||
const [expanderBox, brandBox] = await Promise.all([
|
||
expander.boundingBox(),
|
||
page.getByRole("link", { name: "Allstarr home" }).boundingBox(),
|
||
]);
|
||
expect(expanderBox!.y + expanderBox!.height).toBeLessThanOrEqual(brandBox!.y);
|
||
await expect(page.locator(".desktop-navigation").getByRole("link")).toHaveCount(5);
|
||
await expect(page.locator(".desktop-navigation").getByRole("link", { name: "Intelligence" })).toHaveCount(0);
|
||
const libraryIcon = page.locator(".desktop-navigation")
|
||
.getByRole("link", { name: "Library" }).locator("svg");
|
||
const expandedIcon = await libraryIcon.boundingBox();
|
||
const expandedMark = await expander.locator("svg").innerHTML();
|
||
await expander.click();
|
||
await expect(shell).toHaveClass(/slim/);
|
||
await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible();
|
||
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.width ?? 0).toBe(80);
|
||
const collapsedIcon = await libraryIcon.boundingBox();
|
||
expect(Math.abs((expandedIcon!.x + expandedIcon!.width / 2) -
|
||
(collapsedIcon!.x + collapsedIcon!.width / 2))).toBeLessThanOrEqual(0.5);
|
||
const collapsedMark = await page.getByRole("button", { name: "Expand sidebar" })
|
||
.locator("svg").innerHTML();
|
||
expect(collapsedMark).not.toBe(expandedMark);
|
||
const library = page.locator(".desktop-navigation").getByRole("link", { name: "Library" });
|
||
await expect.poll(async () => {
|
||
const [link, icon] = await Promise.all([library.boundingBox(), library.locator("svg").boundingBox()]);
|
||
return Math.abs((link!.y + link!.height / 2) - (icon!.y + icon!.height / 2));
|
||
}).toBeLessThan(0.5);
|
||
|
||
await page.setViewportSize({ width: 850, height: 800 });
|
||
await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible();
|
||
await expect.poll(async () => (await page.locator(".sidebar").boundingBox())?.width ?? 0).toBe(80);
|
||
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect(page.locator(".mobile-navigation").getByRole("link")).toHaveCount(3);
|
||
await expect(page.locator(".mobile-navigation > :is(a, button)")).toHaveCount(4);
|
||
await expect.poll(() => page.evaluate(() =>
|
||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
});
|
||
|
||
test("Intelligence keeps the most recently selected library when responses finish out of order", async ({ page }) => {
|
||
await mockApi(page);
|
||
const firstLibrary = routeRelease();
|
||
await page.route("**/api/admin/media-targets", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
targets: [{
|
||
id: "music-target", protocol: "jellyfin", backendInstanceId: "main",
|
||
libraryScopeId: "music", displayName: "Jellyfin Music",
|
||
}, {
|
||
id: "jazz-target", protocol: "jellyfin", backendInstanceId: "main",
|
||
libraryScopeId: "jazz", displayName: "Jellyfin Jazz",
|
||
}],
|
||
}),
|
||
}));
|
||
await page.route("**/api/admin/intelligence?*", async (route) => {
|
||
const library = new URL(route.request().url()).searchParams.get("libraryScopeId") ?? "music";
|
||
if (library === "music") await firstLibrary.promise;
|
||
const state = structuredClone(responses["/api/admin/intelligence"]) as Record<string, unknown>;
|
||
state.scope = { protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: library };
|
||
state.candidates = (state.candidates as Array<Record<string, unknown>>).map((candidate, index) =>
|
||
index === 0 ? { ...candidate, title: `${library} recommendation` } : candidate);
|
||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(state) });
|
||
});
|
||
|
||
await page.goto("#/intelligence?section=discover");
|
||
await page.getByRole("button", { name: "Music library" }).click();
|
||
await page.getByRole("option", { name: "Jazz · Jellyfin" }).click();
|
||
await expect(page.getByText("jazz recommendation", { exact: true })).toBeVisible();
|
||
firstLibrary.release();
|
||
await expect(page.getByText("jazz recommendation", { exact: true })).toBeVisible();
|
||
await expect(page.getByText("music recommendation", { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test("copying a listening-app key confirms the action", async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
configurable: true,
|
||
value: { writeText: async () => undefined },
|
||
});
|
||
});
|
||
await mockApi(page);
|
||
await page.goto("#/intelligence?section=automation");
|
||
const savePolicy = page.getByRole("button", { name: "Save settings" });
|
||
await expect(savePolicy).toBeDisabled();
|
||
const backgroundRefresh = page.waitForResponse((response) => {
|
||
const url = new URL(response.url());
|
||
return response.request().method() === "GET" && url.pathname === "/api/admin/intelligence";
|
||
});
|
||
await page.getByRole("button", { name: "How long to keep listening history" }).click();
|
||
await page.getByRole("option", { name: "90 days" }).click();
|
||
await expect(savePolicy).toBeEnabled();
|
||
await backgroundRefresh;
|
||
await expect(savePolicy).toBeEnabled();
|
||
const listeningApps = page.locator(".listening-apps-card");
|
||
await listeningApps.getByRole("button", { name: "Create private key" }).click();
|
||
await expect(listeningApps.getByRole("button", { name: "Copy" })).toBeVisible();
|
||
await listeningApps.getByRole("button", { name: "Copy" }).click();
|
||
await expect(listeningApps.getByRole("button", { name: "Copied" })).toBeVisible();
|
||
});
|
||
|
||
test("mapping and playlist review actions communicate work instead of silently ignoring input", async ({ page }) => {
|
||
await mockApi(page);
|
||
const mappingRematch = routeRelease();
|
||
await page.route("**/api/admin/track-matches/snapshot/rematch", async (route) => {
|
||
await mappingRematch.promise;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ rematched: true, state: "suggested" }),
|
||
});
|
||
});
|
||
await page.goto("#/library/mappings");
|
||
const mappingRow = page.locator(".mapping-row").first();
|
||
await mappingRow.getByRole("button", { name: "Rematch", exact: true }).click();
|
||
await expect(mappingRow.getByRole("button", { name: "Rematching…" })).toBeDisabled();
|
||
await expect(mappingRow.getByRole("button", { name: "Accept" })).toBeDisabled();
|
||
await expect(mappingRow.getByRole("button", { name: /More actions/ })).toBeDisabled();
|
||
mappingRematch.release();
|
||
await expect(page.getByText("Test song - Remix rematched.", { exact: true })).toBeVisible();
|
||
|
||
const matchDetails = routeRelease();
|
||
await page.route("**/api/admin/track-matches?*", async (route) => {
|
||
const snapshot = new URL(route.request().url()).searchParams.get("externalSnapshotId");
|
||
if (snapshot === "snapshot") await matchDetails.promise;
|
||
return route.fallback();
|
||
});
|
||
await page.goto("#/library/playlists");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const playlist = page.getByRole("dialog", { name: "Test playlist" });
|
||
await playlist.getByRole("button", { name: "Open mapping details for Test song" }).click();
|
||
await expect(playlist.getByRole("button", { name: "Loading mapping details for Test song" })).toBeDisabled();
|
||
await expect(playlist.getByText("Loading Test song…", { exact: true })).toBeVisible();
|
||
matchDetails.release();
|
||
await expect(page.getByRole("dialog", { name: "Test song" })).toBeVisible();
|
||
});
|
||
|
||
test("retry and refresh controls expose pending work", async ({ page }) => {
|
||
const failures = ["/api/admin/ui/activity"];
|
||
await mockApi(page, { fail: failures });
|
||
let delayedRequest: Promise<void> | null = null;
|
||
await page.route("**/api/admin/ui/activity?*", async (route) => {
|
||
if (delayedRequest) await delayedRequest;
|
||
return route.fallback();
|
||
});
|
||
await page.goto("#/activity");
|
||
await expect(page.getByRole("heading", { name: "Allstarr could not load durable activity." })).toBeVisible();
|
||
|
||
failures.length = 0;
|
||
const retry = routeRelease();
|
||
delayedRequest = retry.promise;
|
||
await page.getByRole("button", { name: "Try again" }).click();
|
||
await expect(page.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||
retry.release();
|
||
delayedRequest = null;
|
||
await expect(page.getByRole("heading", { name: "Event log" })).toBeVisible();
|
||
|
||
const refresh = routeRelease();
|
||
delayedRequest = refresh.promise;
|
||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||
await expect(page.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||
refresh.release();
|
||
delayedRequest = null;
|
||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled();
|
||
});
|
||
|
||
test("Source deep links are consumed once and account actions expose their pending state", async ({ page }) => {
|
||
await mockApi(page);
|
||
await page.goto("#/integrations/services?source=lumen-audio");
|
||
const sourceDetails = page.getByRole("dialog", { name: "Lumen Audio" });
|
||
await expect(sourceDetails).toBeVisible();
|
||
await sourceDetails.getByRole("button", { name: "Close Source details" }).click();
|
||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled();
|
||
await expect(sourceDetails).toBeHidden();
|
||
|
||
const toggleAccount = routeRelease();
|
||
const account = (responses["/api/admin/provider-accounts"] as { accounts: Array<Record<string, unknown>> }).accounts[0];
|
||
await page.route("**/api/admin/provider-accounts/account", async (route) => {
|
||
if (route.request().method() !== "PATCH") return route.fallback();
|
||
await toggleAccount.promise;
|
||
return route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ ...account, enabled: false, revision: 2 }),
|
||
});
|
||
});
|
||
await page.goto("#/integrations/accounts");
|
||
const actions = page.getByRole("button", { name: "Actions for Lumen account" });
|
||
await actions.click();
|
||
await page.getByRole("menuitem", { name: "Disable" }).click();
|
||
await expect(actions).toBeDisabled();
|
||
toggleAccount.release();
|
||
await expect(actions).toBeEnabled();
|
||
});
|
||
|
||
test("playlist projections, operations, and cancellation keep the selected playlist authoritative", async ({ page }) => {
|
||
await mockApi(page);
|
||
const list = structuredClone(responses["/api/admin/playlist-links"]) as {
|
||
playlistLinks: Array<Record<string, unknown>>;
|
||
inventory: Record<string, unknown>;
|
||
};
|
||
list.playlistLinks.push({ ...list.playlistLinks[0], id: "playlist-2", name: "Second playlist" });
|
||
await page.route("**/api/admin/playlist-links", (route) => {
|
||
if (route.request().method() !== "GET") return route.fallback();
|
||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(list) });
|
||
});
|
||
await page.route("**/api/admin/jobs/11111111-1111-1111-1111-111111111111/cancel", (route) => route.fulfill({
|
||
status: 503,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ error: "The worker could not be reached." }),
|
||
}));
|
||
await page.goto("#/library/playlists");
|
||
const detailsResponse = page.waitForResponse((response) =>
|
||
new URL(response.url()).pathname === "/api/admin/playlist-links/playlist-link" && response.request().method() === "GET");
|
||
await page.getByRole("button", { name: "Open Test playlist playlist details" }).click();
|
||
const fixture = await (await detailsResponse).json() as Record<string, unknown>;
|
||
const details = page.getByRole("dialog", { name: "Test playlist" });
|
||
|
||
await details.getByRole("button", { name: /Operation details:/ }).click();
|
||
await page.getByRole("button", { name: "Cancel operation" }).click();
|
||
const cancel = page.getByRole("alertdialog", { name: "Cancel this operation?" });
|
||
await cancel.getByRole("button", { name: "Cancel operation" }).click();
|
||
await expect(cancel.getByRole("alert")).toContainText("The worker could not be reached.");
|
||
await expect(cancel.getByRole("button", { name: "Cancel operation" })).toBeEnabled();
|
||
await cancel.getByRole("button", { name: "Keep running" }).click();
|
||
|
||
const projection = routeRelease();
|
||
await page.route("**/api/admin/playlist-links/playlist-link?projectionMode=source", async (route) => {
|
||
await projection.promise;
|
||
return route.fallback();
|
||
});
|
||
await details.getByRole("tab", { name: "Every song from Lumen Audio" }).click();
|
||
await details.getByRole("tab", { name: "Jellyfin playlist" }).click();
|
||
await expect(details.getByRole("tab", { name: "Jellyfin playlist" })).toHaveAttribute("aria-selected", "true");
|
||
await expect(details.getByText("target track", { exact: true })).toBeVisible();
|
||
projection.release();
|
||
await expect(details.getByRole("tab", { name: "Jellyfin playlist" })).toHaveAttribute("aria-selected", "true");
|
||
|
||
await details.getByRole("button", { name: "Actions" }).click();
|
||
await page.getByRole("menuitem", { name: "Update playlist now" }).click();
|
||
await expect(details.getByRole("button", { name: /Operation details:/ })).toBeVisible();
|
||
await details.getByRole("button", { name: "Close playlist details" }).click();
|
||
|
||
await page.route("**/api/admin/playlist-links/playlist-2?*", (route) => route.fulfill({
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ ...fixture, id: "playlist-2", name: "Second playlist" }),
|
||
}));
|
||
await page.getByRole("button", { name: "Open Second playlist playlist details" }).click();
|
||
const second = page.getByRole("dialog", { name: "Second playlist" });
|
||
await expect(second).toBeVisible();
|
||
await expect(second.getByRole("button", { name: /Operation details:/ })).toHaveCount(0);
|
||
});
|