import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; import { Drive115Adapter } from '../../../src/adapters/115/adapter'; import { Drive123Adapter } from '../../../src/adapters/123/adapter'; import { CMCCAdapter, md5 } from '../../../src/adapters/cmcc/adapter'; import { EsurfingAdapter } from '../../../src/adapters/esurfing/adapter'; import { XunleiAdapter } from '../../../src/adapters/xunlei/adapter'; import { WoozoooAdapter } from '../../../src/adapters/woozooo/adapter'; import { GuangyaPanAdapter } from '../../../src/adapters/guangyapan/adapter'; import { getDrive115PageScriptInjector } from '../../../src/adapters/115/page-script-injector'; import { getDrive123PageScriptInjector } from '../../../src/adapters/123/page-script-injector'; import { getCMCCPageScriptInjector } from '../../../src/adapters/cmcc/page-script-injector'; import { CMCC_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/cmcc/page-script'; import { getEsurfingPageScriptInjector } from '../../../src/adapters/esurfing/page-script-injector'; import { getXunleiPageScriptInjector } from '../../../src/adapters/xunlei/page-script-injector'; import { XUNLEI_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/xunlei/page-script'; import { getWoozoooPageScriptInjector } from '../../../src/adapters/woozooo/page-script-injector'; import { WOOZOOO_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/woozooo/page-script'; import { getGuangyaPanPageScriptInjector } from '../../../src/adapters/guangyapan/page-script-injector'; import { GUANGYAPAN_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/guangyapan/page-script'; import { WKBrowserAdapter } from '../../../src/adapters/wkbrowser/adapter'; import { getWKBrowserPageScriptInjector } from '../../../src/adapters/wkbrowser/page-script-injector'; import { WKBROWSER_PAGE_SCRIPT_OPTIONS } from '../../../src/adapters/wkbrowser/page-script'; vi.mock('../../../src/adapters/115/page-script-injector', () => ({ getDrive115PageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/123/page-script-injector', () => ({ getDrive123PageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/cmcc/page-script-injector', () => ({ getCMCCPageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/esurfing/page-script-injector', () => ({ getEsurfingPageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/xunlei/page-script-injector', () => ({ getXunleiPageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/woozooo/page-script-injector', () => ({ getWoozoooPageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/guangyapan/page-script-injector', () => ({ getGuangyaPanPageScriptInjector: vi.fn(), })); vi.mock('../../../src/adapters/wkbrowser/page-script-injector', () => ({ getWKBrowserPageScriptInjector: vi.fn(), })); describe('missing platform adapters', () => { let mock115CallAPI: Mock; let mock123CallAPI: Mock; let mockCMCCCallAPI: Mock; let mockEsurfingCallAPI: Mock; let mockXunleiCallAPI: Mock; let mockWoozoooCallAPI: Mock; let mockGuangyaPanCallAPI: Mock; let mockWKBrowserCallAPI: Mock; beforeEach(() => { mock115CallAPI = vi.fn(); mock123CallAPI = vi.fn(); mockCMCCCallAPI = vi.fn(); mockEsurfingCallAPI = vi.fn(); mockXunleiCallAPI = vi.fn(); mockWoozoooCallAPI = vi.fn(); mockGuangyaPanCallAPI = vi.fn(); mockWKBrowserCallAPI = vi.fn(); (globalThis as Record).chrome = { runtime: { sendMessage: vi.fn(() => Promise.resolve({ success: true })) }, }; vi.mocked(getDrive115PageScriptInjector).mockReturnValue({ callAPI: mock115CallAPI }); vi.mocked(getDrive123PageScriptInjector).mockReturnValue({ callAPI: mock123CallAPI }); vi.mocked(getCMCCPageScriptInjector).mockReturnValue({ callAPI: mockCMCCCallAPI }); vi.mocked(getEsurfingPageScriptInjector).mockReturnValue({ callAPI: mockEsurfingCallAPI }); vi.mocked(getXunleiPageScriptInjector).mockReturnValue({ callAPI: mockXunleiCallAPI }); vi.mocked(getWoozoooPageScriptInjector).mockReturnValue({ callAPI: mockWoozoooCallAPI }); vi.mocked(getGuangyaPanPageScriptInjector).mockReturnValue({ callAPI: mockGuangyaPanCallAPI }); vi.mocked(getWKBrowserPageScriptInjector).mockReturnValue({ callAPI: mockWKBrowserCallAPI }); Object.defineProperty(window, 'location', { value: { href: 'https://115.com/storage/netdisk?mode=wangpan&cid=folder115', search: '?mode=wangpan&cid=folder115', pathname: '/storage/netdisk', hash: '', }, writable: true, configurable: true, }); }); afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ''; }); it('uses 115 APIs for listing and batch rename', async () => { const adapter = new Drive115Adapter({ requestInterval: 0 }); mock115CallAPI .mockResolvedValueOnce({ state: true, count: 1, data: [{ fid: 'file-115', cid: 'folder115', n: 'Episode 01.mkv', size: 12, upt: 123 }], }) .mockResolvedValueOnce({ state: true }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('file-115', 'Episode 01-fixed.mkv'); expect(files).toEqual([ { id: 'file-115', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder115', size: 12, mtime: 123 }, ]); expect(rename).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(mock115CallAPI).toHaveBeenNthCalledWith( 1, 'GET', expect.stringContaining('https://webapi.115.com/files'), undefined, 30000 ); expect(mock115CallAPI.mock.calls[0][1]).toContain('cid=folder115'); expect(mock115CallAPI).toHaveBeenNthCalledWith( 2, 'POST', 'https://webapi.115.com/files/batch_rename', { bodyMode: 'form-data', entries: [ ['files_new_name[file-115]', 'Episode 01-fixed.mkv'], ['format', 'json'], ], }, 30000 ); }); it('uses 123Pan APIs for listing and rename', async () => { const adapter = new Drive123Adapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { search: '?homeFilePath=0,folder123', pathname: '/', hash: '' }, writable: true, configurable: true, }); mock123CallAPI .mockResolvedValueOnce({ data: { InfoList: [{ FileId: 'file-123', FileName: 'Episode 01.mkv', ParentFileId: 'folder123', Size: 34, UpdateAt: 456, type: 0 }], Next: '-1', Total: 1, Len: 1, }, }) .mockResolvedValueOnce({ code: 0, message: 'success' }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('file-123', 'Episode 01-fixed.mkv'); expect(files[0]).toEqual({ id: 'file-123', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder123', size: 34, mtime: 456 }); expect(rename).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(mock123CallAPI.mock.calls[0][1]).toContain('https://yun.123pan.cn/b/api/file/list/new'); expect(mock123CallAPI.mock.calls[0][1]).toContain('parentFileId=folder123'); expect(mock123CallAPI).toHaveBeenNthCalledWith( 2, 'POST', 'https://yun.123pan.cn/b/api/file/rename', expect.objectContaining({ fileId: 'file-123', fileName: 'Episode 01-fixed.mkv', duplicate: 1 }), 30000 ); }); it('does not duplicate CMCC explicit auth headers or capture encryption-triggering headers', () => { const headers = CMCC_PAGE_SCRIPT_OPTIONS.captureHeaders?.map((header) => header.toLowerCase()); expect(headers).not.toContain('authorization'); expect(headers).not.toContain('hcy-cool-flag'); }); it('parses CMCC list responses that arrive as JSON strings', async () => { const adapter = new CMCCAdapter({ requestInterval: 0 }); localStorage.setItem('currentCatalogID', 'folder139'); mockCMCCCallAPI.mockResolvedValue(JSON.stringify({ code: '0000', message: '请求成功', data: { items: [{ fileId: 'file-139', name: 'Episode 01.mkv', parentFileId: 'folder139', fileExtension: 'mkv', size: 56, updatedAt: 789, type: 'file' }], nextPageCursor: null, }, })); const files = await adapter.getAllFiles(); expect(files[0]).toEqual({ id: 'file-139', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder139', size: 56, mtime: 789 }); }); it('accepts CMCC rename responses with the real success code', async () => { const adapter = new CMCCAdapter({ requestInterval: 0 }); mockCMCCCallAPI.mockResolvedValue({ code: '0000', message: '请求成功' }); const result = await adapter.renameFile('file-139', 'Episode 01-fixed.mkv'); expect(result).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); }); it('uses CMCC APIs and deduplicates concurrent conflict checks', async () => { const adapter = new CMCCAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { search: '', pathname: '/w/', hash: '#/main' }, writable: true, configurable: true, }); localStorage.setItem('currentCatalogID', 'folder139'); mockCMCCCallAPI.mockResolvedValue({ data: { items: [{ fileId: 'file-139', name: 'Episode 01.mkv', parentFileId: 'folder139', fileExtension: 'mkv', size: 56, updatedAt: 789, type: 'file' }], nextPageCursor: null, }, }); const results = await Promise.all([ adapter.checkNameConflict('Episode 01.mkv', 'folder139'), adapter.checkNameConflict('Episode 02.mkv', 'folder139'), adapter.checkNameConflict('Episode 03.mkv', 'folder139'), ]); expect(results).toEqual([true, false, false]); expect(mockCMCCCallAPI).toHaveBeenCalledTimes(1); expect(mockCMCCCallAPI.mock.calls[0][1]).toBe('https://personal-kd-njs.yun.139.com/hcy/file/list'); }); it('captures Xunlei dynamic API headers from native page requests', () => { const headers = XUNLEI_PAGE_SCRIPT_OPTIONS.captureHeaders?.map((header) => header.toLowerCase()); expect(headers).toEqual(expect.arrayContaining([ 'authorization', 'x-device-id', 'x-client-id', 'x-captcha-token', 'content-type', ])); }); it('uses Woozooo APIs from the same-origin file iframe and preserves server-managed extensions', async () => { const adapter = new WoozoooAdapter({ requestInterval: 0 }); const iframe = document.createElement('iframe'); iframe.id = 'mainframe'; iframe.src = 'https://pc.woozooo.com/mydisk.php?item=files&action=index&u=484561'; document.body.appendChild(iframe); const frameDoc = iframe.contentDocument!; frameDoc.open(); frameDoc.write(` `); frameDoc.close(); mockWoozoooCallAPI .mockResolvedValueOnce({ zt: 1, info: 1, text: [{ id: '308207718', name: '未命名项目-图层 1.png', name_all: '未命名项目-图层 1.png', size: '246.6 K', time: '3 分钟前' }], }) .mockResolvedValueOnce({ zt: 1, info: 1, text: [{ id: '308207716', name: '数字键盘.jpg', name_all: '数字键盘.jpg', size: '68.2 K', time: '3 分钟前' }], }) .mockResolvedValueOnce({ zt: 1, info: 0, text: [] }) .mockResolvedValueOnce({ zt: 1, info: '新名称' }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('308207718', '新名称.png'); expect(files).toHaveLength(2); expect(files[0]).toMatchObject({ id: '308207718', name: '未命名项目-图层 1.png', ext: '.png', parentId: '8006795', size: 0 }); expect(files[1]).toMatchObject({ id: '308207716', name: '数字键盘.jpg', ext: '.jpg', parentId: '8006795', size: 0 }); expect(rename).toEqual({ success: true, newName: '新名称.png' }); expect(mockWoozoooCallAPI).toHaveBeenNthCalledWith( 1, 'POST', 'https://pc.woozooo.com/doupload.php?uid=484561', { bodyMode: 'urlencoded', entries: [ ['task', '5'], ['folder_id', '8006795'], ['pg', '1'], ['vei', 'B1VQUgNRAw9TBFdW'], ], }, 30000 ); expect(mockWoozoooCallAPI).toHaveBeenNthCalledWith( 4, 'POST', 'https://pc.woozooo.com/doupload.php', { bodyMode: 'urlencoded', entries: [ ['task', '46'], ['file_id', '308207718'], ['file_name', '新名称'], ['type', '2'], ], }, 30000 ); }); it('uses Xunlei APIs for listing and rename', async () => { const adapter = new XunleiAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.xunlei.com/?path=%2F%E6%88%91%E7%9A%84%E8%B5%84%E6%BA%90%2F%E5%AD%90%E7%9B%AE%E5%BD%95%2F%E5%BD%93%E5%89%8D%E7%9B%AE%E5%BD%95', search: '?path=%2F%E6%88%91%E7%9A%84%E8%B5%84%E6%BA%90%2F%E5%AD%90%E7%9B%AE%E5%BD%95%2F%E5%BD%93%E5%89%8D%E7%9B%AE%E5%BD%95', pathname: '/', hash: '', }, writable: true, configurable: true, }); localStorage.setItem('xlPanHomeRoutes', JSON.stringify({ '/我的资源/子目录/当前目录': '/root-xl/child-xl/folder-xl', })); mockXunleiCallAPI .mockResolvedValueOnce({ kind: 'drive#fileList', next_page_token: '', files: [{ kind: 'drive#file', id: 'file-xl', parent_id: 'folder-xl', name: 'Episode 01.mkv', file_extension: '.mkv', size: '12345', modified_time: '2026-08-15T09:00:00.000+08:00', }], }) .mockResolvedValueOnce({ id: 'file-xl', name: 'Episode 01-fixed.mkv' }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('file-xl', 'Episode 01-fixed.mkv'); expect(files[0]).toMatchObject({ id: 'file-xl', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder-xl', size: 12345 }); expect(rename).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(mockXunleiCallAPI.mock.calls[0][1]).toContain('https://api-pan.xunlei.com/drive/v1/files'); expect(mockXunleiCallAPI.mock.calls[0][1]).toContain('parent_id=folder-xl'); expect(mockXunleiCallAPI.mock.calls[0][1]).not.toContain('parent_id=root-xl%2Fchild-xl%2Ffolder-xl'); expect(mockXunleiCallAPI.mock.calls[0][1]).toContain('limit=50'); expect(mockXunleiCallAPI).toHaveBeenNthCalledWith( 2, 'PATCH', 'https://api-pan.xunlei.com/drive/v1/files/file-xl', { name: 'Episode 01-fixed.mkv' }, 30000 ); }); it('uses no special captured headers for Woozooo page-script requests', () => { expect(WOOZOOO_PAGE_SCRIPT_OPTIONS.captureHeaders ?? []).toEqual([]); }); it('throws instead of returning an empty list when Xunlei list API fails', async () => { const adapter = new XunleiAdapter({ requestInterval: 0 }); mockXunleiCallAPI.mockResolvedValue({ error: 'bad_request', error_description: 'list failed' }); await expect(adapter.getAllFiles()).rejects.toThrow('list failed'); }); it('uses Esurfing APIs for listing and form-encoded rename', async () => { const adapter = new EsurfingAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://cloud.189.cn/web/main/file/folder/folder189', search: '', pathname: '/web/main/file/folder/folder189', hash: '' }, writable: true, configurable: true, }); mockEsurfingCallAPI .mockResolvedValueOnce({ fileListAO: { fileList: [{ id: 'file-189', name: 'Episode 01.mkv', size: 78, lastOpTime: 987 }], }, }) .mockResolvedValueOnce({ res_code: 0 }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('file-189', 'Episode 01-fixed.mkv'); expect(files[0]).toEqual({ id: 'file-189', name: 'Episode 01.mkv', ext: '.mkv', parentId: 'folder189', size: 78, mtime: 987 }); expect(rename).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(mockEsurfingCallAPI.mock.calls[0][1]).toContain('https://cloud.189.cn/api/open/file/listFiles.action'); expect(mockEsurfingCallAPI.mock.calls[0][1]).toContain('folderId=folder189'); expect(mockEsurfingCallAPI).toHaveBeenNthCalledWith( 2, 'POST', 'https://cloud.189.cn/api/open/file/renameFile.action', { bodyMode: 'urlencoded', entries: [['fileId', 'file-189'], ['destFileName', 'Episode 01-fixed.mkv']], headers: { Accept: 'application/json;charset=UTF-8' }, }, 30000 ); expect(mockEsurfingCallAPI.mock.calls[0][2]).toEqual({ bodyMode: 'headers', headers: { Accept: 'application/json;charset=UTF-8', 'sign-type': '1', }, }); }); it('throws instead of returning an empty list when 123Pan list API fails', async () => { const adapter = new Drive123Adapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { search: '?homeFilePath=0,folder123', pathname: '/', hash: '' }, writable: true, configurable: true, }); mock123CallAPI.mockResolvedValue({ code: 500, message: 'list failed' }); await expect(adapter.getAllFiles()).rejects.toThrow('list failed'); }); it('throws instead of returning an empty list when CMCC list API fails', async () => { const adapter = new CMCCAdapter({ requestInterval: 0 }); localStorage.setItem('currentCatalogID', 'folder139'); mockCMCCCallAPI.mockResolvedValue({ code: 500, message: 'list failed' }); await expect(adapter.getAllFiles()).rejects.toThrow('list failed'); }); it('reports CMCC response shape when the list payload is missing', async () => { const adapter = new CMCCAdapter({ requestInterval: 0 }); localStorage.setItem('currentCatalogID', 'folder139'); mockCMCCCallAPI.mockResolvedValue({ code: 0, data: { catalogList: [] } }); await expect(adapter.getAllFiles()).rejects.toThrow('response keys: code,data; data keys: catalogList'); }); it('throws instead of returning an empty list when Esurfing list API fails', async () => { const adapter = new EsurfingAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://cloud.189.cn/web/main/file/folder/folder189', search: '', pathname: '/web/main/file/folder/folder189', hash: '' }, writable: true, configurable: true, }); mockEsurfingCallAPI.mockResolvedValue({ res_code: 401, res_message: 'list failed' }); await expect(adapter.getAllFiles()).rejects.toThrow('list failed'); }); it('keeps CMCC MD5 compatible with standard vectors', () => { expect(md5('')).toBe('d41d8cd98f00b204e9800998ecf8427e'); expect(md5('abc')).toBe('900150983cd24fb0d6963f7d28e17f72'); expect(md5('message digest')).toBe('f96b697d7cb7938d525a2f31aaf161d0'); expect(md5('中文')).toBe('a7bac2239fcdcb3a067903d8077c4a07'); }); it('patches 115 visible rows inside the file-list iframe after rename', async () => { document.body.innerHTML = ''; const frame = document.querySelector('iframe'); const frameDocument = frame?.contentDocument; expect(frameDocument).toBeTruthy(); frameDocument!.body.innerHTML = ` `; const adapter = new Drive115Adapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-115', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); const row = frameDocument!.querySelector('li[file_id="file-115"]'); const link = frameDocument!.querySelector('a.name'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(row?.getAttribute('title')).toBe('Episode 01-fixed.mkv'); expect(link?.getAttribute('title')).toBe('Episode 01-fixed.mkv'); expect(link?.textContent?.replace(/\s+/g, '').trim()).toBe('Episode01-fixed.mkv'); }); it('patches 115 split filename text nodes inside iframe rows', async () => { document.body.innerHTML = ''; const frameDocument = document.querySelector('iframe')?.contentDocument; expect(frameDocument).toBeTruthy(); frameDocument!.body.innerHTML = ` `; const adapter = new Drive115Adapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-115', oldName: '《啊哈!算法》.pdf', newName: 'test-《啊哈!算法》.pdf' }, ]); const link = frameDocument!.querySelector('a.name'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(link?.getAttribute('title')).toBe('test-《啊哈!算法》.pdf'); expect(link?.textContent?.replace(/\s+/g, '').trim()).toBe('test-《啊哈!算法》.pdf'); }); it('patches 123Pan visible rows with Ant Table filename classes after rename', async () => { document.body.innerHTML = `
Android-apk icon
test-WADBS_1.3.apk
`; const adapter = new Drive123Adapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-123', oldName: 'test-WADBS_1.3.apk', newName: 'WADBS_1.3.apk' }, ]); const nameNode = document.querySelector('.table-file-name'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(nameNode?.textContent).toBe('WADBS_1.3.apk'); }); it('patches Xunlei visible rows with SourceListItem filename classes after rename', async () => { document.body.innerHTML = ` `; const adapter = new XunleiAdapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-xl', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); const nameNode = document.querySelector('[class*="SourceListItem__name"]'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(nameNode?.textContent).toBe('Episode 01-fixed.mkv'); }); it('patches CMCC visible rows with document table filename classes after rename', async () => { document.body.innerHTML = `
未命名项目-图层 1.png
未命名项目-图层 1.png
今天 11:40
246.59KB
`; const adapter = new CMCCAdapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-139', oldName: '未命名项目-图层 1.png', newName: 'test-未命名项目-图层 1.png' }, ]); const nameNode = document.querySelector('.touch-div'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(nameNode?.textContent).toBe('test-未命名项目-图层 1.png'); }); it('disconnects older DOM sync observers before undo sync starts', async () => { document.body.innerHTML = '
'; const adapter = new CMCCAdapter({ requestInterval: 0 }); await adapter.syncAfterRename([ { fileId: 'file-139', oldName: '未命名项目-图层 1.png', newName: 'test-未命名项目-图层 1.png' }, ]); await adapter.syncAfterRename([ { fileId: 'file-139', oldName: 'test-未命名项目-图层 1.png', newName: '未命名项目-图层 1.png' }, ]); const observedNames: string[] = []; const observer = new MutationObserver(() => { const text = document.querySelector('.touch-div')?.textContent?.trim(); if (text) observedNames.push(text); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); document.querySelector('#file-list')!.insertAdjacentHTML('beforeend', `
未命名项目-图层 1.png
`); await new Promise((resolve) => window.setTimeout(resolve, 20)); observer.disconnect(); expect(observedNames).not.toContain('test-未命名项目-图层 1.png'); expect(document.querySelector('.touch-div')?.textContent).toBe('未命名项目-图层 1.png'); }); it('patches later 123Pan row mutations without rescanning the whole document', async () => { document.body.innerHTML = '
'; const querySelectorAll = vi.spyOn(document, 'querySelectorAll'); const adapter = new Drive123Adapter({ requestInterval: 0 }); await adapter.syncAfterRename([ { fileId: 'file-123', oldName: 'test-WADBS_1.3.apk', newName: 'WADBS_1.3.apk' }, ]); querySelectorAll.mockClear(); document.querySelector('#file-list')!.insertAdjacentHTML('beforeend', `
test-WADBS_1.3.apk
`); await new Promise((resolve) => window.setTimeout(resolve, 10)); expect(document.querySelector('.table-file-name')?.textContent).toBe('WADBS_1.3.apk'); expect(querySelectorAll).not.toHaveBeenCalled(); }); it('patches Woozooo iframe rows with f_name_title nodes after rename', async () => { const adapter = new WoozoooAdapter({ requestInterval: 0 }); const iframe = document.createElement('iframe'); iframe.id = 'mainframe'; document.body.appendChild(iframe); const frameDoc = iframe.contentDocument!; frameDoc.body.innerHTML = `
旧名称.png
`; const result = await adapter.syncAfterRename([{ fileId: '308207718', oldName: '旧名称.png', newName: '新名称.png' }]); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(frameDoc.querySelector('#filename308207718')?.textContent).toBe('新名称.png'); }); it('patches visible rows for new adapters instead of reporting unsupported sync', async () => { document.body.innerHTML = `
Episode 01.mkv
`; const adapter = new Drive115Adapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'file-115', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); const nameNode = document.querySelector('.file-name'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(nameNode?.textContent?.replace(/\s+/g, '').trim()).toBe('Episode01-fixed.mkv'); expect(nameNode?.getAttribute('title')).toBe('Episode 01-fixed.mkv'); }); // --- GuangyaPan adapter tests --- it('captures GuangyaPan dynamic API headers from native page XHR requests', () => { const headers = GUANGYAPAN_PAGE_SCRIPT_OPTIONS.captureHeaders?.map((header) => header.toLowerCase()); expect(headers).toEqual(expect.arrayContaining([ 'authorization', 'did', 'smid', 'dt', 'traceparent', 'content-type', ])); }); it('routes GuangyaPan API calls through the background proxy instead of page-script', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all' }, writable: true, configurable: true, }); localStorage.setItem('credentials_test', JSON.stringify({ access_token: 'gyp-token-abc' })); localStorage.setItem('swangpan_web_device_id', 'device-123'); localStorage.setItem('shumei_id:v1', 'smid-base64'); const sendMessageMock = vi.fn().mockResolvedValue({ success: true, data: { msg: 'success', data: { total: 1, list: [{ fileId: 'g-1', fileName: 'a.mp4', fileSize: 10, dirType: 1, ext: '.mp4', ctime: '', utime: '' }] } }, }); (chrome.runtime.sendMessage as ReturnType).mockImplementation(sendMessageMock); const files = await adapter.getAllFiles(); expect(files).toHaveLength(1); expect(files[0]).toMatchObject({ id: 'g-1', name: 'a.mp4' }); expect(sendMessageMock).toHaveBeenCalled(); const sentMessage = sendMessageMock.mock.calls[0][0]; expect(sentMessage.type).toBe('GUANGYAPAN_API_REQUEST'); expect(sentMessage.method).toBe('POST'); expect(sentMessage.url).toBe('https://api.guangyapan.com/userres/v1/file/get_file_list'); expect(sentMessage.headers).toMatchObject({ 'Authorization': 'Bearer gyp-token-abc', 'did': 'device-123', 'smid': 'smid-base64', 'dt': '4', 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json', }); expect(sentMessage.headers.traceparent).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/); }); it('rate limits GuangyaPan background API calls between paginated requests', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-15T00:00:00.000Z')); const adapter = new GuangyaPanAdapter({ requestInterval: 1000 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all' }, writable: true, configurable: true, }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock .mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [{ fileId: 'g-1', fileName: 'a.mp4', fileSize: 10, dirType: 1, ext: '.mp4', ctime: '', utime: '' }], }, }, }) .mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [{ fileId: 'g-2', fileName: 'b.mp4', fileSize: 20, dirType: 1, ext: '.mp4', ctime: '', utime: '' }], }, }, }); const filesPromise = adapter.getAllFiles(); await vi.advanceTimersByTimeAsync(0); expect(sendMessageMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(999); expect(sendMessageMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); await expect(filesPromise).resolves.toHaveLength(2); expect(sendMessageMock).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); it('uses XHR transport for GuangyaPan so request header casing is preserved', () => { expect(GUANGYAPAN_PAGE_SCRIPT_OPTIONS.transport).toBe('xhr'); }); it('uses GuangyaPan APIs for paginated listing and rename', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all', }, writable: true, configurable: true, }); localStorage.setItem('credentials_test', JSON.stringify({ access_token: 'tok' })); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock .mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [ { fileId: 'gyp-1', fileName: 'Episode 01.mkv', fileSize: 100, dirType: 1, mineType: 'video', ext: '.mkv', ctime: '2026-01-01', utime: '2026-01-02' }, { fileId: 'gyp-2', fileName: 'Episode 02.mkv', fileSize: 200, dirType: 1, mineType: 'video', ext: '.mkv', ctime: '2026-01-01', utime: '2026-01-03' }, ], }, }, }) .mockResolvedValueOnce({ success: true, data: { msg: 'success' } }); const files = await adapter.getAllFiles(); const rename = await adapter.renameFile('gyp-1', 'Episode 01-fixed.mkv'); expect(files).toHaveLength(2); expect(files[0]).toEqual({ id: 'gyp-1', name: 'Episode 01.mkv', ext: '.mkv', parentId: '', size: 100, mtime: expect.any(Number) }); expect(files[1]).toEqual({ id: 'gyp-2', name: 'Episode 02.mkv', ext: '.mkv', parentId: '', size: 200, mtime: expect.any(Number) }); expect(rename).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(sendMessageMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'GUANGYAPAN_API_REQUEST', method: 'POST', url: 'https://api.guangyapan.com/userres/v1/file/get_file_list', body: { pageSize: 50, orderBy: 3, sortType: 1, parentId: '', page: 0 }, })); expect(sendMessageMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: 'GUANGYAPAN_API_REQUEST', method: 'POST', url: 'https://api.guangyapan.com/userres/v1/file/rename', body: { fileId: 'gyp-1', newName: 'Episode 01-fixed.mkv' }, })); }); it('paginates GuangyaPan listing until all items are loaded', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all' }, writable: true, configurable: true, }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock .mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [{ fileId: 'g-1', fileName: 'a.mp4', fileSize: 10, dirType: 1, ext: '.mp4', ctime: '', utime: '' }], }, }, }) .mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [{ fileId: 'g-2', fileName: 'b.mp4', fileSize: 20, dirType: 1, ext: '.mp4', ctime: '', utime: '' }], }, }, }); const files = await adapter.getAllFiles(); expect(files).toHaveLength(2); expect(sendMessageMock).toHaveBeenCalledTimes(2); expect(sendMessageMock.mock.calls[0][0].body).toMatchObject({ page: 0 }); expect(sendMessageMock.mock.calls[1][0].body).toMatchObject({ page: 1 }); }); it('parses GuangyaPan hash route to get current folder ID', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all/12345-some-folder/67890-subfolder', search: '', pathname: '/', hash: '#/home/all/12345-some-folder/67890-subfolder', }, writable: true, configurable: true, }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 0, list: [] } }, }); await adapter.getAllFiles(); expect(sendMessageMock).toHaveBeenCalledWith(expect.objectContaining({ body: expect.objectContaining({ parentId: '67890' }), })); }); it('parses GuangyaPan file IDs correctly when names contain hyphens', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all/12345-ep-01-mkv', search: '', pathname: '/', hash: '#/home/all/12345-ep-01-mkv', }, writable: true, configurable: true, }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 0, list: [] } }, }); await adapter.getAllFiles(); expect(sendMessageMock).toHaveBeenCalledWith(expect.objectContaining({ body: expect.objectContaining({ parentId: '12345' }), })); }); it('throws GuangyaPan background status 0 instead of unknown when the proxy fails without an error message', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValueOnce({ success: false, status: 0 }); await expect(adapter.getAllFiles()).rejects.toThrow('GuangyaPan API request failed with status 0'); }); it('throws instead of returning an empty list when GuangyaPan list API fails', async () => { const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValue({ success: true, data: { msg: 'error', data: null }, }); await expect(adapter.getAllFiles()).rejects.toThrow(); }); it('patches GuangyaPan visible rows with swangpan filename classes after rename', async () => { document.body.innerHTML = `
Episode 01.mkv
`; const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: 'gyp-1', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); const labelNode = document.querySelector('.swangpan-file-list-table__label'); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); expect(labelNode?.textContent).toBe('Episode 01-fixed.mkv'); expect(labelNode?.getAttribute('title')).toBe('Episode 01-fixed.mkv'); }); it('patches GuangyaPan rows inserted by virtual-list rerender after syncAfterRename', async () => { document.body.innerHTML = `
Episode 01.mkv
`; const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); await adapter.syncAfterRename([ { fileId: 'gyp-1', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); // Verify the initial synchronous patch worked const label = document.querySelector('.swangpan-file-list-table__label')!; expect(label.textContent).toBe('Episode 01-fixed.mkv'); expect(label.getAttribute('title')).toBe('Episode 01-fixed.mkv'); // Simulate React re-render replacing the row with the old filename // This exercises the MutationObserver path where the body is the mutation // root and the new row+label are descendant nodes. document.querySelector('.swangpan-file-list-table__body')!.innerHTML = `
Episode 01.mkv
`; await new Promise((resolve) => window.setTimeout(resolve, 20)); const newLabel = document.querySelector('.swangpan-file-list-table__label')!; expect(newLabel.textContent).toBe('Episode 01-fixed.mkv'); expect(newLabel.getAttribute('title')).toBe('Episode 01-fixed.mkv'); }); it('patches GuangyaPan label when mutation target is the label itself (characterData path)', async () => { // Set up a row with an empty label, install observer, then set textContent // to trigger a characterData mutation where the target is a text node // inside the label. The observer's getMutationPatchRoots must resolve // the label's parent .swangpan-file-list-table__row as the root so the // label is found via querySelectorAll on the root. document.body.innerHTML = `
`; const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); await adapter.syncAfterRename([ { fileId: 'gyp-1', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); const label = document.querySelector('.swangpan-file-list-table__label')!; // Trigger a textContent change on the label label.textContent = 'Episode 01.mkv'; await new Promise((resolve) => window.setTimeout(resolve, 20)); // The label should be patched by the observer expect(label.textContent).toBe('Episode 01-fixed.mkv'); expect(label.getAttribute('title')).toBe('Episode 01-fixed.mkv'); }); it('maps GuangyaPan selected DOM rows to getAllFiles results by fileName', async () => { Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all', }, writable: true, configurable: true, }); const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); document.body.innerHTML = `
Episode 01.mkv
Episode 02.mkv
`; const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [ { fileId: 'gyp-1', fileName: 'Episode 01.mkv', fileSize: 100, dirType: 1, ext: '.mkv', ctime: '', utime: '' }, { fileId: 'gyp-2', fileName: 'Episode 02.mkv', fileSize: 200, dirType: 1, ext: '.mkv', ctime: '', utime: '' }, ], }, }, }); const selected = await adapter.getSelectedFiles(); expect(selected).toHaveLength(1); expect(selected[0]).toMatchObject({ id: 'gyp-1', name: 'Episode 01.mkv' }); }); it('maps GuangyaPan selected rows using real site data-state="selected" and checked checkbox input', async () => { Object.defineProperty(window, 'location', { value: { href: 'https://www.guangyapan.com/#/home/all', search: '', pathname: '/', hash: '#/home/all', }, writable: true, configurable: true, }); const adapter = new GuangyaPanAdapter({ requestInterval: 0 }); // Simulates the real GuangyaPan DOM observed in Chrome DevTools: // - Row: data-state="selected" (not a CSS class modifier) // - Checkbox: input.swangpan-checkbox__input with checked=true, aria-checked="true" // - Label root: label.swangpan-checkbox__root with data-state="checked" document.body.innerHTML = `
\u672a\u547d\u540d\u9879\u76ee-\u56fe\u5c42 1.png
Episode 02.mkv
`; const sendMessageMock = (chrome.runtime.sendMessage as ReturnType); sendMessageMock.mockResolvedValueOnce({ success: true, data: { msg: 'success', data: { total: 2, list: [ { fileId: 'gyp-1', fileName: '\u672a\u547d\u540d\u9879\u76ee-\u56fe\u5c42 1.png', fileSize: 100, dirType: 1, ext: '.png', ctime: '', utime: '' }, { fileId: 'gyp-2', fileName: 'Episode 02.mkv', fileSize: 200, dirType: 1, ext: '.mkv', ctime: '', utime: '' }, ], }, }, }); const selected = await adapter.getSelectedFiles(); expect(selected).toHaveLength(1); expect(selected[0]).toMatchObject({ id: 'gyp-1', name: '\u672a\u547d\u540d\u9879\u76ee-\u56fe\u5c42 1.png' }); }); // --- WKBrowser adapter tests --- it('uses WKBrowser APIs for paginated listing with filter_file', async () => { const adapter = new WKBrowserAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.wkbrowser.com/main?category=all', search: '?category=all', pathname: '/main', hash: '' }, writable: true, configurable: true, }); mockWKBrowserCallAPI .mockResolvedValueOnce({ code: 0, message: 'success', data: { file_list: [ { file_id: 101, file_name: 'Episode 01.mkv', father_id: 0, extension: 'mkv', is_directory: 0, file_type: 2000, size: 100, updated_at: 1700000000, created_at: 1700000000 }, { file_id: 102, file_name: 'Folder1', father_id: 0, extension: '', is_directory: 1, file_type: 0, size: 0, updated_at: 1700000000, created_at: 1700000000 }, ], has_more: 0, }, }); const files = await adapter.getAllFiles(); // Should skip directories (is_directory: 1) expect(files).toHaveLength(1); expect(files[0]).toEqual({ id: '101', name: 'Episode 01.mkv', ext: '.mkv', parentId: '0', size: 100, mtime: 1700000000000, // Unix seconds -> ms }); expect(mockWKBrowserCallAPI).toHaveBeenNthCalledWith( 1, 'POST', expect.stringContaining('https://api.wkbrowser.com/netdisk/user_file/filter_file?offset=0&limit=20&'), { father_id: 0, filter_type: 2, is_desc: 1, file_type: 0 }, 30000 ); }); it('paginates WKBrowser listing until has_more is false', async () => { const adapter = new WKBrowserAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.wkbrowser.com/main?category=all', search: '?category=all', pathname: '/main', hash: '' }, writable: true, configurable: true, }); mockWKBrowserCallAPI .mockResolvedValueOnce({ code: 0, message: 'success', data: { file_list: [ { file_id: 1, file_name: 'a.mp4', father_id: 0, extension: 'mp4', is_directory: 0, file_type: 2000, size: 10, updated_at: 1700000000, created_at: 1700000000 }, { file_id: 2, file_name: 'b.mp4', father_id: 0, extension: 'mp4', is_directory: 0, file_type: 2000, size: 20, updated_at: 1700000000, created_at: 1700000000 }, ], has_more: 1, }, }) .mockResolvedValueOnce({ code: 0, message: 'success', data: { file_list: [ { file_id: 3, file_name: 'c.mp4', father_id: 0, extension: 'mp4', is_directory: 0, file_type: 2000, size: 30, updated_at: 1700000000, created_at: 1700000000 }, ], has_more: 0, }, }); const files = await adapter.getAllFiles(); expect(files).toHaveLength(3); expect(mockWKBrowserCallAPI).toHaveBeenCalledTimes(2); expect(mockWKBrowserCallAPI.mock.calls[0][1]).toContain('offset=0'); expect(mockWKBrowserCallAPI.mock.calls[1][1]).toContain('offset=2'); }); it('uses WKBrowser rename endpoint with correct body', async () => { const adapter = new WKBrowserAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.wkbrowser.com/main?category=all', search: '?category=all', pathname: '/main', hash: '' }, writable: true, configurable: true, }); mockWKBrowserCallAPI.mockResolvedValueOnce({ code: 0, message: 'success' }); const result = await adapter.renameFile('101', 'Episode 01-fixed.mkv'); expect(result).toEqual({ success: true, newName: 'Episode 01-fixed.mkv' }); expect(mockWKBrowserCallAPI).toHaveBeenNthCalledWith( 1, 'POST', expect.stringContaining('https://api.wkbrowser.com/netdisk/user_file/rename_file?'), { file_id: 101, new_name: 'Episode 01-fixed.mkv' }, 30000 ); }); it('throws instead of returning empty list when WKBrowser list API fails', async () => { const adapter = new WKBrowserAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.wkbrowser.com/main?category=all', search: '?category=all', pathname: '/main', hash: '' }, writable: true, configurable: true, }); mockWKBrowserCallAPI.mockResolvedValueOnce({ code: 60006, message: 'file not found' }); await expect(adapter.getAllFiles()).rejects.toThrow('文件不存在'); }); it('maps WKBrowser selected Arco table rows to getAllFiles by checkbox value', async () => { const adapter = new WKBrowserAdapter({ requestInterval: 0 }); Object.defineProperty(window, 'location', { value: { href: 'https://pan.wkbrowser.com/main?category=all', search: '?category=all', pathname: '/main', hash: '' }, writable: true, configurable: true, }); document.body.innerHTML = `
Name
Episode 01.mkv
Episode 02.mkv
`; mockWKBrowserCallAPI.mockResolvedValueOnce({ code: 0, message: 'success', data: { file_list: [ { file_id: 101, file_name: 'Episode 01.mkv', father_id: 0, extension: 'mkv', is_directory: 0, file_type: 2000, size: 100, updated_at: 1700000000, created_at: 1700000000 }, { file_id: 102, file_name: 'Episode 02.mkv', father_id: 0, extension: 'mkv', is_directory: 0, file_type: 2000, size: 200, updated_at: 1700000000, created_at: 1700000000 }, ], has_more: 0, }, }); const selected = await adapter.getSelectedFiles(); expect(selected).toHaveLength(1); expect(selected[0]).toMatchObject({ id: '101', name: 'Episode 01.mkv' }); }); it('uses no special captured headers for WKBrowser page-script requests', () => { expect(WKBROWSER_PAGE_SCRIPT_OPTIONS.captureHeaders ?? []).toEqual([]); }); it('patches WKBrowser Arco table rows after rename', async () => { document.body.innerHTML = `
Episode 01.mkv
`; const adapter = new WKBrowserAdapter({ requestInterval: 0 }); const result = await adapter.syncAfterRename([ { fileId: '101', oldName: 'Episode 01.mkv', newName: 'Episode 01-fixed.mkv' }, ]); expect(result.success).toBe(true); expect(result.method).toBe('dom-patch'); const nameNode = document.querySelector('.arco-table-tr td:nth-child(2) span'); expect(nameNode?.textContent).toBe('Episode 01-fixed.mkv'); }); });