@usercase/mcp 0.2.2-next.4 → 0.2.2-next.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/mcp/hub.js CHANGED
@@ -444,7 +444,10 @@ class Hub {
444
444
  const v = rec.visit;
445
445
  rec.lastAction = decision.action;
446
446
  const r = await v.act(decision);
447
- if (r.interrupted) return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
447
+ if (r.interrupted) {
448
+ rec.cutOff = true;
449
+ return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
450
+ }
448
451
  const said = [];
449
452
  if (r.offsite) said.push(`That leads to another website (${r.offsite}). It did not open here — you are still on this site. If going there is what you would do, that means leaving this site.`);
450
453
  if (r.skipped) said.push(`That did not happen: ${r.skipped}.`);
@@ -453,6 +456,7 @@ class Hub {
453
456
  said.push("That would have started signing in or signing up. On this visit you do not sign in — you stay as you are, on this page. Carry on as this person would without an account, or leave.");
454
457
  else if (r.notSent) said.push("That would have sent something to the site (saving, posting or signing up). In this run nothing is sent to the site, so it was not saved. Whatever the screen shows next (an error, or no change) comes from that, not from the site — carry on as you would have if it had worked.");
455
458
  if (r.external) said.push(`Part of this page needs another server (${r.external.join(", ")}), which this test run of the site on your computer does not reach. Whatever depends on it (live chat, sending, live prices) cannot finish here — that is the test setup, not the site. Carry on as you would have if it had worked.`);
459
+ if (r.sentWithEnter) said.push("You pressed Enter to send it, as you would in a chat box.");
456
460
  if (r.heldEnter) said.push("The screen changed as you typed, so you did not press Enter — what you see is what typing alone did. If you would press Enter now, type the same text into that field again.");
457
461
  if (r.fileWindow) said.push(r.fileWindow);
458
462
  if (r.openedApp) said.push("That tried to open another app outside the browser (a share or app link). Nothing opened, and you are back on the page you were on.");
@@ -481,7 +485,10 @@ class Hub {
481
485
  const rec = this.get(id);
482
486
  const v = rec.visit;
483
487
  const c = await v.answer(decision);
484
- const done = c.result === "close" ? "You closed the card." : `You answered: ${c.answer}`;
488
+ let done = c.result === "close" ? "You closed the card." : `You answered: ${c.answer}`;
489
+ // 카드가 가로챈 동작은 일어나지 않았다 — 카드를 닫고 나서도 말해야 다시 한다. 안 하면 "클릭이 됐나?" 를 몰랐다(10/21 실험실 e4·H2)
490
+ if (rec.cutOff) done += " What you were about to do before the card appeared did not happen — do it again if you still want to.";
491
+ rec.cutOff = false;
485
492
  if (v.ended) {
486
493
  const sum = await this.finish(rec);
487
494
  return [done, "This visit is over.", "", sum].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.2-next.4",
3
+ "version": "0.2.2-next.5",
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",
@@ -264,6 +264,14 @@ async function listControls(page, max = 60) {
264
264
  // 넘치면 이름 없는 아이콘부터 뺀다. 달력 칸마다 아이콘이 서너 개라 60개를 다 먹고 날짜·버튼이 잘렸다(10/14 실험실 p29)
265
265
  let pool = shown;
266
266
  let iconsLeft = 0;
267
+ // 화면 안 입력칸(글 쓰는 곳)과, 그 둘레 48px 안에 있는 것
268
+ for (const c of shown) c.field = c.where === "in view" && ["input", "textarea"].includes(c.tag);
269
+ const fieldBoxes = shown.filter((c) => c.field).map((c) => c.el.getBoundingClientRect());
270
+ const nearField = (c) => {
271
+ if (!fieldBoxes.length) return false;
272
+ const r = c.el.getBoundingClientRect();
273
+ return fieldBoxes.some((f) => r.right >= f.left - 48 && r.left <= f.right + 48 && r.bottom >= f.top - 48 && r.top <= f.bottom + 48);
274
+ };
267
275
  // 화면 안의 것만으로 넘칠 때만 — 지나온 위쪽 때문에 넘친 거면 위쪽을 자르면 된다. 아이콘까지 빼면 보이는 것이 빠진다(10/15)
268
276
  if (pool.filter((c) => c.where === "in view").length > max) {
269
277
  // 같은 이름으로 되풀이되는 그림도 아이콘처럼 먼저 뺀다 — 카드마다의 "Project logo image" 17개가 자리를 먹어
@@ -287,9 +295,17 @@ async function listControls(page, max = 60) {
287
295
  ...ranked.filter((x) => x.rep <= 2).slice(0, 4).map((x) => x.c),
288
296
  ...ranked.slice(0, Math.max(0, max - named)).map((x) => x.c),
289
297
  ]);
298
+ // 입력칸 바로 옆 아이콘(보내기·첨부·검색·지우기)은 남긴다 — 채팅방은 왼쪽 방 목록 아이콘과 크기가 같아
299
+ // 보내기·첨부가 같이 빠졌고, 방문자는 "보내기 버튼이 없다" 며 못 보냈다(10/21 bluetags 82fd)
300
+ for (const c of inView) if (isIcon(c) && nearField(c)) keep.add(c);
290
301
  pool = pool.filter((c) => c.where !== "in view" || !isIcon(c) || keep.has(c));
291
302
  iconsLeft = shown.length - pool.length;
292
303
  }
304
+ // 입력칸과 그 곁 아이콘은 앞으로 — 60 개로 자를 때 페이지 아래쪽 채팅칸이 잘리지 않게
305
+ if (pool.length > max) {
306
+ const front = pool.filter((c) => c.where === "in view" && (c.field || nearField(c)));
307
+ pool = [...front, ...pool.filter((c) => !front.includes(c))];
308
+ }
293
309
  const picked = pool.slice(0, max);
294
310
  const groups = {};
295
311
  for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
@@ -317,12 +333,27 @@ async function listControls(page, max = 60) {
317
333
  // 층을 하나로 맞추면 안 갈리는 것 — 댓글칸 Post 는 바로 위에, 답글칸 Post 는 한 층 더 위에 칸이 있었다(10/21 실험실 m1).
318
334
  // 항목마다 가장 가까운 곁 글을 따로 찾는다
319
335
  if (!hints) {
336
+ // 둘레 글이 없으면 가장 가까운 입력칸의 안내 문구 — 댓글칸 Post 는 곁 덩어리에 답글칸 Post 까지 들어 있어 끝내 못 갈랐다(10/21 실험실 m1·f1)
337
+ const fieldHint = (c) => {
338
+ const r = c.el.getBoundingClientRect();
339
+ let best = null;
340
+ let dist = 160;
341
+ for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
342
+ const b = f.getBoundingClientRect();
343
+ if (!b.width || !b.height) continue;
344
+ const dx = Math.max(b.left - r.right, r.left - b.right, 0);
345
+ const dy = Math.max(b.top - r.bottom, r.top - b.bottom, 0);
346
+ const dd = Math.hypot(dx, dy);
347
+ if (dd < dist) (dist = dd), (best = f.getAttribute("placeholder"));
348
+ }
349
+ return best ? `the "${best}" box` : "";
350
+ };
320
351
  const h = g.map((c) => {
321
352
  for (let d = 0; d < 6; d++) {
322
353
  const x = nearAt(c, d, g);
323
354
  if (x.length >= 2) return x;
324
355
  }
325
- return "";
356
+ return fieldHint(c);
326
357
  });
327
358
  if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
328
359
  }
@@ -333,7 +364,7 @@ async function listControls(page, max = 60) {
333
364
  const out = picked.map((c, i) => {
334
365
  els.push(c.el);
335
366
  // 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
336
- return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}) };
367
+ return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}), ...(c.where === "in view" && !c.field && nearField(c) ? { nearField: true } : {}) };
337
368
  });
338
369
  Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
339
370
  return {
@@ -447,6 +478,21 @@ async function visibleText(page, max = 2500) {
447
478
  out.push(txt);
448
479
  lastTop = r.top;
449
480
  }
481
+ // 빈 입력칸의 흐린 안내 글(placeholder)도 사람 눈에 보인다 — 없으면 "Your Reply" 칸이 목록에만 있고 화면에는 없었다(10/21 실험실 G1).
482
+ // 글 사이 자리를 찾기 어려워 끝에 모은다
483
+ const boxes = [];
484
+ for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
485
+ if (f.closest(ownSel) || f.value) continue;
486
+ const r = f.getBoundingClientRect();
487
+ if (r.width < 2 || r.height < 2 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
488
+ if (getComputedStyle(f).visibility === "hidden") continue;
489
+ if (buried(f, r) === "under") continue;
490
+ // 끝에 모으니 어디 있는 칸인지 몰랐다(10/21 실험실 H3) — 대략 자리를 붙인다
491
+ const mid = (r.top + r.bottom) / 2;
492
+ const where = mid < innerHeight / 3 ? "near the top" : mid > (innerHeight * 2) / 3 ? "near the bottom" : "in the middle";
493
+ boxes.push(`[empty box ${where}: "${f.getAttribute("placeholder").trim().slice(0, 40)}"]`);
494
+ }
495
+ if (boxes.length) out.push("\n", boxes.join(" "));
450
496
  // 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
451
497
  if (behind.length) {
452
498
  const b = behind.join(" · ");
@@ -527,7 +573,11 @@ function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
527
573
  */
528
574
  async function iconPicture(page, items, browser, max = 16) {
529
575
  // 숫자뿐인 것(하트·말풍선 옆의 좋아요·답글 수)도 — 글자가 "0" 이라 이름이 있는 줄 알았는데 무엇의 수인지 몰랐다(10/21 실험실 i4)
530
- const icons = items.filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label))).slice(0, max);
576
+ const icons = items
577
+ .filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label)))
578
+ // 입력칸 곁(보내기·첨부)을 먼저 — 아이콘이 많은 화면에서 16 개 안에 들게
579
+ .sort((a, b) => (b.nearField ? 1 : 0) - (a.nearField ? 1 : 0))
580
+ .slice(0, max);
531
581
  if (!icons.length || !browser) return null;
532
582
  const vp = page.viewportSize() || { width: 1280, height: 860 };
533
583
  const crops = [];
package/src/run/visit.js CHANGED
@@ -530,6 +530,8 @@ class Visit {
530
530
  }
531
531
  // 치기 전 화면. 치는 대로 걸러 주는 칸이면 친 뒤 화면이 이미 바뀌어 있다
532
532
  const shownBefore = multiline ? "" : await visibleText(page, 2500);
533
+ // 채팅칸인지는 치기 **전** 높이로 본다 — 글 길이에 따라 커지는 칸은 긴 질문을 치면 여러 줄이 되어 채팅칸으로 안 보였다(10/21 bluetags 82fd)
534
+ const oneLine = multiline && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false));
533
535
  record.typos = await hand.typeLikePerson(page, this.handState, typed).catch(() => 0);
534
536
  let changed = false;
535
537
  if (!multiline) {
@@ -554,12 +556,14 @@ class Visit {
554
556
  * 한 줄짜리 여러 줄 칸 = 채팅 입력칸. 사람은 메신저처럼 Enter 로 보낸다 — 안 누르면 이름 없는 보내기 아이콘을
555
557
  * 못 찾아 메시지를 못 보냈다(10/21 bluetags 2b72, 커뮤니티 채팅). Enter 가 줄만 바꿨으면 Enter 로 안 보내는 칸이니 지운다
556
558
  */
557
- if (multiline && typed && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false))) {
559
+ if (oneLine && typed) {
558
560
  await sleep(300 + Math.random() * 500);
559
561
  await page.keyboard.press("Enter").catch(() => {});
560
562
  await sleep(250);
561
563
  const after = await el.evaluate((n) => String(n.value ?? n.innerText ?? "")).catch(() => "");
562
564
  if (/\n\s*$/.test(after) && after.trim() === typed.trim()) await page.keyboard.press("Backspace").catch(() => {});
565
+ // 칸이 비었으면 Enter 로 보낸 것 — 방문자에게 말한다. 안 하면 "보내지도 않았는데 올라갔다" 가 됐다(10/21 실험실 g2)
566
+ else if (!after.trim()) record.sentWithEnter = true;
563
567
  }
564
568
  this.heldEnter = !multiline && changed ? typed : null;
565
569
  if (this.heldEnter) record.heldEnter = true;