browser-workspace 0.4.5 → 0.4.9

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.
Files changed (68) hide show
  1. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  2. package/package.json +1 -1
  3. package/platform_runner.py +11 -2
  4. package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
  5. package/platforms/chatgpt/actions/_new_turn.py +1 -0
  6. package/platforms/chatgpt/actions/_readiness.py +29 -3
  7. package/platforms/chatgpt/actions/_submit_thread.py +20 -3
  8. package/platforms/chatgpt/actions/_thread_turn.py +31 -2
  9. package/platforms/chatgpt/tests/test_contract.py +107 -1
  10. package/platforms/iyf/AIRPLAY.md +25 -0
  11. package/platforms/iyf/actions/_find.py +18 -0
  12. package/platforms/iyf/actions/_open.py +10 -0
  13. package/platforms/iyf/manifest.yaml +11 -0
  14. package/platforms/iyf/safari-airplay +57 -0
  15. package/pyproject.toml +2 -2
  16. package/session_client.py +10 -0
  17. package/src/browser_workspace.egg-info/PKG-INFO +2 -2
  18. package/src/browser_workspace.egg-info/SOURCES.txt +53 -46
  19. package/src/browser_workspace.egg-info/requires.txt +1 -1
  20. package/src/browser_workspace_app/package.json +1 -1
  21. package/src/browser_workspace_app/pyproject.toml +2 -2
  22. package/src/browser_workspace_app/session_client.py +10 -0
  23. package/src/browser_workspace_app/platform_runner.py +0 -204
  24. package/src/browser_workspace_app/platforms/chatgpt/README.md +0 -60
  25. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +0 -58
  26. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +0 -119
  27. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +0 -80
  28. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +0 -85
  29. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +0 -33
  30. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +0 -353
  31. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +0 -52
  32. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +0 -129
  33. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +0 -591
  34. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +0 -362
  35. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +0 -34
  36. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +0 -183
  37. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +0 -49
  38. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +0 -425
  39. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +0 -7
  40. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +0 -10
  41. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +0 -10
  42. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +0 -18
  43. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +0 -11
  44. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +0 -9
  45. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +0 -33
  46. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +0 -83
  47. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +0 -32
  48. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +0 -210
  49. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +0 -21
  50. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +0 -25
  51. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +0 -24
  52. package/src/browser_workspace_app/platforms/tiktok/README.md +0 -10
  53. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +0 -180
  54. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +0 -99
  55. package/src/browser_workspace_app/platforms/wechat-channels/README.md +0 -10
  56. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +0 -163
  57. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +0 -130
  58. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +0 -231
  59. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +0 -124
  60. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +0 -36
  61. package/src/browser_workspace_app/platforms/xhs/README.md +0 -10
  62. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +0 -355
  63. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +0 -281
  64. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +0 -107
  65. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +0 -34
  66. package/src/browser_workspace_app/platforms/youtube/README.md +0 -10
  67. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +0 -103
  68. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +0 -94
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-workspace",
3
- "version": "0.4.5",
3
+ "version": "0.4.9",
4
4
  "description": "Agent browser workspace with persistent sessions, tab leasing, snapshots, and platform workflows.",
5
5
  "bin": {
6
6
  "browser-workspace": "bin/browser-workspace"
@@ -135,7 +135,11 @@ def action_path(platform: str, action: str) -> Path:
135
135
 
136
136
  def prepare_action(path: Path, config_path: Path | None = None) -> str:
137
137
  source = path.read_text()
138
- source = "import sys\nsys.path.insert(0, " + repr(str(path.parent.resolve())) + ")\n" + source
138
+ # The daemon is long-lived: importing a previous installed platform module
139
+ # must never shadow the current action source in sys.modules.
140
+ siblings = [p.stem for p in path.parent.glob("_*.py")]
141
+ prelude = "import sys\n" + "\n".join(f"sys.modules.pop({name!r}, None)" for name in siblings)
142
+ source = prelude + "\nsys.path.insert(0, " + repr(str(path.parent.resolve())) + ")\n" + source
139
143
  if "__CFG_PATH__" in source:
140
144
  if config_path is None:
141
145
  raise ValueError(f"{path.parent.parent.name}/{path.stem}: --config is required")
@@ -173,7 +177,12 @@ def run_platform_action(
173
177
 
174
178
  owned_session = bool(auto_session)
175
179
  started = request({"op": "start", "workspace": None, "url": url}) if owned_session else {"session_id": session_id, "workspace_supported": None}
176
- sid = started["session_id"]
180
+ sid = started.get("session_id")
181
+ if not sid:
182
+ return {"platform": platform, "action": action, "workspace_supported": started.get("workspace_supported"),
183
+ "session_mode": "auto_session_no_self_heal" if owned_session else "caller_owned_self_heal_capable",
184
+ "self_heal_capable": not owned_session,
185
+ "result": {"ok": False, "error": started.get("error", "session start returned no session_id")}}
177
186
  result = None
178
187
  cleanup = None
179
188
  try:
@@ -1,6 +1,12 @@
1
1
  import json
2
2
  import time
3
- from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
3
+ from _readiness import (
4
+ SubmissionVerificationError,
5
+ submission_failure_diagnostic,
6
+ submission_receipt,
7
+ prompt_text_matches,
8
+ wait_until_stable,
9
+ )
4
10
 
5
11
 
6
12
  def composer_selector():
@@ -12,6 +18,24 @@ def composer_selector():
12
18
  const r=e.getBoundingClientRect();
13
19
  if (r.width>0 && r.height>0) return selector;
14
20
  }
21
+ const generic=[...document.querySelectorAll('[contenteditable="true"]')].filter(e=>{
22
+ if (e.id==='pending-home-input' || e.disabled) return false;
23
+ const r=e.getBoundingClientRect();
24
+ return r.width>0 && r.height>0;
25
+ });
26
+ return generic.length===1 ? '[contenteditable="true"]:not(#pending-home-input)' : null;
27
+ })()""")
28
+
29
+
30
+ def primary_composer_selector():
31
+ return js("""(() => {
32
+ const selectors=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]'];
33
+ for (const selector of selectors) {
34
+ const e=document.querySelector(selector);
35
+ if (!e || e.disabled) continue;
36
+ const r=e.getBoundingClientRect();
37
+ if (r.width>0 && r.height>0) return selector;
38
+ }
15
39
  return null;
16
40
  })()""")
17
41
 
@@ -33,6 +57,16 @@ def send_enabled():
33
57
  })()"""))
34
58
 
35
59
 
60
+ def verification_ui_state(selector):
61
+ return {
62
+ "composer_selector": selector,
63
+ "composer_text_length": len((composer_text(selector) or "").strip()),
64
+ "send_enabled": send_enabled(),
65
+ "generating": is_generating(),
66
+ "user_turn_count": len(user_turns()),
67
+ }
68
+
69
+
36
70
  def is_generating():
37
71
  return bool(js("""(() => !![...document.querySelectorAll('button')].find(b => {
38
72
  const a=(b.getAttribute('aria-label')||'').toLowerCase();
@@ -66,12 +100,21 @@ def wait_for_idle(timeout=300):
66
100
 
67
101
 
68
102
  def fill_prompt(prompt):
69
- selector=wait_until_stable(
70
- lambda: composer_selector(),
71
- lambda value: bool(value),
72
- timeout=30,
73
- phase="ChatGPT composer readiness",
74
- )
103
+ try:
104
+ selector=wait_until_stable(
105
+ lambda: primary_composer_selector(),
106
+ lambda value: bool(value),
107
+ timeout=20,
108
+ phase="ChatGPT canonical composer readiness",
109
+ )
110
+ except RuntimeError:
111
+ # The preferred selector timed out; still try the generic composer.
112
+ selector=wait_until_stable(
113
+ lambda: composer_selector(),
114
+ lambda value: bool(value),
115
+ timeout=5,
116
+ phase="ChatGPT composer readiness",
117
+ )
75
118
  editable=bool(js(f"""(() => document.querySelector({json.dumps(selector)})?.getAttribute('contenteditable')==='true')()"""))
76
119
  if editable:
77
120
  chunks=[prompt[i:i+256] for i in range(0, len(prompt), 256)]
@@ -93,27 +136,37 @@ def fill_prompt(prompt):
93
136
  if not ok: raise RuntimeError("ChatGPT composer rejected lifecycle continuation")
94
137
  else:
95
138
  fill_input(selector,prompt,clear_first=True)
96
- wait_until_stable(
97
- lambda: {"text":composer_text(selector),"send":send_enabled()},
98
- lambda state: prompt_text_matches(state["text"],prompt) and state["send"],
99
- timeout=20,
100
- phase="ChatGPT lifecycle continuation readiness",
101
- )
139
+ try:
140
+ wait_until_stable(
141
+ lambda: {"text":composer_text(selector),"send":send_enabled()},
142
+ lambda state: prompt_text_matches(state["text"],prompt) and state["send"],
143
+ timeout=20,
144
+ phase="ChatGPT lifecycle continuation readiness",
145
+ )
146
+ except RuntimeError as exc:
147
+ raise RuntimeError(f"{exc}; diagnostic={json.dumps(verification_ui_state(selector), ensure_ascii=False)}") from exc
102
148
  return selector
103
149
 
104
150
 
105
151
  def submit(prompt):
106
- before=len(user_turns())
107
152
  selector=fill_prompt(prompt)
153
+ ui_before=verification_ui_state(selector)
108
154
  clicked=js("""(() => {
109
155
  const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
110
156
  if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return false;
111
157
  b.click(); return true;
112
158
  })()""")
113
159
  if not clicked: raise RuntimeError("ChatGPT send button changed before lifecycle submit")
114
- deadline=time.time()+30
115
- while time.time()<deadline:
116
- receipt=submission_receipt(user_turns(),before,prompt,composer_text(selector))
117
- if receipt: return receipt
118
- time.sleep(.2)
119
- raise RuntimeError("ChatGPT lifecycle submission was not observed")
160
+ try:
161
+ wait_until_stable(
162
+ lambda: {"composer_empty": not bool(composer_text(selector).strip()), "send_disabled": not send_enabled()},
163
+ lambda state: state["composer_empty"] and state["send_disabled"],
164
+ timeout=30, interval=.2, stable_samples=3,
165
+ phase="post-submit composer reset",
166
+ )
167
+ except RuntimeError as exc:
168
+ raise SubmissionVerificationError(
169
+ "ChatGPT submission did not reach a stable cleared composer and disabled Send button",
170
+ {"ui_before":ui_before,"ui_after":verification_ui_state(selector),"reason":"post_submit_ui_unverified"},
171
+ ) from exc
172
+ return {"verified_by":"composer-cleared", "turn": {"id":""}}
@@ -28,6 +28,7 @@ print(json.dumps({
28
28
  "mode":"new-turn",
29
29
  "project_id":project_id,
30
30
  "thread_id":thread_id,
31
+ "thread_url":thread_url,
31
32
  "verified_by":receipt["verified_by"],
32
33
  "user_message_id":receipt["turn"].get("id") or None,
33
34
  },ensure_ascii=False),flush=True)
@@ -1,13 +1,22 @@
1
1
  """Bounded polling helpers for ChatGPT submit readiness."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import re
5
6
  import time
6
7
 
8
+
9
+ class SubmissionVerificationError(RuntimeError):
10
+ def __init__(self, message, diagnostic):
11
+ serialized = json.dumps(diagnostic, ensure_ascii=False, sort_keys=True)
12
+ super().__init__(f"{message}: {serialized}")
13
+ self.diagnostic = diagnostic
14
+
7
15
  def wait_until_stable(read_state, ready, *, timeout, interval=0.25, stable_samples=2, phase):
8
16
  deadline = time.monotonic() + timeout
9
17
  stable = 0
10
18
  last_state = {}
19
+ stable_state = None
11
20
  while time.monotonic() < deadline:
12
21
  try:
13
22
  last_state = read_state()
@@ -16,7 +25,11 @@ def wait_until_stable(read_state, ready, *, timeout, interval=0.25, stable_sampl
16
25
  time.sleep(interval)
17
26
  continue
18
27
  if ready(last_state):
19
- stable += 1
28
+ if stable_state == last_state:
29
+ stable += 1
30
+ else:
31
+ stable_state = last_state
32
+ stable = 1
20
33
  if stable >= stable_samples:
21
34
  return last_state
22
35
  else:
@@ -47,6 +60,19 @@ def submission_receipt(turns, before_count, prompt, composer_text):
47
60
  for turn in turns[before_count:]:
48
61
  if prompt_text_matches(turn.get("text"), prompt):
49
62
  return {"verified_by": "user-turn", "turn": turn}
50
- if not (composer_text or "").strip():
51
- return {"verified_by": "composer-cleared", "turn": {"id": None, "text": prompt}}
52
63
  return None
64
+
65
+
66
+ def submission_failure_diagnostic(turns, before_count, prompt, composer_text, ui_before=None, ui_after=None):
67
+ return {
68
+ "accepted": False,
69
+ "reason": "no_accepted_user_turn",
70
+ "user_turn_matched": False,
71
+ "before_user_turn_count": len(turns[:before_count]),
72
+ "user_turn_count": len(turns),
73
+ "composer_populated": bool((composer_text or "").strip()),
74
+ "composer_text_length": len((composer_text or "").strip()),
75
+ "ui_changed": None if ui_before is None or ui_after is None else bool(ui_before != ui_after),
76
+ "ui_before": ui_before,
77
+ "ui_after": ui_after,
78
+ }
@@ -2,7 +2,13 @@ import json
2
2
  import os
3
3
  import time
4
4
  from urllib.parse import quote
5
- from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
5
+ from _readiness import (
6
+ SubmissionVerificationError,
7
+ submission_failure_diagnostic,
8
+ submission_receipt,
9
+ prompt_text_matches,
10
+ wait_until_stable,
11
+ )
6
12
 
7
13
  CFG=json.load(open("__CFG_PATH__",encoding="utf-8"))
8
14
  project_id=str(CFG.get("project_id") or "").strip()
@@ -117,6 +123,8 @@ wait_until_stable(
117
123
  timeout=45 if attachments else 20,
118
124
  phase="send readiness",
119
125
  )
126
+ state_before=composer_state()
127
+ ui_before={"composer_present":state_before["present"],"composer_text_length":len(state_before["text"]),"send_enabled":state_before["sendEnabled"]}
120
128
  if not click_visible_send(): raise RuntimeError("ChatGPT send button changed before click")
121
129
  deadline=time.time()+15
122
130
  receipt=None
@@ -124,6 +132,15 @@ while time.time()<deadline:
124
132
  receipt=submission_receipt(user_turns(),before_users,prompt,visible_composer_text())
125
133
  if receipt: break
126
134
  time.sleep(.1)
127
- if not receipt: raise RuntimeError("ChatGPT submission verification did not observe an accepted submission")
135
+ if not receipt:
136
+ state_after=composer_state()
137
+ raise SubmissionVerificationError(
138
+ "ChatGPT submission verification did not observe an accepted submission",
139
+ submission_failure_diagnostic(
140
+ user_turns(), before_users, prompt, visible_composer_text(),
141
+ ui_before,
142
+ {"composer_present":state_after["present"],"composer_text_length":len(state_after["text"]),"send_enabled":state_after["sendEnabled"]},
143
+ ),
144
+ )
128
145
 
129
- print(json.dumps({"status":"submitted","project_id":project_id,"thread_id":thread_id,"verified_by":receipt["verified_by"],"user_message_id":receipt["turn"].get("id") or None,"app":app_name or None},ensure_ascii=False),flush=True)
146
+ print(json.dumps({"status":"submitted","project_id":project_id,"thread_id":thread_id,"thread_url":thread_url,"verified_by":receipt["verified_by"],"user_message_id":receipt["turn"].get("id") or None,"app":app_name or None},ensure_ascii=False),flush=True)
@@ -3,7 +3,13 @@ import os
3
3
  import re
4
4
  import time
5
5
  from urllib.parse import urlsplit
6
- from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
6
+ from _readiness import (
7
+ SubmissionVerificationError,
8
+ submission_failure_diagnostic,
9
+ submission_receipt,
10
+ prompt_text_matches,
11
+ wait_until_stable,
12
+ )
7
13
 
8
14
 
9
15
  CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
@@ -260,6 +266,14 @@ def _submit(prompt, files, target_id):
260
266
  phase="send readiness",
261
267
  )
262
268
  selector=state["selector"]
269
+ ui_before={
270
+ "composer_present": bool(_composer()),
271
+ "composer_text_length": len((_composer_text(selector) or "").strip()),
272
+ "send_present": bool(_send_state().get("present")),
273
+ "send_enabled": bool(_send_state().get("enabled")),
274
+ "generating": _is_generating(),
275
+ "user_turn_count": len(_user_turns()),
276
+ }
263
277
  clicked=js("""(() => {
264
278
  const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
265
279
  if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
@@ -272,7 +286,22 @@ def _submit(prompt, files, target_id):
272
286
  receipt=submission_receipt(_user_turns(), before_users, prompt, _composer_text(selector))
273
287
  if receipt: break
274
288
  time.sleep(.25)
275
- if not receipt: raise RuntimeError("ChatGPT submission verification did not observe an accepted submission")
289
+ if not receipt:
290
+ raise SubmissionVerificationError(
291
+ "ChatGPT submission verification did not observe an accepted submission",
292
+ submission_failure_diagnostic(
293
+ _user_turns(), before_users, prompt, _composer_text(selector),
294
+ ui_before,
295
+ {
296
+ "composer_present": bool(_composer()),
297
+ "composer_text_length": len((_composer_text(selector) or "").strip()),
298
+ "send_present": bool(_send_state().get("present")),
299
+ "send_enabled": bool(_send_state().get("enabled")),
300
+ "generating": _is_generating(),
301
+ "user_turn_count": len(_user_turns()),
302
+ },
303
+ ),
304
+ )
276
305
  return before_assistants, attachments, receipt
277
306
 
278
307
  def _result(before_assistants, timeout):
@@ -34,7 +34,8 @@ def test_action_uses_browser_helpers_without_infrastructure_import():
34
34
  assert "switch_tab(target_id)" in text
35
35
  assert "new_tab(thread_url)" in text
36
36
  assert '"recovered":recovered' in text
37
- assert "from _readiness import wait_until_stable, submission_receipt" in text
37
+ assert "from _readiness import" in text
38
+ assert "submission_receipt" in text
38
39
 
39
40
 
40
41
  def test_project_setup_supports_required_inputs():
@@ -68,6 +69,44 @@ def test_prompt_match_tolerates_block_tag_boundary_whitespace():
68
69
  assert ns["prompt_text_matches"](observed, expected)
69
70
 
70
71
 
72
+ def test_submission_receipt_requires_accepted_user_turn():
73
+ ns = {}
74
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
75
+ accepted_turn = {"id": "msg_1", "text": "run the repair"}
76
+ assert ns["submission_receipt"]([accepted_turn], 0, "run the repair", "") == {
77
+ "verified_by": "user-turn",
78
+ "turn": accepted_turn,
79
+ }
80
+ assert ns["submission_receipt"]([], 0, "run the repair", "") is None
81
+ assert ns["submission_receipt"]([], 0, "run the repair", "run the repair") is None
82
+
83
+
84
+ def test_submission_failure_preserves_bounded_fallback_diagnostic():
85
+ ns = {}
86
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
87
+ existing_turn = {"id": "old", "text": "old"}
88
+ diagnostic = ns["submission_failure_diagnostic"](
89
+ [existing_turn],
90
+ 1,
91
+ "run the repair",
92
+ "run the repair",
93
+ {"composer_present": True, "composer_text_length": 14, "send_enabled": True},
94
+ {"composer_present": False, "composer_text_length": 0, "send_enabled": False},
95
+ )
96
+ assert diagnostic == {
97
+ "accepted": False,
98
+ "reason": "no_accepted_user_turn",
99
+ "user_turn_matched": False,
100
+ "before_user_turn_count": 1,
101
+ "user_turn_count": 1,
102
+ "composer_populated": True,
103
+ "composer_text_length": 14,
104
+ "ui_changed": True,
105
+ "ui_before": {"composer_present": True, "composer_text_length": 14, "send_enabled": True},
106
+ "ui_after": {"composer_present": False, "composer_text_length": 0, "send_enabled": False},
107
+ }
108
+
109
+
71
110
  def test_wait_until_stable_retries_transient_read_errors():
72
111
  ns = {}
73
112
  exec((ACTIONS / "_readiness.py").read_text(), ns)
@@ -89,6 +128,29 @@ def test_wait_until_stable_retries_transient_read_errors():
89
128
  assert calls["n"] == 3
90
129
 
91
130
 
131
+ def test_wait_until_stable_requires_same_ready_state():
132
+ ns = {}
133
+ exec((ACTIONS / "_readiness.py").read_text(), ns)
134
+ states = iter([{"selector": "textarea"}, {"selector": "#composer"}, {"selector": "#composer"}])
135
+ result = ns["wait_until_stable"](
136
+ lambda: next(states),
137
+ lambda state: bool(state["selector"]),
138
+ timeout=0.2,
139
+ interval=0.001,
140
+ stable_samples=2,
141
+ phase="test",
142
+ )
143
+ assert result == {"selector": "#composer"}
144
+
145
+
146
+ def test_fill_prompt_prefers_canonical_composer_before_fallback():
147
+ text=(ACTIONS / "_lifecycle_common.py").read_text()
148
+ fill_prompt=text.split("def fill_prompt(",1)[1].split("def submit(",1)[0]
149
+ assert fill_prompt.index("primary_composer_selector(),") < fill_prompt.index("composer_selector(),")
150
+ assert "canonical composer readiness" in text
151
+ assert "ChatGPT composer readiness" in text
152
+
153
+
92
154
  def test_submit_thread_is_submission_only():
93
155
  text=(ACTIONS/"_submit_thread.py").read_text()
94
156
  assert 'project_id' in text and 'thread_id' in text
@@ -98,6 +160,15 @@ def test_submit_thread_is_submission_only():
98
160
  assert "app_name" in text and "attach_app_and_restore_prompt" in text
99
161
 
100
162
 
163
+ def test_submit_thread_fails_closed_with_diagnostic():
164
+ text = (ACTIONS / "_submit_thread.py").read_text()
165
+ submit = text.split("if not click_visible_send():", 1)[1]
166
+ assert "SubmissionVerificationError" in text
167
+ assert "submission_failure_diagnostic(" in submit
168
+ assert "ui_before" in submit
169
+ assert "composer-cleared" not in text
170
+
171
+
101
172
  def test_manifest_declares_mcp_app_action():
102
173
  text = (ROOT / "manifest.yaml").read_text()
103
174
  assert 'mcp-app: "actions/_mcp_app.py"' in text
@@ -142,6 +213,25 @@ def test_lifecycle_actions_use_stable_ids_and_do_not_wait_for_new_answer():
142
213
  assert "submission_receipt" in common
143
214
 
144
215
 
216
+ def test_lifecycle_submission_fails_closed_with_diagnostic():
217
+ common = (ACTIONS / "_lifecycle_common.py").read_text()
218
+ submit = common.split("def submit(prompt):", 1)[1]
219
+ assert "SubmissionVerificationError" in common
220
+ assert "verification_ui_state(selector)" in common
221
+ assert "submission_failure_diagnostic(" in submit
222
+ assert "ui_before" in submit
223
+ assert "composer-cleared" not in common
224
+
225
+
226
+ def test_thread_turn_fails_closed_with_diagnostic():
227
+ text = (ACTIONS / "_thread_turn.py").read_text()
228
+ submit = text.split("def _submit(prompt, files, target_id):", 1)[1].split("def _result(", 1)[0]
229
+ assert "SubmissionVerificationError" in text
230
+ assert "submission_failure_diagnostic(" in submit
231
+ assert "ui_before" in submit
232
+ assert "composer-cleared" not in text
233
+
234
+
145
235
  def test_new_thread_supports_projectless_and_temporary_modes():
146
236
  thread=(ACTIONS/"_new_thread.py").read_text()
147
237
  assert 'temporary=bool(CFG.get("temporary", False))' in thread
@@ -181,3 +271,19 @@ def test_temporary_submit_recovers_only_before_send_from_prefill_hydration():
181
271
  assert 'goto_url(temporary_chat_entry_url())' in code
182
272
  assert '"composer readiness was not observed ready" not in str(exc)' in code
183
273
  assert 'if not _temporary_chat_enabled():' in code
274
+
275
+
276
+ def test_thread_url_returned_by_new_turn_and_submit_thread():
277
+ from pathlib import Path
278
+ actions = Path(__file__).resolve().parents[1] / "actions"
279
+ for name in ("_new_turn.py", "_submit_thread.py"):
280
+ source = (actions / name).read_text(encoding="utf-8")
281
+ assert "\"thread_url\":thread_url" in source
282
+
283
+
284
+ def test_generic_contenteditable_fallback_is_unique_and_excludes_pending_home_input():
285
+ text=(ACTIONS / "_lifecycle_common.py").read_text()
286
+ part=text.split("def composer_selector():",1)[1].split("def primary_composer_selector():",1)[0]
287
+ assert "generic.length===1" in part
288
+ assert "pending-home-input" in part
289
+ assert "[contenteditable=\"true\"]:not(#pending-home-input)" in part
@@ -0,0 +1,25 @@
1
+ # IYF Safari → Roku AirPlay 操作手册
2
+
3
+ ## 已验证环境(2026-10-11)
4
+ - Mac 上 Safari 播放爱壹帆视频,Roku 设备名:`43in MAGNAVOX Roku TV`。
5
+ - CLI:`/Users/chengli/Workspace/browser-workspace/platforms/iyf/safari-airplay`。
6
+ - 《沙丘》播放地址:`https://www.iyf.tv/play/Ob1Hrzz7x7t`。
7
+ - 已执行 3 次 Safari 退出、重新打开《沙丘》、CLI 连接的冷启动测试,3/3 返回 `CONNECTED`;不代表长时间播放或《沙丘2》已验证。
8
+
9
+ ## 新会话操作
10
+ 1. 通过 NeoY 在 Mac 上执行 `open -a Safari 'https://www.iyf.tv/play/Ob1Hrzz7x7t'`(其他影片先找到其正确 URL)。
11
+ 2. 确保 Safari 当前标签页中 HTML video 已开始播放;如需要,通过 Safari UI 操作播放。
12
+ 3. 执行 `zsh /Users/chengli/Workspace/browser-workspace/platforms/iyf/safari-airplay '43in MAGNAVOX Roku TV'`。
13
+ 4. 必须确认输出 `CONNECTED: 43in MAGNAVOX Roku TV`,并查询 Safari 当前视频的 `webkitCurrentPlaybackTargetIsWireless`;必要时让用户确认电视实际画面。
14
+ 5. 若失败,逐项检查 Safari 页面、视频播放状态、设备发现、macOS 辅助功能权限和 Roku AirPlay 设置。避免把屏幕镜像误当成视频直投。
15
+
16
+ ## CLI 工作机制和限制
17
+ - 通过 Safari AppleScript `do JavaScript` 调用 HTMLVideoElement `webkitShowPlaybackTargetPicker()`,再由 System Events 选择 AirPlay 目标。
18
+ - 对失败最多尝试 5 次;以 `webkitCurrentPlaybackTargetIsWireless` 为成功判据。
19
+ - 依赖 Safari UI 辅助功能树;UI 改版、播放器变更可能导致失效。
20
+ - `platforms/iyf/manifest.yaml` 仍标记 experimental,AirPlay 尚未注册为平台 action。
21
+ - Safari JavaScript 自动化需已允许;若不允许,按实际权限提示处理。
22
+
23
+ ## 后续测试
24
+ - 《沙丘2》影片 URL、冷启动多轮、断线重连、长时间播放均待验证。
25
+ - 测试时把退出 Safari、打开页面、调用 CLI 拆为独立命令;不要声称未执行的测试成功。
@@ -0,0 +1,18 @@
1
+ import json
2
+ from urllib.parse import urlparse
3
+
4
+ CFG = json.load(open("__CFG_PATH__"))
5
+ query = str(CFG.get("query", "")).strip()
6
+ if not query:
7
+ raise ValueError("config.query is required")
8
+ if not page_info()["url"].startswith("https://www.iyf.tv/"):
9
+ goto("https://www.iyf.tv/")
10
+ matches = js("""(() => {
11
+ const q = %s;
12
+ return Array.from(document.querySelectorAll('a[href*="/play/"]'))
13
+ .filter(a => a.textContent.includes(q))
14
+ .map(a => ({title: a.textContent.trim().slice(0,120), url:a.href}))
15
+ .filter((a,i,all) => all.findIndex(b => b.url === a.url) === i)
16
+ .slice(0,15)
17
+ })()""" % json.dumps(query, ensure_ascii=False))
18
+ print(json.dumps({"query": query, "matches": matches}, ensure_ascii=False))
@@ -0,0 +1,10 @@
1
+ import json
2
+ CFG = json.load(open("__CFG_PATH__"))
3
+ url = CFG.get("url","")
4
+ if not url.startswith("https://www.iyf.tv/play/"):
5
+ raise ValueError("Only IYF desktop play URLs are allowed")
6
+ new_tab(url)
7
+ wait_for_load()
8
+ info=page_info()
9
+ player = js("!!document.querySelector('video')")
10
+ print(json.dumps({"url":info["url"],"title":info["title"],"player":player},ensure_ascii=False))
@@ -0,0 +1,11 @@
1
+ platform: iyf
2
+ status: experimental
3
+ display_name: 爱壹帆
4
+ url_patterns:
5
+ - https://www.iyf.tv/*
6
+ actions:
7
+ find: actions/_find.py
8
+ open: actions/_open.py
9
+ verification:
10
+ status: experimental
11
+ evidence: find verified with Spider-Man title. Chrome page verified. AirPlay action not implemented.
@@ -0,0 +1,57 @@
1
+ #!/bin/zsh
2
+ # Select an AirPlay target for the currently playing Safari HTML video.
3
+ # Usage: safari-airplay [exact device name]
4
+ set -euo pipefail
5
+ TARGET="${1:-43in MAGNAVOX Roku TV}"
6
+ for attempt in 1 2 3 4 5; do
7
+ if result=$(/usr/bin/osascript - "$TARGET" 2>&1 <<'APPLESCRIPT'
8
+ on run argv
9
+ set targetName to item 1 of argv
10
+ tell application "Safari"
11
+ if (count of windows) = 0 then error "Safari has no open windows"
12
+ activate
13
+ set resultText to do JavaScript "(()=>{let v=[...document.querySelectorAll('video')].find(x=>!x.paused)||document.querySelector('video');if(!v)return 'NO_VIDEO';if(typeof v.webkitShowPlaybackTargetPicker!=='function')return 'NO_AIRPLAY';v.webkitShowPlaybackTargetPicker();return 'PICKER_REQUESTED'})()" in current tab of front window
14
+ if resultText is not "PICKER_REQUESTED" then error resultText
15
+ end tell
16
+ tell application "System Events"
17
+ tell process "Safari"
18
+ set matched to false
19
+ repeat 35 times
20
+ try
21
+ set deviceRows to rows of table 1 of scroll area 1 of pop over 1 of window 1
22
+ repeat with deviceRow in deviceRows
23
+ try
24
+ set itemName to value of static text 1 of UI element 1 of deviceRow
25
+ if itemName is targetName then
26
+ set selectedBox to checkbox 1 of UI element 1 of deviceRow
27
+ if (value of selectedBox as integer) is not 1 then click selectedBox
28
+ set matched to true
29
+ exit repeat
30
+ end if
31
+ end try
32
+ end repeat
33
+ if matched then exit repeat
34
+ end try
35
+ delay 0.2
36
+ end repeat
37
+ if not matched then error "AirPlay target not found: " & targetName
38
+ end tell
39
+ end tell
40
+ repeat 20 times
41
+ delay 0.5
42
+ tell application "Safari"
43
+ set wireless to do JavaScript "String([...document.querySelectorAll('video')].some(v=>!v.paused&&v.webkitCurrentPlaybackTargetIsWireless))" in current tab of front window
44
+ end tell
45
+ if wireless is "true" then return "CONNECTED: " & targetName
46
+ end repeat
47
+ error "Selected target but wireless playback not confirmed: " & targetName
48
+ end run
49
+ APPLESCRIPT
50
+ ); then
51
+ print -r -- "$result"
52
+ exit 0
53
+ fi
54
+ print -u2 -- "AirPlay attempt $attempt failed: $result"
55
+ sleep 2
56
+ done
57
+ exit 1
package/pyproject.toml CHANGED
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "browser-workspace"
7
- version = "0.4.5"
7
+ version = "0.4.9"
8
8
  description = "Agent browser workspace with persistent Python sessions, tab leasing, snapshots, and platform workflows."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
11
- "browser-harness @ git+https://github.com/lalalic/browser-harness.git@80b74e116b9126f5159c84e5e05a750573e5f192",
11
+ "browser-harness==0.1.13",
12
12
  "cdp-use==1.4.5",
13
13
  "fetch-use==0.4.0",
14
14
  "pillow==12.3.0",