mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
ca5fc341cb
Addresses CodeRabbit review feedback on the Anime Browser: - reject repository package/apk names that are not plain identifiers, and verify the install target resolves inside the extensions directory - count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in header values so a trailing one cannot eat the list separator - key the bridge extension-id cache by APK content, so an in-place upgrade re-uploads instead of running the previous build - bound every bridge, release-listing, and download request with a timeout - enforce the APK size limit while streaming rather than after buffering - read APK bytes on demand instead of holding a base64 copy per extension for the lifetime of the browser - serialize preference mutations and write the file atomically - handle the sidecar spawn error event, and wait for the child to exit in stop() before returning - report a failed Anime Browser bootstrap instead of showing the starting banner forever - keep the preferences panel's save confirmation and in-flight multi-select edits by re-rendering only on a structural schema change
160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
listExtensionSources,
|
|
readInstalledExtensions,
|
|
toBridgeSource,
|
|
toInstalledExtensionViews,
|
|
type ExtensionSource,
|
|
type InstalledExtension,
|
|
} from './extension-store';
|
|
import type { AnimeBridgeClient } from './bridge-client';
|
|
|
|
async function makeExtensionDir(files: Record<string, string>): Promise<string> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
|
|
for (const [name, contents] of Object.entries(files)) {
|
|
await writeFile(path.join(dir, name), contents);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function fakeClient(
|
|
impl: (source: { fingerprint: string }) => Promise<unknown[]>,
|
|
): AnimeBridgeClient {
|
|
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
|
}
|
|
|
|
test('readInstalledExtensions fingerprints apks without holding their bytes', async () => {
|
|
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
|
const extensions = await readInstalledExtensions(dir);
|
|
|
|
assert.equal(extensions.length, 1);
|
|
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
|
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
|
});
|
|
|
|
test('the fingerprint changes when an apk is replaced in place', async () => {
|
|
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
|
|
const before = (await readInstalledExtensions(dir))[0]?.sha256;
|
|
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
|
|
const after = (await readInstalledExtensions(dir))[0]?.sha256;
|
|
|
|
assert.notEqual(before, after);
|
|
});
|
|
|
|
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
|
|
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
|
|
const extension = (await readInstalledExtensions(dir))[0]!;
|
|
|
|
const bridgeSource = toBridgeSource(extension);
|
|
assert.equal(bridgeSource.fingerprint, extension.sha256);
|
|
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), 'base64').toString(), 'APK-BYTES');
|
|
});
|
|
|
|
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
|
const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
|
|
await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
|
|
|
|
const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
|
|
// Sorted, case-insensitive extension match, directories excluded.
|
|
assert.deepEqual(names, ['a', 'b']);
|
|
});
|
|
|
|
test('readInstalledExtensions returns empty for a missing directory', async () => {
|
|
assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
|
|
});
|
|
|
|
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
|
const extension: InstalledExtension = { file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' };
|
|
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
|
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
|
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
|
});
|
|
|
|
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
|
const extensions: InstalledExtension[] = [
|
|
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
|
];
|
|
const client = fakeClient(async () => [
|
|
{ id: 101, name: 'Source One', lang: 'en' },
|
|
{ id: '102', name: 'Source Two', lang: 'ja' },
|
|
]);
|
|
|
|
const sources = await listExtensionSources(client, extensions);
|
|
|
|
assert.equal(sources.length, 2);
|
|
// Numeric ids are normalized to strings so they can key UI state.
|
|
assert.equal(sources[0]?.id, '101');
|
|
assert.equal(sources[0]?.name, 'Source One');
|
|
assert.equal(sources[1]?.lang, 'ja');
|
|
});
|
|
|
|
test('listExtensionSources falls back to the file name and a default language', async () => {
|
|
const extensions: InstalledExtension[] = [
|
|
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
|
|
];
|
|
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
|
|
|
const sources = await listExtensionSources(client, extensions);
|
|
assert.equal(sources[0]?.name, 'my-ext');
|
|
assert.equal(sources[0]?.lang, 'all');
|
|
});
|
|
|
|
test('listExtensionSources drops descriptors with no usable id', async () => {
|
|
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
|
const sources = await listExtensionSources(client, [
|
|
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' },
|
|
]);
|
|
assert.deepEqual(sources, []);
|
|
});
|
|
|
|
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
|
const extensions: InstalledExtension[] = [
|
|
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
|
];
|
|
const sources: ExtensionSource[] = [
|
|
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
|
{ id: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
|
|
];
|
|
|
|
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
|
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
|
|
]);
|
|
});
|
|
|
|
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
|
const extensions: InstalledExtension[] = [
|
|
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
|
];
|
|
|
|
// A broken APK is still installed, so it must stay listed and removable.
|
|
assert.deepEqual(
|
|
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
|
|
[{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
|
|
);
|
|
});
|
|
|
|
test('one broken extension does not hide the working ones', async () => {
|
|
const extensions: InstalledExtension[] = [
|
|
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
|
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
|
|
];
|
|
const failures: string[] = [];
|
|
const client = fakeClient(async (source) => {
|
|
if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed');
|
|
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
|
});
|
|
|
|
const sources = await listExtensionSources(client, extensions, (extension) => {
|
|
failures.push(extension.fallbackName);
|
|
});
|
|
|
|
assert.deepEqual(failures, ['broken']);
|
|
assert.equal(sources.length, 1);
|
|
assert.equal(sources[0]?.name, 'Good Source');
|
|
});
|