@usercase/mcp 0.2.10-next.0 → 0.2.10-next.2

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.
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * 리서치 참여(가상 사용자 리서치 진행). SDK 위젯(#eb-plugin 그림자 DOM)을 읽고 누른다.
4
+ *
5
+ * 사람 참여자와 같은 길이다 — SDK 가 agentResearch 로 그 리서치 초대를 바로 띄우고(core TriggerEngine.showAgentResearch),
6
+ * 우리는 수락 → 동의 → 과제 진행 → 과제마다의 질문 → 끝난 뒤 설문 탭까지 화면에 보이는 대로 간다.
7
+ * 수락·동의·다음 과제 넘기기는 참여하기로 한 사람이 늘 하는 것이라 도구가 누른다. 과제를 어떻게 하는지,
8
+ * 질문에 무엇을 답하는지, 포기할지는 방문자가 정한다.
9
+ *
10
+ * 화면 구성(earningbirds-core layouts/Widget.ts · components/frost/*):
11
+ * #eb-pill[data-state] — idle·invite·consent·loading·testing·timeout·survey·next·submit·done …
12
+ * #eb-frost(.eb-visible 이면 열림) > #eb-frost-body
13
+ * testing: .eb-frost-testing .eb-gf-section(진행) .eb-gf-flat-title(과제) .eb-gf-flat-sub(설명) #eb-task-lefttime-text #eb-test-skip
14
+ * timeout: .eb-frost-timeout #eb-timeout-continue #eb-timeout-skip
15
+ * survey: .eb-frost-survey .eb-gf-card-title .eb-gf-card-sub #eb-survey-action-btn (과제 질문·실패 이유)
16
+ * next: #eb-pill-next-btn · done: #eb-pill-done-btn
17
+ * Playwright 의 CSS 선택자는 열린 그림자 DOM 안까지 본다.
18
+ *
19
+ * 의존성 없음. Playwright page 를 받아 쓴다.
20
+ */
21
+
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+
24
+ /** 끝난 뒤 설문(survey.usercase.io / 개발 survey.localhost:3000)의 시작 주소 */
25
+ const SURVEY_URL = /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?\/(?:[a-z]{2}\/)?surveys\/[^/]+\/start/i;
26
+ const isSurveyUrl = (u) => SURVEY_URL.test(String(u || ""));
27
+
28
+ /** 위젯이 지금 보여 주는 것. 위젯이 없으면 null */
29
+ async function readStudy(page) {
30
+ return page
31
+ .evaluate(() => {
32
+ const host = document.getElementById("eb-plugin");
33
+ const sr = host && host.shadowRoot;
34
+ if (!sr) return null;
35
+ const txt = (el) => (el ? (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim() : "");
36
+ const shown = (el) => {
37
+ if (!el) return false;
38
+ const st = getComputedStyle(el);
39
+ if (st.display === "none" || st.visibility === "hidden") return false;
40
+ const r = el.getBoundingClientRect();
41
+ return r.width > 0 && r.height > 0;
42
+ };
43
+ const pill = sr.querySelector("#eb-pill");
44
+ const frost = sr.querySelector("#eb-frost");
45
+ const out = {
46
+ state: pill ? pill.getAttribute("data-state") || "" : "",
47
+ open: !!(frost && frost.classList.contains("eb-visible")),
48
+ pillText: txt(pill),
49
+ };
50
+ const testing = sr.querySelector(".eb-frost-testing");
51
+ if (testing) {
52
+ out.task = {
53
+ progress: txt(testing.querySelector(".eb-gf-section")),
54
+ title: txt(testing.querySelector(".eb-gf-flat-title")),
55
+ about: txt(testing.querySelector(".eb-gf-flat-sub")),
56
+ timeLeft: shown(sr.querySelector("#eb-gf-lefttime-wrap")) ? txt(sr.querySelector("#eb-task-lefttime-text")) : "",
57
+ skip: shown(sr.querySelector("#eb-test-skip")) ? txt(sr.querySelector("#eb-test-skip")) || "Skip" : null,
58
+ };
59
+ }
60
+ const timeout = sr.querySelector(".eb-frost-timeout");
61
+ if (timeout) {
62
+ const c = sr.querySelector("#eb-timeout-continue");
63
+ const s = sr.querySelector("#eb-timeout-skip");
64
+ const clone = timeout.cloneNode(true);
65
+ clone.querySelectorAll("button").forEach((b) => b.remove());
66
+ out.timeout = { text: txt(clone), keepGoing: txt(c) || "Continue", skip: txt(s) || "Skip" };
67
+ }
68
+ const survey = sr.querySelector(".eb-frost-survey");
69
+ if (survey) {
70
+ const btn = sr.querySelector("#eb-survey-action-btn");
71
+ const q = {
72
+ title: txt(survey.querySelector(".eb-gf-card-title")),
73
+ sub: txt(survey.querySelector(".eb-gf-card-sub")),
74
+ label: txt(sr.querySelector("#eb-survey-pill-label")),
75
+ button: txt(btn) || "Next",
76
+ // 필수 질문 단추는 eb-survey-disabled-button 이 붙은 채 disabled 만 풀린다(core Choice.ts) — disabled 만 본다
77
+ canSubmit: !!(btn && !btn.disabled),
78
+ kind: "text",
79
+ options: [],
80
+ other: false,
81
+ multiline: false,
82
+ };
83
+ const choices = [...survey.querySelectorAll(".eb-sq-choice")].filter((c) => !c.querySelector("input"));
84
+ const likert = [...survey.querySelectorAll(".eb-sq-likert-btn")];
85
+ const yesno = [...survey.querySelectorAll(".eb-sq-yesno-btn")];
86
+ if (choices.length) {
87
+ q.kind = "choice";
88
+ q.options = choices.map(txt);
89
+ q.other = !!survey.querySelector("input.eb-sq-choice-other");
90
+ } else if (likert.length) {
91
+ q.kind = "scale";
92
+ q.options = likert.map(txt);
93
+ q.scaleLabels = [...survey.querySelectorAll(".eb-sq-likert-labels span")].map(txt).filter(Boolean);
94
+ } else if (yesno.length) {
95
+ q.kind = "yesno";
96
+ // 라벨 번역이 없으면 빈 단추다(core YesNo.ts — 기본 글이 없다). 첫째가 예다
97
+ q.options = yesno.map((b, i) => txt(b) || (i === 0 ? "Yes" : "No"));
98
+ } else {
99
+ q.multiline = !!survey.querySelector("textarea.eb-sq-textarea");
100
+ }
101
+ out.question = q;
102
+ }
103
+ const next = sr.querySelector("#eb-pill-next-btn");
104
+ if (shown(next)) out.next = txt(next) || "Next task";
105
+ const done = sr.querySelector("#eb-pill-done-btn");
106
+ if (shown(done)) out.done = { label: txt(done), enabled: !done.disabled };
107
+ return out;
108
+ })
109
+ .catch(() => null);
110
+ }
111
+
112
+ /** 위젯이 이 상태들 중 하나가 될 때까지. 안 되면 마지막에 본 것. 끝난 뒤 설문으로 옮겨 가면 그만 본다(null) */
113
+ async function waitState(page, states, ms) {
114
+ let s = null;
115
+ for (let t = 0; t <= ms; t += 400) {
116
+ if (isSurveyUrl(page.url())) return null;
117
+ s = await readStudy(page);
118
+ if (s && states.includes(s.state)) return s;
119
+ await sleep(400);
120
+ }
121
+ return s;
122
+ }
123
+
124
+ /** 리서치 초대 카드(#eb-insight 의 수락 단추가 있는 카드)를 기다린다 */
125
+ async function waitInvite(page, ms) {
126
+ for (let t = 0; t <= ms; t += 500) {
127
+ const ok = await page
128
+ .evaluate(() => {
129
+ const host = document.getElementById("eb-insight");
130
+ const card = host && host.shadowRoot && host.shadowRoot.querySelector(".eb-ip-card.eb-ip-visible");
131
+ return !!(card && card.querySelector(".eb-ip-accept"));
132
+ })
133
+ .catch(() => false);
134
+ if (ok) return true;
135
+ await sleep(500);
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /**
141
+ * 초대를 받고 동의해 첫 과제가 시작될 때까지. 참여하기로 한 사람이라 도구가 누른다.
142
+ * @returns { ok: true, study } | { ok: false, why }
143
+ */
144
+ async function join(page, { inviteMs = 25000 } = {}) {
145
+ if (!(await waitInvite(page, inviteMs)))
146
+ return { ok: false, why: "the research invitation never appeared (is the research deployed, and is the Usercase SDK running on this page?)" };
147
+ // 초대를 읽는 시간
148
+ await sleep(1500 + Math.random() * 2000);
149
+ await page.locator("#eb-insight .eb-ip-accept").click({ timeout: 5000 }).catch(() => {});
150
+ const consent = await waitState(page, ["consent", "loading", "testing"], 12000);
151
+ if (consent && consent.state === "consent") {
152
+ await sleep(1500 + Math.random() * 2500);
153
+ await page.locator("#eb-plugin #eb-consent-agree").click({ timeout: 5000 }).catch(() => {});
154
+ }
155
+ const s = await waitState(page, ["testing", "survey", "done"], 40000);
156
+ if (!s || s.state !== "testing") return { ok: false, why: `the research did not start (the study panel is at "${s ? s.state || "?" : "nothing"}")` };
157
+ return { ok: true, study: s };
158
+ }
159
+
160
+ const PRESS = {
161
+ skip: "#eb-plugin #eb-test-skip",
162
+ keepGoing: "#eb-plugin #eb-timeout-continue",
163
+ timeoutSkip: "#eb-plugin #eb-timeout-skip",
164
+ next: "#eb-plugin #eb-pill-next-btn",
165
+ submit: "#eb-plugin #eb-survey-action-btn",
166
+ };
167
+
168
+ /** 위젯 단추를 누른다 */
169
+ async function press(page, key) {
170
+ const sel = PRESS[key];
171
+ if (!sel) throw new Error(`Unknown study button ${key}`);
172
+ await page.locator(sel).first().click({ timeout: 5000 });
173
+ }
174
+
175
+ /**
176
+ * 과제 질문(실패 이유 포함)에 답하고 보낸다.
177
+ * @param answer { index } 고르기 · { text } 쓰기(고르는 질문이면 "기타") · {} 비워 두고 넘기기
178
+ * @returns 보냈으면 null, 못 보냈으면 사람이 읽을 이유
179
+ */
180
+ async function answerQuestion(page, q, answer) {
181
+ const root = page.locator("#eb-plugin .eb-frost-survey");
182
+ if (Number.isInteger(answer.index) && answer.index >= 0) {
183
+ const sel = q.kind === "choice" ? ".eb-sq-choice:not(:has(input))" : q.kind === "scale" ? ".eb-sq-likert-btn" : ".eb-sq-yesno-btn";
184
+ await root.locator(sel).nth(answer.index).click({ timeout: 5000 });
185
+ } else if (answer.text) {
186
+ const box = q.kind === "choice" ? root.locator("input.eb-sq-choice-other") : root.locator("textarea.eb-sq-textarea, input.eb-sq-input").first();
187
+ if (!(await box.count())) return "there is no box to type into for this question — pick one of the options";
188
+ await box.click({ timeout: 5000 }).catch(() => {});
189
+ await box.fill(String(answer.text).slice(0, q.multiline ? 1000 : 200), { timeout: 5000 });
190
+ }
191
+ await sleep(400);
192
+ const now = await readStudy(page);
193
+ if (!now || !now.question) return null;
194
+ if (!now.question.canSubmit) return "this question needs an answer before you can go on";
195
+ await press(page, "submit");
196
+ return null;
197
+ }
198
+
199
+ module.exports = { readStudy, waitState, join, press, answerQuestion, isSurveyUrl };
package/src/run/visit.js CHANGED
@@ -398,8 +398,13 @@ class Visit {
398
398
  * @param shape persona.drawShape 가 뽑은 이번 방문 모양(튕김·훑기·깊게). 인내심 표시에 쓴다
399
399
  * @param offsite 사이트 밖으로 가려던 주소가 쌓이는 배열(guard.js onOffsite 가 채운다)
400
400
  */
401
- constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], log = () => {} } = {}) {
401
+ /**
402
+ * @param study 리서치에 참여 중인 방문이면 { press(key) } — 위젯 단추(과제 건너뛰기)를 누른다(hub · browser/study.js).
403
+ * 그 방문에서 리서치 초대 카드는 질문 카드로 다루지 않는다
404
+ */
405
+ constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], study = null, log = () => {} } = {}) {
402
406
  this.page = page;
407
+ this.study = study;
403
408
  this.person = person;
404
409
  /** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
405
410
  this.t0 = Date.now();
@@ -492,6 +497,11 @@ class Visit {
492
497
  if (this.dialogWake) this.dialogWake();
493
498
  }
494
499
 
500
+ /** 질문 카드로 다룰 카드인가. 리서치에 참여 중인 방문의 리서치 초대는 아니다(study.js 가 받는다) */
501
+ asCard(c) {
502
+ return c && !(this.study && c.invite) ? c : null;
503
+ }
504
+
495
505
  pathNow() {
496
506
  try {
497
507
  const u = new URL(this.page.url());
@@ -617,7 +627,7 @@ class Visit {
617
627
  // 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
618
628
  if (this.dialog) return this.dialogLook();
619
629
  if (!this.card && !this.pending) {
620
- const shown = await waitCard(this.page, 500);
630
+ const shown = this.asCard(await waitCard(this.page, 500));
621
631
  if (shown) this.pending = shown;
622
632
  }
623
633
  // 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
@@ -684,18 +694,31 @@ class Visit {
684
694
  if (this.pending) throw new Error("A card is showing — answer or close it first");
685
695
  await this.waitTurn();
686
696
  if (!this.card) {
687
- const c = await readCard(this.page);
697
+ const c = this.asCard(await readCard(this.page));
688
698
  if (c && c.visible) {
689
699
  this.pending = c;
690
700
  return { interrupted: true };
691
701
  }
692
702
  }
703
+ // 보는 사이 리서치가 질문을 띄웠다(과제 타이머가 끝났다 등). 페이지를 덮으니 하려던 걸 하지 않는다 — 질문부터
704
+ if (this.study && this.study.blocking && (await this.study.blocking())) return { interrupted: true, study: true };
693
705
  if (this.steps.length >= HARD_STEP_CAP) decision = { ...decision, action: "leave", why: `(${HARD_STEP_CAP}-step limit) ${decision.why}` };
694
706
  const page = this.page;
695
707
  const view = this.lastView || (await listControls(page));
696
708
  const item = view.items[decision.index];
697
709
  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 };
698
710
  this.curRecord = record;
711
+ // 리서치 위젯의 단추(과제 건너뛰기). 페이지 조작이 아니라 위젯을 누른다 — 화면 목록에 hub 가 붙여 둔 것이다
712
+ if (decision.action === "click" && item && item.study && this.study) {
713
+ record.study = item.study;
714
+ await this.study.press(item.study).catch(() => (record.skipped = "it did not respond"));
715
+ await sleep(1500);
716
+ this.steps.push(record);
717
+ this.history.push(`click "${item.label}" — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}`);
718
+ this.log(`${record.step}. study ${item.study} — ${decision.why}`);
719
+ this.lastView = null;
720
+ return record;
721
+ }
699
722
  await this.checkLastJudgement(decision);
700
723
  const diagFrom = this.diag.length;
701
724
  const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
@@ -724,7 +747,7 @@ class Visit {
724
747
  this.ended = true;
725
748
  // 떠나려는 순간에 뜨는 카드가 있다. 모바일은 SDK 가 멈춤으로 대신 보므로 더 기다린다
726
749
  if (!this.card) {
727
- const parting = await waitCard(page, this.person.device === "mobile" ? 16000 : 6000);
750
+ const parting = this.asCard(await waitCard(page, this.person.device === "mobile" ? 16000 : 6000));
728
751
  if (parting) this.pending = parting;
729
752
  }
730
753
  return record;
@@ -929,6 +952,11 @@ class Visit {
929
952
  record.external = ext;
930
953
  }
931
954
  if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
955
+ // 누르는 순간 리서치 질문이 페이지를 덮었다 — 도구가 못 한 게 아니라 리서치가 끼어든 것이다(10/30 실험실: 과제 타이머가 클릭과 겹쳤다)
956
+ if (record.skipped && /survey card is on top/.test(record.skipped) && this.study && this.study.blocking && (await this.study.blocking())) {
957
+ record.skipped = "the study's question came up just as you clicked, so the click did not reach the page";
958
+ record.studyCut = true;
959
+ }
932
960
  await this.noteDiag(record, decision, diagEl);
933
961
 
934
962
  // 화면이 깨졌다 — 오류로 온 문서, 오류 문구뿐인 화면. 바깥 사이트·앱·빈 페이지로 간 것은 뺀다