feat: 增强 GPC 结账流程的错误处理与重试机制,优化超时管理
This commit is contained in:
@@ -7743,6 +7743,17 @@ function isGpcTaskEndedFailure(error) {
|
||||
return /GPC_TASK_ENDED::/i.test(message);
|
||||
}
|
||||
|
||||
function isGpcCheckoutRestartRequiredFailure(error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (/PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message)) {
|
||||
return false;
|
||||
}
|
||||
if (/GPC_TASK_ENDED::/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
return /GPC\s*API\s*请求超时|步骤\s*[67][\s\S]*GPC[\s\S]*(?:任务轮询超时|请求超时|超时|timeout|timed\s*out|卡死|无响应)|account\s+already\s+linked|GOPAY已经绑了订阅|(?:账号|账户|GoPay|GOPAY)[\s\S]*(?:已绑定|已经绑定|已绑|绑了订阅|绑定了订阅)|创建\s*GPC\s*订单失败[\s\S]*(?:任务已结束|任务结束|failed|expired|discarded|请求超时|timeout|timed\s*out)/i.test(message);
|
||||
}
|
||||
|
||||
function isGoPayCheckoutRestartRequiredFailure(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /GOPAY_RESTART_FROM_STEP6::|GOPAY_RETRY_REQUIRED::/i.test(message);
|
||||
@@ -10432,6 +10443,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
|
||||
const { targetRun, totalRuns, attemptRuns, continued = false } = context;
|
||||
let postStep7RestartCount = 0;
|
||||
let goPayCheckoutRestartCount = 0;
|
||||
let gpcCheckoutRestartCount = 0;
|
||||
let step4RestartCount = 0;
|
||||
let currentStartStep = startStep;
|
||||
let continueCurrentAttempt = continued;
|
||||
@@ -10525,6 +10537,26 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const stepExecutionKey = typeof getStepExecutionKeyForState === 'function'
|
||||
? getStepExecutionKeyForState(step, latestState)
|
||||
: '';
|
||||
const isGpcCheckoutStep = normalizePlusPaymentMethod(latestState?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GPC_HELPER
|
||||
|| String(latestState?.plusCheckoutSource || '').trim() === PLUS_PAYMENT_METHOD_GPC_HELPER;
|
||||
if (isGpcCheckoutStep
|
||||
&& (stepExecutionKey === 'plus-checkout-create' || stepExecutionKey === 'plus-checkout-billing')
|
||||
&& isGpcCheckoutRestartRequiredFailure(err)) {
|
||||
gpcCheckoutRestartCount += 1;
|
||||
await addLog(
|
||||
`步骤 ${step}:检测到 GPC 任务失败/卡住,准备回到步骤 6 重新创建 GPC 任务(第 ${gpcCheckoutRestartCount} 次)。原因:${getErrorMessage(err)}`,
|
||||
'warn'
|
||||
);
|
||||
await invalidateDownstreamAfterStepRestart(5, {
|
||||
logLabel: `步骤 ${step} GPC 任务失败后准备回到步骤 6 重试(第 ${gpcCheckoutRestartCount} 次)`,
|
||||
});
|
||||
step = 6;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step === 8 && isGoPayCheckoutRestartRequiredFailure(err)) {
|
||||
goPayCheckoutRestartCount += 1;
|
||||
if (goPayCheckoutRestartCount > 3) {
|
||||
|
||||
@@ -257,11 +257,34 @@
|
||||
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
|
||||
}
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
|
||||
const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000);
|
||||
let didTimeout = false;
|
||||
let timer = null;
|
||||
const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
reject(buildTimeoutError());
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
}, effectiveTimeoutMs);
|
||||
});
|
||||
try {
|
||||
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const response = await Promise.race([
|
||||
fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
const data = await Promise.race([
|
||||
response.json().catch(() => ({})),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return { response, data };
|
||||
} catch (error) {
|
||||
if (didTimeout || error?.name === 'AbortError') {
|
||||
throw buildTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -209,11 +209,34 @@
|
||||
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
|
||||
}
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
|
||||
const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000);
|
||||
let didTimeout = false;
|
||||
let timer = null;
|
||||
const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
reject(buildTimeoutError());
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
}, effectiveTimeoutMs);
|
||||
});
|
||||
try {
|
||||
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const response = await Promise.race([
|
||||
fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
const data = await Promise.race([
|
||||
response.json().catch(() => ({})),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return { response, data };
|
||||
} catch (error) {
|
||||
if (didTimeout || error?.name === 'AbortError') {
|
||||
throw buildTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -56,10 +56,24 @@ const bundle = [
|
||||
extractFunction('isAddPhoneAuthFailure'),
|
||||
extractFunction('isAddPhoneAuthUrl'),
|
||||
extractFunction('isAddPhoneAuthState'),
|
||||
extractFunction('isGpcCheckoutRestartRequiredFailure'),
|
||||
extractFunction('getPostStep6AutoRestartDecision'),
|
||||
extractFunction('runAutoSequenceFromStep'),
|
||||
].join('\n');
|
||||
|
||||
const defaultStepDefinitions = {
|
||||
1: { key: 'open-signup' },
|
||||
2: { key: 'prepare-email' },
|
||||
3: { key: 'fill-password' },
|
||||
4: { key: 'verify-email' },
|
||||
5: { key: 'profile-basic' },
|
||||
6: { key: 'profile-finish' },
|
||||
7: { key: 'auth-login' },
|
||||
8: { key: 'auth-email-code' },
|
||||
9: { key: 'confirm-oauth' },
|
||||
10: { key: 'platform-verify' },
|
||||
};
|
||||
|
||||
function createHarness(options = {}) {
|
||||
const {
|
||||
startStep = 7,
|
||||
@@ -68,13 +82,18 @@ function createHarness(options = {}) {
|
||||
failureMessage = '认证失败: Request failed with status code 502',
|
||||
authState = { state: 'password_page', url: 'https://auth.openai.com/log-in' },
|
||||
customState = {},
|
||||
stepDefinitions = defaultStepDefinitions,
|
||||
stepIds = Object.keys(stepDefinitions).map(Number).sort((a, b) => a - b),
|
||||
lastStepId = Math.max(...stepIds),
|
||||
finalOAuthChainStartStep = 7,
|
||||
} = options;
|
||||
|
||||
return new Function(`
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0 };
|
||||
const LAST_STEP_ID = ${JSON.stringify(lastStepId)};
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = ${JSON.stringify(finalOAuthChainStartStep)};
|
||||
const SIGNUP_METHOD_PHONE = 'phone';
|
||||
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
|
||||
const LOG_PREFIX = '[test]';
|
||||
const chrome = {
|
||||
tabs: {
|
||||
@@ -104,21 +123,10 @@ async function getState() {
|
||||
};
|
||||
}
|
||||
function getStepIdsForState() {
|
||||
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
return ${JSON.stringify(stepIds)};
|
||||
}
|
||||
function getStepDefinitionForState(step) {
|
||||
const map = {
|
||||
1: { key: 'open-signup' },
|
||||
2: { key: 'prepare-email' },
|
||||
3: { key: 'fill-password' },
|
||||
4: { key: 'verify-email' },
|
||||
5: { key: 'profile-basic' },
|
||||
6: { key: 'profile-finish' },
|
||||
7: { key: 'auth-login' },
|
||||
8: { key: 'auth-email-code' },
|
||||
9: { key: 'confirm-oauth' },
|
||||
10: { key: 'platform-verify' },
|
||||
};
|
||||
const map = ${JSON.stringify(stepDefinitions)};
|
||||
return map[Number(step)] || null;
|
||||
}
|
||||
function getStepExecutionKeyForState(step, state = {}) {
|
||||
@@ -149,6 +157,10 @@ function getLoginAuthStateLabel(state) {
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === PLUS_PAYMENT_METHOD_GPC_HELPER ? PLUS_PAYMENT_METHOD_GPC_HELPER : normalized;
|
||||
}
|
||||
function isPhoneSmsPlatformRateLimitFailure(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message);
|
||||
@@ -321,3 +333,95 @@ test('auto-run restarts from confirm-oauth step after transient step10 token_exc
|
||||
});
|
||||
assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message)));
|
||||
});
|
||||
|
||||
test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls', async () => {
|
||||
const plusGpcSteps = {
|
||||
6: { key: 'plus-checkout-create' },
|
||||
7: { key: 'plus-checkout-billing' },
|
||||
10: { key: 'oauth-login' },
|
||||
11: { key: 'fetch-login-code' },
|
||||
12: { key: 'confirm-oauth' },
|
||||
13: { key: 'platform-verify' },
|
||||
};
|
||||
const harness = createHarness({
|
||||
startStep: 6,
|
||||
failureStep: 7,
|
||||
failureBudget: 2,
|
||||
failureMessage: 'GPC API 请求超时(>30 秒):https://gpc.qlhazycoder.top/api/gp/tasks/task_stalled',
|
||||
stepDefinitions: plusGpcSteps,
|
||||
finalOAuthChainStartStep: 10,
|
||||
customState: {
|
||||
stepStatuses: { 3: 'completed' },
|
||||
plusPaymentMethod: 'gpc-helper',
|
||||
plusCheckoutSource: 'gpc-helper',
|
||||
},
|
||||
});
|
||||
|
||||
const events = await harness.run();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
events.steps,
|
||||
[6, 7, 6, 7, 6, 7, 10, 11, 12, 13]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
events.invalidations.map((entry) => entry.step),
|
||||
[5, 5]
|
||||
);
|
||||
assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message)));
|
||||
});
|
||||
|
||||
test('auto-run treats GPC account binding as recoverable step 6 restart', async () => {
|
||||
const plusGpcSteps = {
|
||||
6: { key: 'plus-checkout-create' },
|
||||
7: { key: 'plus-checkout-billing' },
|
||||
10: { key: 'oauth-login' },
|
||||
11: { key: 'fetch-login-code' },
|
||||
12: { key: 'confirm-oauth' },
|
||||
13: { key: 'platform-verify' },
|
||||
};
|
||||
const harness = createHarness({
|
||||
startStep: 6,
|
||||
failureStep: 7,
|
||||
failureBudget: 1,
|
||||
failureMessage: 'GPC_TASK_ENDED::GOPAY已经绑了订阅,需要手动解绑',
|
||||
stepDefinitions: plusGpcSteps,
|
||||
finalOAuthChainStartStep: 10,
|
||||
customState: {
|
||||
stepStatuses: { 3: 'completed' },
|
||||
plusPaymentMethod: 'gpc-helper',
|
||||
plusCheckoutSource: 'gpc-helper',
|
||||
},
|
||||
});
|
||||
|
||||
const events = await harness.run();
|
||||
|
||||
assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 10, 11, 12, 13]);
|
||||
assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]);
|
||||
});
|
||||
|
||||
test('auto-run does not restart GPC checkout when Plus account has no free-trial eligibility', async () => {
|
||||
const plusGpcSteps = {
|
||||
6: { key: 'plus-checkout-create' },
|
||||
7: { key: 'plus-checkout-billing' },
|
||||
10: { key: 'oauth-login' },
|
||||
};
|
||||
const harness = createHarness({
|
||||
startStep: 6,
|
||||
failureStep: 7,
|
||||
failureBudget: 1,
|
||||
failureMessage: 'PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(IDR 299000),当前账号没有免费试用资格,已跳过支付提交。',
|
||||
stepDefinitions: plusGpcSteps,
|
||||
finalOAuthChainStartStep: 10,
|
||||
customState: {
|
||||
stepStatuses: { 3: 'completed' },
|
||||
plusPaymentMethod: 'gpc-helper',
|
||||
plusCheckoutSource: 'gpc-helper',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await harness.runAndCaptureError();
|
||||
|
||||
assert.ok(result?.error);
|
||||
assert.deepStrictEqual(result.events.steps, [6, 7]);
|
||||
assert.equal(result.events.invalidations.length, 0);
|
||||
});
|
||||
|
||||
+8
-7
@@ -328,13 +328,14 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
|
||||
|
||||
1. 解析本轮应使用的注册方式:默认邮箱注册,开启接码且普通模式下可切到手机号注册
|
||||
2. 打开或复用注册页
|
||||
3. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式
|
||||
4. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label`
|
||||
5. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交
|
||||
6. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号
|
||||
7. 等待身份提交后的真实落地页
|
||||
8. 如果进入密码页,则继续执行 Step 3
|
||||
9. 如果直接进入邮箱验证码页或手机验证码页,则自动跳过 Step 3 并进入 Step 4
|
||||
3. 内容脚本识别到官网“免费注册 / Sign up / Register”入口后,会先等待 3 秒再点击;如果点击后仍停留在官网入口页、没有进入邮箱或手机号输入页,会再次等待 3 秒后重试,最多额外重试 5 次,且每次真正点击前都会检查 Stop 状态
|
||||
4. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式
|
||||
5. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label`
|
||||
6. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交
|
||||
7. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号
|
||||
8. 等待身份提交后的真实落地页
|
||||
9. 如果进入密码页,则继续执行 Step 3
|
||||
10. 如果直接进入邮箱验证码页或手机验证码页,则自动跳过 Step 3 并进入 Step 4
|
||||
|
||||
### Step 3
|
||||
|
||||
|
||||
+2
-2
@@ -96,7 +96,7 @@
|
||||
- `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。
|
||||
- `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。
|
||||
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
|
||||
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、手机号国家下拉框同步校验、手机号填写与提交;Step 3 收尾会识别手机号注册密码页的 `Incorrect phone number or password` 并上报当前轮重开信号;Step 7 / 8 登录链路会额外识别手机号输入页、手机号登录入口、`phone-verification` 与真实 `add-email` 页面,避免把手机验证码页或添加邮箱页误判成普通邮箱登录入口;Step 9 后置手机号验证完成后若偶发进入资料页,会复用 Step 5 的姓名生日填写逻辑再继续等待 OAuth 同意页。
|
||||
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、官网注册入口点击前固定等待 3 秒、入口点击失败后最多 5 次重试、手机号国家下拉框同步校验、手机号填写与提交;Step 3 收尾会识别手机号注册密码页的 `Incorrect phone number or password` 并上报当前轮重开信号;Step 7 / 8 登录链路会额外识别手机号输入页、手机号登录入口、`phone-verification` 与真实 `add-email` 页面,避免把手机验证码页或添加邮箱页误判成普通邮箱登录入口;Step 9 后置手机号验证完成后若偶发进入资料页,会复用 Step 5 的姓名生日填写逻辑再继续等待 OAuth 同意页。
|
||||
- `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;平台验证请求会读取 `visibleStep`,普通模式承接步骤 10,Plus 模式承接步骤 13。
|
||||
- `content/utils.js`:内容脚本公共工具层,负责结构化日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击;内容脚本日志通过 `{ step, stepKey }` 上报,正文不再承担步骤号解析职责。
|
||||
- `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做平台验证判定;当前支持普通步骤 10 与 Plus 可见步骤 13。
|
||||
@@ -249,7 +249,7 @@
|
||||
- `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。
|
||||
- `tests/sidepanel-paypal-manager.test.js`:测试侧边栏公共表单弹窗脚本与 PayPal manager 的加载顺序,以及 PayPal 账号保存后会立即选中当前账号。
|
||||
- `tests/signup-entry-diagnostics.test.js`:测试注册入口与密码页诊断快照输出,包括密码页错误文案字段。
|
||||
- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式、本地化邮箱输入框识别,以及手机号注册时国家下拉框可视区号同步。
|
||||
- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式、本地化邮箱输入框识别、官网注册入口点击等待与最多 5 次重试,以及手机号注册时国家下拉框可视区号同步。
|
||||
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
|
||||
- `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
|
||||
- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。
|
||||
|
||||
Reference in New Issue
Block a user