@reconcrap/boss-recommend-mcp 1.1.4 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/adapters.js
CHANGED
|
@@ -1392,6 +1392,221 @@ function pickBossRecommendReloadTarget(tabs = []) {
|
|
|
1392
1392
|
) || null;
|
|
1393
1393
|
}
|
|
1394
1394
|
|
|
1395
|
+
async function evaluateCdpExpression(client, expression) {
|
|
1396
|
+
const result = await client.Runtime.evaluate({
|
|
1397
|
+
expression,
|
|
1398
|
+
returnByValue: true,
|
|
1399
|
+
awaitPromise: true
|
|
1400
|
+
});
|
|
1401
|
+
if (result.exceptionDetails) {
|
|
1402
|
+
throw new Error(result.exceptionDetails.exception?.description || "Runtime.evaluate failed");
|
|
1403
|
+
}
|
|
1404
|
+
return result.result?.value;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function buildRecommendRefreshStateExpression() {
|
|
1408
|
+
return `(() => {
|
|
1409
|
+
const frame = document.querySelector('iframe[name="recommendFrame"]')
|
|
1410
|
+
|| document.querySelector('iframe[src*="/web/frame/recommend/"]')
|
|
1411
|
+
|| document.querySelector('iframe');
|
|
1412
|
+
if (!frame || !frame.contentDocument) {
|
|
1413
|
+
return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
|
|
1414
|
+
}
|
|
1415
|
+
const doc = frame.contentDocument;
|
|
1416
|
+
const isVisible = (el) => {
|
|
1417
|
+
if (!el) return false;
|
|
1418
|
+
const win = doc.defaultView;
|
|
1419
|
+
if (!win) return el.offsetParent !== null;
|
|
1420
|
+
const style = win.getComputedStyle(el);
|
|
1421
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
const rect = el.getBoundingClientRect();
|
|
1425
|
+
return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
|
|
1426
|
+
};
|
|
1427
|
+
const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
|
|
1428
|
+
const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
|
|
1429
|
+
.find((el) => isVisible(el)) || null;
|
|
1430
|
+
const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
|
|
1431
|
+
const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
|
|
1432
|
+
const finishedText = finishedWrap ? String(finishedWrap.textContent || '').replace(/\\s+/g, ' ').trim() : '';
|
|
1433
|
+
const buttonText = refreshButton ? String(refreshButton.textContent || '').replace(/\\s+/g, ' ').trim() : '';
|
|
1434
|
+
return {
|
|
1435
|
+
ok: true,
|
|
1436
|
+
frame_url: (() => {
|
|
1437
|
+
try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
|
|
1438
|
+
})(),
|
|
1439
|
+
finished_wrap_visible: Boolean(finishedWrap),
|
|
1440
|
+
finished_wrap_text: finishedText || null,
|
|
1441
|
+
refresh_button_visible: Boolean(refreshButton),
|
|
1442
|
+
refresh_button_text: buttonText || null,
|
|
1443
|
+
candidate_count: candidateCards.length,
|
|
1444
|
+
total_card_count: cards.length,
|
|
1445
|
+
list_ready: candidateCards.length > 0
|
|
1446
|
+
};
|
|
1447
|
+
})()`;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function buildRecommendRefreshClickExpression() {
|
|
1451
|
+
return `(() => {
|
|
1452
|
+
const frame = document.querySelector('iframe[name="recommendFrame"]')
|
|
1453
|
+
|| document.querySelector('iframe[src*="/web/frame/recommend/"]')
|
|
1454
|
+
|| document.querySelector('iframe');
|
|
1455
|
+
if (!frame || !frame.contentDocument) {
|
|
1456
|
+
return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
|
|
1457
|
+
}
|
|
1458
|
+
const doc = frame.contentDocument;
|
|
1459
|
+
const isVisible = (el) => {
|
|
1460
|
+
if (!el) return false;
|
|
1461
|
+
const win = doc.defaultView;
|
|
1462
|
+
if (!win) return el.offsetParent !== null;
|
|
1463
|
+
const style = win.getComputedStyle(el);
|
|
1464
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
|
|
1465
|
+
return false;
|
|
1466
|
+
}
|
|
1467
|
+
const rect = el.getBoundingClientRect();
|
|
1468
|
+
return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
|
|
1469
|
+
};
|
|
1470
|
+
const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
|
|
1471
|
+
.find((el) => isVisible(el)) || null;
|
|
1472
|
+
if (!refreshButton) {
|
|
1473
|
+
return { ok: false, state: 'REFRESH_BUTTON_NOT_FOUND' };
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
refreshButton.click();
|
|
1477
|
+
return {
|
|
1478
|
+
ok: true,
|
|
1479
|
+
state: 'REFRESH_BUTTON_CLICKED',
|
|
1480
|
+
refresh_button_text: String(refreshButton.textContent || '').replace(/\\s+/g, ' ').trim() || null
|
|
1481
|
+
};
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
return {
|
|
1484
|
+
ok: false,
|
|
1485
|
+
state: 'REFRESH_BUTTON_CLICK_FAILED',
|
|
1486
|
+
message: error?.message || String(error)
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
})()`;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
export async function refreshBossRecommendList(workspaceRoot, options = {}) {
|
|
1493
|
+
const debugPort = Number.isFinite(options.port)
|
|
1494
|
+
? options.port
|
|
1495
|
+
: resolveWorkspaceDebugPort(workspaceRoot);
|
|
1496
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 600;
|
|
1497
|
+
const reloadTimeoutMs = Number.isFinite(options.reloadTimeoutMs) ? options.reloadTimeoutMs : 10000;
|
|
1498
|
+
|
|
1499
|
+
let client = null;
|
|
1500
|
+
try {
|
|
1501
|
+
const tabs = await listChromeTabs(debugPort);
|
|
1502
|
+
const target = pickBossRecommendReloadTarget(tabs);
|
|
1503
|
+
if (!target) {
|
|
1504
|
+
return {
|
|
1505
|
+
ok: false,
|
|
1506
|
+
action: "in_page_refresh",
|
|
1507
|
+
debug_port: debugPort,
|
|
1508
|
+
state: "BOSS_TAB_NOT_FOUND",
|
|
1509
|
+
message: "未找到可操作的 Boss recommend 标签页。",
|
|
1510
|
+
before_state: null,
|
|
1511
|
+
after_state: null
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
client = await CDP({ port: debugPort, target });
|
|
1516
|
+
const { Page, Runtime } = client;
|
|
1517
|
+
if (Runtime && typeof Runtime.enable === "function") {
|
|
1518
|
+
await Runtime.enable();
|
|
1519
|
+
}
|
|
1520
|
+
if (Page && typeof Page.enable === "function") {
|
|
1521
|
+
await Page.enable();
|
|
1522
|
+
}
|
|
1523
|
+
if (Page && typeof Page.bringToFront === "function") {
|
|
1524
|
+
await Page.bringToFront();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
const beforeState = await evaluateCdpExpression(client, buildRecommendRefreshStateExpression());
|
|
1528
|
+
if (!beforeState?.ok) {
|
|
1529
|
+
return {
|
|
1530
|
+
ok: false,
|
|
1531
|
+
action: "in_page_refresh",
|
|
1532
|
+
debug_port: debugPort,
|
|
1533
|
+
state: beforeState?.error || "NO_RECOMMEND_IFRAME",
|
|
1534
|
+
message: "未能读取 recommend iframe,无法执行页内刷新。",
|
|
1535
|
+
before_state: beforeState || null,
|
|
1536
|
+
after_state: null
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (!beforeState.refresh_button_visible) {
|
|
1540
|
+
return {
|
|
1541
|
+
ok: false,
|
|
1542
|
+
action: "in_page_refresh",
|
|
1543
|
+
debug_port: debugPort,
|
|
1544
|
+
state: "REFRESH_BUTTON_NOT_FOUND",
|
|
1545
|
+
message: "推荐列表到底后未发现可点击的刷新按钮。",
|
|
1546
|
+
before_state: beforeState,
|
|
1547
|
+
after_state: beforeState
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const clickResult = await evaluateCdpExpression(client, buildRecommendRefreshClickExpression());
|
|
1552
|
+
if (!clickResult?.ok) {
|
|
1553
|
+
return {
|
|
1554
|
+
ok: false,
|
|
1555
|
+
action: "in_page_refresh",
|
|
1556
|
+
debug_port: debugPort,
|
|
1557
|
+
state: clickResult?.state || "REFRESH_BUTTON_CLICK_FAILED",
|
|
1558
|
+
message: clickResult?.message || "页内刷新按钮点击失败。",
|
|
1559
|
+
before_state: beforeState,
|
|
1560
|
+
after_state: null
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const deadline = Date.now() + reloadTimeoutMs;
|
|
1565
|
+
let lastState = beforeState;
|
|
1566
|
+
while (Date.now() < deadline) {
|
|
1567
|
+
await sleep(pollMs);
|
|
1568
|
+
lastState = await evaluateCdpExpression(client, buildRecommendRefreshStateExpression());
|
|
1569
|
+
if (lastState?.ok && lastState.finished_wrap_visible === false && lastState.list_ready === true) {
|
|
1570
|
+
return {
|
|
1571
|
+
ok: true,
|
|
1572
|
+
action: "in_page_refresh",
|
|
1573
|
+
debug_port: debugPort,
|
|
1574
|
+
state: "RECOMMEND_READY",
|
|
1575
|
+
message: "已点击页内刷新按钮并重新拿到候选人列表。",
|
|
1576
|
+
before_state: beforeState,
|
|
1577
|
+
after_state: lastState
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
return {
|
|
1583
|
+
ok: false,
|
|
1584
|
+
action: "in_page_refresh",
|
|
1585
|
+
debug_port: debugPort,
|
|
1586
|
+
state: "LIST_NOT_RELOADED",
|
|
1587
|
+
message: "已点击页内刷新按钮,但候选人列表未在超时内重新就绪。",
|
|
1588
|
+
before_state: beforeState,
|
|
1589
|
+
after_state: lastState
|
|
1590
|
+
};
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
return {
|
|
1593
|
+
ok: false,
|
|
1594
|
+
action: "in_page_refresh",
|
|
1595
|
+
debug_port: debugPort,
|
|
1596
|
+
state: "REFRESH_BUTTON_CLICK_FAILED",
|
|
1597
|
+
message: error?.message || "页内刷新失败。",
|
|
1598
|
+
before_state: null,
|
|
1599
|
+
after_state: null
|
|
1600
|
+
};
|
|
1601
|
+
} finally {
|
|
1602
|
+
if (client) {
|
|
1603
|
+
try {
|
|
1604
|
+
await client.close();
|
|
1605
|
+
} catch {}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1395
1610
|
export async function reloadBossRecommendPage(workspaceRoot, options = {}) {
|
|
1396
1611
|
const debugPort = Number.isFinite(options.port)
|
|
1397
1612
|
? options.port
|
package/src/pipeline.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
attemptPipelineAutoRepair,
|
|
5
5
|
ensureBossRecommendPageReady,
|
|
6
6
|
listRecommendJobs,
|
|
7
|
+
refreshBossRecommendList,
|
|
7
8
|
reloadBossRecommendPage,
|
|
8
9
|
runPipelinePreflight,
|
|
9
10
|
runRecommendSearchCli,
|
|
@@ -499,6 +500,7 @@ const defaultDependencies = {
|
|
|
499
500
|
parseRecommendInstruction,
|
|
500
501
|
ensureBossRecommendPageReady,
|
|
501
502
|
listRecommendJobs,
|
|
503
|
+
refreshBossRecommendList,
|
|
502
504
|
reloadBossRecommendPage,
|
|
503
505
|
runPipelinePreflight,
|
|
504
506
|
runRecommendSearchCli,
|
|
@@ -517,6 +519,7 @@ export async function runRecommendPipeline(
|
|
|
517
519
|
parseRecommendInstruction: parseInstruction,
|
|
518
520
|
ensureBossRecommendPageReady: ensureRecommendPageReady,
|
|
519
521
|
listRecommendJobs: listJobs,
|
|
522
|
+
refreshBossRecommendList: refreshRecommendList,
|
|
520
523
|
reloadBossRecommendPage: reloadRecommendPage,
|
|
521
524
|
runPipelinePreflight: runPreflight,
|
|
522
525
|
runRecommendSearchCli: searchCli,
|
|
@@ -946,43 +949,156 @@ export async function runRecommendPipeline(
|
|
|
946
949
|
});
|
|
947
950
|
}
|
|
948
951
|
if (!screenResult.ok) {
|
|
952
|
+
const screenErrorCode = String(screenResult.error?.code || "");
|
|
949
953
|
const partialScreenResult = screenResult.summary || screenResult.structured?.result || null;
|
|
950
954
|
const resumeOutputCsv = normalizeText(partialScreenResult?.output_csv || currentResumeConfig.output_csv || "");
|
|
951
955
|
const hasCheckpointForRecovery = Boolean(normalizeText(currentResumeConfig.checkpoint_path || ""));
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
956
|
+
const screenPartialForRecovery = partialScreenResult
|
|
957
|
+
? {
|
|
958
|
+
processed_count: partialScreenResult.processed_count ?? null,
|
|
959
|
+
passed_count: partialScreenResult.passed_count ?? null,
|
|
960
|
+
skipped_count: partialScreenResult.skipped_count ?? null,
|
|
961
|
+
output_csv: partialScreenResult.output_csv || currentResumeConfig.output_csv || null,
|
|
962
|
+
checkpoint_path: partialScreenResult.checkpoint_path || currentResumeConfig.checkpoint_path || null,
|
|
963
|
+
completion_reason: partialScreenResult.completion_reason || null
|
|
964
|
+
}
|
|
965
|
+
: null;
|
|
966
|
+
const isResumeCaptureRecovery = screenErrorCode === "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT";
|
|
967
|
+
const isPageExhaustedRecovery = screenErrorCode === "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED";
|
|
968
|
+
const isRecoverableScreenFailure = isResumeCaptureRecovery || isPageExhaustedRecovery;
|
|
969
|
+
const canRecoverSafely = hasCheckpointForRecovery && Boolean(resumeOutputCsv);
|
|
970
|
+
const hasRecoveryAttemptsRemaining = screenAutoRecoveryCount < MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS;
|
|
971
|
+
|
|
972
|
+
if (isRecoverableScreenFailure && !canRecoverSafely) {
|
|
973
|
+
return buildFailedResponse(
|
|
974
|
+
"SCREEN_AUTO_RECOVERY_UNSAFE",
|
|
975
|
+
"检测到 recommend 自动恢复触发,但缺少 checkpoint 或 output_csv,无法安全续跑。",
|
|
976
|
+
{
|
|
977
|
+
search_params: effectiveSearchParams,
|
|
978
|
+
screen_params: parsed.screenParams,
|
|
979
|
+
selected_job: selectedJob,
|
|
980
|
+
partial_result: partialScreenResult,
|
|
981
|
+
diagnostics: {
|
|
982
|
+
debug_port: preflight.debug_port,
|
|
983
|
+
stdout: screenResult.stdout?.slice(-1000),
|
|
984
|
+
stderr: screenResult.stderr?.slice(-1000),
|
|
985
|
+
result: screenResult.structured || null,
|
|
986
|
+
auto_recovery: {
|
|
987
|
+
trigger: screenErrorCode,
|
|
988
|
+
attempt: screenAutoRecoveryCount,
|
|
989
|
+
max_attempts: MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS,
|
|
990
|
+
original_recent_not_view: parsed.searchParams.recent_not_view,
|
|
991
|
+
effective_recent_not_view: effectiveSearchParams.recent_not_view,
|
|
992
|
+
partial_result: screenPartialForRecovery
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (isRecoverableScreenFailure && !hasRecoveryAttemptsRemaining) {
|
|
1000
|
+
return buildFailedResponse(
|
|
1001
|
+
screenResult.error?.code || "RECOMMEND_SCREEN_FAILED",
|
|
1002
|
+
`${screenResult.error?.message || "推荐页筛选执行失败。"} 已达到自动恢复上限 ${MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS} 次。`,
|
|
1003
|
+
{
|
|
1004
|
+
search_params: effectiveSearchParams,
|
|
1005
|
+
screen_params: parsed.screenParams,
|
|
1006
|
+
selected_job: selectedJob,
|
|
1007
|
+
partial_result: partialScreenResult,
|
|
1008
|
+
diagnostics: {
|
|
1009
|
+
debug_port: preflight.debug_port,
|
|
1010
|
+
stdout: screenResult.stdout?.slice(-1000),
|
|
1011
|
+
stderr: screenResult.stderr?.slice(-1000),
|
|
1012
|
+
result: screenResult.structured || null,
|
|
1013
|
+
auto_recovery: lastAutoRecovery
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
958
1018
|
|
|
959
|
-
if (
|
|
1019
|
+
if (isRecoverableScreenFailure && canRecoverSafely && hasRecoveryAttemptsRemaining) {
|
|
960
1020
|
screenAutoRecoveryCount += 1;
|
|
961
|
-
effectiveSearchParams = {
|
|
962
|
-
...effectiveSearchParams,
|
|
963
|
-
recent_not_view: FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY
|
|
964
|
-
};
|
|
965
1021
|
lastAutoRecovery = {
|
|
966
|
-
trigger:
|
|
1022
|
+
trigger: screenErrorCode,
|
|
967
1023
|
attempt: screenAutoRecoveryCount,
|
|
968
1024
|
max_attempts: MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS,
|
|
969
1025
|
original_recent_not_view: parsed.searchParams.recent_not_view,
|
|
970
1026
|
effective_recent_not_view: effectiveSearchParams.recent_not_view,
|
|
971
|
-
partial_result:
|
|
972
|
-
|
|
973
|
-
processed_count: partialScreenResult.processed_count ?? null,
|
|
974
|
-
passed_count: partialScreenResult.passed_count ?? null,
|
|
975
|
-
skipped_count: partialScreenResult.skipped_count ?? null,
|
|
976
|
-
output_csv: partialScreenResult.output_csv || currentResumeConfig.output_csv || null
|
|
977
|
-
}
|
|
978
|
-
: null
|
|
1027
|
+
partial_result: screenPartialForRecovery,
|
|
1028
|
+
page_exhaustion: screenResult.error?.page_exhaustion || null
|
|
979
1029
|
};
|
|
980
1030
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1031
|
+
if (isPageExhaustedRecovery) {
|
|
1032
|
+
runtimeHooks.setStage(
|
|
1033
|
+
"screen_recovery",
|
|
1034
|
+
`推荐列表已到底但未达目标,开始自动补货(第 ${screenAutoRecoveryCount} 次):优先尝试页内刷新。`
|
|
1035
|
+
);
|
|
1036
|
+
runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
|
|
1037
|
+
|
|
1038
|
+
const refreshResult = typeof refreshRecommendList === "function"
|
|
1039
|
+
? await refreshRecommendList(workspaceRoot, {
|
|
1040
|
+
port: preflight.debug_port
|
|
1041
|
+
})
|
|
1042
|
+
: {
|
|
1043
|
+
ok: false,
|
|
1044
|
+
action: "in_page_refresh",
|
|
1045
|
+
state: "REFRESH_ADAPTER_MISSING",
|
|
1046
|
+
message: "缺少页内刷新适配器。"
|
|
1047
|
+
};
|
|
1048
|
+
ensurePipelineNotAborted(runtimeHooks.signal);
|
|
1049
|
+
|
|
1050
|
+
lastAutoRecovery = {
|
|
1051
|
+
...lastAutoRecovery,
|
|
1052
|
+
refresh: refreshResult
|
|
1053
|
+
? {
|
|
1054
|
+
ok: refreshResult.ok,
|
|
1055
|
+
state: refreshResult.state || null,
|
|
1056
|
+
message: refreshResult.message || null,
|
|
1057
|
+
before_state: refreshResult.before_state || null,
|
|
1058
|
+
after_state: refreshResult.after_state || null
|
|
1059
|
+
}
|
|
1060
|
+
: null
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
if (refreshResult?.ok) {
|
|
1064
|
+
lastAutoRecovery = {
|
|
1065
|
+
...lastAutoRecovery,
|
|
1066
|
+
action: "in_page_refresh"
|
|
1067
|
+
};
|
|
1068
|
+
currentResumeConfig = {
|
|
1069
|
+
checkpoint_path: currentResumeConfig.checkpoint_path || null,
|
|
1070
|
+
pause_control_path: currentResumeConfig.pause_control_path || null,
|
|
1071
|
+
output_csv: resumeOutputCsv || null,
|
|
1072
|
+
resume: true,
|
|
1073
|
+
require_checkpoint: true
|
|
1074
|
+
};
|
|
1075
|
+
shouldRunSearch = false;
|
|
1076
|
+
searchSummary = null;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
runtimeHooks.setStage(
|
|
1081
|
+
"screen_recovery",
|
|
1082
|
+
`页内刷新不可用(${refreshResult?.state || "unknown"}),改为刷新 recommend 页面并重跑 search。`
|
|
1083
|
+
);
|
|
1084
|
+
runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
|
|
1085
|
+
} else {
|
|
1086
|
+
runtimeHooks.setStage(
|
|
1087
|
+
"screen_recovery",
|
|
1088
|
+
`screen 连续截图失败,开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
|
|
1089
|
+
);
|
|
1090
|
+
runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
effectiveSearchParams = {
|
|
1094
|
+
...effectiveSearchParams,
|
|
1095
|
+
recent_not_view: FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY
|
|
1096
|
+
};
|
|
1097
|
+
lastAutoRecovery = {
|
|
1098
|
+
...lastAutoRecovery,
|
|
1099
|
+
action: "reload_page_and_rerun_search",
|
|
1100
|
+
effective_recent_not_view: effectiveSearchParams.recent_not_view
|
|
1101
|
+
};
|
|
986
1102
|
|
|
987
1103
|
const reloadResult = typeof reloadRecommendPage === "function"
|
|
988
1104
|
? await reloadRecommendPage(workspaceRoot, {
|
package/src/test-pipeline.js
CHANGED
|
@@ -354,6 +354,505 @@ async function testConsecutiveResumeCaptureFailuresShouldRefreshAndRerunSearchWi
|
|
|
354
354
|
assert.equal(result.search_params.recent_not_view, "近14天没有");
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
async function testPageExhaustedBeforeTargetShouldRefreshInPageAndResumeScreen() {
|
|
358
|
+
const searchCalls = [];
|
|
359
|
+
const screenCalls = [];
|
|
360
|
+
let refreshCalls = 0;
|
|
361
|
+
let reloadCalls = 0;
|
|
362
|
+
let pageReadyCalls = 0;
|
|
363
|
+
const parsed = createParsed();
|
|
364
|
+
parsed.searchParams = {
|
|
365
|
+
...parsed.searchParams,
|
|
366
|
+
recent_not_view: "不限"
|
|
367
|
+
};
|
|
368
|
+
const result = await runRecommendPipeline(
|
|
369
|
+
{
|
|
370
|
+
workspaceRoot: process.cwd(),
|
|
371
|
+
instruction: "test",
|
|
372
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
373
|
+
overrides: {},
|
|
374
|
+
resume: {
|
|
375
|
+
resume: false,
|
|
376
|
+
output_csv: "C:/temp/resume.csv",
|
|
377
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
378
|
+
pause_control_path: "C:/temp/run.json",
|
|
379
|
+
previous_completion_reason: ""
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
parseRecommendInstruction: () => parsed,
|
|
384
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
385
|
+
ensureBossRecommendPageReady: async () => {
|
|
386
|
+
pageReadyCalls += 1;
|
|
387
|
+
return { ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } };
|
|
388
|
+
},
|
|
389
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
390
|
+
refreshBossRecommendList: async () => {
|
|
391
|
+
refreshCalls += 1;
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
action: "in_page_refresh",
|
|
395
|
+
state: "RECOMMEND_READY",
|
|
396
|
+
before_state: {
|
|
397
|
+
finished_wrap_visible: true,
|
|
398
|
+
refresh_button_visible: true
|
|
399
|
+
},
|
|
400
|
+
after_state: {
|
|
401
|
+
finished_wrap_visible: false,
|
|
402
|
+
list_ready: true
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
reloadBossRecommendPage: async () => {
|
|
407
|
+
reloadCalls += 1;
|
|
408
|
+
return {
|
|
409
|
+
ok: true,
|
|
410
|
+
state: "RECOMMEND_READY",
|
|
411
|
+
reloaded_url: "https://www.zhipin.com/web/chat/recommend"
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
runRecommendSearchCli: async ({ searchParams }) => {
|
|
415
|
+
searchCalls.push({ ...searchParams });
|
|
416
|
+
return {
|
|
417
|
+
ok: true,
|
|
418
|
+
summary: {
|
|
419
|
+
candidate_count: 9,
|
|
420
|
+
applied_filters: searchParams,
|
|
421
|
+
page_state: { state: "RECOMMEND_READY" }
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
runRecommendScreenCli: async ({ resume }) => {
|
|
426
|
+
screenCalls.push({ ...resume });
|
|
427
|
+
if (screenCalls.length === 1) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: {
|
|
431
|
+
code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
432
|
+
message: "推荐列表已到底,但尚未达到目标数。",
|
|
433
|
+
page_exhaustion: {
|
|
434
|
+
reason: "bottom_reached",
|
|
435
|
+
bottom: {
|
|
436
|
+
isBottom: true,
|
|
437
|
+
finished_wrap_visible: true,
|
|
438
|
+
refresh_button_visible: true
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
summary: {
|
|
443
|
+
processed_count: 4,
|
|
444
|
+
passed_count: 1,
|
|
445
|
+
skipped_count: 3,
|
|
446
|
+
output_csv: "C:/temp/resume.csv",
|
|
447
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
448
|
+
completion_reason: "page_exhausted_before_target_count"
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
summary: {
|
|
455
|
+
processed_count: 10,
|
|
456
|
+
passed_count: 3,
|
|
457
|
+
skipped_count: 7,
|
|
458
|
+
output_csv: "C:/temp/resume.csv",
|
|
459
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
460
|
+
completion_reason: "target_count_reached"
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
assert.equal(result.status, "COMPLETED");
|
|
468
|
+
assert.equal(searchCalls.length, 1);
|
|
469
|
+
assert.equal(searchCalls[0].recent_not_view, "不限");
|
|
470
|
+
assert.equal(screenCalls.length, 2);
|
|
471
|
+
assert.equal(screenCalls[0].resume, false);
|
|
472
|
+
assert.equal(screenCalls[1].resume, true);
|
|
473
|
+
assert.equal(screenCalls[1].require_checkpoint, true);
|
|
474
|
+
assert.equal(screenCalls[1].output_csv, "C:/temp/resume.csv");
|
|
475
|
+
assert.equal(refreshCalls, 1);
|
|
476
|
+
assert.equal(reloadCalls, 0);
|
|
477
|
+
assert.equal(pageReadyCalls, 1);
|
|
478
|
+
assert.equal(result.result.candidate_count, null);
|
|
479
|
+
assert.equal(result.result.completion_reason, "target_count_reached");
|
|
480
|
+
assert.equal(result.result.auto_recovery.action, "in_page_refresh");
|
|
481
|
+
assert.equal(result.result.auto_recovery.refresh.ok, true);
|
|
482
|
+
assert.equal(result.result.auto_recovery.page_exhaustion.reason, "bottom_reached");
|
|
483
|
+
assert.equal(result.search_params.recent_not_view, "不限");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function testPageExhaustedBeforeTargetShouldReloadWhenRefreshButtonMissing() {
|
|
487
|
+
const searchCalls = [];
|
|
488
|
+
const screenCalls = [];
|
|
489
|
+
let refreshCalls = 0;
|
|
490
|
+
let reloadCalls = 0;
|
|
491
|
+
let pageReadyCalls = 0;
|
|
492
|
+
const parsed = createParsed();
|
|
493
|
+
parsed.searchParams = {
|
|
494
|
+
...parsed.searchParams,
|
|
495
|
+
recent_not_view: "不限"
|
|
496
|
+
};
|
|
497
|
+
const result = await runRecommendPipeline(
|
|
498
|
+
{
|
|
499
|
+
workspaceRoot: process.cwd(),
|
|
500
|
+
instruction: "test",
|
|
501
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
502
|
+
overrides: {},
|
|
503
|
+
resume: {
|
|
504
|
+
resume: false,
|
|
505
|
+
output_csv: "C:/temp/resume.csv",
|
|
506
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
507
|
+
pause_control_path: "C:/temp/run.json",
|
|
508
|
+
previous_completion_reason: ""
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
parseRecommendInstruction: () => parsed,
|
|
513
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
514
|
+
ensureBossRecommendPageReady: async () => {
|
|
515
|
+
pageReadyCalls += 1;
|
|
516
|
+
return { ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } };
|
|
517
|
+
},
|
|
518
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
519
|
+
refreshBossRecommendList: async () => {
|
|
520
|
+
refreshCalls += 1;
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
action: "in_page_refresh",
|
|
524
|
+
state: "REFRESH_BUTTON_NOT_FOUND",
|
|
525
|
+
message: "未找到页内刷新按钮。"
|
|
526
|
+
};
|
|
527
|
+
},
|
|
528
|
+
reloadBossRecommendPage: async () => {
|
|
529
|
+
reloadCalls += 1;
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
state: "RECOMMEND_READY",
|
|
533
|
+
reloaded_url: "https://www.zhipin.com/web/chat/recommend"
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
runRecommendSearchCli: async ({ searchParams }) => {
|
|
537
|
+
searchCalls.push({ ...searchParams });
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
summary: {
|
|
541
|
+
candidate_count: searchCalls.length === 1 ? 9 : 12,
|
|
542
|
+
applied_filters: searchParams,
|
|
543
|
+
page_state: { state: "RECOMMEND_READY" }
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
runRecommendScreenCli: async ({ resume }) => {
|
|
548
|
+
screenCalls.push({ ...resume });
|
|
549
|
+
if (screenCalls.length === 1) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
error: {
|
|
553
|
+
code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
554
|
+
message: "推荐列表已到底,但尚未达到目标数。",
|
|
555
|
+
page_exhaustion: {
|
|
556
|
+
reason: "bottom_reached",
|
|
557
|
+
bottom: {
|
|
558
|
+
isBottom: true,
|
|
559
|
+
finished_wrap_visible: true,
|
|
560
|
+
refresh_button_visible: false
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
summary: {
|
|
565
|
+
processed_count: 4,
|
|
566
|
+
passed_count: 1,
|
|
567
|
+
skipped_count: 3,
|
|
568
|
+
output_csv: "C:/temp/resume.csv",
|
|
569
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
570
|
+
completion_reason: "page_exhausted_before_target_count"
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
ok: true,
|
|
576
|
+
summary: {
|
|
577
|
+
processed_count: 10,
|
|
578
|
+
passed_count: 3,
|
|
579
|
+
skipped_count: 7,
|
|
580
|
+
output_csv: "C:/temp/resume.csv",
|
|
581
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
582
|
+
completion_reason: "target_count_reached"
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
assert.equal(result.status, "COMPLETED");
|
|
590
|
+
assert.equal(searchCalls.length, 2);
|
|
591
|
+
assert.equal(searchCalls[0].recent_not_view, "不限");
|
|
592
|
+
assert.equal(searchCalls[1].recent_not_view, "近14天没有");
|
|
593
|
+
assert.equal(screenCalls.length, 2);
|
|
594
|
+
assert.equal(screenCalls[0].resume, false);
|
|
595
|
+
assert.equal(screenCalls[1].resume, true);
|
|
596
|
+
assert.equal(screenCalls[1].require_checkpoint, true);
|
|
597
|
+
assert.equal(refreshCalls, 1);
|
|
598
|
+
assert.equal(reloadCalls, 1);
|
|
599
|
+
assert.equal(pageReadyCalls, 2);
|
|
600
|
+
assert.equal(result.result.candidate_count, 12);
|
|
601
|
+
assert.equal(result.result.auto_recovery.action, "reload_page_and_rerun_search");
|
|
602
|
+
assert.equal(result.result.auto_recovery.refresh.state, "REFRESH_BUTTON_NOT_FOUND");
|
|
603
|
+
assert.equal(result.result.auto_recovery.reload.ok, true);
|
|
604
|
+
assert.equal(result.search_params.recent_not_view, "近14天没有");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function testPageExhaustedBeforeTargetShouldReloadWhenRefreshDoesNotRecoverList() {
|
|
608
|
+
const searchCalls = [];
|
|
609
|
+
const screenCalls = [];
|
|
610
|
+
let refreshCalls = 0;
|
|
611
|
+
let reloadCalls = 0;
|
|
612
|
+
const parsed = createParsed();
|
|
613
|
+
parsed.searchParams = {
|
|
614
|
+
...parsed.searchParams,
|
|
615
|
+
recent_not_view: "不限"
|
|
616
|
+
};
|
|
617
|
+
const result = await runRecommendPipeline(
|
|
618
|
+
{
|
|
619
|
+
workspaceRoot: process.cwd(),
|
|
620
|
+
instruction: "test",
|
|
621
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
622
|
+
overrides: {},
|
|
623
|
+
resume: {
|
|
624
|
+
resume: false,
|
|
625
|
+
output_csv: "C:/temp/resume.csv",
|
|
626
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
627
|
+
pause_control_path: "C:/temp/run.json",
|
|
628
|
+
previous_completion_reason: ""
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
parseRecommendInstruction: () => parsed,
|
|
633
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
634
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } }),
|
|
635
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
636
|
+
refreshBossRecommendList: async () => {
|
|
637
|
+
refreshCalls += 1;
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
action: "in_page_refresh",
|
|
641
|
+
state: "LIST_NOT_RELOADED",
|
|
642
|
+
message: "点击刷新后列表没有重新就绪。"
|
|
643
|
+
};
|
|
644
|
+
},
|
|
645
|
+
reloadBossRecommendPage: async () => {
|
|
646
|
+
reloadCalls += 1;
|
|
647
|
+
return {
|
|
648
|
+
ok: true,
|
|
649
|
+
state: "RECOMMEND_READY",
|
|
650
|
+
reloaded_url: "https://www.zhipin.com/web/chat/recommend"
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
runRecommendSearchCli: async ({ searchParams }) => {
|
|
654
|
+
searchCalls.push({ ...searchParams });
|
|
655
|
+
return {
|
|
656
|
+
ok: true,
|
|
657
|
+
summary: {
|
|
658
|
+
candidate_count: searchCalls.length === 1 ? 9 : 11,
|
|
659
|
+
applied_filters: searchParams,
|
|
660
|
+
page_state: { state: "RECOMMEND_READY" }
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
},
|
|
664
|
+
runRecommendScreenCli: async ({ resume }) => {
|
|
665
|
+
screenCalls.push({ ...resume });
|
|
666
|
+
if (screenCalls.length === 1) {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: {
|
|
670
|
+
code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
671
|
+
message: "推荐列表已到底,但尚未达到目标数。"
|
|
672
|
+
},
|
|
673
|
+
summary: {
|
|
674
|
+
processed_count: 4,
|
|
675
|
+
passed_count: 1,
|
|
676
|
+
skipped_count: 3,
|
|
677
|
+
output_csv: "C:/temp/resume.csv",
|
|
678
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
679
|
+
completion_reason: "page_exhausted_before_target_count"
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
summary: {
|
|
686
|
+
processed_count: 10,
|
|
687
|
+
passed_count: 3,
|
|
688
|
+
skipped_count: 7,
|
|
689
|
+
output_csv: "C:/temp/resume.csv",
|
|
690
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
691
|
+
completion_reason: "target_count_reached"
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
assert.equal(result.status, "COMPLETED");
|
|
699
|
+
assert.equal(searchCalls.length, 2);
|
|
700
|
+
assert.equal(searchCalls[1].recent_not_view, "近14天没有");
|
|
701
|
+
assert.equal(screenCalls.length, 2);
|
|
702
|
+
assert.equal(screenCalls[1].resume, true);
|
|
703
|
+
assert.equal(refreshCalls, 1);
|
|
704
|
+
assert.equal(reloadCalls, 1);
|
|
705
|
+
assert.equal(result.result.candidate_count, 11);
|
|
706
|
+
assert.equal(result.result.auto_recovery.action, "reload_page_and_rerun_search");
|
|
707
|
+
assert.equal(result.result.auto_recovery.refresh.state, "LIST_NOT_RELOADED");
|
|
708
|
+
assert.equal(result.search_params.recent_not_view, "近14天没有");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function testPageExhaustedBeforeTargetShouldFailAfterFiveRecoveryAttempts() {
|
|
712
|
+
const searchCalls = [];
|
|
713
|
+
const screenCalls = [];
|
|
714
|
+
let refreshCalls = 0;
|
|
715
|
+
const parsed = createParsed();
|
|
716
|
+
parsed.searchParams = {
|
|
717
|
+
...parsed.searchParams,
|
|
718
|
+
recent_not_view: "不限"
|
|
719
|
+
};
|
|
720
|
+
const result = await runRecommendPipeline(
|
|
721
|
+
{
|
|
722
|
+
workspaceRoot: process.cwd(),
|
|
723
|
+
instruction: "test",
|
|
724
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
725
|
+
overrides: {},
|
|
726
|
+
resume: {
|
|
727
|
+
resume: false,
|
|
728
|
+
output_csv: "C:/temp/resume.csv",
|
|
729
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
730
|
+
pause_control_path: "C:/temp/run.json",
|
|
731
|
+
previous_completion_reason: ""
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
parseRecommendInstruction: () => parsed,
|
|
736
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
737
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: { state: "RECOMMEND_READY" } }),
|
|
738
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
739
|
+
refreshBossRecommendList: async () => {
|
|
740
|
+
refreshCalls += 1;
|
|
741
|
+
return {
|
|
742
|
+
ok: true,
|
|
743
|
+
action: "in_page_refresh",
|
|
744
|
+
state: "RECOMMEND_READY",
|
|
745
|
+
before_state: {
|
|
746
|
+
finished_wrap_visible: true,
|
|
747
|
+
refresh_button_visible: true
|
|
748
|
+
},
|
|
749
|
+
after_state: {
|
|
750
|
+
finished_wrap_visible: false,
|
|
751
|
+
list_ready: true
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
},
|
|
755
|
+
runRecommendSearchCli: async ({ searchParams }) => {
|
|
756
|
+
searchCalls.push({ ...searchParams });
|
|
757
|
+
return {
|
|
758
|
+
ok: true,
|
|
759
|
+
summary: {
|
|
760
|
+
candidate_count: 9,
|
|
761
|
+
applied_filters: searchParams,
|
|
762
|
+
page_state: { state: "RECOMMEND_READY" }
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
},
|
|
766
|
+
runRecommendScreenCli: async ({ resume }) => {
|
|
767
|
+
screenCalls.push({ ...resume });
|
|
768
|
+
return {
|
|
769
|
+
ok: false,
|
|
770
|
+
error: {
|
|
771
|
+
code: "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
772
|
+
message: "推荐列表已到底,但尚未达到目标数。",
|
|
773
|
+
page_exhaustion: {
|
|
774
|
+
reason: "bottom_reached",
|
|
775
|
+
bottom: {
|
|
776
|
+
isBottom: true,
|
|
777
|
+
finished_wrap_visible: true,
|
|
778
|
+
refresh_button_visible: true
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
summary: {
|
|
783
|
+
processed_count: 4,
|
|
784
|
+
passed_count: 1,
|
|
785
|
+
skipped_count: 3,
|
|
786
|
+
output_csv: "C:/temp/resume.csv",
|
|
787
|
+
checkpoint_path: "C:/temp/checkpoint.json",
|
|
788
|
+
completion_reason: "page_exhausted_before_target_count"
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
assert.equal(result.status, "FAILED");
|
|
796
|
+
assert.equal(result.error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
|
|
797
|
+
assert.match(result.error.message, /已达到自动恢复上限 5 次/);
|
|
798
|
+
assert.equal(searchCalls.length, 1);
|
|
799
|
+
assert.equal(screenCalls.length, 6);
|
|
800
|
+
assert.equal(refreshCalls, 5);
|
|
801
|
+
assert.equal(result.partial_result.output_csv, "C:/temp/resume.csv");
|
|
802
|
+
assert.equal(result.diagnostics.auto_recovery.attempt, 5);
|
|
803
|
+
assert.equal(result.diagnostics.auto_recovery.action, "in_page_refresh");
|
|
804
|
+
assert.equal(result.diagnostics.auto_recovery.partial_result.checkpoint_path, "C:/temp/checkpoint.json");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function testNullTargetCountShouldKeepPageExhaustedCompletion() {
|
|
808
|
+
const parsed = createParsed();
|
|
809
|
+
parsed.screenParams = {
|
|
810
|
+
...parsed.screenParams,
|
|
811
|
+
target_count: null
|
|
812
|
+
};
|
|
813
|
+
let receivedScreenParams = null;
|
|
814
|
+
const result = await runRecommendPipeline(
|
|
815
|
+
{
|
|
816
|
+
workspaceRoot: process.cwd(),
|
|
817
|
+
instruction: "test",
|
|
818
|
+
confirmation: createJobConfirmedConfirmation(),
|
|
819
|
+
overrides: {}
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
parseRecommendInstruction: () => parsed,
|
|
823
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
824
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
825
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
826
|
+
runRecommendSearchCli: async () => ({
|
|
827
|
+
ok: true,
|
|
828
|
+
summary: {
|
|
829
|
+
candidate_count: 9,
|
|
830
|
+
applied_filters: {},
|
|
831
|
+
page_state: { state: "RECOMMEND_READY" }
|
|
832
|
+
}
|
|
833
|
+
}),
|
|
834
|
+
runRecommendScreenCli: async ({ screenParams }) => {
|
|
835
|
+
receivedScreenParams = screenParams;
|
|
836
|
+
return {
|
|
837
|
+
ok: true,
|
|
838
|
+
summary: {
|
|
839
|
+
processed_count: 4,
|
|
840
|
+
passed_count: 1,
|
|
841
|
+
skipped_count: 3,
|
|
842
|
+
output_csv: "C:/temp/resume.csv",
|
|
843
|
+
completion_reason: "page_exhausted"
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
assert.equal(receivedScreenParams.target_count, null);
|
|
851
|
+
assert.equal(result.status, "COMPLETED");
|
|
852
|
+
assert.equal(result.result.completion_reason, "page_exhausted");
|
|
853
|
+
assert.equal(result.result.auto_recovery, null);
|
|
854
|
+
}
|
|
855
|
+
|
|
357
856
|
async function testNeedConfirmationGate() {
|
|
358
857
|
let preflightCalled = false;
|
|
359
858
|
const result = await runRecommendPipeline(
|
|
@@ -1036,6 +1535,11 @@ async function main() {
|
|
|
1036
1535
|
await testResumeFromScreenPauseShouldSkipSearch();
|
|
1037
1536
|
await testResumeFromPausedBeforeScreenShouldRerunSearch();
|
|
1038
1537
|
await testConsecutiveResumeCaptureFailuresShouldRefreshAndRerunSearchWithForcedRecentFilter();
|
|
1538
|
+
await testPageExhaustedBeforeTargetShouldRefreshInPageAndResumeScreen();
|
|
1539
|
+
await testPageExhaustedBeforeTargetShouldReloadWhenRefreshButtonMissing();
|
|
1540
|
+
await testPageExhaustedBeforeTargetShouldReloadWhenRefreshDoesNotRecoverList();
|
|
1541
|
+
await testPageExhaustedBeforeTargetShouldFailAfterFiveRecoveryAttempts();
|
|
1542
|
+
await testNullTargetCountShouldKeepPageExhaustedCompletion();
|
|
1039
1543
|
await testNeedConfirmationGate();
|
|
1040
1544
|
await testNeedSchoolTagConfirmationGate();
|
|
1041
1545
|
await testNeedTargetCountConfirmationGate();
|
|
@@ -450,6 +450,20 @@ const jsDetectBottom = `(() => {
|
|
|
450
450
|
return { isBottom: false, error: 'NO_RECOMMEND_IFRAME' };
|
|
451
451
|
}
|
|
452
452
|
const doc = frame.contentDocument;
|
|
453
|
+
const isVisible = (el) => {
|
|
454
|
+
if (!el) return false;
|
|
455
|
+
const win = doc.defaultView;
|
|
456
|
+
if (!win) return el.offsetParent !== null;
|
|
457
|
+
const style = win.getComputedStyle(el);
|
|
458
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
const rect = el.getBoundingClientRect();
|
|
462
|
+
return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
|
|
463
|
+
};
|
|
464
|
+
const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
|
|
465
|
+
const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
|
|
466
|
+
.find((el) => isVisible(el)) || null;
|
|
453
467
|
const keywords = ['没有更多', '已显示全部', '已经到底', '暂无更多', '推荐完了', '没有更多人选'];
|
|
454
468
|
const elements = Array.from(doc.querySelectorAll('div,span,p'));
|
|
455
469
|
for (const el of elements) {
|
|
@@ -458,12 +472,24 @@ const jsDetectBottom = `(() => {
|
|
|
458
472
|
if (!text || text.length > 40) continue;
|
|
459
473
|
for (const keyword of keywords) {
|
|
460
474
|
if (text.includes(keyword)) {
|
|
461
|
-
return {
|
|
475
|
+
return {
|
|
476
|
+
isBottom: true,
|
|
477
|
+
reason: keyword,
|
|
478
|
+
finished_wrap_visible: Boolean(finishedWrap),
|
|
479
|
+
refresh_button_visible: Boolean(refreshButton),
|
|
480
|
+
refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
|
|
481
|
+
};
|
|
462
482
|
}
|
|
463
483
|
}
|
|
464
484
|
}
|
|
465
|
-
return {
|
|
466
|
-
|
|
485
|
+
return {
|
|
486
|
+
isBottom: Boolean(finishedWrap),
|
|
487
|
+
reason: finishedWrap ? 'finished-wrap' : null,
|
|
488
|
+
finished_wrap_visible: Boolean(finishedWrap),
|
|
489
|
+
refresh_button_visible: Boolean(refreshButton),
|
|
490
|
+
refresh_button_text: refreshButton ? String(refreshButton.textContent || '').replace(/\s+/g, ' ').trim() : null
|
|
491
|
+
};
|
|
492
|
+
})()`;
|
|
467
493
|
const jsWaitForDetail = `(() => {
|
|
468
494
|
const frame = document.querySelector('iframe[name="recommendFrame"]')
|
|
469
495
|
|| document.querySelector('iframe[src*="/web/frame/recommend/"]')
|
|
@@ -1113,6 +1139,24 @@ class RecommendScreenCli {
|
|
|
1113
1139
|
};
|
|
1114
1140
|
}
|
|
1115
1141
|
|
|
1142
|
+
buildProgressSnapshot(completionReason = null) {
|
|
1143
|
+
const snapshot = {
|
|
1144
|
+
processed_count: this.processedCount,
|
|
1145
|
+
passed_count: this.passedCandidates.length,
|
|
1146
|
+
skipped_count: this.skippedCount,
|
|
1147
|
+
output_csv: this.args.output,
|
|
1148
|
+
checkpoint_path: this.checkpointPath,
|
|
1149
|
+
post_action: this.args.postAction,
|
|
1150
|
+
max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
|
|
1151
|
+
greet_count: this.greetCount,
|
|
1152
|
+
greet_limit_fallback_count: this.greetLimitFallbackCount
|
|
1153
|
+
};
|
|
1154
|
+
if (completionReason) {
|
|
1155
|
+
snapshot.completion_reason = completionReason;
|
|
1156
|
+
}
|
|
1157
|
+
return snapshot;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1116
1160
|
resetResumeCaptureFailureStreak() {
|
|
1117
1161
|
this.consecutiveResumeCaptureFailures = 0;
|
|
1118
1162
|
this.resumeCaptureFailureStreakKeys = [];
|
|
@@ -1916,24 +1960,14 @@ class RecommendScreenCli {
|
|
|
1916
1960
|
this.sortCandidateQueue();
|
|
1917
1961
|
}
|
|
1918
1962
|
|
|
1963
|
+
let pageExhaustion = null;
|
|
1919
1964
|
while (!this.args.targetCount || this.processedCount < this.args.targetCount) {
|
|
1920
1965
|
if (this.shouldPauseAtBoundary()) {
|
|
1921
1966
|
this.saveCsv();
|
|
1922
1967
|
this.saveCheckpoint();
|
|
1923
1968
|
return {
|
|
1924
1969
|
status: "PAUSED",
|
|
1925
|
-
result:
|
|
1926
|
-
processed_count: this.processedCount,
|
|
1927
|
-
passed_count: this.passedCandidates.length,
|
|
1928
|
-
skipped_count: this.skippedCount,
|
|
1929
|
-
output_csv: this.args.output,
|
|
1930
|
-
checkpoint_path: this.checkpointPath,
|
|
1931
|
-
completion_reason: "paused",
|
|
1932
|
-
post_action: this.args.postAction,
|
|
1933
|
-
max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
|
|
1934
|
-
greet_count: this.greetCount,
|
|
1935
|
-
greet_limit_fallback_count: this.greetLimitFallbackCount
|
|
1936
|
-
}
|
|
1970
|
+
result: this.buildProgressSnapshot("paused")
|
|
1937
1971
|
};
|
|
1938
1972
|
}
|
|
1939
1973
|
const periodicDiscovery = await this.discoverCandidates();
|
|
@@ -1958,6 +1992,18 @@ class RecommendScreenCli {
|
|
|
1958
1992
|
const didScroll = Number(scroll.after?.scrollTop || 0) !== Number(scroll.before?.scrollTop || 0)
|
|
1959
1993
|
|| Number(scroll.after?.scrollHeight || 0) !== Number(scroll.before?.scrollHeight || 0);
|
|
1960
1994
|
if (scroll.bottom?.isBottom) {
|
|
1995
|
+
pageExhaustion = {
|
|
1996
|
+
reason: "bottom_reached",
|
|
1997
|
+
bottom: scroll.bottom || null,
|
|
1998
|
+
scroll: scroll.scrollResult || null,
|
|
1999
|
+
before: scroll.before || null,
|
|
2000
|
+
after: scroll.after || null,
|
|
2001
|
+
discovery: {
|
|
2002
|
+
added: discovery.added ?? 0,
|
|
2003
|
+
candidate_count: discovery.candidate_count ?? null,
|
|
2004
|
+
total_cards: discovery.total_cards ?? null
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
1961
2007
|
break;
|
|
1962
2008
|
}
|
|
1963
2009
|
if (didGrow || didDiscover) {
|
|
@@ -1967,12 +2013,36 @@ class RecommendScreenCli {
|
|
|
1967
2013
|
if (!didScroll) {
|
|
1968
2014
|
this.scrollRetryCount += 1;
|
|
1969
2015
|
if (this.scrollRetryCount >= this.maxScrollRetries) {
|
|
2016
|
+
pageExhaustion = {
|
|
2017
|
+
reason: "scroll_stalled",
|
|
2018
|
+
bottom: scroll.bottom || null,
|
|
2019
|
+
scroll: scroll.scrollResult || null,
|
|
2020
|
+
before: scroll.before || null,
|
|
2021
|
+
after: scroll.after || null,
|
|
2022
|
+
discovery: {
|
|
2023
|
+
added: discovery.added ?? 0,
|
|
2024
|
+
candidate_count: discovery.candidate_count ?? null,
|
|
2025
|
+
total_cards: discovery.total_cards ?? null
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
1970
2028
|
break;
|
|
1971
2029
|
}
|
|
1972
2030
|
continue;
|
|
1973
2031
|
}
|
|
1974
2032
|
this.scrollRetryCount += 1;
|
|
1975
2033
|
if (this.scrollRetryCount >= this.maxScrollRetries) {
|
|
2034
|
+
pageExhaustion = {
|
|
2035
|
+
reason: "scroll_retry_exhausted",
|
|
2036
|
+
bottom: scroll.bottom || null,
|
|
2037
|
+
scroll: scroll.scrollResult || null,
|
|
2038
|
+
before: scroll.before || null,
|
|
2039
|
+
after: scroll.after || null,
|
|
2040
|
+
discovery: {
|
|
2041
|
+
added: discovery.added ?? 0,
|
|
2042
|
+
candidate_count: discovery.candidate_count ?? null,
|
|
2043
|
+
total_cards: discovery.total_cards ?? null
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
1976
2046
|
break;
|
|
1977
2047
|
}
|
|
1978
2048
|
continue;
|
|
@@ -2076,6 +2146,18 @@ class RecommendScreenCli {
|
|
|
2076
2146
|
}
|
|
2077
2147
|
}
|
|
2078
2148
|
|
|
2149
|
+
if (this.args.targetCount && this.processedCount < this.args.targetCount) {
|
|
2150
|
+
throw this.buildError(
|
|
2151
|
+
"TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED",
|
|
2152
|
+
`推荐列表已到底,但当前仅处理 ${this.processedCount} 位,尚未达到目标 ${this.args.targetCount} 位。`,
|
|
2153
|
+
true,
|
|
2154
|
+
{
|
|
2155
|
+
partial_result: this.buildProgressSnapshot("page_exhausted_before_target_count"),
|
|
2156
|
+
page_exhaustion: pageExhaustion
|
|
2157
|
+
}
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2079
2161
|
this.saveCsv();
|
|
2080
2162
|
try {
|
|
2081
2163
|
this.saveCheckpoint();
|
|
@@ -2085,17 +2167,14 @@ class RecommendScreenCli {
|
|
|
2085
2167
|
return {
|
|
2086
2168
|
status: "COMPLETED",
|
|
2087
2169
|
result: {
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2170
|
+
...this.buildProgressSnapshot(
|
|
2171
|
+
this.args.targetCount && this.processedCount >= this.args.targetCount
|
|
2172
|
+
? "target_count_reached"
|
|
2173
|
+
: "page_exhausted"
|
|
2174
|
+
),
|
|
2092
2175
|
completion_reason: this.args.targetCount && this.processedCount >= this.args.targetCount
|
|
2093
2176
|
? "target_count_reached"
|
|
2094
2177
|
: "page_exhausted",
|
|
2095
|
-
post_action: this.args.postAction,
|
|
2096
|
-
max_greet_count: this.args.postAction === "greet" ? this.args.maxGreetCount : null,
|
|
2097
|
-
greet_count: this.greetCount,
|
|
2098
|
-
greet_limit_fallback_count: this.greetLimitFallbackCount
|
|
2099
2178
|
}
|
|
2100
2179
|
};
|
|
2101
2180
|
} catch (error) {
|
|
@@ -2110,12 +2189,7 @@ class RecommendScreenCli {
|
|
|
2110
2189
|
log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
|
|
2111
2190
|
}
|
|
2112
2191
|
if (!error.partial_result) {
|
|
2113
|
-
error.partial_result =
|
|
2114
|
-
processed_count: this.processedCount,
|
|
2115
|
-
passed_count: this.passedCandidates.length,
|
|
2116
|
-
skipped_count: this.skippedCount,
|
|
2117
|
-
output_csv: this.args.output
|
|
2118
|
-
};
|
|
2192
|
+
error.partial_result = this.buildProgressSnapshot();
|
|
2119
2193
|
}
|
|
2120
2194
|
throw error;
|
|
2121
2195
|
} finally {
|
|
@@ -2144,13 +2218,18 @@ async function main() {
|
|
|
2144
2218
|
|
|
2145
2219
|
if (require.main === module) {
|
|
2146
2220
|
main().catch((error) => {
|
|
2221
|
+
const errorPayload = {
|
|
2222
|
+
code: error.code || "RECOMMEND_SCREEN_FAILED",
|
|
2223
|
+
message: error.message || "推荐页筛选执行失败。",
|
|
2224
|
+
retryable: error.retryable !== false
|
|
2225
|
+
};
|
|
2226
|
+
for (const [key, value] of Object.entries(error || {})) {
|
|
2227
|
+
if (["code", "message", "retryable", "partial_result", "stack"].includes(key)) continue;
|
|
2228
|
+
errorPayload[key] = value;
|
|
2229
|
+
}
|
|
2147
2230
|
const payload = {
|
|
2148
2231
|
status: "FAILED",
|
|
2149
|
-
error:
|
|
2150
|
-
code: error.code || "RECOMMEND_SCREEN_FAILED",
|
|
2151
|
-
message: error.message || "推荐页筛选执行失败。",
|
|
2152
|
-
retryable: error.retryable !== false
|
|
2153
|
-
},
|
|
2232
|
+
error: errorPayload,
|
|
2154
2233
|
result: error.partial_result || null
|
|
2155
2234
|
};
|
|
2156
2235
|
console.log(JSON.stringify(payload));
|
|
@@ -232,10 +232,83 @@ async function testConsecutiveResumeCaptureFailuresStillAbort() {
|
|
|
232
232
|
);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
async function testPageExhaustedBeforeTargetShouldRaiseRecoverableError() {
|
|
236
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-exhausted-"));
|
|
237
|
+
const args = createArgs(tempDir);
|
|
238
|
+
args.targetCount = 5;
|
|
239
|
+
const cli = new FakeRecommendScreenCli(args);
|
|
240
|
+
cli.scrollAndLoadMore = async () => ({
|
|
241
|
+
before: {
|
|
242
|
+
candidateCount: 0,
|
|
243
|
+
scrollTop: 120,
|
|
244
|
+
scrollHeight: 900
|
|
245
|
+
},
|
|
246
|
+
after: {
|
|
247
|
+
candidateCount: 0,
|
|
248
|
+
scrollTop: 900,
|
|
249
|
+
scrollHeight: 900
|
|
250
|
+
},
|
|
251
|
+
bottom: {
|
|
252
|
+
isBottom: true,
|
|
253
|
+
finished_wrap_visible: true,
|
|
254
|
+
refresh_button_visible: true,
|
|
255
|
+
refresh_button_text: "刷新"
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await assert.rejects(
|
|
260
|
+
() => cli.run(),
|
|
261
|
+
(error) => {
|
|
262
|
+
assert.equal(error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
|
|
263
|
+
assert.equal(error.retryable, true);
|
|
264
|
+
assert.equal(error.partial_result?.processed_count, 0);
|
|
265
|
+
assert.equal(error.partial_result?.output_csv, args.output);
|
|
266
|
+
assert.equal(error.partial_result?.checkpoint_path, args.checkpointPath);
|
|
267
|
+
assert.equal(error.partial_result?.completion_reason, "page_exhausted_before_target_count");
|
|
268
|
+
assert.equal(error.page_exhaustion?.reason, "bottom_reached");
|
|
269
|
+
assert.equal(error.page_exhaustion?.bottom?.finished_wrap_visible, true);
|
|
270
|
+
assert.equal(error.page_exhaustion?.bottom?.refresh_button_visible, true);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function testPageExhaustedWithoutTargetShouldStillComplete() {
|
|
277
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-complete-"));
|
|
278
|
+
const cli = new FakeRecommendScreenCli(createArgs(tempDir));
|
|
279
|
+
cli.scrollAndLoadMore = async () => ({
|
|
280
|
+
before: {
|
|
281
|
+
candidateCount: 0,
|
|
282
|
+
scrollTop: 120,
|
|
283
|
+
scrollHeight: 900
|
|
284
|
+
},
|
|
285
|
+
after: {
|
|
286
|
+
candidateCount: 0,
|
|
287
|
+
scrollTop: 900,
|
|
288
|
+
scrollHeight: 900
|
|
289
|
+
},
|
|
290
|
+
bottom: {
|
|
291
|
+
isBottom: true,
|
|
292
|
+
finished_wrap_visible: true,
|
|
293
|
+
refresh_button_visible: true,
|
|
294
|
+
refresh_button_text: "刷新"
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const result = await cli.run();
|
|
299
|
+
assert.equal(result.status, "COMPLETED");
|
|
300
|
+
assert.equal(result.result.processed_count, 0);
|
|
301
|
+
assert.equal(result.result.output_csv, cli.args.output);
|
|
302
|
+
assert.equal(result.result.checkpoint_path, cli.args.checkpointPath);
|
|
303
|
+
assert.equal(result.result.completion_reason, "page_exhausted");
|
|
304
|
+
}
|
|
305
|
+
|
|
235
306
|
async function main() {
|
|
236
307
|
testShouldAbortResumeProbeEarly();
|
|
237
308
|
await testSingleResumeCaptureFailureIsSkipped();
|
|
238
309
|
await testConsecutiveResumeCaptureFailuresStillAbort();
|
|
310
|
+
await testPageExhaustedBeforeTargetShouldRaiseRecoverableError();
|
|
311
|
+
await testPageExhaustedWithoutTargetShouldStillComplete();
|
|
239
312
|
console.log("recoverable resume failure tests passed");
|
|
240
313
|
}
|
|
241
314
|
|