273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
/**
|
|
* 確認画面を出した検索先を、しばらく飛ばす(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<void> {}
|
|
async goto(url: string): Promise<void> { this.url = url; visitedUrls.push(url); }
|
|
async waitForSelector(): Promise<void> {}
|
|
async bringToFront(): Promise<void> {}
|
|
private blocked(): boolean { return blockedHosts.some((h) => this.url.includes(h)); }
|
|
async evaluate(script: string): Promise<unknown> {
|
|
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<void> { 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<void> {}
|
|
pages(): FakePage[] { return [...this.pageList]; }
|
|
removePage(p: FakePage): void { this.pageList = this.pageList.filter((x) => x !== p); }
|
|
async newPage(): Promise<FakePage> {
|
|
const p = new FakePage(this);
|
|
this.pageList.push(p);
|
|
return p;
|
|
}
|
|
async addCookies(): Promise<void> {}
|
|
async close(): Promise<void> { this.pageList = []; }
|
|
}
|
|
|
|
class FakeBrowser {
|
|
private ctxList: FakeContext[] = [];
|
|
isConnected(): boolean { return true; }
|
|
contexts(): FakeContext[] { return [...this.ctxList]; }
|
|
async newContext(): Promise<FakeContext> {
|
|
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` の内側にある)。人が解けない設定なら、なおさら叩き続ける理由が無い。
|
|
*/
|