Skip to content

Commit 6557622

Browse files
jackwenercodex-mini0
andauthored
fix(adapters): surface empty results as EmptyResultError, not sentinel rows (#1981)
* fix(adapters): surface empty results as EmptyResultError, not sentinel rows Four adapters returned a fabricated row (exit 0) on the not-found/empty path instead of throwing a typed error, so an agent reading the exit code or rows could not tell "no results" from success — the framework assigns EMPTY_RESULT its own exit code precisely so this is detectable: - maimai/search-talents: returned [{error, query}] on zero candidates. Worse, `error`/`query` are not in `columns`, so the message was dropped by column projection — the user saw an empty/garbage row, never the reason. Now throws EmptyResultError (fixes the silent-column-drop too). - discord-app/search: returned a synthetic "System" row on no matches. - pixiv/download: returned a failed sentinel when an illust had 0 pages (the file already throws typed errors elsewhere). Test updated to assert the throw. - xiaohongshu/download: returned a failed sentinel when a note had no media (the file already throws CliError for the security-block branch). Per-image partial-failure status rows in the download loops are left as-is (legitimate batch reporting). Auth-path conversions (tiktok/facebook string-prefix + maimai in-page throw) are a separate follow-up since they involve in-page-throw handling. Adapter suites green; typed-error-lint and silent-column-drop audits report no new violations. * fix(adapters): fail closed on malformed empty payloads * fix(audit): bump undici in lockfile * fix(pixiv): fail closed on missing pages payload --------- Co-authored-by: codex-mini0 <codex-mini0@slock.local>
1 parent de2ef4c commit 6557622

8 files changed

Lines changed: 151 additions & 17 deletions

File tree

clis/discord-app/commands.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getRegistry } from '@jackwener/opencli/registry';
55
import './channels.js';
66
import './goto.js';
77
import './read.js';
8+
import './search.js';
89
import './servers.js';
910
import './thread-read.js';
1011
import './threads.js';
@@ -148,6 +149,46 @@ describe('discord-app command registration', () => {
148149
});
149150
});
150151

152+
describe('discord-app search', () => {
153+
function createSearchPage(bodyText = '', resultRows = []) {
154+
return {
155+
pressKey: vi.fn().mockResolvedValue(undefined),
156+
wait: vi.fn().mockResolvedValue(undefined),
157+
evaluate: vi.fn(async (script) => {
158+
if (script.includes('const input = document.querySelector')) return undefined;
159+
if (script.includes('const items = []')) {
160+
const rowsHtml = resultRows.map((row, index) => `
161+
<div class="searchResult_${index}" id="search-result-${index}">
162+
<span class="username">${row.author}</span>
163+
<div id="message-content-${index}">${row.message}</div>
164+
</div>
165+
`).join('');
166+
const dom = new JSDOM(`<!doctype html><body>${bodyText}${rowsHtml}</body>`, {
167+
url: 'https://discord.com/channels/111/222',
168+
runScripts: 'outside-only',
169+
});
170+
return dom.window.eval(script);
171+
}
172+
throw new Error(`unexpected evaluate script: ${script.slice(0, 80)}`);
173+
}),
174+
};
175+
}
176+
177+
it('throws EmptyResultError when Discord shows an explicit no-results state', async () => {
178+
const cmd = getRegistry().get('discord-app/search');
179+
const page = createSearchPage('<div>No results found</div>');
180+
181+
await expect(cmd.func(page, { query: 'missing' })).rejects.toBeInstanceOf(EmptyResultError);
182+
});
183+
184+
it('throws CommandExecutionError when result selectors return no rows and no empty-state marker', async () => {
185+
const cmd = getRegistry().get('discord-app/search');
186+
const page = createSearchPage('<main>search panel changed</main>');
187+
188+
await expect(cmd.func(page, { query: 'missing' })).rejects.toBeInstanceOf(CommandExecutionError);
189+
});
190+
});
191+
151192
describe('discord-app list row validation', () => {
152193
it('typed-fails channel rows missing stable channel identity', async () => {
153194
const page = {

clis/discord-app/search.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
23
export const searchCommand = cli({
34
site: 'discord-app',
45
name: 'search',
@@ -29,7 +30,7 @@ export const searchCommand = cli({
2930
await page.pressKey('Enter');
3031
await page.wait(2);
3132
// Scrape search results
32-
const results = await page.evaluate(`
33+
const searchState = await page.evaluate(`
3334
(function() {
3435
const items = [];
3536
const resultNodes = document.querySelectorAll('[class*="searchResult_"], [id*="search-result"]');
@@ -44,13 +45,22 @@ export const searchCommand = cli({
4445
});
4546
});
4647
47-
return items;
48+
const bodyText = document.body?.innerText || document.body?.textContent || '';
49+
const empty = /no results|no messages match|没有结果|无结果/i.test(bodyText);
50+
return { items, empty };
4851
})()
4952
`);
53+
if (!searchState || !Array.isArray(searchState.items)) {
54+
throw new CommandExecutionError('Discord search returned malformed browser payload.');
55+
}
56+
const results = searchState.items;
5057
// Close search
5158
await page.pressKey('Escape');
5259
if (results.length === 0) {
53-
return [{ Index: 0, Author: 'System', Message: `No results for "${query}"` }];
60+
if (!searchState.empty) {
61+
throw new CommandExecutionError('Discord search result selector returned no rows and no explicit empty-state marker.');
62+
}
63+
throw new EmptyResultError('discord-app search', `No results for "${query}".`);
5464
}
5565
return results;
5666
},

clis/maimai/search-talents.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Reuses Chrome login session to search for candidates on maimai.cn
44
*/
55
import { cli, Strategy } from '@jackwener/opencli/registry';
6+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
67

78
cli({
89
site: 'maimai',
@@ -123,11 +124,25 @@ cli({
123124
return result;
124125
}`);
125126

126-
// Extract talent list from response
127-
const talentList = data.data?.list || data.data?.talent_list || data.list || data.talent_list || [];
127+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
128+
throw new CommandExecutionError('Maimai search returned malformed API payload');
129+
}
130+
131+
// Extract talent list from response. Missing list fields mean the API
132+
// shape drifted; only an explicit empty array is a true empty result.
133+
const talentListCandidates = [
134+
data.data?.list,
135+
data.data?.talent_list,
136+
data.list,
137+
data.talent_list,
138+
];
139+
const talentList = talentListCandidates.find((value) => Array.isArray(value));
140+
if (!talentList) {
141+
throw new CommandExecutionError('Maimai search API payload missing talent list');
142+
}
128143

129-
if (!talentList || talentList.length === 0) {
130-
return [{ error: '未找到匹配的候选人', query: query }];
144+
if (talentList.length === 0) {
145+
throw new EmptyResultError('maimai search-talents', `未找到匹配 "${query}" 的候选人`);
131146
}
132147

133148
// Map to output format

clis/maimai/search-talents.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4+
import './search-talents.js';
5+
6+
function createPageMock(apiPayload) {
7+
return {
8+
goto: vi.fn().mockResolvedValue(undefined),
9+
waitForTimeout: vi.fn().mockResolvedValue(undefined),
10+
getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf' }]),
11+
evaluate: vi.fn().mockResolvedValue(apiPayload),
12+
};
13+
}
14+
15+
describe('maimai search-talents', () => {
16+
const command = getRegistry().get('maimai/search-talents');
17+
18+
it('throws EmptyResultError for an explicit empty talent list', async () => {
19+
const page = createPageMock({ code: 200, data: { list: [] } });
20+
21+
await expect(command.func(page, { query: 'Java' })).rejects.toBeInstanceOf(EmptyResultError);
22+
});
23+
24+
it('throws CommandExecutionError when API payload is missing the talent list shape', async () => {
25+
const page = createPageMock({ code: 200, data: { items: [] } });
26+
27+
await expect(command.func(page, { query: 'Java' })).rejects.toBeInstanceOf(CommandExecutionError);
28+
});
29+
});

clis/pixiv/download.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as path from 'node:path';
99
import { cli, Strategy } from '@jackwener/opencli/registry';
1010
import { formatCookieHeader, httpDownload } from '@jackwener/opencli/download';
1111
import { formatBytes } from '@jackwener/opencli/download/progress';
12-
import { CommandExecutionError, getErrorMessage } from '@jackwener/opencli/errors';
12+
import { CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors';
1313
import { pixivFetch } from './utils.js';
1414
cli({
1515
site: 'pixiv',
@@ -32,9 +32,12 @@ cli({
3232
// pixivFetch handles navigate + error checking; returns the response body directly
3333
const pages = await pixivFetch(page, `/ajax/illust/${illustId}/pages`, {
3434
notFoundMsg: `Illustration not found: ${illustId}`,
35-
}) || [];
35+
});
36+
if (!Array.isArray(pages)) {
37+
throw new CommandExecutionError('Pixiv pages API returned malformed payload');
38+
}
3639
if (pages.length === 0) {
37-
return [{ index: 0, type: '-', status: 'failed', size: 'No images found' }];
40+
throw new EmptyResultError('pixiv download', `No images found for illustration ${illustId}.`);
3841
}
3942
// Extract cookies for authenticated downloads
4043
const cookies = formatCookieHeader(await page.getCookies({ domain: 'pixiv.net' }));

clis/pixiv/download.test.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { getRegistry } from '@jackwener/opencli/registry';
3-
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3+
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
44
import { createPageMock } from '../test-utils.js';
55
// Mock download dependencies before importing the adapter
66
const { mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
@@ -42,10 +42,20 @@ describe('pixiv download', () => {
4242
const page = createPageMock([{ __httpError: 500 }]);
4343
await expect(cmd.func(page, { 'illust-id': '12345', output: '/tmp/test' })).rejects.toThrow(CommandExecutionError);
4444
});
45-
it('returns failure when no images found', async () => {
45+
it('throws EmptyResultError when no images found', async () => {
4646
const page = createPageMock([{ body: [] }]);
47-
const result = (await cmd.func(page, { 'illust-id': '12345', output: '/tmp/test' }));
48-
expect(result).toEqual([{ index: 0, type: '-', status: 'failed', size: 'No images found' }]);
47+
await expect(cmd.func(page, { 'illust-id': '12345', output: '/tmp/test' }))
48+
.rejects.toThrow(EmptyResultError);
49+
});
50+
it('throws CommandExecutionError when pages payload is malformed', async () => {
51+
const page = createPageMock([{ body: { pages: [] } }]);
52+
await expect(cmd.func(page, { 'illust-id': '12345', output: '/tmp/test' }))
53+
.rejects.toThrow(CommandExecutionError);
54+
});
55+
it('throws CommandExecutionError when pages payload is null', async () => {
56+
const page = createPageMock([{ body: null }]);
57+
await expect(cmd.func(page, { 'illust-id': '12345', output: '/tmp/test' }))
58+
.rejects.toThrow(CommandExecutionError);
4959
});
5060
it('downloads images with Referer header', async () => {
5161
mockHttpDownload.mockResolvedValue({ success: true, size: 1024000 });

clis/xiaohongshu/download.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { cli, Strategy } from '@jackwener/opencli/registry';
1010
import { formatCookieHeader } from '@jackwener/opencli/download';
1111
import { downloadMedia } from '@jackwener/opencli/download/media-download';
12-
import { CliError } from '@jackwener/opencli/errors';
12+
import { CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
1313
import { buildNoteUrl, parseNoteId } from './note-helpers.js';
1414
/**
1515
* Build the media-extraction IIFE. The note id is interpolated as a default
@@ -227,8 +227,11 @@ export const command = cli({
227227
? 'The page may be temporarily restricted. Try again later or from a different session.'
228228
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
229229
}
230-
if (!data || !data.media || data.media.length === 0) {
231-
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
230+
if (!data || typeof data !== 'object' || !Array.isArray(data.media)) {
231+
throw new CommandExecutionError('Xiaohongshu media extraction returned malformed payload.');
232+
}
233+
if (data.media.length === 0) {
234+
throw new EmptyResultError('xiaohongshu download', 'No downloadable media found on this note.');
232235
}
233236
// Extract cookies for authenticated downloads
234237
const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));

clis/xiaohongshu/download.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ vi.mock('@jackwener/opencli/download', () => ({
1010
formatCookieHeader: mockFormatCookieHeader,
1111
}));
1212
import { getRegistry } from '@jackwener/opencli/registry';
13+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
1314
import { JSDOM } from 'jsdom';
1415
import './download.js';
1516
import { buildDownloadExtractJs } from './download.js';
@@ -114,6 +115,28 @@ describe('xiaohongshu download', () => {
114115
});
115116
expect(mockDownloadMedia).not.toHaveBeenCalled();
116117
});
118+
it('throws EmptyResultError when extraction returns an explicit empty media array', async () => {
119+
const page = createPageMock({
120+
noteId: '69bc166f000000001a02069a',
121+
media: [],
122+
});
123+
await expect(command.func(page, {
124+
'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc',
125+
output: './out',
126+
})).rejects.toBeInstanceOf(EmptyResultError);
127+
expect(mockDownloadMedia).not.toHaveBeenCalled();
128+
});
129+
it('throws CommandExecutionError when extraction media shape is malformed', async () => {
130+
const page = createPageMock({
131+
noteId: '69bc166f000000001a02069a',
132+
media: { url: 'https://ci.xiaohongshu.com/example.jpg' },
133+
});
134+
await expect(command.func(page, {
135+
'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc',
136+
output: './out',
137+
})).rejects.toBeInstanceOf(CommandExecutionError);
138+
expect(mockDownloadMedia).not.toHaveBeenCalled();
139+
});
117140
});
118141

119142
describe('xiaohongshu download buildDownloadExtractJs carousel ordering (JSDOM)', () => {

0 commit comments

Comments
 (0)