Files
maestro/src/engine/tools/web.engine-cooldown.test.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

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` の内側にある)。人が解けない設定なら、なおさら叩き続ける理由が無い。
*/