@nemu.pm/tachiyomi-cli 0.1.0
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.
- package/app.ts +27 -0
- package/bin.ts +8 -0
- package/bun.lock +92 -0
- package/commands/build.ts +81 -0
- package/commands/explore.ts +495 -0
- package/commands/info.ts +59 -0
- package/commands/list.ts +39 -0
- package/commands/test.ts +550 -0
- package/config.ts +87 -0
- package/lib/extension-loader.ts +163 -0
- package/lib/http.ts +122 -0
- package/lib/output.ts +37 -0
- package/package.json +39 -0
- package/tsconfig.json +16 -0
package/commands/test.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { buildCommand, buildRouteMap } from "@stricli/core";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadOutputConfig } from "../config";
|
|
4
|
+
import { loadExtension, unwrapResult, type MangasPage, type TachiyomiExports } from "../lib/extension-loader";
|
|
5
|
+
import { printHeader, printJson, printListItem } from "../lib/output";
|
|
6
|
+
|
|
7
|
+
function getExtensionAndSource(extensionId: string) {
|
|
8
|
+
const config = loadOutputConfig();
|
|
9
|
+
const { exports, sources } = loadExtension(config.output, extensionId);
|
|
10
|
+
|
|
11
|
+
if (sources.length === 0) {
|
|
12
|
+
throw new Error("No sources found in extension");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const source = sources[0];
|
|
16
|
+
console.log(`Loaded: ${source.name} (${source.lang})\n`);
|
|
17
|
+
|
|
18
|
+
return { exports, source, sourceId: source.id };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const popular = buildCommand({
|
|
22
|
+
docs: {
|
|
23
|
+
brief: "Get popular manga",
|
|
24
|
+
},
|
|
25
|
+
parameters: {
|
|
26
|
+
positional: {
|
|
27
|
+
kind: "tuple",
|
|
28
|
+
parameters: [
|
|
29
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
flags: {
|
|
33
|
+
page: {
|
|
34
|
+
kind: "parsed",
|
|
35
|
+
brief: "Page number",
|
|
36
|
+
parse: (s: string) => parseInt(s, 10),
|
|
37
|
+
optional: true,
|
|
38
|
+
},
|
|
39
|
+
json: {
|
|
40
|
+
kind: "boolean",
|
|
41
|
+
brief: "Output as JSON",
|
|
42
|
+
optional: true,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
func: async (flags: { page?: number; json?: boolean }, extensionId: string) => {
|
|
47
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
48
|
+
const page = flags.page ?? 1;
|
|
49
|
+
|
|
50
|
+
console.log(`Fetching popular manga (page ${page})...`);
|
|
51
|
+
const result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
|
|
52
|
+
|
|
53
|
+
if (flags.json) {
|
|
54
|
+
printJson(result);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
printHeader(`Popular Manga (${result.mangas?.length ?? 0} items, hasNextPage: ${result.hasNextPage})`);
|
|
59
|
+
for (const m of result.mangas?.slice(0, 10) ?? []) {
|
|
60
|
+
printListItem(m.title);
|
|
61
|
+
console.log(` URL: ${m.url}`);
|
|
62
|
+
if (m.thumbnailUrl) console.log(` Thumb: ${m.thumbnailUrl}`);
|
|
63
|
+
}
|
|
64
|
+
if ((result.mangas?.length ?? 0) > 10) {
|
|
65
|
+
console.log(`\n... and ${result.mangas.length - 10} more`);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const latest = buildCommand({
|
|
71
|
+
docs: {
|
|
72
|
+
brief: "Get latest manga updates",
|
|
73
|
+
},
|
|
74
|
+
parameters: {
|
|
75
|
+
positional: {
|
|
76
|
+
kind: "tuple",
|
|
77
|
+
parameters: [
|
|
78
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
flags: {
|
|
82
|
+
page: {
|
|
83
|
+
kind: "parsed",
|
|
84
|
+
brief: "Page number",
|
|
85
|
+
parse: (s: string) => parseInt(s, 10),
|
|
86
|
+
optional: true,
|
|
87
|
+
},
|
|
88
|
+
json: {
|
|
89
|
+
kind: "boolean",
|
|
90
|
+
brief: "Output as JSON",
|
|
91
|
+
optional: true,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
func: async (flags: { page?: number; json?: boolean }, extensionId: string) => {
|
|
96
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
97
|
+
const page = flags.page ?? 1;
|
|
98
|
+
|
|
99
|
+
console.log(`Fetching latest updates (page ${page})...`);
|
|
100
|
+
const result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
|
|
101
|
+
|
|
102
|
+
if (flags.json) {
|
|
103
|
+
printJson(result);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
printHeader(`Latest Updates (${result.mangas?.length ?? 0} items, hasNextPage: ${result.hasNextPage})`);
|
|
108
|
+
for (const m of result.mangas?.slice(0, 10) ?? []) {
|
|
109
|
+
printListItem(m.title);
|
|
110
|
+
console.log(` URL: ${m.url}`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const search = buildCommand({
|
|
116
|
+
docs: {
|
|
117
|
+
brief: "Search for manga",
|
|
118
|
+
},
|
|
119
|
+
parameters: {
|
|
120
|
+
positional: {
|
|
121
|
+
kind: "tuple",
|
|
122
|
+
parameters: [
|
|
123
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
124
|
+
{ brief: "Search query", parse: String, placeholder: "query" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
flags: {
|
|
128
|
+
page: {
|
|
129
|
+
kind: "parsed",
|
|
130
|
+
brief: "Page number",
|
|
131
|
+
parse: (s: string) => parseInt(s, 10),
|
|
132
|
+
optional: true,
|
|
133
|
+
},
|
|
134
|
+
json: {
|
|
135
|
+
kind: "boolean",
|
|
136
|
+
brief: "Output as JSON",
|
|
137
|
+
optional: true,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
func: async (flags: { page?: number; json?: boolean }, extensionId: string, query: string) => {
|
|
142
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
143
|
+
const page = flags.page ?? 1;
|
|
144
|
+
|
|
145
|
+
console.log(`Searching for "${query}"...`);
|
|
146
|
+
const result = unwrapResult<MangasPage>(exports.searchManga(sourceId, page, query));
|
|
147
|
+
|
|
148
|
+
if (flags.json) {
|
|
149
|
+
printJson(result);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
printHeader(`Search Results (${result.mangas?.length ?? 0} items)`);
|
|
154
|
+
for (const m of result.mangas?.slice(0, 10) ?? []) {
|
|
155
|
+
printListItem(m.title);
|
|
156
|
+
console.log(` URL: ${m.url}`);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const details = buildCommand({
|
|
162
|
+
docs: {
|
|
163
|
+
brief: "Get manga details",
|
|
164
|
+
},
|
|
165
|
+
parameters: {
|
|
166
|
+
positional: {
|
|
167
|
+
kind: "tuple",
|
|
168
|
+
parameters: [
|
|
169
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
170
|
+
{ brief: "Manga URL (relative)", parse: String, placeholder: "url" },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
flags: {
|
|
174
|
+
json: {
|
|
175
|
+
kind: "boolean",
|
|
176
|
+
brief: "Output as JSON",
|
|
177
|
+
optional: true,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
|
|
182
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
183
|
+
|
|
184
|
+
console.log(`Fetching manga details...`);
|
|
185
|
+
const result = unwrapResult<unknown>(exports.getMangaDetails(sourceId, url));
|
|
186
|
+
|
|
187
|
+
if (flags.json) {
|
|
188
|
+
printJson(result);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
printHeader("Manga Details");
|
|
193
|
+
console.log(JSON.stringify(result, null, 2));
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
export const chapters = buildCommand({
|
|
198
|
+
docs: {
|
|
199
|
+
brief: "Get chapter list",
|
|
200
|
+
},
|
|
201
|
+
parameters: {
|
|
202
|
+
positional: {
|
|
203
|
+
kind: "tuple",
|
|
204
|
+
parameters: [
|
|
205
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
206
|
+
{ brief: "Manga URL (relative)", parse: String, placeholder: "url" },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
flags: {
|
|
210
|
+
json: {
|
|
211
|
+
kind: "boolean",
|
|
212
|
+
brief: "Output as JSON",
|
|
213
|
+
optional: true,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
|
|
218
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
219
|
+
|
|
220
|
+
console.log(`Fetching chapter list...`);
|
|
221
|
+
const result = unwrapResult<Array<{ name: string; url: string }>>(
|
|
222
|
+
exports.getChapterList(sourceId, url)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (flags.json) {
|
|
226
|
+
printJson(result);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
printHeader(`Chapters (${result?.length ?? 0})`);
|
|
231
|
+
for (const c of result?.slice(0, 10) ?? []) {
|
|
232
|
+
printListItem(c.name);
|
|
233
|
+
console.log(` URL: ${c.url}`);
|
|
234
|
+
}
|
|
235
|
+
if ((result?.length ?? 0) > 10) {
|
|
236
|
+
console.log(`\n... and ${result.length - 10} more`);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export const pages = buildCommand({
|
|
242
|
+
docs: {
|
|
243
|
+
brief: "Get page list for a chapter",
|
|
244
|
+
},
|
|
245
|
+
parameters: {
|
|
246
|
+
positional: {
|
|
247
|
+
kind: "tuple",
|
|
248
|
+
parameters: [
|
|
249
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
250
|
+
{ brief: "Chapter URL (relative)", parse: String, placeholder: "url" },
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
flags: {
|
|
254
|
+
json: {
|
|
255
|
+
kind: "boolean",
|
|
256
|
+
brief: "Output as JSON",
|
|
257
|
+
optional: true,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
|
|
262
|
+
const { exports, sourceId } = getExtensionAndSource(extensionId);
|
|
263
|
+
|
|
264
|
+
console.log(`Fetching page list...`);
|
|
265
|
+
const result = unwrapResult<Array<{ index: number; imageUrl?: string; url?: string }>>(
|
|
266
|
+
exports.getPageList(sourceId, url)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (flags.json) {
|
|
270
|
+
printJson(result);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
printHeader(`Pages (${result?.length ?? 0})`);
|
|
275
|
+
for (const p of result?.slice(0, 5) ?? []) {
|
|
276
|
+
console.log(`Page ${p.index + 1}: ${p.imageUrl || p.url}`);
|
|
277
|
+
}
|
|
278
|
+
if ((result?.length ?? 0) > 5) {
|
|
279
|
+
console.log(`\n... and ${result.length - 5} more`);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Image magic bytes for validation
|
|
285
|
+
const IMAGE_SIGNATURES: Array<{ name: string; bytes: number[] }> = [
|
|
286
|
+
{ name: "PNG", bytes: [0x89, 0x50, 0x4e, 0x47] },
|
|
287
|
+
{ name: "JPEG", bytes: [0xff, 0xd8, 0xff] },
|
|
288
|
+
{ name: "GIF", bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
289
|
+
{ name: "WebP", bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF header
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
function detectImageFormat(buffer: Buffer): string | null {
|
|
293
|
+
for (const sig of IMAGE_SIGNATURES) {
|
|
294
|
+
if (sig.bytes.every((b, i) => buffer[i] === b)) {
|
|
295
|
+
return sig.name;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface TestResult {
|
|
302
|
+
test: string;
|
|
303
|
+
passed: boolean;
|
|
304
|
+
error?: string;
|
|
305
|
+
data?: unknown;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const all = buildCommand({
|
|
309
|
+
docs: {
|
|
310
|
+
brief: "Run all API tests on an extension",
|
|
311
|
+
fullDescription: "Comprehensive test that validates popular, latest, search, details, chapters, pages, and image download. Useful for CI/CD.",
|
|
312
|
+
},
|
|
313
|
+
parameters: {
|
|
314
|
+
positional: {
|
|
315
|
+
kind: "tuple",
|
|
316
|
+
parameters: [
|
|
317
|
+
{ brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
flags: {
|
|
321
|
+
json: {
|
|
322
|
+
kind: "boolean",
|
|
323
|
+
brief: "Output results as JSON",
|
|
324
|
+
optional: true,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
func: async (flags: { json?: boolean }, extensionId: string) => {
|
|
329
|
+
const config = loadOutputConfig();
|
|
330
|
+
const { exports, sources } = loadExtension(config.output, extensionId);
|
|
331
|
+
|
|
332
|
+
if (sources.length === 0) {
|
|
333
|
+
throw new Error("No sources found in extension");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const source = sources[0];
|
|
337
|
+
const sourceId = source.id;
|
|
338
|
+
const results: TestResult[] = [];
|
|
339
|
+
const log = flags.json ? () => {} : console.log;
|
|
340
|
+
|
|
341
|
+
log(pc.cyan(`\n🧪 Testing: ${source.name} (${source.lang})\n`));
|
|
342
|
+
|
|
343
|
+
// Collect manga samples from popular and latest
|
|
344
|
+
const mangaSamples: Array<{ title: string; url: string }> = [];
|
|
345
|
+
|
|
346
|
+
// Test 1: Popular
|
|
347
|
+
try {
|
|
348
|
+
log(pc.dim("Testing popular..."));
|
|
349
|
+
const popular = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, 1));
|
|
350
|
+
const count = popular.mangas?.length ?? 0;
|
|
351
|
+
if (count === 0) throw new Error("No manga returned");
|
|
352
|
+
// Collect up to 5 samples
|
|
353
|
+
mangaSamples.push(...popular.mangas.slice(0, 5));
|
|
354
|
+
results.push({ test: "popular", passed: true, data: { count, hasNextPage: popular.hasNextPage } });
|
|
355
|
+
log(pc.green(` ✓ popular: ${count} manga`));
|
|
356
|
+
} catch (e) {
|
|
357
|
+
results.push({ test: "popular", passed: false, error: String(e) });
|
|
358
|
+
log(pc.red(` ✗ popular: ${e}`));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Test 2: Latest
|
|
362
|
+
try {
|
|
363
|
+
log(pc.dim("Testing latest..."));
|
|
364
|
+
const latest = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, 1));
|
|
365
|
+
const count = latest.mangas?.length ?? 0;
|
|
366
|
+
if (count === 0) throw new Error("No manga returned");
|
|
367
|
+
// Add more samples if needed
|
|
368
|
+
if (mangaSamples.length < 5) {
|
|
369
|
+
mangaSamples.push(...latest.mangas.slice(0, 5 - mangaSamples.length));
|
|
370
|
+
}
|
|
371
|
+
results.push({ test: "latest", passed: true, data: { count, hasNextPage: latest.hasNextPage } });
|
|
372
|
+
log(pc.green(` ✓ latest: ${count} manga`));
|
|
373
|
+
} catch (e) {
|
|
374
|
+
results.push({ test: "latest", passed: false, error: String(e) });
|
|
375
|
+
log(pc.red(` ✗ latest: ${e}`));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Test 3: Search (using exact title from samples)
|
|
379
|
+
if (mangaSamples.length > 0) {
|
|
380
|
+
try {
|
|
381
|
+
// Use a known manga title for more reliable search
|
|
382
|
+
const searchManga = mangaSamples[0];
|
|
383
|
+
const query = searchManga.title;
|
|
384
|
+
log(pc.dim(`Testing search ("${query.slice(0, 30)}${query.length > 30 ? "..." : ""}")...`));
|
|
385
|
+
const search = unwrapResult<MangasPage>(exports.searchManga(sourceId, 1, query));
|
|
386
|
+
const count = search.mangas?.length ?? 0;
|
|
387
|
+
// Check if we found the manga we searched for
|
|
388
|
+
const found = search.mangas?.some(m =>
|
|
389
|
+
m.title.toLowerCase().includes(searchManga.title.toLowerCase().slice(0, 10)) ||
|
|
390
|
+
m.url === searchManga.url
|
|
391
|
+
);
|
|
392
|
+
results.push({ test: "search", passed: true, data: { query, count, foundTarget: found } });
|
|
393
|
+
log(pc.green(` ✓ search: ${count} results${found ? " (target found)" : ""}`));
|
|
394
|
+
} catch (e) {
|
|
395
|
+
results.push({ test: "search", passed: false, error: String(e) });
|
|
396
|
+
log(pc.red(` ✗ search: ${e}`));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Test 4: Manga Details (try up to 3 manga)
|
|
401
|
+
let detailsManga: { title: string; url: string } | null = null;
|
|
402
|
+
for (const manga of mangaSamples.slice(0, 3)) {
|
|
403
|
+
try {
|
|
404
|
+
log(pc.dim(`Testing details (${manga.title.slice(0, 30)})...`));
|
|
405
|
+
const details = unwrapResult<{ title?: string; url?: string }>(
|
|
406
|
+
exports.getMangaDetails(sourceId, manga.url)
|
|
407
|
+
);
|
|
408
|
+
detailsManga = manga;
|
|
409
|
+
results.push({ test: "details", passed: true, data: { title: details.title || manga.title } });
|
|
410
|
+
log(pc.green(` ✓ details: ${details.title || manga.title}`));
|
|
411
|
+
break;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
log(pc.dim(` (${manga.title.slice(0, 20)} failed, trying next...)`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!detailsManga) {
|
|
417
|
+
results.push({ test: "details", passed: false, error: "All manga samples failed" });
|
|
418
|
+
log(pc.red(` ✗ details: All ${Math.min(3, mangaSamples.length)} samples failed`));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Test 5: Chapters (try up to 3 manga)
|
|
422
|
+
let chaptersData: { manga: { title: string; url: string }; chapters: Array<{ name: string; url: string }> } | null = null;
|
|
423
|
+
for (const manga of mangaSamples.slice(0, 3)) {
|
|
424
|
+
try {
|
|
425
|
+
log(pc.dim(`Testing chapters (${manga.title.slice(0, 30)})...`));
|
|
426
|
+
const chapters = unwrapResult<Array<{ name: string; url: string }>>(
|
|
427
|
+
exports.getChapterList(sourceId, manga.url)
|
|
428
|
+
);
|
|
429
|
+
if (chapters?.length > 0) {
|
|
430
|
+
chaptersData = { manga, chapters };
|
|
431
|
+
results.push({ test: "chapters", passed: true, data: { manga: manga.title, count: chapters.length } });
|
|
432
|
+
log(pc.green(` ✓ chapters: ${chapters.length} chapters`));
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
log(pc.dim(` (${manga.title.slice(0, 20)} has no chapters, trying next...)`));
|
|
436
|
+
} catch (e) {
|
|
437
|
+
log(pc.dim(` (${manga.title.slice(0, 20)} failed, trying next...)`));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!chaptersData) {
|
|
441
|
+
results.push({ test: "chapters", passed: false, error: "No manga with chapters found" });
|
|
442
|
+
log(pc.red(` ✗ chapters: No manga with chapters found in ${Math.min(3, mangaSamples.length)} samples`));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Test 6: Pages (try up to 3 chapters)
|
|
446
|
+
let samplePages: Array<{ index: number; imageUrl?: string; url?: string }> = [];
|
|
447
|
+
if (chaptersData) {
|
|
448
|
+
for (const chapter of chaptersData.chapters.slice(0, 3)) {
|
|
449
|
+
try {
|
|
450
|
+
log(pc.dim(`Testing pages (${chapter.name.slice(0, 30)})...`));
|
|
451
|
+
const pages = unwrapResult<Array<{ index: number; imageUrl?: string; url?: string }>>(
|
|
452
|
+
exports.getPageList(sourceId, chapter.url)
|
|
453
|
+
);
|
|
454
|
+
if (pages?.length > 0) {
|
|
455
|
+
samplePages = pages.slice(0, 3);
|
|
456
|
+
results.push({ test: "pages", passed: true, data: { chapter: chapter.name, count: pages.length } });
|
|
457
|
+
log(pc.green(` ✓ pages: ${pages.length} pages`));
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
log(pc.dim(` (${chapter.name.slice(0, 20)} has no pages, trying next...)`));
|
|
461
|
+
} catch (e) {
|
|
462
|
+
log(pc.dim(` (${chapter.name.slice(0, 20)} failed, trying next...)`));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (samplePages.length === 0) {
|
|
466
|
+
results.push({ test: "pages", passed: false, error: "No chapter with pages found" });
|
|
467
|
+
log(pc.red(` ✗ pages: No chapter with pages found in 3 samples`));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Test 7: Image Download (up to 3 pages)
|
|
472
|
+
if (samplePages.length > 0) {
|
|
473
|
+
log(pc.dim("Testing image download..."));
|
|
474
|
+
let imagesPassed = 0;
|
|
475
|
+
const imageResults: Array<{ page: number; format: string | null; size: number }> = [];
|
|
476
|
+
|
|
477
|
+
for (const page of samplePages) {
|
|
478
|
+
const imageUrl = page.imageUrl || "";
|
|
479
|
+
if (!imageUrl) continue;
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const base64 = unwrapResult<string>(
|
|
483
|
+
exports.fetchImage(sourceId, page.url || "", imageUrl)
|
|
484
|
+
);
|
|
485
|
+
const buffer = Buffer.from(base64, "base64");
|
|
486
|
+
const format = detectImageFormat(buffer);
|
|
487
|
+
|
|
488
|
+
if (format) {
|
|
489
|
+
imagesPassed++;
|
|
490
|
+
imageResults.push({ page: page.index + 1, format, size: buffer.length });
|
|
491
|
+
log(pc.green(` ✓ page ${page.index + 1}: ${format} (${(buffer.length / 1024).toFixed(1)}KB)`));
|
|
492
|
+
} else {
|
|
493
|
+
imageResults.push({ page: page.index + 1, format: null, size: buffer.length });
|
|
494
|
+
log(pc.yellow(` ⚠ page ${page.index + 1}: unknown format (${(buffer.length / 1024).toFixed(1)}KB)`));
|
|
495
|
+
}
|
|
496
|
+
} catch (e) {
|
|
497
|
+
imageResults.push({ page: page.index + 1, format: null, size: 0 });
|
|
498
|
+
log(pc.red(` ✗ page ${page.index + 1}: ${e}`));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
results.push({
|
|
503
|
+
test: "images",
|
|
504
|
+
passed: imagesPassed > 0,
|
|
505
|
+
data: { tested: samplePages.length, valid: imagesPassed, results: imageResults },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Summary
|
|
510
|
+
const passed = results.filter((r) => r.passed).length;
|
|
511
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
512
|
+
|
|
513
|
+
if (flags.json) {
|
|
514
|
+
printJson({
|
|
515
|
+
extension: extensionId,
|
|
516
|
+
source: { id: sourceId, name: source.name, lang: source.lang },
|
|
517
|
+
summary: { passed, failed, total: results.length },
|
|
518
|
+
results,
|
|
519
|
+
});
|
|
520
|
+
} else {
|
|
521
|
+
log("");
|
|
522
|
+
if (failed === 0) {
|
|
523
|
+
log(pc.green(`✓ All ${passed} tests passed`));
|
|
524
|
+
} else {
|
|
525
|
+
log(pc.yellow(`⚠ ${passed}/${passed + failed} tests passed`));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Exit with error code if tests failed
|
|
530
|
+
if (failed > 0) {
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Route map for test subcommands
|
|
537
|
+
export const testRoutes = buildRouteMap({
|
|
538
|
+
routes: {
|
|
539
|
+
all,
|
|
540
|
+
popular,
|
|
541
|
+
latest,
|
|
542
|
+
search,
|
|
543
|
+
details,
|
|
544
|
+
chapters,
|
|
545
|
+
pages,
|
|
546
|
+
},
|
|
547
|
+
docs: {
|
|
548
|
+
brief: "Test extension API endpoints",
|
|
549
|
+
},
|
|
550
|
+
});
|
package/config.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
export interface Config {
|
|
5
|
+
/** Path to extensions source repo (Kotlin sources) */
|
|
6
|
+
source: string;
|
|
7
|
+
/** Path to built extensions output */
|
|
8
|
+
output: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE_NAMES = ["tachiyomi.config.json", ".tachiyomirc.json"];
|
|
12
|
+
|
|
13
|
+
function findConfigFile(): string | null {
|
|
14
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
15
|
+
const p = path.join(process.cwd(), name);
|
|
16
|
+
if (fs.existsSync(p)) return p;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfigFile(): Partial<Config> {
|
|
22
|
+
const file = findConfigFile();
|
|
23
|
+
if (!file) return {};
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadConfig(): Config {
|
|
34
|
+
const fileConfig = loadConfigFile();
|
|
35
|
+
|
|
36
|
+
const source = process.env.TACHIYOMI_SOURCE ?? fileConfig.source;
|
|
37
|
+
const output = process.env.TACHIYOMI_OUTPUT ?? fileConfig.output;
|
|
38
|
+
|
|
39
|
+
const missing: string[] = [];
|
|
40
|
+
if (!source) missing.push("source");
|
|
41
|
+
if (!output) missing.push("output");
|
|
42
|
+
|
|
43
|
+
if (missing.length > 0) {
|
|
44
|
+
console.error(`Missing config: ${missing.join(", ")}
|
|
45
|
+
|
|
46
|
+
Set via environment variables:
|
|
47
|
+
TACHIYOMI_SOURCE - path to extensions source repo
|
|
48
|
+
TACHIYOMI_OUTPUT - path for built extensions
|
|
49
|
+
|
|
50
|
+
Or create tachiyomi.config.json:
|
|
51
|
+
{
|
|
52
|
+
"source": "/path/to/extensions-source",
|
|
53
|
+
"output": "./dist/extensions"
|
|
54
|
+
}
|
|
55
|
+
`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve to absolute paths
|
|
60
|
+
return {
|
|
61
|
+
source: path.resolve(source!),
|
|
62
|
+
output: path.resolve(output!),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load config, but only require output (for test-only commands) */
|
|
67
|
+
export function loadOutputConfig(): Pick<Config, "output"> {
|
|
68
|
+
const fileConfig = loadConfigFile();
|
|
69
|
+
const output = process.env.TACHIYOMI_OUTPUT ?? fileConfig.output;
|
|
70
|
+
|
|
71
|
+
if (!output) {
|
|
72
|
+
console.error(`Missing config: output
|
|
73
|
+
|
|
74
|
+
Set via environment variable:
|
|
75
|
+
TACHIYOMI_OUTPUT - path to built extensions
|
|
76
|
+
|
|
77
|
+
Or create tachiyomi.config.json:
|
|
78
|
+
{
|
|
79
|
+
"output": "./dist/extensions"
|
|
80
|
+
}
|
|
81
|
+
`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { output: path.resolve(output) };
|
|
86
|
+
}
|
|
87
|
+
|