Skip to content

Commit aa0af71

Browse files
Add CI workflows for Python and Chrome extension unit tests
- Introduced a new GitHub Actions workflow for Python unit tests, including setup for dependencies and test execution. - Added a separate workflow for Chrome extension unit tests using Vitest, with appropriate setup and test commands. - Updated the create-release workflow to exclude test files and unnecessary directories from the release packages. - Added configuration files for Vitest and created initial test cases for the Chrome extension. - Enhanced Docker ignore files to exclude test and cache directories for both API and worker services.
1 parent eaed9ca commit aa0af71

17 files changed

Lines changed: 699 additions & 9 deletions

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,55 @@ on:
99
branches: [main]
1010

1111
jobs:
12+
python-unit:
13+
runs-on: ubuntu-latest
14+
defaults:
15+
run:
16+
shell: bash
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup uv
22+
uses: astral-sh/setup-uv@v5
23+
24+
- name: Setup Python
25+
run: uv python install 3.11
26+
27+
- name: Install Python test dependencies
28+
run: |
29+
uv venv --python 3.11
30+
uv pip install pytest \
31+
-r video-downloader/docker/worker/requirements.txt \
32+
-r video-downloader/docker/api/requirements.txt
33+
34+
- name: Run Python unit tests (worker + api)
35+
run: |
36+
uv run pytest -q \
37+
video-downloader/docker/worker/tests \
38+
video-downloader/docker/api/tests
39+
40+
chrome-extension-unit:
41+
runs-on: ubuntu-latest
42+
defaults:
43+
run:
44+
shell: bash
45+
working-directory: chrome-extension
46+
steps:
47+
- name: Checkout
48+
uses: actions/checkout@v4
49+
50+
- name: Setup Node
51+
uses: actions/setup-node@v4
52+
with:
53+
node-version: '20'
54+
55+
- name: Install dependencies
56+
run: npm install
57+
58+
- name: Run unit tests
59+
run: npm test
60+
1261
api-smoke:
1362
runs-on: ubuntu-latest
1463
defaults:

.github/workflows/create-release.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,20 @@ jobs:
4545
- name: 'Create chrome-extension package'
4646
run: |
4747
cd chrome-extension
48-
zip -r ../WebVideo2NAS-chrome-extension.zip .
48+
zip -r ../WebVideo2NAS-chrome-extension.zip . \
49+
-x "tests/*" "tests/**" \
50+
"node_modules/*" "node_modules/**" \
51+
"vitest.config.js" \
52+
"package.json" "package-lock.json"
4953
5054
- name: 'Create docker package'
5155
run: |
5256
cd video-downloader
53-
zip -r ../WebVideo2NAS-downloader-docker.zip .
57+
zip -r ../WebVideo2NAS-downloader-docker.zip . \
58+
-x "docker/api/tests/*" "docker/api/tests/**" \
59+
"docker/worker/tests/*" "docker/worker/tests/**" \
60+
"**/__pycache__/**" "**/.pytest_cache/**" \
61+
"*.pyc" "*.pyo" "*.pyd"
5462
5563
- name: 'Create Release'
5664
uses: softprops/action-gh-release@v2

chrome-extension/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "webvideo2nas-chrome-extension",
3+
"private": true,
4+
"version": "0.0.0",
5+
"description": "Unit tests for WebVideo2NAS Chrome extension",
6+
"devDependencies": {
7+
"vitest": "^2.1.8",
8+
"jsdom": "^25.0.1"
9+
},
10+
"scripts": {
11+
"test": "vitest run",
12+
"test:watch": "vitest"
13+
}
14+
}

chrome-extension/sidepanel.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,8 @@ function extractQualitiesFromUrl(url) {
673673
const found = new Set();
674674

675675
// Common patterns: "...1080p...", "..._720p...", "/480p/", etc.
676-
const pMatches = lower.matchAll(/(?:^|[^0-9])([0-9]{3,4})p(?:$|[^a-z0-9])/g);
676+
// Use lookarounds so multiple adjacent matches (e.g. "...720p_1080p...") are not skipped.
677+
const pMatches = lower.matchAll(/(?<![0-9])([0-9]{3,4})p(?![a-z0-9])/g);
677678
for (const m of pMatches) {
678679
const n = Number(m[1]);
679680
if (allowed.has(n)) found.add(n);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { loadScriptIntoContext } from './helpers/load-script.js';
4+
5+
function makeChromeStub() {
6+
return {
7+
runtime: {
8+
sendMessage: () => {},
9+
lastError: null,
10+
onInstalled: { addListener: () => {} },
11+
onMessage: { addListener: () => {} },
12+
openOptionsPage: () => {},
13+
getManifest: () => ({ version: '0.0.0' }),
14+
},
15+
storage: {
16+
sync: {
17+
get: (_keys, cb) => cb({}),
18+
set: async () => {},
19+
},
20+
local: {
21+
set: async () => {},
22+
get: async () => ({}),
23+
},
24+
onChanged: { addListener: () => {} },
25+
},
26+
webRequest: {
27+
onBeforeRequest: { addListener: () => {} },
28+
onSendHeaders: { addListener: () => {} },
29+
},
30+
action: {
31+
setBadgeText: () => {},
32+
setBadgeBackgroundColor: () => {},
33+
onClicked: { addListener: () => {} },
34+
},
35+
tabs: {
36+
onRemoved: { addListener: () => {} },
37+
onUpdated: { addListener: () => {} },
38+
onActivated: { addListener: () => {} },
39+
query: (_q, cb) => cb([]),
40+
get: (_id, cb) => cb(null),
41+
},
42+
webNavigation: {
43+
onCommitted: { addListener: () => {} },
44+
},
45+
contextMenus: {
46+
create: () => {},
47+
onClicked: { addListener: () => {} },
48+
},
49+
notifications: {
50+
create: () => {},
51+
},
52+
sidePanel: {
53+
open: async () => {},
54+
},
55+
cookies: {
56+
getAll: async () => [],
57+
},
58+
};
59+
}
60+
61+
function withFixedNow(ctx, nowMs) {
62+
ctx.Date = class extends Date {
63+
static now() {
64+
return nowMs;
65+
}
66+
};
67+
}
68+
69+
describe('background.js pure helpers', () => {
70+
it('isCandidateVideoUrl accepts m3u8/mp4 and rejects obvious non-video', () => {
71+
const ctx = loadScriptIntoContext('background.js', {
72+
chrome: makeChromeStub(),
73+
fetch: async () => ({ ok: true, json: async () => ({}) }),
74+
});
75+
76+
expect(ctx.isCandidateVideoUrl('https://a/b/c.m3u8')).toBe(true);
77+
expect(ctx.isCandidateVideoUrl('https://a/b/c.mp4')).toBe(true);
78+
79+
// segments
80+
expect(ctx.isCandidateVideoUrl('https://a/b/seg0001.ts')).toBe(false);
81+
expect(ctx.isCandidateVideoUrl('https://a/b/seg0001.m4s')).toBe(false);
82+
83+
// false positives
84+
expect(ctx.isCandidateVideoUrl('https://a/b/preview_720p.mp4.jpg')).toBe(false);
85+
expect(ctx.isCandidateVideoUrl('https://a/b/playlist.m3u8.png')).toBe(false);
86+
expect(ctx.isCandidateVideoUrl('https://a/b/app.js?video=1.mp4')).toBe(false);
87+
});
88+
89+
it('scoreUrlInfo prefers recent + range hits + media type', () => {
90+
const ctx = loadScriptIntoContext('background.js', {
91+
chrome: makeChromeStub(),
92+
});
93+
94+
const now = 1_000_000;
95+
withFixedNow(ctx, now);
96+
97+
const base = {
98+
url: 'https://cdn.example.com/v/video.mp4',
99+
timestamp: now - 5_000,
100+
requestType: 'media',
101+
hitCount: 1,
102+
rangeHitCount: 0,
103+
};
104+
105+
const s1 = ctx.scoreUrlInfo(base);
106+
const s2 = ctx.scoreUrlInfo({ ...base, rangeHitCount: 1 });
107+
const s3 = ctx.scoreUrlInfo({ ...base, rangeHitCount: 1, hitCount: 10 });
108+
109+
expect(s2).toBeGreaterThan(s1);
110+
expect(s3).toBeGreaterThan(s2);
111+
});
112+
113+
it('getSortedUrlsForTab marks a clear winner as now playing', () => {
114+
const ctx = loadScriptIntoContext('background.js', {
115+
chrome: makeChromeStub(),
116+
});
117+
118+
const now = 2_000_000;
119+
withFixedNow(ctx, now);
120+
121+
const tabId = 123;
122+
ctx.__eval(`currentTabUrls[${tabId}] = ${JSON.stringify([
123+
{
124+
url: 'https://cdn.example.com/v/low.m3u8',
125+
timestamp: now - 60_000,
126+
requestType: 'xmlhttprequest',
127+
hitCount: 1,
128+
rangeHitCount: 0,
129+
},
130+
{
131+
url: 'https://cdn.example.com/v/high.mp4',
132+
timestamp: now - 2_000,
133+
requestType: 'media',
134+
hitCount: 3,
135+
rangeHitCount: 2,
136+
},
137+
])};`);
138+
139+
const sorted = ctx.getSortedUrlsForTab(tabId);
140+
expect(sorted[0].url).toContain('high.mp4');
141+
expect(sorted[0].isNowPlaying).toBe(true);
142+
});
143+
144+
it('safeOrigin returns null on invalid URL', () => {
145+
const ctx = loadScriptIntoContext('background.js', {
146+
chrome: makeChromeStub(),
147+
});
148+
149+
expect(ctx.safeOrigin('https://example.com/a')).toBe('https://example.com');
150+
expect(ctx.safeOrigin('not a url')).toBe(null);
151+
});
152+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import vm from 'node:vm';
4+
5+
export function loadScriptIntoContext(scriptPath, context = {}) {
6+
const abs = path.isAbsolute(scriptPath) ? scriptPath : path.resolve(process.cwd(), scriptPath);
7+
const code = fs.readFileSync(abs, 'utf-8');
8+
9+
const ctx = vm.createContext({
10+
console,
11+
URL,
12+
setTimeout,
13+
clearTimeout,
14+
// Default: disable intervals unless test explicitly wants them
15+
setInterval: () => 0,
16+
clearInterval: () => {},
17+
...context,
18+
});
19+
20+
// Execute as a script in the provided context.
21+
vm.runInContext(code, ctx, { filename: abs });
22+
23+
// Allow tests to mutate top-level `let` variables inside the context
24+
// (they are not exposed as properties on `ctx`).
25+
ctx.__eval = (js) => vm.runInContext(String(js), ctx);
26+
return ctx;
27+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { loadScriptIntoContext } from './helpers/load-script.js';
4+
5+
function makeChromeStub() {
6+
return {
7+
storage: {
8+
sync: { get: async () => ({}) },
9+
onChanged: { addListener: () => {} },
10+
},
11+
runtime: {
12+
onMessage: { addListener: () => {} },
13+
openOptionsPage: () => {},
14+
sendMessage: (_msg, cb) => cb && cb({ urls: [] }),
15+
lastError: null,
16+
},
17+
tabs: {
18+
query: (_q, cb) => cb([{ id: 1, title: 't', url: 'https://example.com' }]),
19+
onUpdated: { addListener: () => {} },
20+
onActivated: { addListener: () => {} },
21+
},
22+
};
23+
}
24+
25+
function makeDocumentStub() {
26+
return {
27+
addEventListener: () => {},
28+
getElementById: () => null,
29+
createElement: () => ({
30+
textContent: '',
31+
innerHTML: '',
32+
}),
33+
body: { appendChild: () => {} },
34+
};
35+
}
36+
37+
describe('sidepanel.js helper functions', () => {
38+
it('extractQualitiesFromUrl finds and sorts unique qualities', () => {
39+
const ctx = loadScriptIntoContext('sidepanel.js', {
40+
chrome: makeChromeStub(),
41+
document: makeDocumentStub(),
42+
navigator: { clipboard: { writeText: async () => {} } },
43+
fetch: async () => ({ ok: true, json: async () => ({}) }),
44+
window: {},
45+
});
46+
47+
const q = ctx.extractQualitiesFromUrl('https://a/b/video_720p_1080p.mp4?quality=720&res=2160');
48+
expect(q).toEqual(['2160p', '1080p', '720p']);
49+
expect(ctx.getMaxQualityNumber('https://a/b/480p/playlist.m3u8')).toBe(480);
50+
});
51+
52+
it('formatDuration outputs mm:ss or hh:mm:ss', () => {
53+
const ctx = loadScriptIntoContext('sidepanel.js', {
54+
chrome: makeChromeStub(),
55+
document: makeDocumentStub(),
56+
window: {},
57+
});
58+
59+
expect(ctx.formatDuration(59)).toBe('00:59');
60+
expect(ctx.formatDuration(61)).toBe('01:01');
61+
expect(ctx.formatDuration(3600)).toBe('01:00:00');
62+
});
63+
64+
it('containsIpAddress detects ip= query parameter', () => {
65+
const ctx = loadScriptIntoContext('sidepanel.js', {
66+
chrome: makeChromeStub(),
67+
document: makeDocumentStub(),
68+
window: {},
69+
});
70+
71+
expect(ctx.containsIpAddress('https://a/b/c?ip=114.24.18.78')).toBe(true);
72+
expect(ctx.containsIpAddress('https://a/b/114.24.18.78/video.mp4')).toBe(false);
73+
});
74+
75+
it('connectionReasonFromError maps AbortError to timeout key when i18n is missing', () => {
76+
const ctx = loadScriptIntoContext('sidepanel.js', {
77+
chrome: makeChromeStub(),
78+
document: makeDocumentStub(),
79+
window: {},
80+
});
81+
82+
const err = new Error('x');
83+
err.name = 'AbortError';
84+
expect(ctx.connectionReasonFromError(err)).toBe('error.timeout.type');
85+
});
86+
});

chrome-extension/vitest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
environment: 'jsdom',
6+
include: ['tests/**/*.test.js'],
7+
},
8+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Exclude tests and Python caches from image build context
2+
tests/
3+
__pycache__/
4+
.pytest_cache/
5+
*.pyc
6+
*.pyo
7+
*.pyd
8+
*.log
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import sys
2+
from pathlib import Path
3+
4+
# Allow importing api modules that use flat imports.
5+
API_DIR = Path(__file__).resolve().parents[1]
6+
if str(API_DIR) not in sys.path:
7+
sys.path.insert(0, str(API_DIR))

0 commit comments

Comments
 (0)