@reconcrap/boss-recommend-mcp 1.1.4 → 1.1.6
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 +13 -2
- package/skills/boss-recommend-pipeline/SKILL.md +1 -4
- package/src/adapters.js +307 -203
- package/src/pipeline.js +450 -375
- package/src/test-adapters-runtime.js +10 -1
- package/src/test-pipeline.js +506 -4
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +113 -34
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +287 -89
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +413 -245
|
@@ -1,245 +1,413 @@
|
|
|
1
|
-
const assert = require("node:assert/strict");
|
|
2
|
-
const fs = require("node:fs");
|
|
3
|
-
const os = require("node:os");
|
|
4
|
-
const path = require("node:path");
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const { __testables
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
error
|
|
129
|
-
error.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
["
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
assert.equal(result.
|
|
200
|
-
assert.equal(result.result.
|
|
201
|
-
assert.equal(result.result.
|
|
202
|
-
assert.equal(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
(
|
|
224
|
-
|
|
225
|
-
assert.
|
|
226
|
-
assert.
|
|
227
|
-
assert.equal(error.
|
|
228
|
-
assert.equal(error.partial_result?.
|
|
229
|
-
assert.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const sharp = require("sharp");
|
|
6
|
+
|
|
7
|
+
const { RecommendScreenCli, __testables } = require("./boss-recommend-screen-cli.cjs");
|
|
8
|
+
const { __testables: captureTestables } = require("./scripts/capture-full-resume-canvas.cjs");
|
|
9
|
+
|
|
10
|
+
class FakeRecommendScreenCli extends RecommendScreenCli {
|
|
11
|
+
constructor(args, options = {}) {
|
|
12
|
+
super(args);
|
|
13
|
+
this.testCandidates = options.candidates || [];
|
|
14
|
+
this.captureOutcomes = options.captureOutcomes || new Map();
|
|
15
|
+
this.screeningByKey = options.screeningByKey || new Map();
|
|
16
|
+
this.discoveryCalls = 0;
|
|
17
|
+
this.lastCapturedCandidateKey = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async connect() {}
|
|
21
|
+
|
|
22
|
+
async disconnect() {}
|
|
23
|
+
|
|
24
|
+
async getDetailClosedState() {
|
|
25
|
+
return { closed: true, reason: "test" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async closeDetailPage() {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async waitForListReady() {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async ensureHealthyListViewport() {
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
state: { ok: true }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async discoverCandidates() {
|
|
44
|
+
if (this.discoveryCalls === 0) {
|
|
45
|
+
for (const candidate of this.testCandidates) {
|
|
46
|
+
this.candidateByKey.set(candidate.key, candidate);
|
|
47
|
+
this.discoveredKeys.add(candidate.key);
|
|
48
|
+
this.candidateQueue.push(candidate.key);
|
|
49
|
+
this.insertCounter += 1;
|
|
50
|
+
this.insertedAt.set(candidate.key, this.insertCounter);
|
|
51
|
+
}
|
|
52
|
+
this.discoveryCalls += 1;
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
added: this.testCandidates.length,
|
|
56
|
+
candidate_count: this.testCandidates.length,
|
|
57
|
+
total_cards: this.testCandidates.length
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
this.discoveryCalls += 1;
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
added: 0,
|
|
64
|
+
candidate_count: this.testCandidates.length,
|
|
65
|
+
total_cards: this.testCandidates.length
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async scrollAndLoadMore() {
|
|
70
|
+
return {
|
|
71
|
+
before: {
|
|
72
|
+
candidateCount: this.testCandidates.length,
|
|
73
|
+
scrollTop: 0,
|
|
74
|
+
scrollHeight: 100
|
|
75
|
+
},
|
|
76
|
+
after: {
|
|
77
|
+
candidateCount: this.testCandidates.length,
|
|
78
|
+
scrollTop: 0,
|
|
79
|
+
scrollHeight: 100
|
|
80
|
+
},
|
|
81
|
+
bottom: {
|
|
82
|
+
isBottom: true
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async clickCandidate() {}
|
|
88
|
+
|
|
89
|
+
async ensureDetailOpen() {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async captureResumeImage(candidate) {
|
|
94
|
+
const outcome = this.captureOutcomes.get(candidate.key);
|
|
95
|
+
if (outcome instanceof Error) {
|
|
96
|
+
throw outcome;
|
|
97
|
+
}
|
|
98
|
+
this.lastCapturedCandidateKey = candidate.key;
|
|
99
|
+
return outcome || {
|
|
100
|
+
stitchedImage: path.join(os.tmpdir(), `${candidate.key}.png`)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async callVisionModel() {
|
|
105
|
+
return this.screeningByKey.get(this.lastCapturedCandidateKey) || {
|
|
106
|
+
passed: false,
|
|
107
|
+
reason: "not matched",
|
|
108
|
+
summary: "not matched"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async favoriteCandidate() {
|
|
113
|
+
return { actionTaken: "favorite" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async greetCandidate() {
|
|
117
|
+
return { actionTaken: "greet" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async takeBreakIfNeeded() {}
|
|
121
|
+
|
|
122
|
+
saveCsv() {}
|
|
123
|
+
|
|
124
|
+
saveCheckpoint() {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createResumeCaptureError(message = "Resume canvas not found") {
|
|
128
|
+
const error = new Error(message);
|
|
129
|
+
error.code = "RESUME_CAPTURE_FAILED";
|
|
130
|
+
error.retryable = true;
|
|
131
|
+
return error;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createArgs(tempDir) {
|
|
135
|
+
return {
|
|
136
|
+
baseUrl: "https://example.invalid/v1",
|
|
137
|
+
apiKey: "test-key",
|
|
138
|
+
model: "test-model",
|
|
139
|
+
criteria: "test criteria",
|
|
140
|
+
targetCount: null,
|
|
141
|
+
maxGreetCount: null,
|
|
142
|
+
port: 9222,
|
|
143
|
+
output: path.join(tempDir, "result.csv"),
|
|
144
|
+
checkpointPath: path.join(tempDir, "checkpoint.json"),
|
|
145
|
+
pauseControlPath: path.join(tempDir, "pause.json"),
|
|
146
|
+
resume: false,
|
|
147
|
+
postAction: "none",
|
|
148
|
+
postActionConfirmed: true,
|
|
149
|
+
help: false,
|
|
150
|
+
__provided: {
|
|
151
|
+
baseUrl: true,
|
|
152
|
+
apiKey: true,
|
|
153
|
+
model: true,
|
|
154
|
+
criteria: true,
|
|
155
|
+
targetCount: true,
|
|
156
|
+
maxGreetCount: false,
|
|
157
|
+
port: true,
|
|
158
|
+
postAction: true,
|
|
159
|
+
postActionConfirmed: true
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function testShouldAbortResumeProbeEarly() {
|
|
165
|
+
const probe = {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: "NO_CRESUME_IFRAME",
|
|
168
|
+
debug: {
|
|
169
|
+
activeScopeCount: 0,
|
|
170
|
+
totalResumeIframes: 0,
|
|
171
|
+
visibleResumeIframes: 0
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const shouldAbort = captureTestables.shouldAbortResumeProbeEarly({
|
|
175
|
+
probe,
|
|
176
|
+
stableNoResumeIframePolls: captureTestables.EARLY_FAIL_NO_RESUME_IFRAME_STABLE_POLLS,
|
|
177
|
+
elapsedMs: captureTestables.EARLY_FAIL_NO_RESUME_IFRAME_MIN_WAIT_MS,
|
|
178
|
+
waitResumeMs: 60000
|
|
179
|
+
});
|
|
180
|
+
assert.equal(shouldAbort, true);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function testSingleResumeCaptureFailureIsSkipped() {
|
|
184
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-skip-"));
|
|
185
|
+
const badCandidate = { key: "bad", geek_id: "bad", name: "bad candidate" };
|
|
186
|
+
const goodCandidate = { key: "good", geek_id: "good", name: "good candidate" };
|
|
187
|
+
const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
|
|
188
|
+
candidates: [badCandidate, goodCandidate],
|
|
189
|
+
captureOutcomes: new Map([
|
|
190
|
+
["bad", createResumeCaptureError()],
|
|
191
|
+
["good", { stitchedImage: path.join(tempDir, "good.png") }]
|
|
192
|
+
]),
|
|
193
|
+
screeningByKey: new Map([
|
|
194
|
+
["good", { passed: true, reason: "matched", summary: "matched" }]
|
|
195
|
+
])
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await cli.run();
|
|
199
|
+
assert.equal(result.status, "COMPLETED");
|
|
200
|
+
assert.equal(result.result.processed_count, 2);
|
|
201
|
+
assert.equal(result.result.passed_count, 1);
|
|
202
|
+
assert.equal(result.result.skipped_count, 1);
|
|
203
|
+
assert.equal(cli.consecutiveResumeCaptureFailures, 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function testConsecutiveResumeCaptureFailuresStillAbort() {
|
|
207
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-abort-"));
|
|
208
|
+
const maxFailures = __testables.MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES;
|
|
209
|
+
const candidates = Array.from({ length: maxFailures }, (_, index) => ({
|
|
210
|
+
key: `fail-${index + 1}`,
|
|
211
|
+
geek_id: `fail-${index + 1}`,
|
|
212
|
+
name: `fail-${index + 1}`
|
|
213
|
+
}));
|
|
214
|
+
const captureOutcomes = new Map(
|
|
215
|
+
candidates.map((candidate) => [candidate.key, createResumeCaptureError(`Resume capture failed for ${candidate.key}`)])
|
|
216
|
+
);
|
|
217
|
+
const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
|
|
218
|
+
candidates,
|
|
219
|
+
captureOutcomes
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await assert.rejects(
|
|
223
|
+
() => cli.run(),
|
|
224
|
+
(error) => {
|
|
225
|
+
assert.equal(error.code, "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT");
|
|
226
|
+
assert.match(error.message, /连续 .* 位候选人简历捕获失败/);
|
|
227
|
+
assert.equal(error.rollback?.rollback_count, maxFailures);
|
|
228
|
+
assert.equal(error.partial_result?.processed_count, 0);
|
|
229
|
+
assert.equal(error.partial_result?.skipped_count, 0);
|
|
230
|
+
assert.deepEqual(Array.from(cli.processedKeys), []);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function testPageExhaustedBeforeTargetShouldRaiseRecoverableError() {
|
|
237
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-exhausted-"));
|
|
238
|
+
const args = createArgs(tempDir);
|
|
239
|
+
args.targetCount = 5;
|
|
240
|
+
const cli = new FakeRecommendScreenCli(args);
|
|
241
|
+
cli.scrollAndLoadMore = async () => ({
|
|
242
|
+
before: {
|
|
243
|
+
candidateCount: 0,
|
|
244
|
+
scrollTop: 120,
|
|
245
|
+
scrollHeight: 900
|
|
246
|
+
},
|
|
247
|
+
after: {
|
|
248
|
+
candidateCount: 0,
|
|
249
|
+
scrollTop: 900,
|
|
250
|
+
scrollHeight: 900
|
|
251
|
+
},
|
|
252
|
+
bottom: {
|
|
253
|
+
isBottom: true,
|
|
254
|
+
finished_wrap_visible: true,
|
|
255
|
+
refresh_button_visible: true,
|
|
256
|
+
refresh_button_text: "刷新"
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await assert.rejects(
|
|
261
|
+
() => cli.run(),
|
|
262
|
+
(error) => {
|
|
263
|
+
assert.equal(error.code, "TARGET_COUNT_NOT_REACHED_PAGE_EXHAUSTED");
|
|
264
|
+
assert.equal(error.retryable, true);
|
|
265
|
+
assert.equal(error.partial_result?.processed_count, 0);
|
|
266
|
+
assert.equal(error.partial_result?.output_csv, args.output);
|
|
267
|
+
assert.equal(error.partial_result?.checkpoint_path, args.checkpointPath);
|
|
268
|
+
assert.equal(error.partial_result?.completion_reason, "page_exhausted_before_target_count");
|
|
269
|
+
assert.equal(error.page_exhaustion?.reason, "bottom_reached");
|
|
270
|
+
assert.equal(error.page_exhaustion?.bottom?.finished_wrap_visible, true);
|
|
271
|
+
assert.equal(error.page_exhaustion?.bottom?.refresh_button_visible, true);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function testPageExhaustedWithoutTargetShouldStillComplete() {
|
|
278
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-page-complete-"));
|
|
279
|
+
const cli = new FakeRecommendScreenCli(createArgs(tempDir));
|
|
280
|
+
cli.scrollAndLoadMore = async () => ({
|
|
281
|
+
before: {
|
|
282
|
+
candidateCount: 0,
|
|
283
|
+
scrollTop: 120,
|
|
284
|
+
scrollHeight: 900
|
|
285
|
+
},
|
|
286
|
+
after: {
|
|
287
|
+
candidateCount: 0,
|
|
288
|
+
scrollTop: 900,
|
|
289
|
+
scrollHeight: 900
|
|
290
|
+
},
|
|
291
|
+
bottom: {
|
|
292
|
+
isBottom: true,
|
|
293
|
+
finished_wrap_visible: true,
|
|
294
|
+
refresh_button_visible: true,
|
|
295
|
+
refresh_button_text: "刷新"
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const result = await cli.run();
|
|
300
|
+
assert.equal(result.status, "COMPLETED");
|
|
301
|
+
assert.equal(result.result.processed_count, 0);
|
|
302
|
+
assert.equal(result.result.output_csv, cli.args.output);
|
|
303
|
+
assert.equal(result.result.checkpoint_path, cli.args.checkpointPath);
|
|
304
|
+
assert.equal(result.result.completion_reason, "page_exhausted");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function testStitchWithSharpShouldComposeExpectedImage() {
|
|
308
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-sharp-stitch-"));
|
|
309
|
+
const chunkA = path.join(tempDir, "chunk_000.png");
|
|
310
|
+
const chunkB = path.join(tempDir, "chunk_001.png");
|
|
311
|
+
const chunkC = path.join(tempDir, "chunk_002.png");
|
|
312
|
+
const metadataPath = path.join(tempDir, "chunks.json");
|
|
313
|
+
const outputPath = path.join(tempDir, "stitched.png");
|
|
314
|
+
|
|
315
|
+
await sharp({
|
|
316
|
+
create: { width: 20, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }
|
|
317
|
+
}).png().toFile(chunkA);
|
|
318
|
+
await sharp({
|
|
319
|
+
create: { width: 20, height: 100, channels: 3, background: { r: 0, g: 255, b: 0 } }
|
|
320
|
+
}).png().toFile(chunkB);
|
|
321
|
+
await sharp({
|
|
322
|
+
create: { width: 20, height: 100, channels: 3, background: { r: 0, g: 0, b: 255 } }
|
|
323
|
+
}).png().toFile(chunkC);
|
|
324
|
+
|
|
325
|
+
fs.writeFileSync(
|
|
326
|
+
metadataPath,
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
chunks: [
|
|
329
|
+
{ index: 0, file: chunkA, scrollTop: 0, clipHeightCss: 100 },
|
|
330
|
+
{ index: 1, file: chunkB, scrollTop: 80, clipHeightCss: 100 },
|
|
331
|
+
{ index: 2, file: chunkC, scrollTop: 160, clipHeightCss: 100 }
|
|
332
|
+
]
|
|
333
|
+
}),
|
|
334
|
+
"utf8"
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const stitched = await captureTestables.stitchWithSharp(metadataPath, outputPath);
|
|
338
|
+
const outputMeta = await sharp(outputPath).metadata();
|
|
339
|
+
|
|
340
|
+
assert.equal(stitched.ok, true);
|
|
341
|
+
assert.equal(stitched.engine, "sharp");
|
|
342
|
+
assert.equal(stitched.segments, 3);
|
|
343
|
+
assert.equal(outputMeta.width, 20);
|
|
344
|
+
assert.equal(outputMeta.height, 260);
|
|
345
|
+
assert.equal(Array.isArray(stitched.used), true);
|
|
346
|
+
assert.equal(stitched.used.length, 3);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function testStitchWithAvailablePythonShouldFallbackToPython() {
|
|
350
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-python-fallback-"));
|
|
351
|
+
const stitchScript = path.join(tempDir, "stitch.py");
|
|
352
|
+
fs.writeFileSync(stitchScript, "print('ok')", "utf8");
|
|
353
|
+
const calls = [];
|
|
354
|
+
const result = captureTestables.stitchWithAvailablePython(
|
|
355
|
+
stitchScript,
|
|
356
|
+
path.join(tempDir, "meta.json"),
|
|
357
|
+
path.join(tempDir, "out.png"),
|
|
358
|
+
(command) => {
|
|
359
|
+
calls.push(command);
|
|
360
|
+
if (command === "python3") {
|
|
361
|
+
return {
|
|
362
|
+
status: 1,
|
|
363
|
+
signal: null,
|
|
364
|
+
error: null,
|
|
365
|
+
stderr: "python3 failed",
|
|
366
|
+
stdout: ""
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
status: 0,
|
|
371
|
+
signal: null,
|
|
372
|
+
error: null,
|
|
373
|
+
stderr: "",
|
|
374
|
+
stdout: "ok"
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
assert.equal(result.ok, true);
|
|
380
|
+
assert.equal(result.command, "python");
|
|
381
|
+
assert.deepEqual(calls, ["python3", "python"]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function testStitchWithAvailablePythonShouldFailWhenScriptMissing() {
|
|
385
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-python-missing-"));
|
|
386
|
+
const result = captureTestables.stitchWithAvailablePython(
|
|
387
|
+
path.join(tempDir, "missing.py"),
|
|
388
|
+
path.join(tempDir, "meta.json"),
|
|
389
|
+
path.join(tempDir, "out.png")
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
assert.equal(result.ok, false);
|
|
393
|
+
assert.equal(Array.isArray(result.attempts), true);
|
|
394
|
+
assert.equal(result.attempts.length, 2);
|
|
395
|
+
assert.equal(result.attempts[0].command, "python3");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function main() {
|
|
399
|
+
testShouldAbortResumeProbeEarly();
|
|
400
|
+
await testSingleResumeCaptureFailureIsSkipped();
|
|
401
|
+
await testConsecutiveResumeCaptureFailuresStillAbort();
|
|
402
|
+
await testPageExhaustedBeforeTargetShouldRaiseRecoverableError();
|
|
403
|
+
await testPageExhaustedWithoutTargetShouldStillComplete();
|
|
404
|
+
await testStitchWithSharpShouldComposeExpectedImage();
|
|
405
|
+
testStitchWithAvailablePythonShouldFallbackToPython();
|
|
406
|
+
testStitchWithAvailablePythonShouldFailWhenScriptMissing();
|
|
407
|
+
console.log("recoverable resume failure tests passed");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
main().catch((error) => {
|
|
411
|
+
console.error(error);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
});
|