yt-grabber 1.8.7 → 1.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.eslintrc.json +29 -29
  2. package/.github/workflows/tests.yml +50 -0
  3. package/.vscode/tasks.json +13 -0
  4. package/README.md +3 -0
  5. package/eslint.config.ts +58 -3
  6. package/jest.config.ts +18 -2
  7. package/jest.setup.ts +10 -24
  8. package/package.json +2 -1
  9. package/src/automations/Helpers.test.ts +65 -0
  10. package/src/automations/Youtube.test.ts +232 -0
  11. package/src/automations/Youtube.ts +4 -6
  12. package/src/automations/YoutubeAlbums.test.ts +194 -0
  13. package/src/automations/YoutubeArtists.test.ts +231 -0
  14. package/src/automations/YoutubeArtists.ts +3 -3
  15. package/src/automations/YoutubeTracks.test.ts +195 -0
  16. package/src/bootstrap.test.tsx +56 -0
  17. package/src/common/CancellablePromise.test.ts +31 -0
  18. package/src/common/Delay.test.ts +32 -0
  19. package/src/common/FileSystem.test.ts +101 -0
  20. package/src/common/Formatters.test.ts +130 -0
  21. package/src/common/Helpers.test.ts +127 -0
  22. package/src/common/Helpers.ts +36 -0
  23. package/src/common/Logger.test.ts +74 -0
  24. package/src/common/Logger.ts +1 -1
  25. package/src/common/Promise.test.ts +53 -0
  26. package/src/common/PuppeteerOptions.test.ts +38 -0
  27. package/src/common/Reporter.test.ts +64 -0
  28. package/src/common/TestHelpers.ts +241 -0
  29. package/src/common/YtdplUtils.test.ts +280 -0
  30. package/src/common/YtdplUtils.ts +1 -1
  31. package/src/components/appBar/AppBar.test.tsx +106 -0
  32. package/src/components/fileField/FileField.test.tsx +93 -0
  33. package/src/components/languagePicker/LanguagePicker.test.tsx +67 -0
  34. package/src/components/languagePicker/LanguagePicker.tsx +7 -7
  35. package/src/components/modals/cutModal/CutModal.styl +21 -0
  36. package/src/components/modals/cutModal/CutModal.test.tsx +86 -0
  37. package/src/components/modals/cutModal/CutModal.tsx +203 -0
  38. package/src/components/modals/detailsModal/DetailsModal.test.tsx +55 -0
  39. package/src/components/modals/imageModal/ImageModal.test.tsx +37 -0
  40. package/src/components/modals/imageModal/ImageModal.tsx +1 -6
  41. package/src/components/modals/selectArtistModal/SelectArtistModal.test.tsx +56 -0
  42. package/src/components/numberField/NumberField.test.tsx +177 -0
  43. package/src/components/numberField/NumberField.tsx +1 -0
  44. package/src/components/themePicker/ThemePicker.test.tsx +51 -0
  45. package/src/components/themePicker/ThemePicker.tsx +3 -3
  46. package/src/components/youtube/formatSelector/FormatSelector.test.tsx +81 -0
  47. package/src/components/youtube/formatSelector/FormatSelector.tsx +3 -2
  48. package/src/components/youtube/infoBar/InfoBar.test.tsx +69 -0
  49. package/src/components/youtube/infoBar/InfoBar.tsx +79 -15
  50. package/src/components/youtube/inputModePicker/InputModePicker.test.tsx +95 -0
  51. package/src/components/youtube/inputModePicker/InputModePicker.tsx +8 -7
  52. package/src/components/youtube/inputPanel/InputPanel.test.tsx +176 -0
  53. package/src/components/youtube/inputPanel/InputPanel.tsx +17 -6
  54. package/src/components/youtube/logMenu/LogMenu.test.tsx +70 -0
  55. package/src/components/youtube/logMenu/LogMenu.tsx +2 -3
  56. package/src/components/youtube/mediaInfoPanel/MediaInfoPanel.styl +5 -0
  57. package/src/components/youtube/mediaInfoPanel/MediaInfoPanel.test.tsx +249 -0
  58. package/src/components/youtube/mediaInfoPanel/MediaInfoPanel.tsx +75 -9
  59. package/src/components/youtube/playlistTabs/PlaylistTabs.test.tsx +180 -0
  60. package/src/components/youtube/playlistTabs/PlaylistTabs.tsx +1 -0
  61. package/src/components/youtube/trackList/TrackList.test.tsx +143 -0
  62. package/src/components/youtube/trackList/TrackList.tsx +3 -23
  63. package/src/hooks/useCancellablePromises.test.ts +58 -0
  64. package/src/hooks/useClickCounter.test.ts +60 -0
  65. package/src/hooks/useClickCounter.ts +1 -1
  66. package/src/hooks/useHelp.test.ts +109 -0
  67. package/src/hooks/useMultiClickHandler.test.ts +133 -0
  68. package/src/hooks/useMultiClickHandler.ts +1 -1
  69. package/src/hooks/useWindowUpdater.test.ts +47 -0
  70. package/src/i18next.test.ts +2 -2
  71. package/src/index.ts +1 -1
  72. package/src/messaging/MessageBus.test.ts +27 -0
  73. package/src/messaging/MessageChannel.test.ts +137 -0
  74. package/src/messaging/MessagingService.test.ts +64 -0
  75. package/src/messaging/MessagingService.ts +3 -0
  76. package/src/messaging/MultiMessageChannel.test.ts +56 -0
  77. package/src/messaging/channels/SystemMessageChannel.test.ts +74 -0
  78. package/src/messaging/channels/SystemMessageChannel.ts +4 -3
  79. package/src/react/actions/AppActions.test.ts +22 -0
  80. package/src/react/contexts/AppContext.tsx +1 -1
  81. package/src/react/reducers/AppReducer.test.ts +42 -0
  82. package/src/react/reducers/Reducer.test.ts +22 -0
  83. package/src/react/states/AppState.test.ts +17 -0
  84. package/src/react/states/State.test.ts +53 -0
  85. package/src/renderer.test.tsx +88 -0
  86. package/src/resources/locales/de-DE/translation.json +3 -1
  87. package/src/resources/locales/en-GB/translation.json +3 -1
  88. package/src/resources/locales/pl-PL/translation.json +3 -1
  89. package/src/styles/MaterialThemes.test.ts +29 -0
  90. package/src/theme/ColorThemes.test.ts +84 -0
  91. package/src/theme/Theme.test.ts +39 -0
  92. package/src/views/development/DevelopmentView.test.tsx +157 -0
  93. package/src/views/home/HomeView.test.tsx +870 -0
  94. package/src/views/home/HomeView.tsx +6 -0
  95. package/src/views/settings/SettingsView.test.tsx +330 -0
  96. package/tests/RootAttributeRemover.tsx +39 -0
  97. package/tests/TestRenderer.tsx +8 -1
  98. package/tests/mocks/App.tsx +3 -0
  99. package/tests/mocks/automations/Helpers.ts +13 -0
  100. package/tests/mocks/child-process.ts +7 -0
  101. package/tests/mocks/common/CancellablePromise.ts +4 -0
  102. package/tests/mocks/common/Delay.ts +4 -0
  103. package/tests/mocks/common/FileSystem.ts +7 -0
  104. package/tests/mocks/common/Formatters.ts +10 -0
  105. package/tests/mocks/common/Helpers.ts +13 -0
  106. package/tests/mocks/common/Logger.ts +10 -0
  107. package/tests/mocks/common/Reporter.ts +23 -0
  108. package/tests/mocks/electron-store.ts +25 -0
  109. package/tests/mocks/electron.ts +29 -0
  110. package/tests/mocks/fs-extra.ts +22 -0
  111. package/tests/mocks/hooks/useCancellablePromises.ts +4 -0
  112. package/tests/mocks/hooks/useClickCounter.ts +9 -0
  113. package/tests/mocks/messaging/MessageBus.ts +10 -0
  114. package/tests/mocks/moment-duration-format.ts +3 -0
  115. package/tests/mocks/mui-material-styles.ts +12 -0
  116. package/tests/mocks/puppeteer-core.ts +7 -0
  117. package/tests/mocks/puppeteer-extra-plugin-stealth.ts +3 -0
  118. package/tests/mocks/puppeteer-extra.ts +10 -0
  119. package/tests/mocks/react/contexts/AppContext.tsx +32 -0
  120. package/tests/mocks/react/contexts/AppThemeContext.tsx +16 -0
  121. package/tests/mocks/react/contexts/DataContext.tsx +17 -0
  122. package/tests/mocks/react-dom-client.ts +10 -0
  123. package/tests/mocks/react-i18next.ts +17 -0
  124. package/tests/mocks/usehooks-ts.ts +10 -0
  125. package/tests/mocks/win-version-info.ts +3 -0
  126. package/tests/mocks/yt-dlp-wrap.ts +14 -0
  127. package/tsconfig.json +5 -2
  128. package/src/components/styledTextField/StyledTextField.tsx +0 -28
  129. package/src/react/hooks/useAppTheme.ts +0 -14
  130. package/tests/TestMultipleMock.ts +0 -91779
  131. package/tests/TestPlaylistMock.ts +0 -17384
  132. /package/tests/{FileMock.ts → mocks/FileMock.ts} +0 -0
package/.eslintrc.json CHANGED
@@ -1,29 +1,29 @@
1
- {
2
- "env": {
3
- "browser": true,
4
- "es2021": true
5
- },
6
- "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier"],
7
- "parser": "@typescript-eslint/parser",
8
- "parserOptions": {
9
- "ecmaVersion": "latest",
10
- "sourceType": "module"
11
- },
12
- "settings": {
13
- "react": {
14
- "version": "detect"
15
- }
16
- },
17
- "plugins": ["react", "@typescript-eslint"],
18
- "rules": {
19
- "indent": ["error", 4],
20
- "linebreak-style": ["error", "windows"],
21
- "quotes": ["error", "double"],
22
- "semi": ["error", "always"],
23
- "@typescript-eslint/no-var-requires": 0,
24
- "@typescript-eslint/no-explicit-any": 0,
25
- "@typescript-eslint/no-empty-interface": 0,
26
- "react/prop-types": 0,
27
- "react/display-name": 0
28
- }
29
- }
1
+ // {
2
+ // "env": {
3
+ // "browser": true,
4
+ // "es2021": true
5
+ // },
6
+ // "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier"],
7
+ // "parser": "@typescript-eslint/parser",
8
+ // "parserOptions": {
9
+ // "ecmaVersion": "latest",
10
+ // "sourceType": "module"
11
+ // },
12
+ // "settings": {
13
+ // "react": {
14
+ // "version": "detect"
15
+ // }
16
+ // },
17
+ // "plugins": ["react", "@typescript-eslint"],
18
+ // "rules": {
19
+ // "indent": ["error", 4],
20
+ // "linebreak-style": ["error", "windows"],
21
+ // "quotes": ["error", "double"],
22
+ // "semi": ["error", "always"],
23
+ // "@typescript-eslint/no-var-requires": 0,
24
+ // "@typescript-eslint/no-explicit-any": 0,
25
+ // "@typescript-eslint/no-empty-interface": 0,
26
+ // "react/prop-types": 0,
27
+ // "react/display-name": 0
28
+ // }
29
+ // }
@@ -0,0 +1,50 @@
1
+ name: Run Unit Tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - name: Checkout code
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Set up Node
16
+ uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+
20
+ - name: Enable Corepack (Yarn 4.x support)
21
+ run: corepack enable
22
+
23
+ - name: Prepare Yarn version
24
+ run: corepack prepare yarn@4.4.1 --activate
25
+
26
+ - name: Cache Yarn PnP
27
+ uses: actions/cache@v4
28
+ with:
29
+ path: .yarn/cache
30
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
31
+ restore-keys: |
32
+ ${{ runner.os }}-yarn-
33
+
34
+ - name: Install dependencies (Yarn PnP)
35
+ run: yarn install --immutable
36
+
37
+ - name: Run unit tests
38
+ run: yarn test --ci --coverage
39
+
40
+ - name: Upload coverage artifact
41
+ uses: actions/upload-artifact@v4
42
+ with:
43
+ name: coverage
44
+ path: coverage
45
+
46
+ - name: Upload coverage to Codecov
47
+ uses: codecov/codecov-action@v5
48
+ with:
49
+ token: 6353cc14-1536-4b0c-bf9c-a0aad51ceff5
50
+ files: ./coverage/lcov.info
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "type": "npm",
6
+ "script": "build",
7
+ "group": "build",
8
+ "problemMatcher": [],
9
+ "label": "npm: build",
10
+ "detail": "yarn clean && \"cross-env NODE_ENV=development yarn webpack-tsx\""
11
+ }
12
+ ]
13
+ }
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ---
4
4
 
5
+ ![Tests](https://github.com/karenpommeroy/yt-grabber/actions/workflows/tests.yml/badge.svg)
6
+ [![Coverage](https://codecov.io/gh/karenpommeroy/yt-grabber/branch/main/graph/badge.svg)](https://codecov.io/gh/karenpommeroy/yt-grabber)
7
+
5
8
  **YT Grabber** is a robust desktop application designed to retrieve multimedia from YouTube and YouTube Music services.
6
9
 
7
10
  It provides responsive UI to manage your downloads and automation features improve and accelerate download process.
package/eslint.config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import react from "eslint-plugin-react";
2
- import {defineConfig} from "eslint/config";
2
+ import {Config, defineConfig} from "eslint/config";
3
3
  import globals from "globals";
4
4
  import path from "node:path";
5
5
  import {fileURLToPath} from "node:url";
@@ -9,6 +9,10 @@ import js from "@eslint/js";
9
9
  import typescriptEslint from "@typescript-eslint/eslint-plugin";
10
10
  import tsParser from "@typescript-eslint/parser";
11
11
 
12
+ type EslintConfig = Config & {
13
+ extends?: any[];
14
+ };
15
+
12
16
  const __filename = fileURLToPath(import.meta.url);
13
17
  const __dirname = path.dirname(__filename);
14
18
  const compat = new FlatCompat({
@@ -17,7 +21,49 @@ const compat = new FlatCompat({
17
21
  allConfig: js.configs.all
18
22
  });
19
23
 
20
- export default defineConfig([{
24
+ const TestConfig: EslintConfig = {
25
+ extends: compat.extends(
26
+ // "eslint:recommended",
27
+ "plugin:react/recommended",
28
+ // "plugin:@typescript-eslint/recommended",
29
+ "prettier",
30
+ ),
31
+ plugins: {
32
+ react,
33
+ "@typescript-eslint": typescriptEslint as any,
34
+ },
35
+ languageOptions: {
36
+ globals: {
37
+ ...globals.browser,
38
+ },
39
+
40
+ parser: tsParser,
41
+ ecmaVersion: "latest",
42
+ sourceType: "module",
43
+ },
44
+ settings: {
45
+ react: {
46
+ version: "detect",
47
+ },
48
+ },
49
+ files: ["**/*.test.{ts,tsx,js,jsx}"],
50
+ rules: {
51
+ indent: ["warn", 4],
52
+ "linebreak-style": ["warn", "windows"],
53
+ quotes: ["warn", "double"],
54
+ semi: ["warn", "always"],
55
+ "@typescript-eslint/no-unused-vars": ["warn"],
56
+ "@typescript-eslint/no-var-requires": 0,
57
+ "@typescript-eslint/no-explicit-any": 0,
58
+ "@typescript-eslint/no-empty-interface": 0,
59
+ "react/prop-types": 0,
60
+ "react/display-name": 0,
61
+ "react/react-in-jsx-scope": 0,
62
+ "import/no-commonjs": "off",
63
+ },
64
+ };
65
+
66
+ const BaseConfig: EslintConfig = {
21
67
  extends: compat.extends(
22
68
  "eslint:recommended",
23
69
  "plugin:react/recommended",
@@ -46,6 +92,10 @@ export default defineConfig([{
46
92
  },
47
93
  },
48
94
 
95
+ files: [
96
+ "**/!(*.test).{ts,tsx,js,jsx}",
97
+ ],
98
+
49
99
  rules: {
50
100
  indent: ["warn", 4],
51
101
  "linebreak-style": ["warn", "windows"],
@@ -59,4 +109,9 @@ export default defineConfig([{
59
109
  "react/display-name": 0,
60
110
  "react/react-in-jsx-scope": 0,
61
111
  },
62
- }]);
112
+ };
113
+
114
+ export default defineConfig([
115
+ BaseConfig,
116
+ TestConfig
117
+ ]);
package/jest.config.ts CHANGED
@@ -11,14 +11,30 @@ const config: Config = {
11
11
  ],
12
12
  moduleNameMapper: {
13
13
  "\\.(css|less|scss|sass|styl)$": "identity-obj-proxy",
14
- "\\.(jpg|jpeg|png|gif|svg)$": "<rootDir>/tests/FileMock.ts",
15
- "^@tests/(.*)$": "<rootDir>/tests/$1"
14
+ "\\.(jpg|jpeg|png|gif|svg)$": "<rootDir>/tests/mocks/FileMock.ts",
15
+ "^@tests/(.*)$": "<rootDir>/tests/$1",
16
+ "^@app/(.*)$": "<rootDir>/src/$1",
16
17
  },
17
18
  setupFilesAfterEnv: [
18
19
  "@testing-library/jest-dom",
19
20
  "<rootDir>/jest.setup.ts",
20
21
  ],
21
22
  testPathIgnorePatterns: ["/node_modules/", "/dist/", "/out/", "/e2e/"],
23
+ reporters: [
24
+ "default",
25
+ ],
26
+ collectCoverage: true,
27
+ collectCoverageFrom: [
28
+ "src/**/*.{ts,tsx}",
29
+ "!src/**/*.d.ts",
30
+ "!src/**/index.ts",
31
+ ],
32
+ coverageDirectory: "<rootDir>/coverage",
33
+ coverageProvider: "v8",
34
+ coverageReporters: [
35
+ "json",
36
+ "lcov",
37
+ ],
22
38
  };
23
39
 
24
40
  export default config;
package/jest.setup.ts CHANGED
@@ -1,33 +1,19 @@
1
1
  import {TextDecoder, TextEncoder} from "util";
2
2
 
3
- global.TextEncoder = TextEncoder;
4
- global.TextDecoder = TextDecoder as typeof global.TextDecoder;
3
+ import electronMock from "@tests/mocks/electron";
4
+ import storeMock from "@tests/mocks/electron-store";
5
+ import reactI18nMock from "@tests/mocks/react-i18next";
5
6
 
6
- const mockStore = {
7
- get: jest.fn().mockReturnValue({language: "en"}),
8
- set: jest.fn(),
9
- delete: jest.fn(),
10
- clear: jest.fn(),
11
- has: jest.fn().mockReturnValue(true),
12
- onDidAnyChange: jest.fn().mockReturnValue(jest.fn()),
13
- onDidChange: jest.fn().mockReturnValue(jest.fn())
14
- };
7
+ global.TextEncoder = TextEncoder as typeof global.TextEncoder;
8
+ global.TextDecoder = TextDecoder as typeof global.TextDecoder;
15
9
 
16
10
  Object.defineProperty(process, "resourcesPath", {
17
- value: "/mocked/resources/path",
11
+ value: "./src/resources",
18
12
  writable: false,
19
13
  });
20
14
 
21
- jest.mock("electron", () => ({
22
- ipcRenderer: {
23
- send: jest.fn(),
24
- on: jest.fn(),
25
- off: jest.fn(),
26
- },
27
- }));
28
-
29
- jest.mock("electron-store", () => {
30
- return jest.fn(() => mockStore);
31
- });
15
+ jest.mock("react-i18next", () => reactI18nMock);
16
+ jest.mock("electron", () => electronMock);
17
+ jest.mock("electron-store", () => storeMock);
32
18
 
33
- (global as any).store = mockStore;
19
+ (global as any).store = storeMock();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-grabber",
3
- "version": "1.8.7",
3
+ "version": "1.8.9",
4
4
  "description": "Youtube Grabber - robust desktop application designed to retrieve multimedia from YouTube and YouTube Music services",
5
5
  "keywords": [
6
6
  "youtube",
@@ -171,6 +171,7 @@
171
171
  "identity-obj-proxy": "^3.0.0",
172
172
  "jest": "^30.2.0",
173
173
  "jest-environment-jsdom": "^30.2.0",
174
+ "jest-html-reporter": "^4.3.0",
174
175
  "jiti": "^2.6.1",
175
176
  "mini-css-extract-plugin": "^2.9.4",
176
177
  "prettier": "^3.6.2",
@@ -0,0 +1,65 @@
1
+ import fs from "fs-extra";
2
+
3
+ import {getProfilePath} from "../common/FileSystem";
4
+ import {waitFor} from "../common/Helpers";
5
+ import {createInput, createPage} from "../common/TestHelpers";
6
+ import {clearInput, navigateToPage, setCookies} from "./Helpers";
7
+
8
+ jest.mock("fs-extra", () => require("@tests/mocks/fs-extra"));
9
+ jest.mock("../common/FileSystem", () => require("@tests/mocks/common/FileSystem"));
10
+ jest.mock("../common/Helpers", () => require("@tests/mocks/common/Helpers"));
11
+
12
+ describe("automation helpers", () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ (getProfilePath as jest.Mock).mockReturnValue("/profile");
16
+ });
17
+
18
+ test("navigateToPage forwards url and timeout options", async () => {
19
+ const page = createPage();
20
+
21
+ await navigateToPage("https://example.com", page);
22
+
23
+ expect(page.goto).toHaveBeenCalledWith("https://example.com", {
24
+ waitUntil: ["networkidle0", "domcontentloaded", "load"],
25
+ timeout: 15000,
26
+ });
27
+ });
28
+
29
+ test("clearInput selects content and presses backspace", async () => {
30
+ const page = createPage();
31
+ const input = createInput();
32
+
33
+ await clearInput(input, page);
34
+
35
+ expect((input as any).click).toHaveBeenCalledWith({clickCount: 3});
36
+ expect(page.keyboard.press).toHaveBeenCalledWith("Backspace");
37
+ });
38
+
39
+ test("setCookies caches fresh cookies when cache empty", async () => {
40
+ (fs.readJSONSync as jest.Mock).mockReturnValue(undefined);
41
+ const pageCookies = [{name: "session", value: "abc"}];
42
+ const page = createPage();
43
+ page.cookies.mockResolvedValue(pageCookies);
44
+
45
+ await setCookies(page);
46
+
47
+ expect(waitFor).toHaveBeenCalledWith(3000);
48
+ expect(page.cookies).toHaveBeenCalled();
49
+ expect(fs.writeJSONSync).toHaveBeenCalledWith("/profile/cookies.json", pageCookies, {spaces: 2});
50
+ expect(page.setCookie).toHaveBeenCalledWith(...pageCookies);
51
+ });
52
+
53
+ test("setCookies reuses cached cookies when available", async () => {
54
+ const cachedCookies = [{name: "session", value: "cached"}];
55
+ (fs.readJSONSync as jest.Mock).mockReturnValue(cachedCookies);
56
+ const page = createPage();
57
+
58
+ await setCookies(page);
59
+
60
+ expect(waitFor).not.toHaveBeenCalled();
61
+ expect(page.cookies).not.toHaveBeenCalled();
62
+ expect(page.setCookie).toHaveBeenCalledWith(...cachedCookies);
63
+ expect(fs.writeJSONSync).not.toHaveBeenCalled();
64
+ });
65
+ });
@@ -0,0 +1,232 @@
1
+ import {TimeoutError} from "puppeteer-core";
2
+ import puppeteer from "puppeteer-extra";
3
+
4
+ import {UserAgent} from "../common/PuppeteerOptions";
5
+ import {Reporter} from "../common/Reporter";
6
+ import {
7
+ createBrowserMock, createElements, createI18n, createPageMock, createYoutubeParams
8
+ } from "../common/TestHelpers";
9
+ import {navigateToPage, setCookies} from "./Helpers";
10
+ import {
11
+ AlbumFilterSelector, AlbumLinkSelector, AlbumsDirectLinkSelector, AlbumsHrefSelector
12
+ } from "./Selectors";
13
+ import execute from "./Youtube";
14
+
15
+ jest.mock("./Helpers", () => require("@tests/mocks/automations/Helpers"));
16
+ jest.mock("puppeteer-core", () => require("@tests/mocks/puppeteer-core"));
17
+ jest.mock("puppeteer-extra", () => require("@tests/mocks/puppeteer-extra"));
18
+ jest.mock("puppeteer-extra-plugin-stealth", () => require("@tests/mocks/puppeteer-extra-plugin-stealth"));
19
+ jest.mock("../common/Reporter", () => require("@tests/mocks/common/Reporter"));
20
+
21
+ const navigateToPageMock = navigateToPage as jest.Mock;
22
+ const setCookiesMock = setCookies as jest.Mock;
23
+ const launchMock = puppeteer.launch as jest.Mock;
24
+ const ReporterMock = new Reporter(() => {});
25
+ const reporterFinishMock = ReporterMock.finish as jest.Mock;
26
+
27
+ describe("Youtube automation", () => {
28
+ beforeEach(() => {
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ test("collects album links via filtered listing", async () => {
33
+ const page = createPageMock();
34
+ page.waitForSelector.mockImplementation((selector: string) => {
35
+ if (selector.includes(AlbumsHrefSelector)) {
36
+ return Promise.resolve({evaluate: jest.fn().mockResolvedValue("channel/albums")});
37
+ }
38
+
39
+ if (selector.includes(AlbumFilterSelector)) {
40
+ return Promise.resolve({click: jest.fn()});
41
+ }
42
+
43
+ return Promise.reject(new Error(`Unexpected selector: ${selector}`));
44
+ });
45
+
46
+ page.$$eval.mockImplementation((selector: string, callback: (elements: Array<{getAttribute: () => string;}>) => string[]) => {
47
+ if (selector === "xpath/" + AlbumLinkSelector) {
48
+ return Promise.resolve(callback(createElements(["album/one", "album/two"])));
49
+ }
50
+
51
+ if (selector === "xpath/" + AlbumsDirectLinkSelector) {
52
+ return Promise.resolve(callback(createElements([])));
53
+ }
54
+ return Promise.reject(new Error(`Unexpected selector: ${selector}`));
55
+ });
56
+
57
+ const browser = createBrowserMock(page);
58
+ launchMock.mockResolvedValue(browser);
59
+ navigateToPageMock.mockResolvedValue(undefined);
60
+ setCookiesMock.mockResolvedValue(undefined);
61
+
62
+ await execute({
63
+ params: createYoutubeParams(),
64
+ options: {},
65
+ i18n: createI18n(),
66
+ onUpdate: jest.fn(),
67
+ signal: new AbortController().signal
68
+ });
69
+
70
+ expect(page.setUserAgent).toHaveBeenCalledWith(UserAgent);
71
+ expect(navigateToPageMock).toHaveBeenCalledWith("https://music.youtube.com", page);
72
+ expect(reporterFinishMock).toHaveBeenCalledWith("done", expect.objectContaining({
73
+ errors: [],
74
+ warnings: [],
75
+ values: [
76
+ "https://music.youtube.com/album/one",
77
+ "https://music.youtube.com/album/two",
78
+ ],
79
+ sources: ["https://music.youtube.com/channel/UC123"],
80
+ }));
81
+ expect(page.close).toHaveBeenCalledTimes(1);
82
+ expect(browser.close).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ test("falls back to direct album links when filters fail", async () => {
86
+ const page = createPageMock();
87
+ page.waitForSelector.mockRejectedValue(new Error("Not available"));
88
+ page.$$eval.mockImplementation((selector: string, callback: (elements: Array<{getAttribute: () => string;}>) => string[]) => {
89
+ if (selector === "xpath/" + AlbumsDirectLinkSelector) {
90
+ return Promise.resolve(callback(createElements(["direct/one"])));
91
+ }
92
+
93
+ return Promise.reject(new Error(`Unexpected selector ${selector}`));
94
+ });
95
+
96
+ const browser = createBrowserMock(page);
97
+ launchMock.mockResolvedValue(browser);
98
+ navigateToPageMock.mockResolvedValue(undefined);
99
+ setCookiesMock.mockResolvedValue(undefined);
100
+
101
+ await execute({
102
+ params: createYoutubeParams(),
103
+ options: {},
104
+ i18n: createI18n(),
105
+ onUpdate: jest.fn(),
106
+ signal: new AbortController().signal,
107
+ });
108
+
109
+ expect(page.$$eval).toHaveBeenCalledWith("xpath/" + AlbumsDirectLinkSelector, expect.any(Function));
110
+ expect(reporterFinishMock).toHaveBeenCalledWith("done", expect.objectContaining({
111
+ values: ["https://music.youtube.com/direct/one"],
112
+ errors: [],
113
+ warnings: [],
114
+ }));
115
+ });
116
+
117
+ test("reports timeout errors", async () => {
118
+ const page = createPageMock();
119
+ const browser = createBrowserMock(page);
120
+ launchMock.mockResolvedValue(browser);
121
+ setCookiesMock.mockResolvedValue(undefined);
122
+ navigateToPageMock.mockRejectedValue(new TimeoutError("timeout"));
123
+
124
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
125
+
126
+ await execute({
127
+ params: createYoutubeParams(),
128
+ options: {},
129
+ i18n: createI18n(),
130
+ onUpdate: jest.fn(),
131
+ signal: new AbortController().signal,
132
+ });
133
+
134
+ const [, result] = reporterFinishMock.mock.calls[0];
135
+
136
+ expect(result.errors).toEqual([
137
+ {
138
+ title: "exceptionTimeout",
139
+ description: "exceptionTimeoutText",
140
+ },
141
+ ]);
142
+ expect(result.warnings ?? []).toEqual([]);
143
+ expect(result.values ?? []).toEqual([]);
144
+ expect(page.close).toHaveBeenCalledTimes(1);
145
+ expect(browser.close).toHaveBeenCalledTimes(1);
146
+ consoleErrorSpy.mockRestore();
147
+ });
148
+
149
+ test("reports navigation detach as warning", async () => {
150
+ const page = createPageMock();
151
+ const browser = createBrowserMock(page);
152
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
153
+
154
+ launchMock.mockResolvedValue(browser);
155
+ setCookiesMock.mockResolvedValue(undefined);
156
+ navigateToPageMock.mockRejectedValue(new Error("Navigating frame was detached"));
157
+
158
+ await execute({
159
+ params: createYoutubeParams(),
160
+ options: {},
161
+ i18n: createI18n(),
162
+ onUpdate: jest.fn(),
163
+ signal: new AbortController().signal,
164
+ });
165
+
166
+ const [, result] = reporterFinishMock.mock.calls[0];
167
+
168
+ expect(result.errors ?? []).toEqual([]);
169
+ expect(result.warnings).toEqual([
170
+ {
171
+ title: "exceptionGetYoutubeUrls",
172
+ description: "exceptionGetYoutubeUrlsText",
173
+ },
174
+ ]);
175
+ expect(page.close).toHaveBeenCalledTimes(1);
176
+ expect(browser.close).toHaveBeenCalledTimes(1);
177
+ consoleErrorSpy.mockRestore();
178
+ });
179
+
180
+ test("reports generic errors", async () => {
181
+ const page = createPageMock();
182
+ const browser = createBrowserMock(page);
183
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
184
+
185
+ launchMock.mockResolvedValue(browser);
186
+ setCookiesMock.mockResolvedValue(undefined);
187
+ navigateToPageMock.mockRejectedValue(new Error("boom"));
188
+
189
+ await execute({
190
+ params: createYoutubeParams(),
191
+ options: {},
192
+ i18n: createI18n(),
193
+ onUpdate: jest.fn(),
194
+ signal: new AbortController().signal,
195
+ });
196
+
197
+ const [, result] = reporterFinishMock.mock.calls[0];
198
+
199
+ expect(result.errors).toEqual([
200
+ {
201
+ title: "exceptionGetYoutubeUrls",
202
+ description: "exceptionGetYoutubeUrlsText",
203
+ },
204
+ ]);
205
+ expect(result.warnings ?? []).toEqual([]);
206
+ expect(page.close).toHaveBeenCalledTimes(1);
207
+ expect(browser.close).toHaveBeenCalledTimes(1);
208
+ consoleErrorSpy.mockRestore();
209
+ });
210
+
211
+ test("cancels operation properly", async () => {
212
+ const page = createPageMock();
213
+ const abortController = new AbortController();
214
+
215
+ page.waitForSelector.mockImplementation(() => {
216
+ abortController.abort();
217
+ });
218
+
219
+ const browser = createBrowserMock(page);
220
+ launchMock.mockResolvedValue(browser);
221
+ navigateToPageMock.mockResolvedValue(undefined);
222
+ setCookiesMock.mockResolvedValue(undefined);
223
+
224
+ expect(execute({
225
+ params: createYoutubeParams(),
226
+ options: {},
227
+ i18n: createI18n(),
228
+ onUpdate: jest.fn(),
229
+ signal: abortController.signal,
230
+ })).rejects.toThrow("aborted");
231
+ });
232
+ });
@@ -1,5 +1,5 @@
1
1
  import {i18n as i18next} from "i18next";
2
- import {map, merge} from "lodash-es";
2
+ import {merge} from "lodash-es";
3
3
  import {Browser, LaunchOptions, Page, TimeoutError} from "puppeteer-core";
4
4
  import puppeteer from "puppeteer-extra";
5
5
  import StealthPlugin from "puppeteer-extra-plugin-stealth";
@@ -32,7 +32,7 @@ export const execute = async (parameters: MessageHandlerParams) => {
32
32
  abortPromise,
33
33
  ]);
34
34
  } catch (error: any) {
35
- const result: GetYoutubeResult = {errors: [], sources: params.values};
35
+ const result: GetYoutubeResult = {warnings: [], errors: [], sources: params.values};
36
36
  if (error.message === "aborted") {
37
37
  throw error;
38
38
  }
@@ -55,7 +55,6 @@ export const execute = async (parameters: MessageHandlerParams) => {
55
55
 
56
56
  const run = async (params: GetYoutubeParams, options: LaunchOptions, i18n: i18next, onUpdate: (data: ProgressInfo<GetYoutubeResult>) => void) => {
57
57
  const result: GetYoutubeResult = {warnings: [], errors: [], values: [], sources: params.values};
58
-
59
58
  await i18n.changeLanguage(params.lang);
60
59
 
61
60
  reporter = new Reporter(onUpdate);
@@ -81,8 +80,7 @@ const run = async (params: GetYoutubeParams, options: LaunchOptions, i18n: i18ne
81
80
 
82
81
  albumFilterButton.click();
83
82
  await page.waitForNetworkIdle();
84
-
85
- const items = await page.$$eval(`xpath/${AlbumLinkSelector}`, (elements: Element[]) => map(elements, (el) => el.getAttribute("href")));
83
+ const items = await page.$$eval(`xpath/${AlbumLinkSelector}`, (elements: Element[]) => elements.map((el) => el.getAttribute("href")));
86
84
 
87
85
  for (const item of items) {
88
86
  results.push(`${params.url}/${item}`);
@@ -90,7 +88,7 @@ const run = async (params: GetYoutubeParams, options: LaunchOptions, i18n: i18ne
90
88
 
91
89
  return results;
92
90
  } catch (error) {
93
- const items = await page.$$eval(`xpath/${AlbumsDirectLinkSelector}`, (elements: Element[]) => map(elements, (el) => el.getAttribute("href")));
91
+ const items = await page.$$eval(`xpath/${AlbumsDirectLinkSelector}`, (elements: Element[]) => elements.map((el) => el.getAttribute("href")));
94
92
 
95
93
  for (const item of items) {
96
94
  results.push(`${params.url}/${item}`);