@usercase/mcp 0.2.3-next.2 → 0.2.3-next.4

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
@@ -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.2",
3
+ "version": "0.2.3-next.4",
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",
@@ -180,7 +180,11 @@ async function visibleTwin(page, el, vw) {
180
180
  const r = n.getBoundingClientRect();
181
181
  const cx = r.x + r.width / 2;
182
182
  const cy = r.y + r.height / 2;
183
- if (r.width > 0 && cx > 1 && cx < w - 1 && cy > 1 && cy < innerHeight - 1) return n;
183
+ if (!(r.width > 0 && cx > 1 && cx < w - 1 && cy > 1 && cy < innerHeight - 1)) continue;
184
+ // 화면 안이어도 잘린 틀 밖이거나 사이드바 밑이면 사람 눈에 없다 — 그 자리를 누르면 그게 맞아야 한다.
185
+ // 안 보면 PC 홈 배너에서 사이드바 밑 복사본을 골라 "덮였다·화면 밖" 으로 끝났다(10/23 실험실 O2)
186
+ const hit = document.elementFromPoint(cx, cy);
187
+ if (hit && (hit === n || n.contains(hit) || hit.contains(n))) return n;
184
188
  }
185
189
  return null;
186
190
  }, vw)
@@ -189,6 +193,22 @@ async function visibleTwin(page, el, vw) {
189
193
  return twin || null;
190
194
  }
191
195
 
196
+ /** 가로로 잘린 줄(회전 배너·가로 목록)의 틀 밖으로 나가 있나 — 가운데가 틀 밖이면 사람 눈에 없다 */
197
+ async function outOfRow(el) {
198
+ return el
199
+ .evaluate((n) => {
200
+ const r = n.getBoundingClientRect();
201
+ const cx = r.x + r.width / 2;
202
+ for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
203
+ if (getComputedStyle(p).overflowX === "visible") continue;
204
+ const pr = p.getBoundingClientRect();
205
+ return cx < pr.left || cx > pr.right;
206
+ }
207
+ return false;
208
+ })
209
+ .catch(() => false);
210
+ }
211
+
192
212
  /**
193
213
  * 저절로 넘어가는 줄(자동 배너)에서 잘린 것 — 사람은 밀지 않고 그 카드가 돌아와 화면 안에 들 때까지 본다.
194
214
  * 3.5초 동안 안 움직이면 멈춰 있는 줄이다(null — 밀어야 한다). 8초 안에 화면 가운데쯤 오면 그것(또는 복사본)
@@ -346,6 +366,20 @@ async function clickLikePerson(page, state, el) {
346
366
  }
347
367
  return { missed: false, closedOverlay: gone };
348
368
  }
369
+ // 덮은 것을 못 찾아도 본다 — 틀 밖 자리를 찍으면 배너 틀 자신이 맞아 "안 덮였다" 가 되고, 끝에 Playwright 클릭이
370
+ // "무언가 위에 있다" 로 끝났다(10/23 실험실 P1, 20초 뒤 누르기)
371
+ if (!(cover && /survey card/.test(cover.say || "")) && (await outOfRow(el))) {
372
+ // 배너가 그 사이 넘어가 카드가 틀 밖으로 나갔다. 사람은 화면에 보이는 그 카드(복사본)를 누르거나 돌아오길 기다린다.
373
+ // 틀 밖 자리에 마침 사이드바가 있어 "사이드바가 덮었다" 로 끝났다 — PC 홈 배너가 처음 몇 초 안에 네 칸을 넘긴다(10/23 실험실 O2)
374
+ const vw = (page.viewportSize() || { width: 1200 }).width;
375
+ const twin = (await visibleTwin(page, el, vw)) || (await waitComeAround(page, el, vw));
376
+ const got = twin ? await pressAt(page, state, twin, await aimAt(page, state, twin)) : null;
377
+ if (got) return { missed: got.missed };
378
+ const say = "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing";
379
+ const e = new Error(say);
380
+ e.human = say;
381
+ throw e;
382
+ }
349
383
  if (cover) {
350
384
  // 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
351
385
  if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
@@ -385,7 +419,12 @@ async function clickLikePerson(page, state, el) {
385
419
  bb = await el.boundingBox().catch(() => null);
386
420
  }
387
421
  if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
388
- const say = bb.x < vw && bb.x + bb.width > 0 ? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it" : "it is off the edge of the screen and you cannot reach it";
422
+ // 보였던 카드가 배너가 넘어가며 나간 것이면 그렇게 말한다 — "화면 밖" 이면 방문자는 처음부터 못 본 줄 안다
423
+ const say = (await outOfRow(el))
424
+ ? "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing"
425
+ : bb.x < vw && bb.x + bb.width > 0
426
+ ? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it"
427
+ : "it is off the edge of the screen and you cannot reach it";
389
428
  const e = new Error(say);
390
429
  e.human = say;
391
430
  throw e;
@@ -17,7 +17,7 @@ async function listControls(page, max = 60) {
17
17
  ({ ownSel, max }) => {
18
18
  // 보이는 화면 폭. 옆으로 넘친 페이지(모바일에서 창이 삐져나감)는 innerWidth 가 배치 폭으로 늘어나 화면 밖을 "보인다" 고 했다(10/15)
19
19
  const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
20
- const SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,input:not([type="hidden"]),select,textarea';
20
+ const SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],summary,input:not([type="hidden"]),select,textarea';
21
21
  const vis = (el) => {
22
22
  if (!el.getClientRects().length) return false;
23
23
  // 크기가 0 인 것은 사람 눈에 없다. 모바일에서 접혀 있는 검색칸이 여기 걸린다 —
@@ -67,8 +67,26 @@ async function listControls(page, max = 60) {
67
67
  if (shown >= Math.min(r.height / 2, 16) && r.right > 0 && r.left < VW) return "in view";
68
68
  return (r.top + r.bottom) / 2 >= innerHeight / 2 ? "below" : "above";
69
69
  };
70
+ /**
71
+ * 체크박스·라디오는 상태와 붙은 글로 부른다. value 로 부르면 기본값 "on" 이 이름이 되어, 방문자가 약관 체크박스를
72
+ * "Notification Settings 링크" 로 알고 두 번 눌렀다 — 체크했다가 도로 풀었다(10/22 bluetags /signup p2018)
73
+ */
74
+ const boxOf = (el) => {
75
+ const role = el.getAttribute("role");
76
+ const native = el.tagName === "INPUT" && (el.type === "checkbox" || el.type === "radio");
77
+ if (!native && role !== "checkbox" && role !== "switch" && role !== "radio") return "";
78
+ const on = native ? el.checked : el.getAttribute("aria-checked") === "true";
79
+ const kind = (native ? el.type : role) === "radio" ? "option" : role === "switch" ? "switch" : "tick box";
80
+ // <label> 이 없으면 곁의 글(같은 틀 안)을 쓴다 — 약관 체크박스는 글이 옆 칸에 따로 있다
81
+ const beside = () => {
82
+ const p = el.parentElement;
83
+ return p && p.querySelectorAll("input,button,a[href],select,textarea").length <= 1 ? p.innerText : "";
84
+ };
85
+ const said = (el.getAttribute("aria-label") || (el.labels && el.labels[0] && el.labels[0].innerText) || (native ? "" : el.innerText) || beside() || "").replace(/\s+/g, " ").trim();
86
+ return `${kind} (${kind === "switch" ? (on ? "on" : "off") : on ? "ticked" : "not ticked"})${said ? " " + said : ""}`;
87
+ };
70
88
  const nameOf = (el) =>
71
- (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
89
+ (boxOf(el) || el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
72
90
  .replace(/\s+/g, " ")
73
91
  .trim()
74
92
  .slice(0, 60);
@@ -143,6 +161,15 @@ async function listControls(page, max = 60) {
143
161
  return c.matches(pointerSel) && clickable(c) && c.getClientRects().length && b.width >= 8 && b.height >= 8;
144
162
  };
145
163
  if (!ownText(el) && Array.from(el.children).some(listable)) continue;
164
+ // 진짜 조작(링크·버튼)을 감싼 껍데기 — 그건 위에서 이미 잡았다. 껍데기를 따로 올리면 주소 없는 같은 말이 하나 더 생겨,
165
+ // 방문자가 그걸 누르면 바깥 사이트로 가는 줄 모르고 "눌러도 그대로" 로 찍혔다(10/23 실험실 Q3, 회원가입 "(see details)")
166
+ // 껍데기만 뺀다 — 안의 조작이 거의 다 덮는 것. 안에 작은 버튼(북마크)이 든 카드는 카드대로 남는다
167
+ const inner = !ownText(el) && el.querySelector(SEL);
168
+ if (inner) {
169
+ const a = el.getBoundingClientRect();
170
+ const b = inner.getBoundingClientRect();
171
+ if (b.width * b.height >= a.width * a.height * 0.6) continue;
172
+ }
146
173
  const up = el.parentElement;
147
174
  // 글자를 직접 가진 버튼 안의 조각(아이콘, "Event 0" 의 숫자 "0")은 그 버튼의 일부다
148
175
  if (up && ownText(up) && clickable(up)) continue;
package/src/run/diag.js CHANGED
@@ -19,7 +19,7 @@ const enabled = () => !OFF.test(String(process.env.USERCASE_DIAGNOSTICS || "").t
19
19
  function kindOf(skipped) {
20
20
  const s = String(skipped || "");
21
21
  if (/cut off at the edge|off the edge of the screen/.test(s)) return "cut_off";
22
- if (/slid away as you went to tap it/.test(s)) return "moving";
22
+ if (/slid (away|out of sight) as you went to tap it/.test(s)) return "moving";
23
23
  if (/survey card is on top/.test(s)) return "covered_by_card";
24
24
  if (/on top of it/.test(s)) return "covered";
25
25
  if (/greyed out/.test(s)) return "disabled";
package/src/run/visit.js CHANGED
@@ -63,7 +63,7 @@ const MAX_DIAG_HTML = 6000;
63
63
  const DIAG_NO_CONTEXT = new Set(["swiped", "missed"]);
64
64
  // 방문자가 바로 다음에 하는 말 — 도구 판단과 엇갈리면 도구가 잘못 본 것이다
65
65
  const SAYS_CHANGED = /\b(saved|bookmarked|liked|went (blue|red|green|grey|gray|dark|bold)|turned (blue|red|green|on|off)|it (worked|opened|changed)|that (worked|opened)|now (shows|says)|popped (up|open)|opened up)\b/i;
66
- const SAYS_NOTHING = /\b(nothing happened|nothing changed|didn.?t (open|work|do anything|respond|change|load|go anywhere)|not (working|responding|loading)|won.?t (open|load|work)|still the same|no response|dead)\b/i;
66
+ const SAYS_NOTHING = /\b(nothing happened|nothing changed|didn.?t (open|work|do anything|respond|change|load|go anywhere)|not (working|responding|loading)|won.?t (open|load|work)|still the same|no response|(it|that|this|button|link)(.?s| is| seems| looks) dead)\b/i;
67
67
  // 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
68
68
  const MAX_SCREEN_BYTES = 600 * 1024;
69
69
 
@@ -413,6 +413,13 @@ class Visit {
413
413
  this.steps = [];
414
414
  /** 이번 조작으로 받은 문서의 상태 코드(본 탭의 화면 이동만). 조작마다 비운다 */
415
415
  this.docStatus = null;
416
+ /** 마지막으로 끊긴 본 탭 이동(주소·이유) — 빈 페이지가 된 까닭을 진단에 남긴다 */
417
+ this.lastFailedNav = null;
418
+ page.on("requestfailed", (req) => {
419
+ try {
420
+ if (req.isNavigationRequest() && req.frame() === page.mainFrame()) this.lastFailedNav = `${new URL(req.url()).pathname} ${(req.failure() && req.failure().errorText) || ""}`.trim();
421
+ } catch {}
422
+ });
416
423
  page.on("response", (res) => {
417
424
  try {
418
425
  if (res.request().isNavigationRequest() && res.frame() === page.mainFrame()) this.docStatus = res.status();
@@ -444,6 +451,113 @@ class Visit {
444
451
  .then((a) => (this.fileWindow = { accept: a || "" }))
445
452
  .catch(() => {});
446
453
  });
454
+ /**
455
+ * 브라우저 창(alert·confirm·prompt). 듣지 않으면 Playwright 가 조용히 "취소" 한다 — bluetags 커뮤니티 나가기는
456
+ * "Are you sure you want to leave?" 가 세 번 다 취소돼 "눌러도 그대로" 로 찍히고 고객 화면에 사이트 탓으로 나갔다(10/22 p2038).
457
+ * 창이 떠 있는 동안 페이지는 멈추고 누르기도 안 끝난다 — 방문자에게 문구를 보여 주고 고르게 한다(answerDialog)
458
+ */
459
+ this.dialog = null;
460
+ this.dialogWake = null;
461
+ this.dialogWork = null;
462
+ this.dialogText = null;
463
+ this.acting = false;
464
+ this.curRecord = null;
465
+ page.on("dialog", (d) => this.onDialog(d));
466
+ }
467
+
468
+ /** 창이 떴다. 걸음 중이면 방문자에게 넘기고, 아니면(화면 보는 중·카드) 사람이 그냥 넘기듯 닫는다 — 열어 두면 페이지가 멈춘다 */
469
+ onDialog(d) {
470
+ const type = d.type();
471
+ const message = String(d.message() || "").slice(0, 500);
472
+ // 떠나며 "저장 안 한 내용이 있다" 는 사람도 대개 떠난다
473
+ if (type === "beforeunload") return void d.accept().catch(() => {});
474
+ if (!this.acting || this.dialog) {
475
+ (type === "alert" ? d.accept() : d.dismiss()).catch(() => {});
476
+ this.history.push(`A box from the browser popped up saying "${message}" and went away.`);
477
+ return;
478
+ }
479
+ this.dialog = { d, type, message, defaultValue: d.defaultValue() || "", path: this.pathNow() };
480
+ this.dialogText = null;
481
+ if (this.dialogWake) this.dialogWake();
482
+ }
483
+
484
+ pathNow() {
485
+ try {
486
+ const u = new URL(this.page.url());
487
+ return u.pathname + u.search;
488
+ } catch {
489
+ return "";
490
+ }
491
+ }
492
+
493
+ /** 창 안에서 고를 것 — 화면의 번호 목록처럼 준다 */
494
+ dialogItems() {
495
+ const d = this.dialog;
496
+ const ok = { key: "ok", label: "OK", where: "in the box" };
497
+ const cancel = { key: "cancel", label: "Cancel", where: "in the box" };
498
+ const items = d.type === "alert" ? [ok] : d.type === "prompt" ? [{ key: "field", label: "(text box)", where: "in the box" }, ok, cancel] : [ok, cancel];
499
+ return items.map((it, i) => ({ ...it, i }));
500
+ }
501
+
502
+ /** 창이 떠 있는 화면. 페이지를 읽을 수 없다(멈춰 있다) — 창만 보인다 */
503
+ dialogLook() {
504
+ const d = this.dialog;
505
+ 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:";
506
+ const lines = [what, "", d.message || "(no text)", ""];
507
+ 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.", "");
508
+ 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.");
509
+ const view = { path: d.path, scrollY: 0, height: 0, viewport: 0, items: this.dialogItems().map(({ i, label, where }) => ({ i, label, where })), dialog: true };
510
+ return { view, text: lines.join("\n") };
511
+ }
512
+
513
+ /**
514
+ * 창에 답한다 — OK·Cancel 을 누르거나 prompt 칸에 친다. 원래 걸음(_act)은 창이 닫힌 뒤 마저 끝난다.
515
+ * 고른 것은 그 걸음 기록에 붙는다(record.dialogs). 다른 조작은 창이 막는다 — 떠나기는 Cancel 하고 떠난다
516
+ */
517
+ async answerDialog(decision) {
518
+ await this.waitTurn();
519
+ const dl = this.dialog;
520
+ const item = this.dialogItems()[decision.index];
521
+ let choice = null;
522
+ if (decision.action === "leave") choice = "cancel";
523
+ else if (decision.action === "type" && item && item.key === "field") {
524
+ this.dialogText = String(decision.text || "").slice(0, 200);
525
+ await sleep(800 + Math.random() * 1500);
526
+ this.nextAt = Date.now() + 600;
527
+ return { dialog: true };
528
+ } else if (decision.action === "click" && item && item.key !== "field") choice = item.key;
529
+ else if (decision.action === "click" && item) return { skipped: "you can only type into that text box" };
530
+ 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" };
531
+
532
+ this.dialog = null;
533
+ const answer = choice === "ok" ? (dl.type === "prompt" ? "typed and pressed OK" : "OK") : "Cancel";
534
+ await (choice === "ok" ? dl.d.accept(dl.type === "prompt" ? this.dialogText ?? dl.defaultValue : undefined) : dl.d.dismiss()).catch(() => {});
535
+ 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 });
536
+ this.log(` browser box "${dl.message.slice(0, 40)}" → ${answer}`);
537
+ // 원래 걸음이 마저 끝나기를 본다. 창이 또 뜨면 다시 방문자에게
538
+ const r = await this.untilDialog(this.dialogWork);
539
+ if (r && r.dialog) return r;
540
+ this.dialogWork = null;
541
+ if (decision.action === "leave") return this.act(decision);
542
+ return r || { step: this.steps.length };
543
+ }
544
+
545
+ /** 걸음을 하다가 창이 뜨면 거기서 돌려준다({ dialog: true }) — 걸음은 창이 닫힌 뒤 마저 간다 */
546
+ async untilDialog(work) {
547
+ let wake;
548
+ const seen = new Promise((res) => (wake = res));
549
+ this.dialogWake = wake;
550
+ this.acting = true;
551
+ const first = await Promise.race([work.then((r) => ({ r })), seen.then(() => null)]);
552
+ if (first) {
553
+ this.acting = false;
554
+ this.dialogWake = null;
555
+ return first.r;
556
+ }
557
+ this.dialogWork = work;
558
+ // 창 글을 읽는 시간
559
+ this.nextAt = Date.now() + 1200 + Math.min(4000, this.dialog.message.length * 40);
560
+ return { dialog: true };
447
561
  }
448
562
 
449
563
  /**
@@ -489,6 +603,8 @@ class Visit {
489
603
  */
490
604
  async look() {
491
605
  if (this.ended) return { ended: true };
606
+ // 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
607
+ if (this.dialog) return this.dialogLook();
492
608
  if (!this.card && !this.pending) {
493
609
  const shown = await waitCard(this.page, 500);
494
610
  if (shown) this.pending = shown;
@@ -545,8 +661,14 @@ class Visit {
545
661
  * 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
546
662
  * 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
547
663
  * 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
664
+ * 하는 도중 브라우저 창이 뜨면 { dialog: true } — 다음 look() 이 창을 보여 주고, 다음 act() 가 창에 답한다
548
665
  */
549
666
  async act(decision) {
667
+ if (this.dialog) return this.answerDialog(decision);
668
+ return this.untilDialog(this._act(decision));
669
+ }
670
+
671
+ async _act(decision) {
550
672
  if (this.ended) throw new Error("This visit has already ended");
551
673
  if (this.pending) throw new Error("A card is showing — answer or close it first");
552
674
  await this.waitTurn();
@@ -562,6 +684,7 @@ class Visit {
562
684
  const view = this.lastView || (await listControls(page));
563
685
  const item = view.items[decision.index];
564
686
  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 };
687
+ this.curRecord = record;
565
688
  await this.checkLastJudgement(decision);
566
689
  const diagFrom = this.diag.length;
567
690
  const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
@@ -701,7 +824,9 @@ class Visit {
701
824
  else {
702
825
  const el = await handleAt(page, decision.index);
703
826
  diagEl = el;
704
- if (!el) record.skipped = "no longer there";
827
+ // 보는 사이 사이트가 저절로 다른 화면으로 보냈다. "없어졌다" 만 주면 방문자가 영문을 몰랐다(10/22 bluetags 홈 → /signup, p2037)
828
+ const movedTo = !el && this.pathNow().split("?")[0] !== String(view.path || "").split("?")[0] ? this.pathNow() : null;
829
+ if (!el) record.skipped = movedTo ? `before you could click it, the site moved you to another page by itself (${movedTo})` : "no longer there";
705
830
  else {
706
831
  // 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
707
832
  // 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
@@ -735,7 +860,8 @@ class Visit {
735
860
  // 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
736
861
  for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
737
862
  await sleep(150 + Math.random() * 450);
738
- if (page.url() !== before || this.offsite.length > offsiteBefore) break;
863
+ // 창이 떴었다 — 화면이 반응한 것이다. 또 누르면 같은 창이 또 뜬다
864
+ if (page.url() !== before || this.offsite.length > offsiteBefore || record.dialogs) break;
739
865
  await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
740
866
  }
741
867
  if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
@@ -748,15 +874,22 @@ class Visit {
748
874
  // 조작 뒤 탭이 빈 페이지(about:blank 등)가 됐다 — 앱을 여는 링크(kakaolink:, intent:, tel:)를 브라우저가 못 열 때 그렇다.
749
875
  // 뒤로 가기로도 안 돌아와 방문자가 빈 화면에 갇혔다(10/15 실험실 p28, 모바일 공유 창). 원래 화면으로 돌려놓는다
750
876
  if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
877
+ const blankAt = page.url().slice(0, 40);
751
878
  await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
752
879
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
753
880
  // 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
754
881
  // "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
882
+ // 누르지도 않았는데 빈 페이지가 됐다 — 앱 링크가 아니다. "앱을 열려 했다" 를 "없어졌다" 와 같이 들으면 모순이다(10/23 실험실 O2·P1).
883
+ // 원인을 아직 못 찾았다 — 그때 주소와 끊긴 이동을 남긴다
884
+ // 서버는 설명에 영숫자·_:.- 와 빈칸만 받는다 — 주소의 / 가 있으면 통째로 버린다
885
+ const detail = `${blankAt}${this.lastFailedNav ? ` after ${this.lastFailedNav}` : ""}`.replace(/[^a-z0-9_:.\- ]/gi, "_").slice(0, 200);
755
886
  if (decision.action === "back") {
756
887
  record.backOut = true;
888
+ } else if (record.skipped) {
889
+ this.diag.push({ kind: "blank_unprompted", action: decision.action, shape: null, detail });
757
890
  } else {
758
891
  record.openedApp = true;
759
- this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
892
+ this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null, detail });
760
893
  }
761
894
  }
762
895
 
@@ -796,10 +929,12 @@ class Visit {
796
929
  )
797
930
  await this.shoot(record, "error");
798
931
 
799
- // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만
932
+ // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만.
933
+ // 브라우저 창이 떴으면 반응한 것이다 — Cancel 을 골라 그대로인 것은 사이트 탓이 아니다
800
934
  if (
801
935
  probe &&
802
936
  !record.skipped &&
937
+ !record.dialogs &&
803
938
  !record.missed &&
804
939
  !record.closedOverlay &&
805
940
  !record.offsite &&
@@ -827,7 +962,7 @@ class Visit {
827
962
 
828
963
  this.steps.push(record);
829
964
  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)" : ""}`,
965
+ `${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
966
  );
832
967
  this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
833
968
  this.lastView = null;
@@ -859,7 +994,8 @@ class Visit {
859
994
  */
860
995
  async checkLastJudgement(decision) {
861
996
  const last = this.steps[this.steps.length - 1];
862
- if (!last || last.action !== "click" || last.skipped || !decision.why) return;
997
+ // 떠나며 하는 말은 방문 전체를 돌아본다 — 앞의 다른 것("그 링크가 안 열렸다")을 바로 앞 걸음 일로 읽었다(10/22 p2018)
998
+ if (!last || last.action !== "click" || last.skipped || !decision.why || decision.action === "leave") return;
863
999
  const from = this.diag.length;
864
1000
  if (last.screen && last.screen.kind === "no_change" && SAYS_CHANGED.test(decision.why)) {
865
1001
  this.diag.push({ kind: "no_change_doubt", action: "click", detail: `step ${last.step}` });