mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
134 lines
7.6 KiB
TypeScript
134 lines
7.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { access, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { createServer } from 'node:http';
|
|
import { Db } from '../src/db/index.ts';
|
|
import { PluginManager } from '../src/sources/plugin-manager.ts';
|
|
import { PluginInstaller } from '../src/sources/plugin-installer.ts';
|
|
import { SourceRegistry } from '../src/sources/registry.ts';
|
|
import { pluginArchive } from './helpers/plugin-package.ts';
|
|
|
|
async function fixture(t: import('node:test').TestContext) {
|
|
const root = await mkdtemp(join(tmpdir(), 'reader-plugin-install-'));
|
|
const db = new Db(':memory:'), registry = new SourceRegistry();
|
|
const manager = new PluginManager(db, root, registry);
|
|
t.after(async () => { await manager.close(); db.close(); await rm(root, { recursive: true, force: true }); });
|
|
return { root, db, registry, manager, installer: new PluginInstaller(root, manager) };
|
|
}
|
|
|
|
test('uploaded npm package is enabled, runnable and restored; scripts never run and duplicate installation preserves it', async t => {
|
|
const f = await fixture(t);
|
|
const archive = pluginArchive();
|
|
const result = await f.installer.installArchive(target => writeFile(target, archive));
|
|
assert.equal(result.enabled, true);
|
|
assert.equal(result.pluginId, 'test.upload');
|
|
const directories = await readdir(join(f.root, 'plugins'));
|
|
const packageDir = join(f.root, 'plugins', directories[0]!, 'node_modules', '@reader', 'example');
|
|
await assert.rejects(access(join(packageDir, 'lifecycle-ran')));
|
|
await assert.rejects(access(join(f.root, 'plugins', directories[0]!, 'upload.tgz')));
|
|
await assert.rejects(f.installer.installArchive(target => writeFile(target, archive)), { code: 'PLUGIN_ALREADY_INSTALLED' });
|
|
assert.deepEqual(await readdir(join(f.root, 'plugins')), directories);
|
|
await f.manager.close();
|
|
const restored = new PluginManager(f.db, f.root, f.registry);
|
|
try {
|
|
await restored.loadInstalled();
|
|
assert.equal(restored.list()[0]?.enabled, true);
|
|
const provider = f.registry.get('test.upload', 'test')!.provider;
|
|
const book = await provider.detail({ userId: 'u', signal: new AbortController().signal,
|
|
instance: { id: 's', pluginId: 'test.upload', sourceType: 'test', name: 'Test', config: {}, enabled: true } }, 'book');
|
|
assert.equal(book.title, 'Installed book');
|
|
} finally { await restored.close(); }
|
|
});
|
|
|
|
test('invalid package and interrupted upload leave no installation behind', async t => {
|
|
const f = await fixture(t);
|
|
await assert.rejects(f.installer.installArchive(target => writeFile(target, pluginArchive('example', 'test.invalid', false))));
|
|
await assert.rejects(f.installer.installArchive(async target => { await writeFile(target, 'partial'); throw new Error('disconnect'); }), /disconnect/);
|
|
assert.deepEqual(await readdir(join(f.root, 'plugins')), []);
|
|
assert.deepEqual(f.manager.list(), []);
|
|
});
|
|
|
|
test('uploading a new version upgrades the existing plugin and retains source configuration across restart', async t => {
|
|
const f = await fixture(t);
|
|
const old = await f.installer.installArchive(target => writeFile(target, pluginArchive()));
|
|
f.db.run("INSERT INTO source_instances (id, plugin_id, source_type, name, config_json, enabled, created_at) VALUES ('keep', 'test.upload', 'test', 'Keep', '{\"custom\":true}', 1, 0)");
|
|
f.db.run("INSERT INTO plugin_storage (key, value) VALUES ('keep', 'state')");
|
|
const upgraded = await f.installer.installArchive(target => writeFile(target, pluginArchive(undefined, undefined, true, '1.1.0')));
|
|
assert.equal(upgraded.version, '1.1.0');
|
|
assert.equal(upgraded.enabled, true);
|
|
assert.notEqual(upgraded.folder, old.folder);
|
|
assert.equal(f.db.all('SELECT * FROM installed_plugins').length, 1);
|
|
assert.equal(f.db.get<{ config_json: string }>("SELECT config_json FROM source_instances WHERE id = 'keep'")?.config_json, '{"custom":true}');
|
|
assert.equal(f.db.get<{ value: string }>("SELECT value FROM plugin_storage WHERE key = 'keep'")?.value, 'state');
|
|
await f.manager.close();
|
|
const restored = new PluginManager(f.db, f.root, f.registry);
|
|
try { await restored.loadInstalled(); assert.equal(restored.list()[0]?.version, '1.1.0'); }
|
|
finally { await restored.close(); }
|
|
});
|
|
|
|
test('npm specs reject options, URLs, local paths and aliases before any npm call', async t => {
|
|
const f = await fixture(t);
|
|
for (const spec of ['--help', '../package', 'file:package.tgz', 'https://example.test/a.tgz', 'git+https://example.test/a', 'x@npm:other', 'x;whoami', '@scope/x/../y', 'x@latest --ignore-scripts=false']) {
|
|
assert.throws(() => f.installer.installPackage(spec), { code: 'PLUGIN_INVALID_PACKAGE' });
|
|
}
|
|
});
|
|
|
|
test('npm package install passes scoped name and version, serializes downloads and cleans failures', async t => {
|
|
const f = await fixture(t);
|
|
let release!: () => void;
|
|
const wait = new Promise<void>(resolve => { release = resolve; });
|
|
let started!: () => void;
|
|
const ready = new Promise<void>(resolve => { started = resolve; });
|
|
const installer = new PluginInstaller(f.root, f.manager, async (_directory, spec) => {
|
|
assert.equal(spec, '@reader/example@1.2.3'); started(); await wait; throw new Error('network error');
|
|
});
|
|
const pending = installer.installPackage('@reader/example@1.2.3');
|
|
const failure = assert.rejects(pending, /network error/);
|
|
await ready;
|
|
await assert.rejects(installer.installPackage('other'), { code: 'PLUGIN_INSTALL_BUSY' });
|
|
release(); await failure;
|
|
assert.deepEqual(await readdir(join(f.root, 'plugins')), []);
|
|
});
|
|
|
|
test('plugin root cannot point outside DATA_DIR', async t => {
|
|
const f = await fixture(t), outside = await mkdtemp(join(tmpdir(), 'reader-plugin-outside-'));
|
|
t.after(() => rm(outside, { recursive: true, force: true }));
|
|
await symlink(outside, join(f.root, 'plugins'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
await assert.rejects(f.installer.installArchive(async () => {}), { code: 'PLUGIN_PATH_ESCAPE' });
|
|
assert.deepEqual(await readdir(outside), []);
|
|
});
|
|
|
|
test('npm name installs through a registry and auto-enables the downloaded scoped package', async t => {
|
|
const f = await fixture(t);
|
|
let version = '1.0.0';
|
|
let registryUrl = '';
|
|
const requests: string[] = [];
|
|
const server = createServer((request, response) => {
|
|
requests.push(request.url!);
|
|
if (request.url === '/example.tgz') { response.end(pluginArchive(undefined, undefined, true, version)); return; }
|
|
response.setHeader('content-type', 'application/json');
|
|
response.end(JSON.stringify({ name: '@reader/example', 'dist-tags': { latest: version }, versions: {
|
|
[version]: { name: '@reader/example', version, dist: { tarball: registryUrl + '/example.tgz' } },
|
|
} }));
|
|
});
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
registryUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
const previous = process.env.npm_config_registry;
|
|
process.env.npm_config_registry = registryUrl;
|
|
try {
|
|
const result = await f.installer.installPackage('@reader/example@latest');
|
|
assert.equal(result.enabled, true);
|
|
assert.equal(result.pluginId, 'test.upload');
|
|
assert.ok(requests.includes('/example.tgz'));
|
|
version = '1.1.0';
|
|
const updated = await f.installer.installPackage('@reader/example@latest');
|
|
assert.equal(updated.version, version); assert.equal(updated.updated, true);
|
|
assert.equal(f.manager.list().length, 1);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.npm_config_registry; else process.env.npm_config_registry = previous;
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
}
|
|
});
|