import subprocess import textwrap import unittest from pathlib import Path class OperationsFrontendTests(unittest.TestCase): def test_local_duplicate_maintenance_scan_renders_summary_inline(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); const source = fs.readFileSync(sourcePath, "utf8"); function createButton(action) { return { handlers: {}, disabled: false, getAttribute(name) { if (name === "data-maintenance-action") { return action; } return null; }, addEventListener(type, handler) { this.handlers[type] = handler; }, }; } const statusNode = { textContent: "", className: "" }; const resultNode = { innerHTML: "" }; const scanButton = createButton("scan"); const dedupeButton = createButton("dedupe"); const panel = { attributes: { "data-maintenance-panel": "local-duplicates", "data-scan-api": "/api/maintenance/local-duplicates", "data-dedupe-api": "/api/maintenance/local-duplicates/dedupe", }, getAttribute(name) { return this.attributes[name] || ""; }, setAttribute(name, value) { this.attributes[name] = String(value); }, querySelector(selector) { if (selector === '[data-maintenance-action="scan"]') { return scanButton; } if (selector === '[data-maintenance-action="dedupe"]') { return dedupeButton; } if (selector === "[data-maintenance-status]") { return statusNode; } if (selector === "[data-maintenance-result]") { return resultNode; } return null; }, }; const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector(selector) { if (selector === '[data-maintenance-panel="local-duplicates"]') { return panel; } return null; }, querySelectorAll(selector) { if (selector === "form[data-json-form]") { return []; } return []; }, createElement() { return { click() {}, remove() {}, }; }, }; const windowObj = { Number, FormData() { return { forEach() {} }; }, setTimeout, clearTimeout, alert() {}, fetch(url, options) { if (url !== "/api/maintenance/local-duplicates") { throw new Error("unexpected fetch url: " + url); } if (options !== undefined) { throw new Error("scan should not send fetch options"); } return Promise.resolve({ ok: true, json() { return Promise.resolve({ summary: { duplicate_group_count: 2, duplicate_location_count: 3, scanned_active_local_location_count: 11, }, groups: [ { song_name: "Song A", backend_name: "default-local", keep: { locator: "Singer A/Song A.flac" }, duplicates: [{ locator: "Singer A/Song A (1).flac" }], }, ], }); }, }); }, EventSource: undefined, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); const clickHandler = scanButton.handlers.click; if (typeof clickHandler !== "function") { throw new Error("maintenance scan button was not bound"); } Promise.resolve() .then(() => clickHandler({ preventDefault() {} })) .then(() => new Promise((resolve) => setTimeout(resolve, 0))) .then(() => { if (statusNode.textContent.indexOf("2 groups") < 0) { throw new Error("unexpected maintenance status: " + statusNode.textContent); } if (resultNode.innerHTML.indexOf("Singer A/Song A (1).flac") < 0) { throw new Error("maintenance result did not render duplicate locator"); } process.exit(0); }) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_task_rows_refresh_interval_is_one_second_for_more_realtime_tree_updates(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { scheduleTaskRowsRefresh, dashboardState };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } let timeoutDelay = null; const body = { getAttribute(name) { if (name === "data-dashboard-api") { return "/api/dashboard"; } return ""; }, }; const document = { body, querySelector() { return null; }, querySelectorAll() { return []; }, }; const windowObj = { Number, clearTimeout() {}, setTimeout(fn, delay) { timeoutDelay = delay; return 1; }, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); const api = window.__catalogsyncTest; api.scheduleTaskRowsRefresh(); if (timeoutDelay !== 1000) { throw new Error("unexpected task rows refresh interval: " + timeoutDelay); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_force_refresh_keeps_existing_task_tree_content_until_new_payload_arrives(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { dashboardState, loadTaskDetail };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } let fetchResolve; const childrenNode = { innerHTML: "
OLD TREE
", textContent: "", hasAttribute(name) { return false; }, setAttribute(name, value) {}, removeAttribute(name) {}, getAttribute(name) { return null; }, querySelector(selector) { return null; }, querySelectorAll(selector) { return []; }, }; const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector(selector) { if (selector === '[data-task-children="13"]') { return childrenNode; } return null; }, querySelectorAll(selector) { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch(path) { return Promise.resolve({ ok: true, json() { return new Promise((resolve) => { fetchResolve = resolve; }); }, }); }, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; api.dashboardState.expandedTaskIds["13"] = true; api.dashboardState.detailCache["13"] = { job: { id: 13 }, playlist_progress: [] }; const promise = api.loadTaskDetail("13", true); if (childrenNode.innerHTML !== "
OLD TREE
") { throw new Error("existing task tree content was replaced during refresh: " + childrenNode.innerHTML); } Promise.resolve() .then(() => { if (typeof fetchResolve !== "function") { throw new Error("fetch was not triggered for force refresh"); } fetchResolve({ job: { id: 13 }, playlist_progress: [] }); return promise; }) .then(() => process.exit(0)) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_task_toggle_collapses_when_dom_is_visible_even_if_state_drifted(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { dashboardState, bindTaskCenter };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } let hidden = false; const toggleButton = { textContent: "-", attributes: { "data-task-toggle": "13", "aria-expanded": "true" }, getAttribute(name) { return this.attributes[name] || null; }, setAttribute(name, value) { this.attributes[name] = String(value); }, closest(selector) { return selector === "[data-task-toggle]" ? this : null; }, }; const childrenNode = { hasAttribute(name) { return name === "hidden" ? hidden : false; }, setAttribute(name, value) { if (name === "hidden") { hidden = true; } }, removeAttribute(name) { if (name === "hidden") { hidden = false; } }, }; const root = { dataset: {}, addEventListener(type, handler) { this.clickHandler = handler; }, }; const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector(selector) { if (selector === "[data-task-tree-root]") { return root; } if (selector === '[data-task-children="13"]') { return childrenNode; } if (selector === '[data-task-toggle="13"]') { return toggleButton; } return null; }, querySelectorAll(selector) { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() { throw new Error("fetch should not run when collapsing"); }, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; api.dashboardState.expandedTaskIds["13"] = false; api.bindTaskCenter(); root.clickHandler({ target: toggleButton }); if (!hidden) { throw new Error("expected visible task children to collapse"); } if (toggleButton.textContent !== "+") { throw new Error("expected collapse to reset toggle text"); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_task_toggle_supports_text_node_event_target(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { dashboardState, bindTaskCenter };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } let hidden = false; const toggleButton = { textContent: "-", attributes: { "data-task-toggle": "13", "aria-expanded": "true" }, getAttribute(name) { return this.attributes[name] || null; }, setAttribute(name, value) { this.attributes[name] = String(value); }, closest(selector) { return selector === "[data-task-toggle]" ? this : null; }, }; const textNode = { nodeType: 3, parentElement: toggleButton, }; const childrenNode = { hasAttribute(name) { return name === "hidden" ? hidden : false; }, setAttribute(name, value) { if (name === "hidden") { hidden = true; } }, removeAttribute(name) { if (name === "hidden") { hidden = false; } }, }; const root = { dataset: {}, addEventListener(type, handler) { this.clickHandler = handler; }, }; const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector(selector) { if (selector === '[data-task-tree-root="doing"]') { return root; } if (selector === "[data-task-tree-root]") { return root; } if (selector === '[data-task-children="13"]') { return childrenNode; } if (selector === '[data-task-toggle="13"]') { return toggleButton; } return null; }, querySelectorAll(selector) { if (selector === '[data-task-tree-root="doing"], [data-task-tree-root="done"], [data-task-tree-root]') { return [root]; } return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() { throw new Error("fetch should not run when collapsing"); }, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; api.dashboardState.expandedTaskIds["13"] = false; api.bindTaskCenter(); root.clickHandler({ target: textNode }); if (!hidden) { throw new Error("expected text-node click to collapse visible task children"); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_split_task_rows_separates_doing_and_done_statuses(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { splitTaskRows };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector(selector) { return null; }, querySelectorAll(selector) { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; if (typeof api.splitTaskRows !== "function") { throw new Error("splitTaskRows hook missing"); } const result = api.splitTaskRows([ { id: 1, status: "running" }, { id: 2, status: "queued" }, { id: 3, status: "paused" }, { id: 4, status: "completed", ended_at: "2026-04-18T10:00:00" }, { id: 5, status: "completed_with_errors", ended_at: "2026-04-18T12:00:00" }, { id: 6, status: "failed", ended_at: "2026-04-18T09:30:00" }, { id: 7, status: "canceled", ended_at: "2026-04-18T11:00:00" }, ]); const doingIds = (result.doing || []).map((row) => row.id).join(","); const doneIds = (result.done || []).map((row) => row.id).join(","); if (doingIds !== "1,2,3") { throw new Error("unexpected doing ids: " + doingIds); } if (doneIds !== "5,7,4,6") { throw new Error("unexpected done ids: " + doneIds); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_split_task_rows_limits_done_bucket_to_latest_ten(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { splitTaskRows };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } const body = { getAttribute(name) { return ""; }, }; const document = { body, querySelector() { return null; }, querySelectorAll() { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; const rows = []; for (let index = 1; index <= 12; index += 1) { rows.push({ id: index, status: "completed", ended_at: `2026-04-18T${String(index).padStart(2, "0")}:00:00`, }); } const doneIds = api.splitTaskRows(rows).done.map((row) => row.id).join(","); if (doneIds !== "12,11,10,9,8,7,6,5,4,3") { throw new Error("unexpected limited done ids: " + doneIds); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_render_song_tree_hides_ignored_non_music_resource_rows(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { renderSongTree };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } const body = { getAttribute() { return ""; }, }; const document = { body, querySelector() { return null; }, querySelectorAll() { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; const html = api.renderSongTree([ { song_name: "Ignored Non Music", status: "skipped", is_non_music_resource: true, singers: "Narrator", remote_song_id: "qqtop_75_x", }, { song_name: "Normal Running Song", status: "running", is_non_music_resource: false, singers: "Singer A", remote_song_id: "qq_1", }, ]); if (html.includes("Ignored Non Music")) { throw new Error("ignored non-music song should not be rendered in doing tree"); } if (!html.includes("Normal Running Song")) { throw new Error("normal running song should remain visible"); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_update_dashboard_refreshes_task_center_transfer_summary(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { updateDashboard };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose dashboard test hooks"); } const transferNode = { textContent: "before", }; const body = { getAttribute() { return ""; }, }; const document = { body, querySelector(selector) { if (selector === "[data-task-center-transfer]") { return transferNode; } return null; }, querySelectorAll() { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; api.updateDashboard({ transfer_stats: { download_speed_text: "2.0 MB/s", upload_speed_text: "-" } }); if (transferNode.textContent !== "Down 2.0 MB/s | Up -") { throw new Error("unexpected transfer summary: " + transferNode.textContent); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_playlist_export_yaml_serializer_includes_playlist_song_and_location_fields(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); let source = fs.readFileSync(sourcePath, "utf8"); source = source.replace( / bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/, " window.__catalogsyncTest = { serializePlaylistExportYaml };\n})();" ); if (!source.includes("__catalogsyncTest")) { throw new Error("failed to expose playlist export test hooks"); } const body = { getAttribute() { return ""; }, }; const document = { body, querySelector() { return null; }, querySelectorAll() { return []; }, }; const windowObj = { Number, setTimeout, clearTimeout, alert() {}, fetch() {}, }; global.window = windowObj; global.document = document; eval(source); const api = window.__catalogsyncTest; const yaml = api.serializePlaylistExportYaml({ playlist: { id: 88, name: "Playlist Export Ready", platform: "qq", play_count: 7654321 }, items: [ { song_id: 801, remote_song_id: "65800", platform: "netease", name: "Song Export Ready", singers: "Singer Export", ext: "flac", file_size_bytes: 1048576, local_file_path: "/music/qq/Singer Export/song-export-ready.flac", uploaded_locations: [ { backend_name: "catalog-cloud", url: "https://cdn.example.invalid/song-export-ready.flac" } ] } ] }); if (!yaml.includes("playlist_id: 88")) { throw new Error("playlist id missing from yaml: " + yaml); } if (!yaml.includes("platform: qq")) { throw new Error("platform missing from yaml: " + yaml); } if (!yaml.includes("play_count: 7654321")) { throw new Error("playlist play_count missing from yaml: " + yaml); } if (!yaml.includes("local_song_id: 801")) { throw new Error("local song id missing from yaml: " + yaml); } if (!yaml.includes("platform_song_id: 65800")) { throw new Error("platform song id missing from yaml: " + yaml); } if (!yaml.includes("platform: netease")) { throw new Error("song platform missing from yaml: " + yaml); } if (!yaml.includes("local_file_path: /music/qq/Singer Export/song-export-ready.flac")) { throw new Error("local file path missing from yaml: " + yaml); } if (!yaml.includes("uploaded_url: https://cdn.example.invalid/song-export-ready.flac")) { throw new Error("uploaded url missing from yaml: " + yaml); } process.exit(0); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_playlists_template_uses_export_selected_and_removes_sync_then_download_button(self): repo_root = Path(__file__).resolve().parents[2] html = (repo_root / "musicdl/catalogsync/templates/ops/playlists.html").read_text( encoding="utf-8" ) self.assertIn("Download Selected Playlists", html) self.assertNotIn("Sync Then Download", html) self.assertNotIn('data-playlist-action="sync-download"', html) self.assertIn("Export Selected", html) self.assertNotIn("Export Selected Playlists", html) self.assertIn("data-playlist-export", html) self.assertNotIn("Export Folder", html) def test_export_selected_action_uses_download_url_when_backend_returns_ready(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); const source = fs.readFileSync(sourcePath, "utf8"); let navigatedTo = ""; const exportButton = { handlers: {}, disabled: false, getAttribute(name) { if (name === "data-playlist-action") return "export-selected"; return null; }, addEventListener(type, handler) { this.handlers[type] = handler; }, }; const checkbox = { checked: true, value: "101", addEventListener() {} }; const selectionCount = { textContent: "" }; const root = { querySelectorAll(selector) { if (selector === "[data-playlist-checkbox]") return [checkbox]; if (selector === "[data-playlist-action]") return [exportButton]; return []; }, querySelector(selector) { if (selector === "[data-playlist-selection-count]") return selectionCount; return null; }, addEventListener() {}, }; const body = { getAttribute() { return ""; }, appendChild() {}, removeChild() {}, }; const document = { body, querySelector(selector) { if (selector === "[data-playlists-page]") return root; return null; }, querySelectorAll() { return []; }, createElement() { return { click() {}, remove() {} }; }, }; const windowObj = { Number, setTimeout(fn) { fn(); return 1; }, clearTimeout() {}, alert() {}, URL: { createObjectURL() { return "blob:test"; }, revokeObjectURL() {} }, Blob: function Blob() {}, location: { set href(value) { navigatedTo = value; }, get href() { return navigatedTo; }, }, fetch(url) { if (url !== "/api/playlists/export-zip") { throw new Error("unexpected url: " + url); } return Promise.resolve({ ok: true, json() { return Promise.resolve({ status: "ready", download_url: "/api/exports/bundles/token.zip", }); }, }); }, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); exportButton.handlers.click({}); Promise.resolve() .then(() => Promise.resolve()) .then(() => { if (navigatedTo !== "/api/exports/bundles/token.zip") { throw new Error("unexpected download target: " + navigatedTo); } process.exit(0); }) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_playlist_modal_export_click_starts_browser_zip_download(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); const source = fs.readFileSync(sourcePath, "utf8"); let navigatedTo = ""; const previewTrigger = { getAttribute(name) { const map = { "data-playlist-open-songs": "101", "data-playlist-name": "Playlist 101", "data-playlist-platform": "qq", "data-playlist-remote-id": "remote-101", }; return map[name] || ""; }, closest(selector) { return selector === "[data-playlist-open-songs]" ? this : null; }, }; const exportTrigger = { closest(selector) { return selector === "[data-playlist-export]" ? this : null; }, }; const exportButton = { disabled: true }; const modalNode = { removeAttribute() {}, setAttribute() {}, }; const modalTitleNode = { textContent: "" }; const modalMetaNode = { textContent: "", className: "" }; const modalStateNode = { textContent: "", className: "" }; const modalTableWrapNode = { removeAttribute() {}, setAttribute() {}, }; const modalSongsBodyNode = { innerHTML: "" }; const root = { clickHandler: null, querySelectorAll(selector) { if (selector === "[data-playlist-checkbox]") return []; if (selector === "[data-playlist-action]") return []; return []; }, querySelector(selector) { if (selector === "[data-playlist-selection-count]") return null; if (selector === "[data-playlist-songs-modal]") return modalNode; if (selector === "[data-playlist-modal-title]") return modalTitleNode; if (selector === "[data-playlist-modal-meta]") return modalMetaNode; if (selector === "[data-playlist-modal-state]") return modalStateNode; if (selector === "[data-playlist-modal-table-wrap]") return modalTableWrapNode; if (selector === "[data-playlist-songs-body]") return modalSongsBodyNode; if (selector === "[data-playlist-export]") return exportButton; return null; }, addEventListener(type, handler) { if (type === "click") { this.clickHandler = handler; } }, }; const body = { getAttribute() { return ""; }, appendChild() {}, removeChild() {}, }; const document = { body, querySelector(selector) { if (selector === "[data-playlists-page]") return root; return null; }, querySelectorAll() { return []; }, createElement() { return { click() {}, remove() {} }; }, }; const windowObj = { Number, setTimeout(fn) { fn(); return 1; }, clearTimeout() {}, alert() {}, location: { set href(value) { navigatedTo = value; }, get href() { return navigatedTo; }, }, fetch(url) { if (url !== "/api/playlists/101/songs") { throw new Error("unexpected url: " + url); } return Promise.resolve({ ok: true, json() { return Promise.resolve({ playlist: { id: 101, name: "Playlist 101", platform: "qq", remote_playlist_id: "remote-101", }, items: [ { song_id: 1, remote_song_id: "s1", platform: "qq", name: "Song 1", singers: "Singer 1", ext: "mp3", file_size_bytes: 128, local_file_path: "/music/qq/Singer 1/song-1.mp3", uploaded_locations: [], }, ], }); }, }); }, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); if (typeof root.clickHandler !== "function") { throw new Error("playlist page click handler was not bound"); } Promise.resolve() .then(() => root.clickHandler({ target: previewTrigger })) .then(() => Promise.resolve()) .then(() => { root.clickHandler({ target: exportTrigger }); if (navigatedTo !== "/api/playlists/101/export.zip") { throw new Error("unexpected single export target: " + navigatedTo); } process.exit(0); }) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_playlist_modal_export_shows_message_when_playlist_is_not_ready(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); const source = fs.readFileSync(sourcePath, "utf8"); let alertText = ""; let navigatedTo = ""; const previewTrigger = { getAttribute(name) { const map = { "data-playlist-open-songs": "101", "data-playlist-name": "Playlist 101", "data-playlist-platform": "qq", "data-playlist-remote-id": "remote-101", }; return map[name] || ""; }, closest(selector) { return selector === "[data-playlist-open-songs]" ? this : null; }, }; const exportTrigger = { closest(selector) { return selector === "[data-playlist-export]" ? this : null; }, }; const exportButton = { disabled: true }; const modalNode = { removeAttribute() {}, setAttribute() {}, }; const modalTitleNode = { textContent: "" }; const modalMetaNode = { textContent: "", className: "" }; const modalStateNode = { textContent: "", className: "" }; const modalTableWrapNode = { removeAttribute() {}, setAttribute() {}, }; const modalSongsBodyNode = { innerHTML: "" }; const root = { clickHandler: null, querySelectorAll(selector) { if (selector === "[data-playlist-checkbox]") return []; if (selector === "[data-playlist-action]") return []; return []; }, querySelector(selector) { if (selector === "[data-playlist-selection-count]") return null; if (selector === "[data-playlist-songs-modal]") return modalNode; if (selector === "[data-playlist-modal-title]") return modalTitleNode; if (selector === "[data-playlist-modal-meta]") return modalMetaNode; if (selector === "[data-playlist-modal-state]") return modalStateNode; if (selector === "[data-playlist-modal-table-wrap]") return modalTableWrapNode; if (selector === "[data-playlist-songs-body]") return modalSongsBodyNode; if (selector === "[data-playlist-export]") return exportButton; return null; }, addEventListener(type, handler) { if (type === "click") { this.clickHandler = handler; } }, }; const body = { getAttribute() { return ""; }, appendChild() {}, removeChild() {}, }; const document = { body, querySelector(selector) { if (selector === "[data-playlists-page]") return root; return null; }, querySelectorAll() { return []; }, createElement() { return { click() {}, remove() {} }; }, }; const windowObj = { Number, setTimeout(fn) { fn(); return 1; }, clearTimeout() {}, alert(message) { alertText = String(message || ""); }, location: { set href(value) { navigatedTo = value; }, get href() { return navigatedTo; }, }, fetch(url) { if (url !== "/api/playlists/101/songs") { throw new Error("unexpected url: " + url); } return Promise.resolve({ ok: true, json() { return Promise.resolve({ playlist: { id: 101, name: "Playlist 101", platform: "qq", remote_playlist_id: "remote-101", }, items: [ { song_id: 1, remote_song_id: "s1", platform: "qq", name: "Song 1", singers: "Singer 1", ext: "mp3", file_size_bytes: 128, local_file_path: null, uploaded_locations: [], }, ], }); }, }); }, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); Promise.resolve() .then(() => root.clickHandler({ target: previewTrigger })) .then(() => Promise.resolve()) .then(() => { root.clickHandler({ target: exportTrigger }); if (navigatedTo) { throw new Error("unexpected navigation: " + navigatedTo); } if (alertText.indexOf("not ready") < 0) { throw new Error("missing not-ready message: " + alertText); } process.exit(0); }) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) def test_download_selected_action_handles_split_download_jobs_without_redirecting_to_single_job(self): repo_root = Path(__file__).resolve().parents[2] script = textwrap.dedent( r""" const fs = require("fs"); const path = require("path"); const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js"); const source = fs.readFileSync(sourcePath, "utf8"); let alertText = ""; let reloaded = false; let navigatedTo = ""; const downloadButton = { handlers: {}, disabled: false, getAttribute(name) { if (name === "data-playlist-action") return "download"; return null; }, addEventListener(type, handler) { this.handlers[type] = handler; }, }; const checkbox = { checked: true, value: "101", addEventListener() {} }; const selectionCount = { textContent: "" }; const root = { querySelectorAll(selector) { if (selector === "[data-playlist-checkbox]") return [checkbox]; if (selector === "[data-playlist-action]") return [downloadButton]; return []; }, querySelector(selector) { if (selector === "[data-playlist-selection-count]") return selectionCount; return null; }, addEventListener() {}, }; const locationObj = { reload() { reloaded = true; }, set href(value) { navigatedTo = value; }, get href() { return navigatedTo; }, }; const body = { getAttribute() { return ""; }, appendChild() {}, removeChild() {}, }; const document = { body, querySelector(selector) { if (selector === "[data-playlists-page]") return root; return null; }, querySelectorAll() { return []; }, createElement() { return { click() {}, remove() {} }; }, }; const windowObj = { Number, setTimeout(fn) { fn(); return 1; }, clearTimeout() {}, alert(message) { alertText = String(message || ""); }, location: locationObj, fetch(url) { if (url !== "/api/playlists/download") { throw new Error("unexpected url: " + url); } return Promise.resolve({ ok: true, json() { return Promise.resolve({ download_job: { id: 11, playlist_scope: { playlist_ids: [101] } }, sync_download_job: { id: 12, playlist_scope: { playlist_ids: [202] } }, }); }, }); }, }; global.window = windowObj; global.document = document; global.setTimeout = windowObj.setTimeout; global.clearTimeout = windowObj.clearTimeout; eval(source); downloadButton.handlers.click({}); Promise.resolve() .then(() => Promise.resolve()) .then(() => { if (navigatedTo) { throw new Error("should not redirect to single job page: " + navigatedTo); } if (!reloaded) { throw new Error("expected page reload after split download jobs"); } if (alertText.indexOf("download job #11") < 0) { throw new Error("missing download job message: " + alertText); } if (alertText.indexOf("sync+download job #12") < 0) { throw new Error("missing sync+download job message: " + alertText); } process.exit(0); }) .catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); """ ) result = subprocess.run( ["node", "-e", script], cwd=repo_root, capture_output=True, text=True, check=False, ) self.assertEqual( 0, result.returncode, msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) if __name__ == "__main__": unittest.main()