@usercase/mcp 0.2.3-next.3 → 0.2.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/package.json +1 -1
- package/src/browser/hand.js +41 -2
- package/src/browser/page.js +29 -2
- package/src/run/diag.js +1 -1
- package/src/run/visit.js +21 -4
package/package.json
CHANGED
package/src/browser/hand.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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;
|
package/src/browser/page.js
CHANGED
|
@@ -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();
|
|
@@ -817,7 +824,9 @@ class Visit {
|
|
|
817
824
|
else {
|
|
818
825
|
const el = await handleAt(page, decision.index);
|
|
819
826
|
diagEl = el;
|
|
820
|
-
|
|
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";
|
|
821
830
|
else {
|
|
822
831
|
// 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
|
|
823
832
|
// 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
|
|
@@ -865,15 +874,22 @@ class Visit {
|
|
|
865
874
|
// 조작 뒤 탭이 빈 페이지(about:blank 등)가 됐다 — 앱을 여는 링크(kakaolink:, intent:, tel:)를 브라우저가 못 열 때 그렇다.
|
|
866
875
|
// 뒤로 가기로도 안 돌아와 방문자가 빈 화면에 갇혔다(10/15 실험실 p28, 모바일 공유 창). 원래 화면으로 돌려놓는다
|
|
867
876
|
if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
|
|
877
|
+
const blankAt = page.url().slice(0, 40);
|
|
868
878
|
await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
869
879
|
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
|
|
870
880
|
// 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
|
|
871
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);
|
|
872
886
|
if (decision.action === "back") {
|
|
873
887
|
record.backOut = true;
|
|
888
|
+
} else if (record.skipped) {
|
|
889
|
+
this.diag.push({ kind: "blank_unprompted", action: decision.action, shape: null, detail });
|
|
874
890
|
} else {
|
|
875
891
|
record.openedApp = true;
|
|
876
|
-
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 });
|
|
877
893
|
}
|
|
878
894
|
}
|
|
879
895
|
|
|
@@ -978,7 +994,8 @@ class Visit {
|
|
|
978
994
|
*/
|
|
979
995
|
async checkLastJudgement(decision) {
|
|
980
996
|
const last = this.steps[this.steps.length - 1];
|
|
981
|
-
|
|
997
|
+
// 떠나며 하는 말은 방문 전체를 돌아본다 — 앞의 다른 것("그 링크가 안 열렸다")을 바로 앞 걸음 일로 읽었다(10/22 p2018)
|
|
998
|
+
if (!last || last.action !== "click" || last.skipped || !decision.why || decision.action === "leave") return;
|
|
982
999
|
const from = this.diag.length;
|
|
983
1000
|
if (last.screen && last.screen.kind === "no_change" && SAYS_CHANGED.test(decision.why)) {
|
|
984
1001
|
this.diag.push({ kind: "no_change_doubt", action: "click", detail: `step ${last.step}` });
|