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.
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/package.json +1 -1
- package/platform_runner.py +11 -2
- package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
- package/platforms/chatgpt/actions/_new_turn.py +1 -0
- package/platforms/chatgpt/actions/_readiness.py +29 -3
- package/platforms/chatgpt/actions/_submit_thread.py +20 -3
- package/platforms/chatgpt/actions/_thread_turn.py +31 -2
- package/platforms/chatgpt/tests/test_contract.py +107 -1
- package/platforms/iyf/AIRPLAY.md +25 -0
- package/platforms/iyf/actions/_find.py +18 -0
- package/platforms/iyf/actions/_open.py +10 -0
- package/platforms/iyf/manifest.yaml +11 -0
- package/platforms/iyf/safari-airplay +57 -0
- package/pyproject.toml +2 -2
- package/session_client.py +10 -0
- package/src/browser_workspace.egg-info/PKG-INFO +2 -2
- package/src/browser_workspace.egg-info/SOURCES.txt +53 -46
- package/src/browser_workspace.egg-info/requires.txt +1 -1
- package/src/browser_workspace_app/package.json +1 -1
- package/src/browser_workspace_app/pyproject.toml +2 -2
- package/src/browser_workspace_app/session_client.py +10 -0
- package/src/browser_workspace_app/platform_runner.py +0 -204
- package/src/browser_workspace_app/platforms/chatgpt/README.md +0 -60
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +0 -58
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +0 -119
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +0 -80
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +0 -85
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +0 -33
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +0 -353
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +0 -52
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +0 -129
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +0 -591
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +0 -362
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +0 -34
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +0 -183
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +0 -49
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +0 -425
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +0 -7
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +0 -10
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +0 -10
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +0 -18
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +0 -11
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +0 -9
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +0 -33
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +0 -83
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +0 -32
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +0 -210
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +0 -21
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +0 -25
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +0 -24
- package/src/browser_workspace_app/platforms/tiktok/README.md +0 -10
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +0 -180
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +0 -99
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +0 -10
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +0 -163
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +0 -130
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +0 -231
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +0 -124
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +0 -36
- package/src/browser_workspace_app/platforms/xhs/README.md +0 -10
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +0 -355
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +0 -281
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +0 -107
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +0 -34
- package/src/browser_workspace_app/platforms/youtube/README.md +0 -10
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +0 -103
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +0 -94
|
Binary file
|
package/package.json
CHANGED
package/platform_runner.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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":""}}
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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",
|