489 lines
18 KiB
JavaScript
489 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const { _private } = require('../src/services/chatgpt_service');
|
|
const { classifyFailure } = require('../src/utils/error_classifier');
|
|
|
|
function missingLocator() {
|
|
return {
|
|
count: async () => 0,
|
|
nth() { return this; },
|
|
isVisible: async () => false,
|
|
};
|
|
}
|
|
|
|
function textPage(url, text) {
|
|
return {
|
|
url: () => url,
|
|
locator: selector => selector === 'body'
|
|
? { innerText: async () => text }
|
|
: missingLocator(),
|
|
};
|
|
}
|
|
|
|
function collectionLocator(items) {
|
|
return {
|
|
count: async () => items.length,
|
|
nth: index => items[index] || missingLocator(),
|
|
};
|
|
}
|
|
|
|
function chooserElement(text, onClick, attributes = {}) {
|
|
let visible = true;
|
|
return {
|
|
count: async () => 1,
|
|
nth() { return this; },
|
|
isVisible: async () => visible,
|
|
innerText: async () => text,
|
|
getAttribute: async name => attributes[name] || '',
|
|
scrollIntoViewIfNeeded: async () => {},
|
|
click: async () => {
|
|
const shouldHide = await onClick();
|
|
if (shouldHide !== false) visible = false;
|
|
},
|
|
};
|
|
}
|
|
|
|
function accountChooserPage({ choices, otherAccount, text, url = 'https://chatgpt.com/' }) {
|
|
return {
|
|
url: () => url,
|
|
waitForTimeout: async () => {},
|
|
locator: selector => {
|
|
if (selector === 'body') return { innerText: async () => text };
|
|
if (selector === 'button, [role="button"], a[href], [tabindex="0"]') {
|
|
return collectionLocator(choices);
|
|
}
|
|
if (selector.includes('Log in to another account')) {
|
|
return otherAccount || missingLocator();
|
|
}
|
|
return missingLocator();
|
|
},
|
|
};
|
|
}
|
|
|
|
(async () => {
|
|
const deleted = await _private.detectOpenAITerminalPage(textPage(
|
|
'https://auth.openai.com/error',
|
|
'Your account has been deactivated.',
|
|
));
|
|
assert.strictEqual(deleted.code, 'CHATGPT_ACCOUNT_DEACTIVATED');
|
|
assert.strictEqual(deleted.retryable, false);
|
|
assert.strictEqual(deleted.restartBrowser, false);
|
|
const deletedRealCopy = await _private.detectOpenAITerminalPage(textPage(
|
|
'https://auth.openai.com/error',
|
|
'Authentication Error. You do not have an account because it has been deleted or deactivated.',
|
|
));
|
|
assert.strictEqual(deletedRealCopy.code, 'CHATGPT_ACCOUNT_DEACTIVATED');
|
|
assert.strictEqual(deletedRealCopy.retryable, false);
|
|
|
|
const oops = await _private.detectOpenAITerminalPage(textPage(
|
|
'https://auth.openai.com/error',
|
|
'Oops! Something went wrong while signing you in.',
|
|
));
|
|
assert.strictEqual(oops.code, 'CHATGPT_OPENAI_OOPS');
|
|
assert.strictEqual(oops.restartBrowser, true);
|
|
assert.strictEqual(
|
|
await _private.detectOpenAITerminalPage(textPage(
|
|
'https://example.com/',
|
|
'Oops! Something went wrong while signing you in.',
|
|
)),
|
|
null,
|
|
'只能在 OpenAI 官方域名上识别终止页',
|
|
);
|
|
let terminalRaceUrl = 'https://auth.openai.com/error';
|
|
const terminalRacePage = {
|
|
url: () => terminalRaceUrl,
|
|
locator: selector => selector === 'body'
|
|
? {
|
|
innerText: async () => {
|
|
terminalRaceUrl = 'https://accounts.google.com/signin';
|
|
return 'Oops! Something went wrong while signing you in.';
|
|
},
|
|
}
|
|
: missingLocator(),
|
|
};
|
|
assert.strictEqual(
|
|
await _private.detectOpenAITerminalPage(terminalRacePage),
|
|
null,
|
|
'读取正文期间已跳到 Google 时不得使用旧 OpenAI 路由误判',
|
|
);
|
|
|
|
const welcomeCases = [
|
|
{
|
|
name: '旧版登录入口',
|
|
text: 'Welcome back. Log in or sign up',
|
|
selector: 'button:text-is("Log in or sign up")',
|
|
visible: true,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: '真实新版 Google 入口',
|
|
text: 'Welcome back. Continue with Google Continue with Apple Continue with Microsoft',
|
|
selector: 'button:text-is("Continue with Google")',
|
|
visible: true,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: 'Google 入口不可见',
|
|
text: 'Welcome back. Continue with Google',
|
|
selector: 'button:text-is("Continue with Google")',
|
|
visible: false,
|
|
expected: false,
|
|
},
|
|
];
|
|
for (const testCase of welcomeCases) {
|
|
const action = {
|
|
count: async () => 1,
|
|
nth() { return this; },
|
|
isVisible: async () => testCase.visible,
|
|
};
|
|
const welcomePage = {
|
|
url: () => 'https://auth.openai.com/log-in',
|
|
locator: selector => {
|
|
if (selector === 'body') return { innerText: async () => testCase.text };
|
|
if (selector === testCase.selector) return action;
|
|
return missingLocator();
|
|
},
|
|
};
|
|
assert.strictEqual(Boolean(await _private.findWelcomeAuthEntry(welcomePage)), testCase.expected, testCase.name);
|
|
}
|
|
assert.strictEqual(
|
|
await _private.findWelcomeAuthEntry(textPage('https://auth.openai.com/log-in', 'Welcome back')),
|
|
null,
|
|
'没有 Log in or sign up 操作时不应误判',
|
|
);
|
|
|
|
let targetAccountClicks = 0;
|
|
let removeAccountClicks = 0;
|
|
let targetOtherAccountClicks = 0;
|
|
const removeTarget = chooserElement(
|
|
'Target User target.user+label@googlemail.com',
|
|
async () => { removeAccountClicks++; },
|
|
{ 'aria-label': 'Remove account' },
|
|
);
|
|
const matchingTarget = chooserElement(
|
|
'Target User target.user+label@googlemail.com',
|
|
async () => { targetAccountClicks++; },
|
|
);
|
|
const targetOtherAccount = chooserElement(
|
|
'Log in to another account',
|
|
async () => { targetOtherAccountClicks++; },
|
|
);
|
|
const matchingChooser = accountChooserPage({
|
|
choices: [removeTarget, matchingTarget],
|
|
otherAccount: targetOtherAccount,
|
|
text: 'Welcome back Choose an account to continue Target User target.user+label@googlemail.com Log in to another account',
|
|
});
|
|
const matchingChooserResult = await _private.handleChatGPTAccountChooser(
|
|
matchingChooser,
|
|
'targetuser@gmail.com',
|
|
() => {},
|
|
{ transitionTimeoutMs: 0 },
|
|
);
|
|
assert.strictEqual(matchingChooserResult.action, 'target_account');
|
|
assert.strictEqual(targetAccountClicks, 1, '邮箱匹配时应点击保存的目标账号卡片');
|
|
assert.strictEqual(removeAccountClicks, 0, '不得误点账号卡片中的移除按钮');
|
|
assert.strictEqual(targetOtherAccountClicks, 0, '找到目标账号后不得切换其他账号');
|
|
|
|
let mismatchedAccountClicks = 0;
|
|
let mismatchedOtherAccountClicks = 0;
|
|
const mismatchedAccount = chooserElement(
|
|
'Another User another@example.com',
|
|
async () => { mismatchedAccountClicks++; },
|
|
);
|
|
const mismatchedOtherAccount = chooserElement(
|
|
'Log in to another account',
|
|
async () => { mismatchedOtherAccountClicks++; },
|
|
);
|
|
const mismatchedChooserResult = await _private.handleChatGPTAccountChooser(
|
|
accountChooserPage({
|
|
choices: [mismatchedAccount],
|
|
otherAccount: mismatchedOtherAccount,
|
|
text: 'Welcome back Choose an account to continue Another User another@example.com Log in to another account',
|
|
}),
|
|
'target@example.com',
|
|
() => {},
|
|
{ transitionTimeoutMs: 0 },
|
|
);
|
|
assert.strictEqual(mismatchedChooserResult.action, 'other_account');
|
|
assert.strictEqual(mismatchedAccountClicks, 0, '邮箱不匹配时不得点击已保存的其他账号');
|
|
assert.strictEqual(mismatchedOtherAccountClicks, 1, '邮箱不匹配时应点击登录其他账号');
|
|
|
|
let nonChooserClicks = 0;
|
|
const nonChooserResult = await _private.handleChatGPTAccountChooser(
|
|
accountChooserPage({
|
|
choices: [chooserElement('target@example.com', async () => { nonChooserClicks++; })],
|
|
text: 'Welcome back Continue with Google',
|
|
}),
|
|
'target@example.com',
|
|
() => {},
|
|
{ transitionTimeoutMs: 0 },
|
|
);
|
|
assert.strictEqual(nonChooserResult.found, false, '普通 Welcome back 页面不应当作账号选择弹窗');
|
|
assert.strictEqual(nonChooserClicks, 0);
|
|
|
|
let stuckChooserClicks = 0;
|
|
const stuckTarget = chooserElement(
|
|
'Target target@example.com',
|
|
async () => { stuckChooserClicks++; return false; },
|
|
);
|
|
const stuckChooserResult = await _private.waitForChatGPTResult(
|
|
accountChooserPage({
|
|
choices: [stuckTarget],
|
|
text: 'Welcome back Choose an account to continue Target target@example.com',
|
|
}),
|
|
{ email: 'target@example.com' },
|
|
23,
|
|
() => {},
|
|
{ accountChooserTransitionTimeoutMs: 0 },
|
|
);
|
|
assert.strictEqual(stuckChooserResult.code, 'CHATGPT_ACCOUNT_CHOOSER_STUCK');
|
|
assert.strictEqual(stuckChooserClicks, 3, '账号选择弹窗最多只点击三次');
|
|
|
|
let ageConfirmClicks = 0;
|
|
const confirmButton = {
|
|
count: async () => 1,
|
|
nth() { return this; },
|
|
isVisible: async () => true,
|
|
click: async () => { ageConfirmClicks++; },
|
|
};
|
|
const dialog = {
|
|
isVisible: async () => true,
|
|
innerText: async () => 'Please confirm your age is correct',
|
|
locator: selector => selector === 'button:text-is("OK")' ? confirmButton : missingLocator(),
|
|
};
|
|
const ageDialogPage = {
|
|
url: () => 'https://auth.openai.com/create-account',
|
|
waitForLoadState: async () => {},
|
|
waitForTimeout: async () => {},
|
|
locator: selector => {
|
|
if (selector === '[role="dialog"]') {
|
|
return { count: async () => 1, nth: () => dialog };
|
|
}
|
|
if (selector === 'body') return { innerText: async () => 'Please confirm your age is correct OK' };
|
|
return missingLocator();
|
|
},
|
|
};
|
|
assert.strictEqual(await _private.acceptAgeConfirmationDialog(ageDialogPage, () => {}), true);
|
|
assert.strictEqual(ageConfirmClicks, 1);
|
|
const ageDialogResult = await _private.fillAgeOnboarding(
|
|
ageDialogPage,
|
|
{ email: 'target@example.com' },
|
|
23,
|
|
() => {},
|
|
{ stabilizeDelay: 0 },
|
|
);
|
|
assert.strictEqual(ageDialogResult.ok, true);
|
|
assert.strictEqual(ageDialogResult.confirmed, true);
|
|
assert.strictEqual(ageConfirmClicks, 2);
|
|
|
|
let realAgeConfirmClicks = 0;
|
|
const realAgeConfirmButton = {
|
|
count: async () => 1,
|
|
nth() { return this; },
|
|
isVisible: async () => true,
|
|
click: async () => { realAgeConfirmClicks++; },
|
|
};
|
|
const realAgeDialogPage = {
|
|
url: () => 'https://auth.openai.com/create-account',
|
|
locator: selector => {
|
|
if (selector === 'body') {
|
|
return { innerText: async () => "You're setting your birthday to Aug 20, 2003. OK Cancel" };
|
|
}
|
|
if (selector === 'button:text-is("OK")') return realAgeConfirmButton;
|
|
return missingLocator();
|
|
},
|
|
};
|
|
assert.strictEqual(
|
|
await _private.acceptAgeConfirmationDialog(realAgeDialogPage, () => {}),
|
|
true,
|
|
'应兼容没有标准 dialog 属性的真实生日确认弹框',
|
|
);
|
|
assert.strictEqual(realAgeConfirmClicks, 1);
|
|
|
|
const blankSnapshot = {
|
|
available: true,
|
|
readyState: 'complete',
|
|
bodyTextLength: 0,
|
|
bodyHtmlLength: 0,
|
|
bodyElementCount: 0,
|
|
visibleContentCount: 0,
|
|
resourceCount: 1,
|
|
resourceTransferSize: 100,
|
|
navigationResponseEnd: 20,
|
|
};
|
|
assert.strictEqual(_private.isPureBlankSnapshot(blankSnapshot), true);
|
|
assert.strictEqual(_private.isPureBlankSnapshot({ ...blankSnapshot, readyState: 'loading' }), false);
|
|
assert.strictEqual(_private.isPureBlankSnapshot({ ...blankSnapshot, visibleContentCount: 1 }), false);
|
|
let blankState = _private.advanceStableBlankTracker(null, 'https://auth.openai.com/x', blankSnapshot, 1000);
|
|
assert.strictEqual(blankState.stable, false);
|
|
blankState = _private.advanceStableBlankTracker(blankState.tracker, 'https://auth.openai.com/x', blankSnapshot, 1000);
|
|
blankState = _private.advanceStableBlankTracker(blankState.tracker, 'https://auth.openai.com/x', blankSnapshot, 1000);
|
|
blankState = _private.advanceStableBlankTracker(blankState.tracker, 'https://auth.openai.com/x', blankSnapshot, 1000);
|
|
assert.strictEqual(blankState.stable, true, '空白页必须稳定持续达到阈值才分类');
|
|
|
|
let blankReloads = 0;
|
|
const blankPage = {
|
|
url: () => 'https://chatgpt.com/',
|
|
title: async () => '',
|
|
evaluate: async () => blankSnapshot,
|
|
waitForTimeout: async () => {},
|
|
reload: async () => { blankReloads++; },
|
|
goto: async () => { throw new Error('刷新成功后不应改用 goto'); },
|
|
locator: selector => selector === 'body'
|
|
? { innerText: async () => '' }
|
|
: missingLocator(),
|
|
};
|
|
const blankResult = await _private.waitForChatGPTResult(
|
|
blankPage,
|
|
{ email: 'target@example.com' },
|
|
23,
|
|
() => {},
|
|
);
|
|
assert.strictEqual(blankResult.code, 'CHATGPT_BLANK_PAGE');
|
|
assert.strictEqual(blankReloads, 1, '稳定空白页只允许自动刷新一次');
|
|
|
|
const cfState = { body: 'ChatGPT home', waits: 0, probeRequests: 0, probeClosed: false };
|
|
const probePage = {
|
|
goto: async () => { cfState.probeRequests++; },
|
|
locator: selector => {
|
|
assert.strictEqual(selector, 'body');
|
|
return { innerText: async () => '{}' };
|
|
},
|
|
close: async () => { cfState.probeClosed = true; },
|
|
};
|
|
const cfMainPage = {
|
|
url: () => 'https://chatgpt.com/',
|
|
context: () => ({ newPage: async () => probePage }),
|
|
waitForTimeout: async () => {
|
|
cfState.waits++;
|
|
cfState.body = 'Performing security verification Verify you are human';
|
|
},
|
|
locator: selector => selector === 'body'
|
|
? { innerText: async () => cfState.body }
|
|
: missingLocator(),
|
|
};
|
|
const interrupted = await _private.readChatGPTSessionInProbePage(cfMainPage, () => {}, {
|
|
maxAttempts: 2,
|
|
retryIntervalMs: 1000,
|
|
expectedHome: true,
|
|
});
|
|
assert.strictEqual(_private.isSessionProbeInterruption(interrupted, 'cloudflare'), true);
|
|
assert.strictEqual(cfState.probeRequests, 1, 'CF 重现后不应继续请求 Session');
|
|
assert.strictEqual(cfState.probeClosed, true);
|
|
|
|
const probeResponseCases = [
|
|
{
|
|
name: 'Cloudflare HTML',
|
|
status: 403,
|
|
contentType: 'text/html; charset=UTF-8',
|
|
body: '<!doctype html><html><body>Performing security verification Cloudflare cf-ray</body></html>',
|
|
code: 'CHATGPT_SESSION_CF_HTML',
|
|
},
|
|
{ name: 'HTTP 403', status: 403, contentType: 'application/json', body: '{}', code: 'CHATGPT_SESSION_HTTP_403' },
|
|
{ name: 'HTTP 429', status: 429, contentType: 'application/json', body: '{}', code: 'CHATGPT_SESSION_HTTP_429' },
|
|
{ name: 'HTTP 503', status: 503, contentType: 'text/plain', body: 'Service Unavailable', code: 'CHATGPT_SESSION_HTTP_5XX' },
|
|
];
|
|
for (const testCase of probeResponseCases) {
|
|
let closed = false;
|
|
const mainPage = {
|
|
context: () => ({
|
|
newPage: async () => ({
|
|
goto: async () => ({
|
|
status: () => testCase.status,
|
|
headers: async () => ({ 'content-type': testCase.contentType }),
|
|
}),
|
|
locator: () => ({ innerText: async () => testCase.body }),
|
|
close: async () => { closed = true; },
|
|
}),
|
|
}),
|
|
waitForTimeout: async () => {},
|
|
};
|
|
const result = await _private.readChatGPTSessionInProbePage(mainPage, () => {}, { maxAttempts: 1 });
|
|
assert.strictEqual(result.code, testCase.code, testCase.name);
|
|
assert.strictEqual(_private.isSessionProbeFailure(result), true, testCase.name);
|
|
assert.strictEqual(closed, true, `${testCase.name} 临时页应关闭`);
|
|
}
|
|
|
|
const routeChangeState = {
|
|
url: 'https://chatgpt.com/',
|
|
probeRequests: 0,
|
|
probeBodyReads: 0,
|
|
};
|
|
const routeChangeMainPage = {
|
|
url: () => routeChangeState.url,
|
|
context: () => ({
|
|
newPage: async () => ({
|
|
goto: async () => {
|
|
routeChangeState.probeRequests++;
|
|
routeChangeState.url = 'https://auth.openai.com/error';
|
|
},
|
|
locator: () => ({
|
|
innerText: async () => {
|
|
routeChangeState.probeBodyReads++;
|
|
return '{}';
|
|
},
|
|
}),
|
|
close: async () => {},
|
|
}),
|
|
}),
|
|
locator: selector => selector === 'body'
|
|
? { innerText: async () => "You're all set" }
|
|
: missingLocator(),
|
|
waitForTimeout: async () => {},
|
|
};
|
|
const routeChanged = await _private.readChatGPTSessionInProbePage(routeChangeMainPage, () => {}, {
|
|
maxAttempts: 2,
|
|
});
|
|
assert.strictEqual(_private.isSessionProbeInterruption(routeChanged, 'route_changed'), true);
|
|
assert.strictEqual(routeChangeState.probeRequests, 1);
|
|
assert.strictEqual(routeChangeState.probeBodyReads, 0, '主页面换路由后应立即退出,不再读取旧 Session 响应');
|
|
|
|
let oauthStarts = 0;
|
|
let oauthUrl = 'https://auth.openai.com/log-in';
|
|
const oauthPage = {
|
|
url: () => oauthUrl,
|
|
title: async () => '',
|
|
waitForTimeout: async () => {},
|
|
goto: async url => {
|
|
assert.match(url, /chatgpt\.com\/auth\/login_with/);
|
|
oauthStarts++;
|
|
oauthUrl = 'https://accounts.google.com/signin/rejected';
|
|
},
|
|
locator: selector => {
|
|
if (selector === 'body') return { innerText: async () => 'Welcome back. Continue with Google' };
|
|
if (oauthUrl.includes('auth.openai.com') && selector === 'button:text-is("Continue with Google")') {
|
|
return { count: async () => 1, nth() { return this; }, isVisible: async () => true };
|
|
}
|
|
return missingLocator();
|
|
},
|
|
};
|
|
const oauthResult = await _private.waitForChatGPTResult(
|
|
oauthPage,
|
|
{ email: 'target@example.com' },
|
|
23,
|
|
() => {},
|
|
);
|
|
assert.strictEqual(oauthStarts, 1);
|
|
assert.strictEqual(oauthResult.code, 'SIGNIN_REJECTED');
|
|
|
|
const classifiedDeleted = classifyFailure({ message: 'OpenAI账号已删除或停用' });
|
|
assert.strictEqual(classifiedDeleted.code, 'CHATGPT_ACCOUNT_DEACTIVATED');
|
|
assert.strictEqual(classifiedDeleted.restartBrowser, false);
|
|
assert.strictEqual(classifiedDeleted.label, '账号已删除/停用');
|
|
assert.strictEqual(
|
|
classifyFailure({ message: 'You do not have an account because it has been deleted or deactivated.' }).code,
|
|
'CHATGPT_ACCOUNT_DEACTIVATED',
|
|
);
|
|
assert.strictEqual(
|
|
classifyFailure({ message: 'OpenAI登录页面出现 Oops 错误' }).code,
|
|
'CHATGPT_OPENAI_OOPS',
|
|
);
|
|
assert.strictEqual(classifyFailure({ message: 'OpenAI页面持续空白' }).code, 'CHATGPT_BLANK_PAGE');
|
|
|
|
console.log('chatgpt_page_states.test.js: PASS');
|
|
})().catch(error => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|