@usercase/mcp 0.2.3-next.2 → 0.2.3-next.3
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/mcp/hub.js +5 -0
- package/package.json +1 -1
- package/src/run/visit.js +122 -3
package/mcp/hub.js
CHANGED
|
@@ -401,6 +401,11 @@ class Hub {
|
|
|
401
401
|
const look = await v.look();
|
|
402
402
|
if (look.ended) return "This visit has ended.";
|
|
403
403
|
if (look.card) return renderCard(look.card);
|
|
404
|
+
// 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
|
|
405
|
+
if (look.view.dialog) {
|
|
406
|
+
this.pictures.set(rec.id, null);
|
|
407
|
+
return renderScreen(look);
|
|
408
|
+
}
|
|
404
409
|
const lines = [];
|
|
405
410
|
// 굴리지 않았는데 맨 위로 돌아갔다(창이 닫히며 주소가 바뀐 사이트). 사람은 화면이 튀는 걸 본다(10/21 실험실 q3)
|
|
406
411
|
// 다른 페이지로 간 것은 원래 맨 위에서 시작한다 — 같은 페이지(? 뒤만 바뀐 것 포함)일 때만
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.3-next.
|
|
3
|
+
"version": "0.2.3-next.3",
|
|
4
4
|
"description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://usercase.io",
|
package/src/run/visit.js
CHANGED
|
@@ -444,6 +444,113 @@ class Visit {
|
|
|
444
444
|
.then((a) => (this.fileWindow = { accept: a || "" }))
|
|
445
445
|
.catch(() => {});
|
|
446
446
|
});
|
|
447
|
+
/**
|
|
448
|
+
* 브라우저 창(alert·confirm·prompt). 듣지 않으면 Playwright 가 조용히 "취소" 한다 — bluetags 커뮤니티 나가기는
|
|
449
|
+
* "Are you sure you want to leave?" 가 세 번 다 취소돼 "눌러도 그대로" 로 찍히고 고객 화면에 사이트 탓으로 나갔다(10/22 p2038).
|
|
450
|
+
* 창이 떠 있는 동안 페이지는 멈추고 누르기도 안 끝난다 — 방문자에게 문구를 보여 주고 고르게 한다(answerDialog)
|
|
451
|
+
*/
|
|
452
|
+
this.dialog = null;
|
|
453
|
+
this.dialogWake = null;
|
|
454
|
+
this.dialogWork = null;
|
|
455
|
+
this.dialogText = null;
|
|
456
|
+
this.acting = false;
|
|
457
|
+
this.curRecord = null;
|
|
458
|
+
page.on("dialog", (d) => this.onDialog(d));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 창이 떴다. 걸음 중이면 방문자에게 넘기고, 아니면(화면 보는 중·카드) 사람이 그냥 넘기듯 닫는다 — 열어 두면 페이지가 멈춘다 */
|
|
462
|
+
onDialog(d) {
|
|
463
|
+
const type = d.type();
|
|
464
|
+
const message = String(d.message() || "").slice(0, 500);
|
|
465
|
+
// 떠나며 "저장 안 한 내용이 있다" 는 사람도 대개 떠난다
|
|
466
|
+
if (type === "beforeunload") return void d.accept().catch(() => {});
|
|
467
|
+
if (!this.acting || this.dialog) {
|
|
468
|
+
(type === "alert" ? d.accept() : d.dismiss()).catch(() => {});
|
|
469
|
+
this.history.push(`A box from the browser popped up saying "${message}" and went away.`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.dialog = { d, type, message, defaultValue: d.defaultValue() || "", path: this.pathNow() };
|
|
473
|
+
this.dialogText = null;
|
|
474
|
+
if (this.dialogWake) this.dialogWake();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
pathNow() {
|
|
478
|
+
try {
|
|
479
|
+
const u = new URL(this.page.url());
|
|
480
|
+
return u.pathname + u.search;
|
|
481
|
+
} catch {
|
|
482
|
+
return "";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 창 안에서 고를 것 — 화면의 번호 목록처럼 준다 */
|
|
487
|
+
dialogItems() {
|
|
488
|
+
const d = this.dialog;
|
|
489
|
+
const ok = { key: "ok", label: "OK", where: "in the box" };
|
|
490
|
+
const cancel = { key: "cancel", label: "Cancel", where: "in the box" };
|
|
491
|
+
const items = d.type === "alert" ? [ok] : d.type === "prompt" ? [{ key: "field", label: "(text box)", where: "in the box" }, ok, cancel] : [ok, cancel];
|
|
492
|
+
return items.map((it, i) => ({ ...it, i }));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 창이 떠 있는 화면. 페이지를 읽을 수 없다(멈춰 있다) — 창만 보인다 */
|
|
496
|
+
dialogLook() {
|
|
497
|
+
const d = this.dialog;
|
|
498
|
+
const what = d.type === "alert" ? "A message box from the browser popped up over the page:" : d.type === "prompt" ? "A box from the browser popped up over the page, asking you to type something:" : "A box from the browser popped up over the page, asking:";
|
|
499
|
+
const lines = [what, "", d.message || "(no text)", ""];
|
|
500
|
+
if (d.type === "prompt") lines.push(this.dialogText !== null ? `You have typed: ${this.dialogText}` : d.defaultValue ? `It already says: ${d.defaultValue}` : "The text box is empty.", "");
|
|
501
|
+
lines.push(d.type === "alert" ? "The page behind it is frozen until you close the box." : "The page behind it is frozen until you answer.");
|
|
502
|
+
const view = { path: d.path, scrollY: 0, height: 0, viewport: 0, items: this.dialogItems().map(({ i, label, where }) => ({ i, label, where })), dialog: true };
|
|
503
|
+
return { view, text: lines.join("\n") };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 창에 답한다 — OK·Cancel 을 누르거나 prompt 칸에 친다. 원래 걸음(_act)은 창이 닫힌 뒤 마저 끝난다.
|
|
508
|
+
* 고른 것은 그 걸음 기록에 붙는다(record.dialogs). 다른 조작은 창이 막는다 — 떠나기는 Cancel 하고 떠난다
|
|
509
|
+
*/
|
|
510
|
+
async answerDialog(decision) {
|
|
511
|
+
await this.waitTurn();
|
|
512
|
+
const dl = this.dialog;
|
|
513
|
+
const item = this.dialogItems()[decision.index];
|
|
514
|
+
let choice = null;
|
|
515
|
+
if (decision.action === "leave") choice = "cancel";
|
|
516
|
+
else if (decision.action === "type" && item && item.key === "field") {
|
|
517
|
+
this.dialogText = String(decision.text || "").slice(0, 200);
|
|
518
|
+
await sleep(800 + Math.random() * 1500);
|
|
519
|
+
this.nextAt = Date.now() + 600;
|
|
520
|
+
return { dialog: true };
|
|
521
|
+
} else if (decision.action === "click" && item && item.key !== "field") choice = item.key;
|
|
522
|
+
else if (decision.action === "click" && item) return { skipped: "you can only type into that text box" };
|
|
523
|
+
else return { skipped: decision.action === "click" || decision.action === "type" ? "that number is not on the screen" : "the box from the browser is still open on top of the page — answer it first" };
|
|
524
|
+
|
|
525
|
+
this.dialog = null;
|
|
526
|
+
const answer = choice === "ok" ? (dl.type === "prompt" ? "typed and pressed OK" : "OK") : "Cancel";
|
|
527
|
+
await (choice === "ok" ? dl.d.accept(dl.type === "prompt" ? this.dialogText ?? dl.defaultValue : undefined) : dl.d.dismiss()).catch(() => {});
|
|
528
|
+
if (this.curRecord) (this.curRecord.dialogs = this.curRecord.dialogs || []).push({ type: dl.type, message: dl.message, answer, why: decision.why, t: Date.now() - this.t0 });
|
|
529
|
+
this.log(` browser box "${dl.message.slice(0, 40)}" → ${answer}`);
|
|
530
|
+
// 원래 걸음이 마저 끝나기를 본다. 창이 또 뜨면 다시 방문자에게
|
|
531
|
+
const r = await this.untilDialog(this.dialogWork);
|
|
532
|
+
if (r && r.dialog) return r;
|
|
533
|
+
this.dialogWork = null;
|
|
534
|
+
if (decision.action === "leave") return this.act(decision);
|
|
535
|
+
return r || { step: this.steps.length };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 걸음을 하다가 창이 뜨면 거기서 돌려준다({ dialog: true }) — 걸음은 창이 닫힌 뒤 마저 간다 */
|
|
539
|
+
async untilDialog(work) {
|
|
540
|
+
let wake;
|
|
541
|
+
const seen = new Promise((res) => (wake = res));
|
|
542
|
+
this.dialogWake = wake;
|
|
543
|
+
this.acting = true;
|
|
544
|
+
const first = await Promise.race([work.then((r) => ({ r })), seen.then(() => null)]);
|
|
545
|
+
if (first) {
|
|
546
|
+
this.acting = false;
|
|
547
|
+
this.dialogWake = null;
|
|
548
|
+
return first.r;
|
|
549
|
+
}
|
|
550
|
+
this.dialogWork = work;
|
|
551
|
+
// 창 글을 읽는 시간
|
|
552
|
+
this.nextAt = Date.now() + 1200 + Math.min(4000, this.dialog.message.length * 40);
|
|
553
|
+
return { dialog: true };
|
|
447
554
|
}
|
|
448
555
|
|
|
449
556
|
/**
|
|
@@ -489,6 +596,8 @@ class Visit {
|
|
|
489
596
|
*/
|
|
490
597
|
async look() {
|
|
491
598
|
if (this.ended) return { ended: true };
|
|
599
|
+
// 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
|
|
600
|
+
if (this.dialog) return this.dialogLook();
|
|
492
601
|
if (!this.card && !this.pending) {
|
|
493
602
|
const shown = await waitCard(this.page, 500);
|
|
494
603
|
if (shown) this.pending = shown;
|
|
@@ -545,8 +654,14 @@ class Visit {
|
|
|
545
654
|
* 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
|
|
546
655
|
* 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
|
|
547
656
|
* 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
|
|
657
|
+
* 하는 도중 브라우저 창이 뜨면 { dialog: true } — 다음 look() 이 창을 보여 주고, 다음 act() 가 창에 답한다
|
|
548
658
|
*/
|
|
549
659
|
async act(decision) {
|
|
660
|
+
if (this.dialog) return this.answerDialog(decision);
|
|
661
|
+
return this.untilDialog(this._act(decision));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async _act(decision) {
|
|
550
665
|
if (this.ended) throw new Error("This visit has already ended");
|
|
551
666
|
if (this.pending) throw new Error("A card is showing — answer or close it first");
|
|
552
667
|
await this.waitTurn();
|
|
@@ -562,6 +677,7 @@ class Visit {
|
|
|
562
677
|
const view = this.lastView || (await listControls(page));
|
|
563
678
|
const item = view.items[decision.index];
|
|
564
679
|
const record = { step: this.steps.length + 1, t: Date.now() - this.t0, path: view.path, action: decision.action, why: decision.why, label: item?.label ?? null };
|
|
680
|
+
this.curRecord = record;
|
|
565
681
|
await this.checkLastJudgement(decision);
|
|
566
682
|
const diagFrom = this.diag.length;
|
|
567
683
|
const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
|
|
@@ -735,7 +851,8 @@ class Visit {
|
|
|
735
851
|
// 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
|
|
736
852
|
for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
|
|
737
853
|
await sleep(150 + Math.random() * 450);
|
|
738
|
-
|
|
854
|
+
// 창이 떴었다 — 화면이 반응한 것이다. 또 누르면 같은 창이 또 뜬다
|
|
855
|
+
if (page.url() !== before || this.offsite.length > offsiteBefore || record.dialogs) break;
|
|
739
856
|
await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
|
|
740
857
|
}
|
|
741
858
|
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
|
|
@@ -796,10 +913,12 @@ class Visit {
|
|
|
796
913
|
)
|
|
797
914
|
await this.shoot(record, "error");
|
|
798
915
|
|
|
799
|
-
// 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한
|
|
916
|
+
// 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만.
|
|
917
|
+
// 브라우저 창이 떴으면 반응한 것이다 — Cancel 을 골라 그대로인 것은 사이트 탓이 아니다
|
|
800
918
|
if (
|
|
801
919
|
probe &&
|
|
802
920
|
!record.skipped &&
|
|
921
|
+
!record.dialogs &&
|
|
803
922
|
!record.missed &&
|
|
804
923
|
!record.closedOverlay &&
|
|
805
924
|
!record.offsite &&
|
|
@@ -827,7 +946,7 @@ class Visit {
|
|
|
827
946
|
|
|
828
947
|
this.steps.push(record);
|
|
829
948
|
this.history.push(
|
|
830
|
-
`${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}`,
|
|
949
|
+
`${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}${(record.dialogs || []).map((d) => ` (a box from the browser said "${d.message.slice(0, 120)}"; you pressed ${d.answer === "Cancel" ? "Cancel" : "OK"})`).join("")}`,
|
|
831
950
|
);
|
|
832
951
|
this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
|
|
833
952
|
this.lastView = null;
|