@jackwener/opencli 1.5.1 → 1.5.2
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/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +28 -2
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +4 -5
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/v2ex/hot.yaml +3 -17
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/daemon.js +1 -0
- package/dist/doctor.js +7 -3
- package/dist/execution.js +0 -6
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +25 -2
- package/src/browser.test.ts +6 -0
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/v2ex/hot.yaml +3 -17
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/daemon.ts +1 -0
- package/src/doctor.ts +9 -5
- package/src/execution.ts +0 -9
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
package/src/clis/weread/shelf.ts
CHANGED
|
@@ -1,7 +1,159 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
2
3
|
import type { IPage } from '../../types.js';
|
|
3
4
|
import { fetchPrivateApi } from './utils.js';
|
|
4
5
|
|
|
6
|
+
const WEREAD_DOMAIN = 'weread.qq.com';
|
|
7
|
+
const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
|
|
8
|
+
|
|
9
|
+
interface ShelfRow {
|
|
10
|
+
title: string;
|
|
11
|
+
author: string;
|
|
12
|
+
progress: string;
|
|
13
|
+
bookId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WebShelfRawBook {
|
|
17
|
+
bookId?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
author?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WebShelfIndexEntry {
|
|
23
|
+
bookId?: string;
|
|
24
|
+
idx?: number;
|
|
25
|
+
role?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WebShelfSnapshot {
|
|
29
|
+
cacheFound: boolean;
|
|
30
|
+
rawBooks: WebShelfRawBook[];
|
|
31
|
+
shelfIndexes: WebShelfIndexEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeShelfLimit(limit: number): number {
|
|
35
|
+
if (!Number.isFinite(limit)) return 0;
|
|
36
|
+
return Math.max(0, Math.trunc(limit));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizePrivateApiRows(data: any, limit: number): ShelfRow[] {
|
|
40
|
+
const books: any[] = data?.books ?? [];
|
|
41
|
+
return books.slice(0, limit).map((item: any) => ({
|
|
42
|
+
title: item.bookInfo?.title ?? item.title ?? '',
|
|
43
|
+
author: item.bookInfo?.author ?? item.author ?? '',
|
|
44
|
+
// TODO: readingProgress field name from community docs, verify with real API response
|
|
45
|
+
progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
|
|
46
|
+
bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeWebShelfRows(snapshot: WebShelfSnapshot, limit: number): ShelfRow[] {
|
|
51
|
+
if (limit <= 0) return [];
|
|
52
|
+
|
|
53
|
+
const bookById = new Map<string, WebShelfRawBook>();
|
|
54
|
+
for (const book of snapshot.rawBooks) {
|
|
55
|
+
const bookId = String(book?.bookId || '').trim();
|
|
56
|
+
if (!bookId) continue;
|
|
57
|
+
bookById.set(bookId, book);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const orderedBookIds = snapshot.shelfIndexes
|
|
61
|
+
.filter((entry) => String(entry?.role || 'book') === 'book')
|
|
62
|
+
.sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
|
|
63
|
+
.map((entry) => String(entry?.bookId || '').trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
|
|
66
|
+
const fallbackOrder = snapshot.rawBooks
|
|
67
|
+
.map((book) => String(book?.bookId || '').trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
|
|
70
|
+
const orderedUniqueBookIds = Array.from(new Set([
|
|
71
|
+
...orderedBookIds,
|
|
72
|
+
...fallbackOrder,
|
|
73
|
+
]));
|
|
74
|
+
|
|
75
|
+
return orderedUniqueBookIds
|
|
76
|
+
.map((bookId) => {
|
|
77
|
+
const book = bookById.get(bookId);
|
|
78
|
+
if (!book) return null;
|
|
79
|
+
return {
|
|
80
|
+
title: String(book.title || '').trim(),
|
|
81
|
+
author: String(book.author || '').trim(),
|
|
82
|
+
progress: '-',
|
|
83
|
+
bookId,
|
|
84
|
+
} satisfies ShelfRow;
|
|
85
|
+
})
|
|
86
|
+
.filter((item): item is ShelfRow => Boolean(item && (item.title || item.bookId)))
|
|
87
|
+
.slice(0, limit);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the structured shelf cache from the web shelf page.
|
|
92
|
+
* The page hydrates localStorage with raw book data plus shelf ordering.
|
|
93
|
+
*/
|
|
94
|
+
async function loadWebShelfSnapshot(page: IPage): Promise<WebShelfSnapshot> {
|
|
95
|
+
await page.goto(WEREAD_SHELF_URL);
|
|
96
|
+
|
|
97
|
+
const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
|
|
98
|
+
const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
|
|
99
|
+
|
|
100
|
+
if (!currentVid) {
|
|
101
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rawBooksKey = `shelf:rawBooks:${currentVid}`;
|
|
105
|
+
const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
|
|
106
|
+
|
|
107
|
+
const result = await page.evaluate(`
|
|
108
|
+
(() => new Promise((resolve) => {
|
|
109
|
+
const deadline = Date.now() + 5000;
|
|
110
|
+
const rawBooksKey = ${JSON.stringify(rawBooksKey)};
|
|
111
|
+
const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
|
|
112
|
+
|
|
113
|
+
const readJson = (raw) => {
|
|
114
|
+
if (typeof raw !== 'string') return null;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const poll = () => {
|
|
123
|
+
const rawBooksRaw = localStorage.getItem(rawBooksKey);
|
|
124
|
+
const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
|
|
125
|
+
const rawBooks = readJson(rawBooksRaw);
|
|
126
|
+
const shelfIndexes = readJson(shelfIndexesRaw);
|
|
127
|
+
const cacheFound = Array.isArray(rawBooks);
|
|
128
|
+
|
|
129
|
+
if (cacheFound || Date.now() >= deadline) {
|
|
130
|
+
resolve({
|
|
131
|
+
cacheFound,
|
|
132
|
+
rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
|
|
133
|
+
shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setTimeout(poll, 100);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
poll();
|
|
142
|
+
}))
|
|
143
|
+
`);
|
|
144
|
+
|
|
145
|
+
if (!result || typeof result !== 'object') {
|
|
146
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snapshot = result as Partial<WebShelfSnapshot>;
|
|
150
|
+
return {
|
|
151
|
+
cacheFound: snapshot.cacheFound === true,
|
|
152
|
+
rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
|
|
153
|
+
shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
5
157
|
cli({
|
|
6
158
|
site: 'weread',
|
|
7
159
|
name: 'shelf',
|
|
@@ -13,14 +165,22 @@ cli({
|
|
|
13
165
|
],
|
|
14
166
|
columns: ['title', 'author', 'progress', 'bookId'],
|
|
15
167
|
func: async (page: IPage, args) => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
168
|
+
const limit = normalizeShelfLimit(Number(args.limit));
|
|
169
|
+
if (limit <= 0) return [];
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
|
|
173
|
+
return normalizePrivateApiRows(data, limit);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const snapshot = await loadWebShelfSnapshot(page);
|
|
180
|
+
if (!snapshot.cacheFound) {
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return normalizeWebShelfRows(snapshot, limit);
|
|
184
|
+
}
|
|
25
185
|
},
|
|
26
186
|
});
|
package/src/clis/weread/utils.ts
CHANGED
|
@@ -12,11 +12,16 @@ import type { BrowserCookie, IPage } from '../../types.js';
|
|
|
12
12
|
const WEB_API = 'https://weread.qq.com/web';
|
|
13
13
|
const API = 'https://i.weread.qq.com';
|
|
14
14
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
15
|
+
const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
|
|
15
16
|
|
|
16
17
|
function buildCookieHeader(cookies: BrowserCookie[]): string {
|
|
17
18
|
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function isAuthErrorResponse(resp: Response, data: any): boolean {
|
|
22
|
+
return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
/**
|
|
21
26
|
* Fetch a public WeRead web endpoint (Node.js direct fetch).
|
|
22
27
|
* Used by search and ranking commands (browser: false).
|
|
@@ -78,7 +83,7 @@ export async function fetchPrivateApi(page: IPage, path: string, params?: Record
|
|
|
78
83
|
throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
if (resp
|
|
86
|
+
if (isAuthErrorResponse(resp, data)) {
|
|
82
87
|
throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
|
|
83
88
|
}
|
|
84
89
|
if (!resp.ok) {
|
package/src/daemon.ts
CHANGED
|
@@ -198,6 +198,7 @@ const wss = new WebSocketServer({
|
|
|
198
198
|
wss.on('connection', (ws: WebSocket) => {
|
|
199
199
|
console.error('[daemon] Extension connected');
|
|
200
200
|
extensionWs = ws;
|
|
201
|
+
extensionVersion = null; // cleared until hello message arrives
|
|
201
202
|
|
|
202
203
|
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
203
204
|
let missedPongs = 0;
|
package/src/doctor.ts
CHANGED
|
@@ -95,11 +95,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
95
95
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
if (status.extensionVersion && opts.cliVersion
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
if (status.extensionVersion && opts.cliVersion) {
|
|
99
|
+
const extMajor = status.extensionVersion.split('.')[0];
|
|
100
|
+
const cliMajor = opts.cliVersion.split('.')[0];
|
|
101
|
+
if (extMajor !== cliMajor) {
|
|
102
|
+
issues.push(
|
|
103
|
+
`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
104
|
+
' Download the latest extension from: https://github.com/jackwener/opencli/releases',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
return {
|
package/src/execution.ts
CHANGED
|
@@ -19,8 +19,6 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
|
19
19
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
20
20
|
import { emitHook, type HookContext } from './hooks.js';
|
|
21
21
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
22
|
-
import { PKG_VERSION } from './version.js';
|
|
23
|
-
import chalk from 'chalk';
|
|
24
22
|
|
|
25
23
|
const _loadedModules = new Set<string>();
|
|
26
24
|
|
|
@@ -186,13 +184,6 @@ export async function executeCommand(
|
|
|
186
184
|
' Then run: opencli doctor',
|
|
187
185
|
);
|
|
188
186
|
}
|
|
189
|
-
// ── Version mismatch: warn but don't block ──
|
|
190
|
-
if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
|
|
191
|
-
process.stderr.write(
|
|
192
|
-
chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`)
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
187
|
ensureRequiredEnv(cmd);
|
|
197
188
|
const BrowserFactory = getBrowserFactory();
|
|
198
189
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('extension manifest regression', () => {
|
|
6
|
+
it('keeps host permissions required by chrome.cookies.getAll', async () => {
|
|
7
|
+
const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
|
|
8
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
9
|
+
const manifest = JSON.parse(raw) as {
|
|
10
|
+
permissions?: string[];
|
|
11
|
+
host_permissions?: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
expect(manifest.permissions).toContain('cookies');
|
|
15
|
+
expect(manifest.host_permissions).toContain('<all_urls>');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -56,6 +56,24 @@ describe('weread private API regression', () => {
|
|
|
56
56
|
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
|
|
60
|
+
const mockPage = {
|
|
61
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
62
|
+
evaluate: vi.fn(),
|
|
63
|
+
} as any;
|
|
64
|
+
|
|
65
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
66
|
+
ok: true,
|
|
67
|
+
status: 200,
|
|
68
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
|
|
72
|
+
code: 'AUTH_REQUIRED',
|
|
73
|
+
message: 'Not logged in to WeRead',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
59
77
|
it('maps non-auth API errors to API_ERROR', async () => {
|
|
60
78
|
const mockPage = {
|
|
61
79
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
@@ -147,4 +165,193 @@ describe('weread private API regression', () => {
|
|
|
147
165
|
},
|
|
148
166
|
]);
|
|
149
167
|
});
|
|
168
|
+
|
|
169
|
+
it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
|
|
170
|
+
const command = getRegistry().get('weread/shelf');
|
|
171
|
+
expect(command?.func).toBeTypeOf('function');
|
|
172
|
+
|
|
173
|
+
const mockPage = {
|
|
174
|
+
getCookies: vi.fn()
|
|
175
|
+
.mockResolvedValueOnce([
|
|
176
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
177
|
+
])
|
|
178
|
+
.mockResolvedValueOnce([
|
|
179
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
180
|
+
]),
|
|
181
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
182
|
+
evaluate: vi.fn().mockImplementation(async (source: string) => {
|
|
183
|
+
expect(source).toContain('shelf:rawBooks:vid-current');
|
|
184
|
+
expect(source).toContain('shelf:shelfIndexes:vid-current');
|
|
185
|
+
return {
|
|
186
|
+
cacheFound: true,
|
|
187
|
+
rawBooks: [
|
|
188
|
+
{
|
|
189
|
+
bookId: '40055543',
|
|
190
|
+
title: '置身事内:中国政府与经济发展',
|
|
191
|
+
author: '兰小欢',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
bookId: '29196155',
|
|
195
|
+
title: '文明、现代化、价值投资与中国',
|
|
196
|
+
author: '李录',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
shelfIndexes: [
|
|
200
|
+
{ bookId: '29196155', idx: 0, role: 'book' },
|
|
201
|
+
{ bookId: '40055543', idx: 1, role: 'book' },
|
|
202
|
+
],
|
|
203
|
+
lastChapters: {
|
|
204
|
+
'29196155': 40,
|
|
205
|
+
'40055543': 60,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}),
|
|
209
|
+
} as any;
|
|
210
|
+
|
|
211
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
212
|
+
ok: false,
|
|
213
|
+
status: 401,
|
|
214
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const result = await command!.func!(mockPage, { limit: 1 });
|
|
218
|
+
|
|
219
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
220
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
221
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(result).toEqual([
|
|
223
|
+
{
|
|
224
|
+
title: '文明、现代化、价值投资与中国',
|
|
225
|
+
author: '李录',
|
|
226
|
+
progress: '-',
|
|
227
|
+
bookId: '29196155',
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
|
|
233
|
+
const command = getRegistry().get('weread/shelf');
|
|
234
|
+
expect(command?.func).toBeTypeOf('function');
|
|
235
|
+
|
|
236
|
+
const mockPage = {
|
|
237
|
+
getCookies: vi.fn()
|
|
238
|
+
.mockResolvedValueOnce([
|
|
239
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
240
|
+
])
|
|
241
|
+
.mockResolvedValueOnce([
|
|
242
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
243
|
+
]),
|
|
244
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
245
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
246
|
+
cacheFound: false,
|
|
247
|
+
rawBooks: [],
|
|
248
|
+
shelfIndexes: [],
|
|
249
|
+
lastChapters: {},
|
|
250
|
+
}),
|
|
251
|
+
} as any;
|
|
252
|
+
|
|
253
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
254
|
+
ok: false,
|
|
255
|
+
status: 401,
|
|
256
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
await expect(command!.func!(mockPage, { limit: 20 })).rejects.toMatchObject({
|
|
260
|
+
code: 'AUTH_REQUIRED',
|
|
261
|
+
message: 'Not logged in to WeRead',
|
|
262
|
+
});
|
|
263
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
264
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('returns an empty list when the current session cache is confirmed but empty', async () => {
|
|
268
|
+
const command = getRegistry().get('weread/shelf');
|
|
269
|
+
expect(command?.func).toBeTypeOf('function');
|
|
270
|
+
|
|
271
|
+
const mockPage = {
|
|
272
|
+
getCookies: vi.fn()
|
|
273
|
+
.mockResolvedValueOnce([
|
|
274
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
275
|
+
])
|
|
276
|
+
.mockResolvedValueOnce([
|
|
277
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
278
|
+
]),
|
|
279
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
280
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
281
|
+
cacheFound: true,
|
|
282
|
+
rawBooks: [],
|
|
283
|
+
shelfIndexes: [],
|
|
284
|
+
lastChapters: {},
|
|
285
|
+
}),
|
|
286
|
+
} as any;
|
|
287
|
+
|
|
288
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
289
|
+
ok: false,
|
|
290
|
+
status: 401,
|
|
291
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
const result = await command!.func!(mockPage, { limit: 20 });
|
|
295
|
+
|
|
296
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
297
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
298
|
+
expect(result).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
|
|
302
|
+
const command = getRegistry().get('weread/shelf');
|
|
303
|
+
expect(command?.func).toBeTypeOf('function');
|
|
304
|
+
|
|
305
|
+
const mockPage = {
|
|
306
|
+
getCookies: vi.fn()
|
|
307
|
+
.mockResolvedValueOnce([
|
|
308
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
309
|
+
])
|
|
310
|
+
.mockResolvedValueOnce([
|
|
311
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
312
|
+
]),
|
|
313
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
314
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
315
|
+
cacheFound: true,
|
|
316
|
+
rawBooks: [
|
|
317
|
+
{
|
|
318
|
+
bookId: '40055543',
|
|
319
|
+
title: '置身事内:中国政府与经济发展',
|
|
320
|
+
author: '兰小欢',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
bookId: '29196155',
|
|
324
|
+
title: '文明、现代化、价值投资与中国',
|
|
325
|
+
author: '李录',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
shelfIndexes: [],
|
|
329
|
+
}),
|
|
330
|
+
} as any;
|
|
331
|
+
|
|
332
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
333
|
+
ok: false,
|
|
334
|
+
status: 401,
|
|
335
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
const result = await command!.func!(mockPage, { limit: 2 });
|
|
339
|
+
|
|
340
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
|
|
341
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
342
|
+
expect(result).toEqual([
|
|
343
|
+
{
|
|
344
|
+
title: '置身事内:中国政府与经济发展',
|
|
345
|
+
author: '兰小欢',
|
|
346
|
+
progress: '-',
|
|
347
|
+
bookId: '40055543',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
title: '文明、现代化、价值投资与中国',
|
|
351
|
+
author: '李录',
|
|
352
|
+
progress: '-',
|
|
353
|
+
bookId: '29196155',
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
150
357
|
});
|
|
@@ -35,7 +35,7 @@ function isImdbChallenge(result: CliResult): boolean {
|
|
|
35
35
|
|
|
36
36
|
function isBrowserBridgeUnavailable(result: CliResult): boolean {
|
|
37
37
|
const text = `${result.stderr}\n${result.stdout}`;
|
|
38
|
-
return /Browser Bridge
|
|
38
|
+
return /Browser Bridge.*not connected|Extension.*not connected/i.test(text);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async function expectImdbDataOrChallengeSkip(args: string[], label: string): Promise<any[] | null> {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* E2E tests for output format rendering.
|
|
3
|
-
* Uses
|
|
3
|
+
* Uses the built-in list command so renderer coverage does not depend on
|
|
4
|
+
* external network availability.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { describe, it, expect } from 'vitest';
|
|
@@ -10,19 +11,22 @@ const FORMATS = ['json', 'yaml', 'csv', 'md'] as const;
|
|
|
10
11
|
|
|
11
12
|
describe('output formats E2E', () => {
|
|
12
13
|
for (const fmt of FORMATS) {
|
|
13
|
-
it(`
|
|
14
|
-
const { stdout, code } = await runCli(['
|
|
14
|
+
it(`list -f ${fmt} produces valid output`, async () => {
|
|
15
|
+
const { stdout, code } = await runCli(['list', '-f', fmt]);
|
|
15
16
|
expect(code).toBe(0);
|
|
16
17
|
expect(stdout.trim().length).toBeGreaterThan(0);
|
|
17
18
|
|
|
18
19
|
if (fmt === 'json') {
|
|
19
20
|
const data = parseJsonOutput(stdout);
|
|
20
21
|
expect(Array.isArray(data)).toBe(true);
|
|
21
|
-
expect(data.length).
|
|
22
|
+
expect(data.length).toBeGreaterThan(50);
|
|
23
|
+
expect(data[0]).toHaveProperty('command');
|
|
24
|
+
expect(data[0]).toHaveProperty('site');
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
if (fmt === 'yaml') {
|
|
25
|
-
expect(stdout).toContain('
|
|
28
|
+
expect(stdout).toContain('command:');
|
|
29
|
+
expect(stdout).toContain('site:');
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
if (fmt === 'csv') {
|
|
@@ -33,16 +37,8 @@ describe('output formats E2E', () => {
|
|
|
33
37
|
|
|
34
38
|
if (fmt === 'md') {
|
|
35
39
|
// Markdown table should have pipe characters
|
|
36
|
-
expect(stdout).toContain('|');
|
|
40
|
+
expect(stdout).toContain('| command |');
|
|
37
41
|
}
|
|
38
42
|
}, 30_000);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
|
-
it('list -f csv produces valid csv', async () => {
|
|
42
|
-
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
|
43
|
-
expect(code).toBe(0);
|
|
44
|
-
const lines = stdout.trim().split('\n');
|
|
45
|
-
// Header + many data lines
|
|
46
|
-
expect(lines.length).toBeGreaterThan(50);
|
|
47
|
-
});
|
|
48
44
|
});
|
|
@@ -60,7 +60,10 @@ describe('plugin management E2E', () => {
|
|
|
60
60
|
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
|
|
61
61
|
expect(lock[PLUGIN_NAME]).toBeDefined();
|
|
62
62
|
expect(lock[PLUGIN_NAME].commitHash).toBeTruthy();
|
|
63
|
-
expect(lock[PLUGIN_NAME].source).
|
|
63
|
+
expect(lock[PLUGIN_NAME].source).toMatchObject({
|
|
64
|
+
kind: 'git',
|
|
65
|
+
});
|
|
66
|
+
expect(lock[PLUGIN_NAME].source.url).toContain('opencli-plugin-hot-digest');
|
|
64
67
|
expect(lock[PLUGIN_NAME].installedAt).toBeTruthy();
|
|
65
68
|
}, 60_000);
|
|
66
69
|
|
|
@@ -21,7 +21,7 @@ function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean
|
|
|
21
21
|
|
|
22
22
|
function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean {
|
|
23
23
|
if (code === 0) return false;
|
|
24
|
-
return /Error \[FETCH_ERROR\]: (Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
|
24
|
+
return /(?:Error \[FETCH_ERROR\]: )?(Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
|
25
25
|
|| stderr === ''; // timeout killed the process before any output
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -34,6 +34,17 @@ function isExpectedGoogleRestriction(code: number, stderr: string): boolean {
|
|
|
34
34
|
// Keep old name as alias for existing tests
|
|
35
35
|
const isExpectedXiaoyuzhouRestriction = isExpectedChineseSiteRestriction;
|
|
36
36
|
|
|
37
|
+
describe('public command restriction detectors', () => {
|
|
38
|
+
it('treats current Apple Podcasts CliError rendering as an expected restriction', () => {
|
|
39
|
+
expect(
|
|
40
|
+
isExpectedApplePodcastsRestriction(
|
|
41
|
+
1,
|
|
42
|
+
'⚠️ Unable to reach Apple Podcasts charts for US\n→ Apple charts may be temporarily unavailable (ECONNRESET). Try again later.\n',
|
|
43
|
+
),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
describe('public commands E2E', () => {
|
|
38
49
|
// ── bloomberg (RSS-backed, browser: false) ──
|
|
39
50
|
it('bloomberg main returns structured headline data', async () => {
|
package/vitest.config.ts
CHANGED
|
@@ -16,21 +16,7 @@ export default defineConfig({
|
|
|
16
16
|
{
|
|
17
17
|
test: {
|
|
18
18
|
name: 'adapter',
|
|
19
|
-
include: [
|
|
20
|
-
'src/clis/bilibili/**/*.test.ts',
|
|
21
|
-
'src/clis/imdb/**/*.test.ts',
|
|
22
|
-
'src/clis/jd/**/*.test.ts',
|
|
23
|
-
'src/clis/linux-do/**/*.test.ts',
|
|
24
|
-
'src/clis/xiaohongshu/**/*.test.ts',
|
|
25
|
-
'src/clis/twitter/**/*.test.ts',
|
|
26
|
-
'src/clis/douban/**/*.test.ts',
|
|
27
|
-
'src/clis/zhihu/**/*.test.ts',
|
|
28
|
-
'src/clis/v2ex/**/*.test.ts',
|
|
29
|
-
'src/clis/weread/**/*.test.ts',
|
|
30
|
-
'src/clis/36kr/**/*.test.ts',
|
|
31
|
-
'src/clis/producthunt/**/*.test.ts',
|
|
32
|
-
'src/clis/paperreview/**/*.test.ts',
|
|
33
|
-
],
|
|
19
|
+
include: ['src/clis/**/*.test.ts'],
|
|
34
20
|
sequence: { groupOrder: 1 },
|
|
35
21
|
},
|
|
36
22
|
},
|