/** * 確認画面を出した検索先を、しばらく飛ばす(issue #1283 二段目)。 * * 直したかった噛み合わせ: 検索は毎回 Google から始まり、どれか 1 つが答えればその検索は * 成功する。成功すると「連続で塞がれた回数」が 0 に戻るので、Google だけが塞がれていて * Yahoo が答えられる間は、全体の休止に入る条件が一度も満たされない。結果、検索のたびに * Google へ 1 回行き続ける。 * * ここで固定するのは 4 つ。 * - 確認画面を出した検索先は、**次の検索で開かれない** * - 飛ばすのはその検索先だけで、**他の検索先は使われる** * - 飛ばした結果ブラウザが 1 つも動かなかったとき、0 件を「見つからなかった」と読ませない * - **人が「解決した」を押したら戻る**(ワークスペース単位) * * 偽物の立て方は web.captcha-partial-block.test.ts と同じ——本物の browser.ts に画面の * 台帳だけ差し替えて渡す。`vi.mock('./browser.js')` だと 2 つ目の検索先から本物へ落ちる。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const configMock = { displayMode: 'novnc' as string, maxCaptchaPages: 5 as unknown, captchaBlockThreshold: 1000, captchaBlockCooldownSec: 0, captchaEngineCooldownSec: 1800, }; vi.mock('../../config.js', () => ({ loadConfig: () => ({ browser: { ...configMock } }), })); const { setSessionManager } = await import('./browser.js'); const { performWebSearch, clearPersistentContexts, resetWebSearchCaptchaBreaker, clearWebSearchCache, isSearchEngineCoolingDown, markSearchEngineCoolingDown, clearSearchEngineCooldowns, resetSearchEngineCooldowns, DEFAULT_CAPTCHA_ENGINE_COOLDOWN_SEC, } = await import('./web.js'); const CAPTCHA_TITLE = 'Our systems have detected unusual traffic'; /** 確認画面で止める検索先(遷移先 URL の一部で見分ける) */ let blockedHosts: string[] = []; /** 実際に開かれた URL。飛ばせているかはここで数える */ let visitedUrls: string[] = []; class FakePage { closed = false; private url = ''; constructor(private readonly ctx: FakeContext) {} isClosed(): boolean { return this.closed; } context(): FakeContext { return this.ctx; } setDefaultTimeout(): void {} on(): void {} async route(): Promise {} async goto(url: string): Promise { this.url = url; visitedUrls.push(url); } async waitForSelector(): Promise {} async bringToFront(): Promise {} private blocked(): boolean { return blockedHosts.some((h) => this.url.includes(h)); } async evaluate(script: string): Promise { if (typeof script === 'string' && script.includes('document.title')) { return this.blocked() ? CAPTCHA_TITLE : 'テスト検索'; } if (typeof script === 'string' && script.includes('document.body')) return ''; return this.blocked() ? [] : [{ title: '見つかった', url: 'https://example.com/1', snippet: '本文' }]; } async close(): Promise { this.closed = true; this.ctx.removePage(this); } } class FakeContext { private pageList: FakePage[] = []; constructor(private readonly parent: FakeBrowser) {} browser(): FakeBrowser { return this.parent; } on(): void {} async route(): Promise {} pages(): FakePage[] { return [...this.pageList]; } removePage(p: FakePage): void { this.pageList = this.pageList.filter((x) => x !== p); } async newPage(): Promise { const p = new FakePage(this); this.pageList.push(p); return p; } async addCookies(): Promise {} async close(): Promise { this.pageList = []; } } class FakeBrowser { private ctxList: FakeContext[] = []; isConnected(): boolean { return true; } contexts(): FakeContext[] { return [...this.ctxList]; } async newContext(): Promise { const c = new FakeContext(this); this.ctxList.push(c); return c; } } function installPool(): void { const poolBrowser = new FakeBrowser(); setSessionManager({ createPoolSession: async () => ({ id: 'pool-1', browser: poolBrowser }), findPoolSession: () => undefined, touchSession: () => {}, markCaptchaPending: () => {}, } as never); } const toolsConfig = { searxngUrl: 'http://searxng.invalid' }; /** 開かれた URL のうち、その検索先のものが何回あったか */ const visitsTo = (host: string) => visitedUrls.filter((u) => u.includes(host)).length; beforeEach(() => { configMock.displayMode = 'novnc'; configMock.captchaEngineCooldownSec = 1800; blockedHosts = []; visitedUrls = []; clearPersistentContexts(); resetWebSearchCaptchaBreaker(); resetSearchEngineCooldowns(); clearWebSearchCache(); installPool(); // SearXNG は結果を返さない(ブラウザ検索の結末をそのまま見たい) vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ results: [] }) }))); }); afterEach(() => { setSessionManager(null); clearPersistentContexts(); resetWebSearchCaptchaBreaker(); resetSearchEngineCooldowns(); clearWebSearchCache(); vi.unstubAllGlobals(); vi.useRealTimers(); }); describe('台帳の決まり', () => { it('付けていなければ休止していない', () => { expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(false); }); it('付けたら休止する。ワークスペースが違えば巻き込まない', () => { markSearchEngineCoolingDown('space-a', 'Google', 60_000); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(true); expect(isSearchEngineCoolingDown('space-b', 'Google')).toBe(false); expect(isSearchEngineCoolingDown('space-a', 'Brave')).toBe(false); }); it('期限が過ぎたら休止が解ける', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-27T00:00:00Z')); markSearchEngineCoolingDown('space-a', 'Google', 60_000); vi.setSystemTime(new Date('2026-08-27T00:00:59Z')); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(true); vi.setSystemTime(new Date('2026-08-27T00:01:01Z')); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(false); }); it('0 秒を渡したら付けない(設定で切れる)', () => { markSearchEngineCoolingDown('space-a', 'Google', 0); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(false); }); it('下ろすのは指定したワークスペースの分だけ', () => { markSearchEngineCoolingDown('space-a', 'Google', 60_000); markSearchEngineCoolingDown('space-a', 'Brave', 60_000); markSearchEngineCoolingDown('space-b', 'Google', 60_000); clearSearchEngineCooldowns('space-a'); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(false); expect(isSearchEngineCoolingDown('space-a', 'Brave')).toBe(false); expect(isSearchEngineCoolingDown('space-b', 'Google'), 'よそまで下ろしている').toBe(true); }); it('ワークスペースの無い検索と space-a は別扱い', () => { markSearchEngineCoolingDown(undefined, 'Google', 60_000); expect(isSearchEngineCoolingDown('space-a', 'Google')).toBe(false); clearSearchEngineCooldowns(null); expect(isSearchEngineCoolingDown(undefined, 'Google')).toBe(false); }); it('既定は 30 分', () => { expect(DEFAULT_CAPTCHA_ENGINE_COOLDOWN_SEC).toBe(1800); }); }); describe('検索の流れにつながっているか', () => { it('確認画面を出した検索先は、次の検索で開かれない', async () => { blockedHosts = ['google.com']; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a'); expect(visitsTo('google.com'), '1 回目で Google を開いていない').toBeGreaterThan(0); visitedUrls = []; await performWebSearch('いぬ', 3, toolsConfig, undefined, 'space-a'); expect(visitsTo('google.com'), '2 回目も Google を叩いている').toBe(0); }); it('飛ばすのはその検索先だけ。次の検索先は使われ、結果も返る', async () => { blockedHosts = ['google.com']; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a'); visitedUrls = []; const results = await performWebSearch('いぬ', 3, toolsConfig, undefined, 'space-a'); expect(results.length).toBeGreaterThan(0); expect(visitedUrls.length, '次の検索先も開いていない').toBeGreaterThan(0); }); it('よそのワークスペースの検索は巻き込まない', async () => { blockedHosts = ['google.com']; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a'); visitedUrls = []; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-b'); expect(visitsTo('google.com'), 'space-b まで飛ばしている').toBeGreaterThan(0); }); it('全部飛ばして 1 つも開けなかったら、0 件ではなく塞がれたとして失敗する', async () => { blockedHosts = ['google.com', 'brave.com', 'yahoo.co.jp']; await expect(performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a')).rejects.toThrow(); visitedUrls = []; // 2 回目は 3 つとも休止中。ブラウザは 1 枚も開かず、SearXNG も 0 件。 await expect(performWebSearch('いぬ', 3, toolsConfig, undefined, 'space-a')).rejects.toThrow(); expect(visitedUrls, 'ブラウザを開いている').toEqual([]); }); it('「解決した」で下ろせば、次の検索から元の検索先に戻る', async () => { blockedHosts = ['google.com']; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a'); clearSearchEngineCooldowns('space-a'); blockedHosts = []; // 人が解いた後の状態 visitedUrls = []; await performWebSearch('いぬ', 3, toolsConfig, undefined, 'space-a'); expect(visitsTo('google.com'), '解いたのに Google へ戻っていない').toBeGreaterThan(0); }); it('0 秒に設定すると、これまでどおり毎回 Google から試す', async () => { configMock.captchaEngineCooldownSec = 0; blockedHosts = ['google.com']; await performWebSearch('ねこ', 3, toolsConfig, undefined, 'space-a'); visitedUrls = []; await performWebSearch('いぬ', 3, toolsConfig, undefined, 'space-a'); expect(visitsTo('google.com')).toBeGreaterThan(0); }); }); /* * 画面を出さない設定(headless)については、ここに流れのテストを置いていない。 * headless では検索が偽のブラウザではなく本物の Chromium を起動しに行くので、この偽物では * 経路そのものを踏めない。「開いた URL が 0 件」は本物が起動できなかっただけでも通ってしまい、 * 素通りするテストになる。 * * 実装としては、休止を付ける 1 行は画面の有無で分けていない(1 行上の `runtime.onCaptcha` * だけが `useNovnc` の内側にある)。人が解けない設定なら、なおさら叩き続ける理由が無い。 */