@nextclaw/x-harness 0.1.0 → 0.1.1
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/README.md +2 -2
- package/cli/main.js +23 -11
- package/dist/api.js +53 -1
- package/dist/browser-dom-capabilities.d.ts +4 -0
- package/dist/browser-dom-capabilities.js +110 -0
- package/dist/browser-dom-dependencies.d.ts +16 -0
- package/dist/browser-dom-editing.d.ts +148 -0
- package/dist/browser-dom-editing.js +386 -0
- package/dist/browser-dom-navigation.d.ts +9 -0
- package/dist/browser-dom-navigation.js +165 -0
- package/dist/browser-dom-network.d.ts +1 -0
- package/dist/browser-dom-network.js +128 -0
- package/dist/browser-dom-submission.d.ts +2 -0
- package/dist/browser-dom-submission.js +82 -0
- package/dist/browser-dom.d.ts +1 -1
- package/dist/browser-dom.js +51 -802
- package/dist/browser-native.js +32 -28
- package/dist/browser.js +6 -2
- package/dist/creator.js +1 -1
- package/dist/growth-batch.d.ts +5 -0
- package/dist/growth-batch.js +168 -0
- package/dist/growth-constants.d.ts +5 -0
- package/dist/growth-constants.js +13 -0
- package/dist/growth-dependencies.d.ts +33 -0
- package/dist/growth-execution.d.ts +11 -0
- package/dist/growth-execution.js +315 -0
- package/dist/growth-parallel.d.ts +4 -0
- package/dist/growth-parallel.js +301 -0
- package/dist/growth-plan.d.ts +4 -0
- package/dist/growth-plan.js +207 -0
- package/dist/growth-policy.d.ts +9 -0
- package/dist/growth-policy.js +181 -0
- package/dist/growth-publishing.d.ts +5 -0
- package/dist/growth-publishing.js +240 -0
- package/dist/growth-reading.d.ts +4 -0
- package/dist/growth-reading.js +135 -0
- package/dist/growth-recovery.d.ts +6 -0
- package/dist/growth-recovery.js +158 -0
- package/dist/growth-state.d.ts +8 -0
- package/dist/growth-state.js +183 -0
- package/dist/growth-tasks.d.ts +17 -0
- package/dist/growth-tasks.js +101 -0
- package/dist/growth.d.ts +1 -0
- package/dist/growth.js +122 -1902
- package/dist/platform-capabilities.d.ts +4 -0
- package/dist/platform-capabilities.js +19 -0
- package/dist/platform-collections.d.ts +7 -0
- package/dist/platform-collections.js +223 -0
- package/dist/platform-dependencies.d.ts +43 -0
- package/dist/platform-diagnostics.d.ts +9 -0
- package/dist/platform-diagnostics.js +51 -0
- package/dist/platform-observation.d.ts +8 -0
- package/dist/platform-observation.js +75 -0
- package/dist/platform-rules.d.ts +17 -0
- package/dist/platform-rules.js +41 -0
- package/dist/platform-targets.d.ts +7 -0
- package/dist/platform-targets.js +129 -0
- package/dist/platform-writing.d.ts +8 -0
- package/dist/platform-writing.js +171 -0
- package/dist/platform.d.ts +1 -1
- package/dist/platform.js +56 -677
- package/dist/readers-actions.d.ts +2 -0
- package/dist/readers-actions.js +66 -0
- package/dist/readers-ax-host.d.ts +2 -0
- package/dist/readers-ax-host.js +125 -0
- package/dist/readers-ax-parser.d.ts +1 -0
- package/dist/readers-ax-parser.js +69 -0
- package/dist/readers-ax-program.d.ts +1 -0
- package/dist/readers-ax-tree.d.ts +19 -0
- package/dist/readers-ax-tree.js +110 -0
- package/dist/readers-ax-views.d.ts +87 -0
- package/dist/readers-ax-views.js +247 -0
- package/dist/readers-cli.d.ts +6 -0
- package/dist/readers-composer.d.ts +2 -0
- package/dist/readers-composer.js +131 -0
- package/dist/readers-navigation.d.ts +2 -0
- package/dist/readers-navigation.js +66 -0
- package/dist/readers-posts.d.ts +2 -0
- package/dist/readers-posts.js +141 -0
- package/dist/readers-profile.d.ts +3 -0
- package/dist/readers-profile.js +157 -0
- package/dist/readers.d.ts +9 -20
- package/dist/results.js +1 -1
- package/dist/round-state.js +2 -2
- package/dist/types.d.ts +10 -0
- package/generated/observation.mjs +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +17 -36
- package/skill/references/api.md +7 -34
- package/skill/references/reads.md +23 -0
- package/skill/references/recovery.md +25 -0
- package/skill/references/setup.md +23 -0
- package/skill/references/writes.md +51 -0
- package/dist/readers.js +0 -1045
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { attachImage } from "./readers-actions.js";
|
|
2
|
+
export function createDOMEditing(dependencies) {
|
|
3
|
+
const { session, unique, locator, tab, observe } = dependencies;
|
|
4
|
+
async function dismissMenu(command) {
|
|
5
|
+
const { query, result } = session.current;
|
|
6
|
+
let loc, submittedEditor;
|
|
7
|
+
if (query.view !== "menu" ||
|
|
8
|
+
result.needsStateCheck ||
|
|
9
|
+
!result.items?.length)
|
|
10
|
+
throw new Error("关闭菜单需已有菜单证据");
|
|
11
|
+
const item = result.items[0];
|
|
12
|
+
session.current = null;
|
|
13
|
+
await (await unique(locator(item.role, item.label))).press("Escape");
|
|
14
|
+
return { completed: true };
|
|
15
|
+
return { completed: false, loc, submittedEditor };
|
|
16
|
+
}
|
|
17
|
+
async function openControl(command) {
|
|
18
|
+
const { query, result } = session.current;
|
|
19
|
+
let loc, submittedEditor;
|
|
20
|
+
if (!["navigation", "menu"].includes(query.view) || result.needsStateCheck)
|
|
21
|
+
throw new Error("导航控件须先唯一观察");
|
|
22
|
+
const matches = (result.controls ?? result.items ?? []).filter((c) => c.role === command.role &&
|
|
23
|
+
c.label === command.label &&
|
|
24
|
+
c.href === command.href &&
|
|
25
|
+
!c.disabled);
|
|
26
|
+
if (matches.length !== 1)
|
|
27
|
+
throw new Error("导航控件未唯一观察");
|
|
28
|
+
session.current = null;
|
|
29
|
+
await (await unique(command.role === "text"
|
|
30
|
+
? tab.playwright.getByText(command.label, { exact: true })
|
|
31
|
+
: locator(command.role, command.label))).click();
|
|
32
|
+
if (command.href?.startsWith("/"))
|
|
33
|
+
await tab.playwright.waitForURL(`https://x.com${command.href}`, {
|
|
34
|
+
timeoutMs: 5000,
|
|
35
|
+
});
|
|
36
|
+
return { completed: true };
|
|
37
|
+
return { completed: false, loc, submittedEditor };
|
|
38
|
+
}
|
|
39
|
+
async function openOwnPostMenu(command) {
|
|
40
|
+
const { query, result } = session.current;
|
|
41
|
+
let loc, submittedEditor;
|
|
42
|
+
const post = result.posts?.[0];
|
|
43
|
+
if (query.view !== "posts" ||
|
|
44
|
+
result.needsStateCheck ||
|
|
45
|
+
result.posts?.length !== 1 ||
|
|
46
|
+
post.id !== command.postId ||
|
|
47
|
+
post.author?.toLowerCase() !== result.accountHandle?.toLowerCase() ||
|
|
48
|
+
post.primary?.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
|
|
49
|
+
throw new Error("编辑需唯一本人原帖");
|
|
50
|
+
const more = post.buttons.filter((b) => /^(更多|More)$/.test(b.label) && !b.disabled);
|
|
51
|
+
if (more.length !== 1)
|
|
52
|
+
throw new Error("原帖更多按钮未唯一观察");
|
|
53
|
+
const article = locator("article").filter({
|
|
54
|
+
has: tab.playwright.locator(`a[href=${JSON.stringify(post.primary)}]`),
|
|
55
|
+
visible: true,
|
|
56
|
+
});
|
|
57
|
+
session.editBinding = {
|
|
58
|
+
postId: post.id,
|
|
59
|
+
accountHandle: result.accountHandle,
|
|
60
|
+
body: post.body,
|
|
61
|
+
};
|
|
62
|
+
session.current = null;
|
|
63
|
+
await (await unique((await unique(article)).getByRole("button", {
|
|
64
|
+
name: more[0].label,
|
|
65
|
+
exact: true,
|
|
66
|
+
}))).click();
|
|
67
|
+
return { completed: true };
|
|
68
|
+
return { completed: false, loc, submittedEditor };
|
|
69
|
+
}
|
|
70
|
+
async function submitEditedPost(command) {
|
|
71
|
+
const { query, result } = session.current;
|
|
72
|
+
let loc, submittedEditor;
|
|
73
|
+
if (query.view !== "composer" ||
|
|
74
|
+
result.needsStateCheck ||
|
|
75
|
+
result.alerts?.length ||
|
|
76
|
+
!session.editBinding ||
|
|
77
|
+
session.editBinding.postId !== command.postId ||
|
|
78
|
+
result.accountHandle !== session.editBinding.accountHandle ||
|
|
79
|
+
!command.evidenceRef ||
|
|
80
|
+
!command.approvedText?.trim() ||
|
|
81
|
+
result.editorText !== command.approvedText ||
|
|
82
|
+
result.editors?.length !== 1)
|
|
83
|
+
throw new Error("编辑提交缺本人原帖绑定或精确审稿证据");
|
|
84
|
+
const updates = result.buttons.filter((b) => /^(更新|Update)$/.test(b.label) && !b.disabled);
|
|
85
|
+
if (updates.length !== 1)
|
|
86
|
+
throw new Error("编辑更新按钮未唯一观察");
|
|
87
|
+
const update = await unique(locator("button", updates[0].label));
|
|
88
|
+
await command.beforeSubmit?.();
|
|
89
|
+
session.current = null;
|
|
90
|
+
session.editBinding = null;
|
|
91
|
+
if (command.activation === "keyboard")
|
|
92
|
+
await update.press("Enter");
|
|
93
|
+
else
|
|
94
|
+
await update.click();
|
|
95
|
+
await tab.playwright.waitForLoadState({
|
|
96
|
+
state: "networkidle",
|
|
97
|
+
timeoutMs: 8000,
|
|
98
|
+
});
|
|
99
|
+
return { completed: true };
|
|
100
|
+
return { completed: false, loc, submittedEditor };
|
|
101
|
+
}
|
|
102
|
+
async function expandPost(command) {
|
|
103
|
+
const { query, result } = session.current;
|
|
104
|
+
let loc, submittedEditor;
|
|
105
|
+
if (query.view !== "posts" ||
|
|
106
|
+
result.posts?.length !== 1 ||
|
|
107
|
+
result.posts[0].id !== command.postId)
|
|
108
|
+
throw new Error("展开需唯一原帖");
|
|
109
|
+
const primary = result.posts[0].primary;
|
|
110
|
+
const article = locator("article").filter({
|
|
111
|
+
has: tab.playwright.locator(`a[href=${JSON.stringify(primary)}]`),
|
|
112
|
+
visible: true,
|
|
113
|
+
});
|
|
114
|
+
const control = (await unique(article)).getByText(/^(显示更多|Show more)$/i);
|
|
115
|
+
session.current = null;
|
|
116
|
+
await (await unique(control)).click();
|
|
117
|
+
return { completed: true };
|
|
118
|
+
return { completed: false, loc, submittedEditor };
|
|
119
|
+
}
|
|
120
|
+
async function nextPosts(command) {
|
|
121
|
+
const { query, result } = session.current;
|
|
122
|
+
let loc, submittedEditor;
|
|
123
|
+
if (query.view !== "posts" || result.needsStateCheck)
|
|
124
|
+
throw new Error("翻页须先观察原列表");
|
|
125
|
+
const primary = result.posts?.at(-1)?.primary;
|
|
126
|
+
if (!primary)
|
|
127
|
+
throw new Error("翻页须有已观察原帖链接");
|
|
128
|
+
const anchor = await unique(tab.playwright
|
|
129
|
+
.locator(`a[href=${JSON.stringify(primary)}]`)
|
|
130
|
+
.filter({ visible: true }));
|
|
131
|
+
session.current = null;
|
|
132
|
+
await anchor.press("PageDown");
|
|
133
|
+
return { completed: true };
|
|
134
|
+
return { completed: false, loc, submittedEditor };
|
|
135
|
+
}
|
|
136
|
+
async function clearPreparedText(command) {
|
|
137
|
+
const { query, result } = session.current;
|
|
138
|
+
let loc, submittedEditor;
|
|
139
|
+
if (query.view !== "composer" ||
|
|
140
|
+
result.needsStateCheck ||
|
|
141
|
+
!command.evidenceRef ||
|
|
142
|
+
typeof command.text !== "string" ||
|
|
143
|
+
result.editorText?.replace(/\n{3,}/g, "\n\n") !==
|
|
144
|
+
command.text.replace(/\n{3,}/g, "\n\n") ||
|
|
145
|
+
result.editors?.length !== 1)
|
|
146
|
+
throw new Error("仅恢复本次已知稿件准备");
|
|
147
|
+
const editor = await unique(locator("textbox", result.editors[0].label));
|
|
148
|
+
await editor.press("Meta+A");
|
|
149
|
+
await editor.press("Backspace");
|
|
150
|
+
return { completed: true, value: observe({ view: "composer" }) };
|
|
151
|
+
return { completed: false, loc, submittedEditor };
|
|
152
|
+
}
|
|
153
|
+
async function closePreparedReply(command) {
|
|
154
|
+
const { query, result } = session.current;
|
|
155
|
+
let loc, submittedEditor;
|
|
156
|
+
if (query.view !== "composer" ||
|
|
157
|
+
result.needsStateCheck ||
|
|
158
|
+
result.editorText?.trim() !== "" ||
|
|
159
|
+
!command.evidenceRef)
|
|
160
|
+
throw new Error("仅关闭有准备失败证据的空回复");
|
|
161
|
+
await (await unique(locator("button", "关闭"))).click();
|
|
162
|
+
session.replyBinding = null;
|
|
163
|
+
return { completed: true, value: observe({ view: "posts", limit: 5 }) };
|
|
164
|
+
return { completed: false, loc, submittedEditor };
|
|
165
|
+
}
|
|
166
|
+
async function closePreparedMedia(command) {
|
|
167
|
+
const { query, result } = session.current;
|
|
168
|
+
let loc, submittedEditor;
|
|
169
|
+
if (query.view !== "media" ||
|
|
170
|
+
result.needsStateCheck ||
|
|
171
|
+
!command.evidenceRef)
|
|
172
|
+
throw new Error("媒体恢复须有准备失败证据");
|
|
173
|
+
const backs = result.controls.filter((b) => b.role === "button" && /^(返回|Back)$/i.test(b.label) && !b.disabled);
|
|
174
|
+
if (backs.length !== 1)
|
|
175
|
+
throw new Error("媒体返回入口不唯一");
|
|
176
|
+
await (await unique(locator("button", backs[0].label))).click();
|
|
177
|
+
await tab.playwright
|
|
178
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
179
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
180
|
+
return { completed: true, value: observe({ view: "composer" }) };
|
|
181
|
+
return { completed: false, loc, submittedEditor };
|
|
182
|
+
}
|
|
183
|
+
async function removePreparedMedia(command) {
|
|
184
|
+
const { query, result } = session.current;
|
|
185
|
+
let loc, submittedEditor;
|
|
186
|
+
if (query.view !== "composer" ||
|
|
187
|
+
result.needsStateCheck ||
|
|
188
|
+
result.editorText?.trim() !== "" ||
|
|
189
|
+
!command.evidenceRef)
|
|
190
|
+
throw new Error("仅恢复有来源证据的空正文媒体准备");
|
|
191
|
+
const removes = result.buttons.filter((b) => /^(移除媒体|Remove media)$/i.test(b.label) && !b.disabled);
|
|
192
|
+
if (removes.length !== 1)
|
|
193
|
+
throw new Error("待恢复媒体不唯一");
|
|
194
|
+
await (await unique(locator("button", removes[0].label))).click();
|
|
195
|
+
return { completed: true, value: observe({ view: "composer" }) };
|
|
196
|
+
return { completed: false, loc, submittedEditor };
|
|
197
|
+
}
|
|
198
|
+
async function attachAssets(command) {
|
|
199
|
+
const { query, result } = session.current;
|
|
200
|
+
let loc, submittedEditor;
|
|
201
|
+
if (query.view !== "composer" ||
|
|
202
|
+
result.needsStateCheck ||
|
|
203
|
+
command.assets?.length !== 1)
|
|
204
|
+
throw new Error("仅上传已观察编辑器的一张带ALT图片");
|
|
205
|
+
const asset = command.assets[0];
|
|
206
|
+
if (!asset.alt?.trim())
|
|
207
|
+
throw new Error("图片须有完整ALT");
|
|
208
|
+
session.current = null;
|
|
209
|
+
await attachImage(tab, { path: asset.path });
|
|
210
|
+
await tab.playwright
|
|
211
|
+
.getByRole("button", {
|
|
212
|
+
name: /^(?:编辑|Edit)(?:图片|照片|媒体| image| photo| media)?(?: \d+)?$/i,
|
|
213
|
+
})
|
|
214
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
215
|
+
const composer = await observe({ view: "composer" });
|
|
216
|
+
const edits = composer.buttons.filter((b) => /^(?:编辑|Edit)(?:图片|照片|媒体| image| photo| media)?(?: \d+)?$/i.test(b.label) && !b.disabled);
|
|
217
|
+
if (edits.length !== 1)
|
|
218
|
+
throw new Error("图片编辑入口未唯一观察");
|
|
219
|
+
await (await unique(locator("button", edits[0].label))).click();
|
|
220
|
+
await tab.playwright
|
|
221
|
+
.getByRole("tab", {
|
|
222
|
+
name: /^(编辑图片描述|Edit image description)$/i,
|
|
223
|
+
})
|
|
224
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
225
|
+
let media = await observe({ view: "media" });
|
|
226
|
+
const tabControl = media.controls.find((c) => c.role === "tab" &&
|
|
227
|
+
/^(编辑图片描述|Edit image description)$/i.test(c.label));
|
|
228
|
+
if (!tabControl)
|
|
229
|
+
throw new Error("ALT页签未观察");
|
|
230
|
+
await (await unique(locator("tab", tabControl.label))).click();
|
|
231
|
+
media = await observe({ view: "media" });
|
|
232
|
+
if (media.needsStateCheck || media.fields.length !== 1)
|
|
233
|
+
throw new Error("ALT输入未唯一观察");
|
|
234
|
+
const field = media.fields[0];
|
|
235
|
+
const input = field.label
|
|
236
|
+
? tab.playwright.getByLabel(field.label, { exact: true })
|
|
237
|
+
: field.placeholder
|
|
238
|
+
? tab.playwright.getByPlaceholder(field.placeholder, {
|
|
239
|
+
exact: true,
|
|
240
|
+
})
|
|
241
|
+
: locator("textbox");
|
|
242
|
+
await (await unique(input)).fill(asset.alt);
|
|
243
|
+
media = await observe({ view: "media" });
|
|
244
|
+
if (media.fields[0]?.value !== asset.alt)
|
|
245
|
+
throw new Error("ALT全文不匹配");
|
|
246
|
+
const save = media.controls.filter((c) => c.role === "button" && /^(保存|Save)$/i.test(c.label) && !c.disabled);
|
|
247
|
+
if (save.length !== 1)
|
|
248
|
+
throw new Error("ALT保存入口不唯一");
|
|
249
|
+
await (await unique(locator("button", save[0].label))).click();
|
|
250
|
+
const after = await observe({ view: "composer" });
|
|
251
|
+
if (after.needsStateCheck || !after.images?.length)
|
|
252
|
+
throw new Error("上传图片未回到编辑器");
|
|
253
|
+
return {
|
|
254
|
+
completed: true,
|
|
255
|
+
value: {
|
|
256
|
+
assets: [{ path: asset.path, alt: asset.alt }],
|
|
257
|
+
mediaEvidence: media,
|
|
258
|
+
composer: after,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
return { completed: false, loc, submittedEditor };
|
|
262
|
+
}
|
|
263
|
+
async function follow(command) {
|
|
264
|
+
const { query, result } = session.current;
|
|
265
|
+
let loc, submittedEditor;
|
|
266
|
+
if (query.view !== "person" ||
|
|
267
|
+
query.handle !== command.handle ||
|
|
268
|
+
result.needsStateCheck)
|
|
269
|
+
throw new Error("人物身份未绑定");
|
|
270
|
+
const name = result.relationship === "follows-you"
|
|
271
|
+
? `回关 @${command.handle}`
|
|
272
|
+
: `关注 @${command.handle}`;
|
|
273
|
+
loc = await unique(locator("button", name));
|
|
274
|
+
return { completed: false, loc, submittedEditor };
|
|
275
|
+
}
|
|
276
|
+
async function openReply(command) {
|
|
277
|
+
const { query, result } = session.current;
|
|
278
|
+
let loc, submittedEditor;
|
|
279
|
+
session.replyBinding = null;
|
|
280
|
+
if (query.targetId !== command.postId || result.posts?.length !== 1)
|
|
281
|
+
throw new Error("回复主帖未唯一绑定");
|
|
282
|
+
const names = result.posts[0].buttons.filter((b) => /回复|Reply/i.test(b.label));
|
|
283
|
+
if (names.length !== 1)
|
|
284
|
+
throw new Error("回复按钮未唯一观察");
|
|
285
|
+
const primary = result.posts[0].primary;
|
|
286
|
+
if (!primary || primary.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
|
|
287
|
+
throw new Error("回复主帖永久链接未绑定");
|
|
288
|
+
const article = locator("article").filter({
|
|
289
|
+
has: tab.playwright.locator(`a[href=${JSON.stringify(primary)}]`),
|
|
290
|
+
visible: true,
|
|
291
|
+
});
|
|
292
|
+
loc = await unique((await unique(article)).getByRole("button", {
|
|
293
|
+
name: names[0].label,
|
|
294
|
+
exact: true,
|
|
295
|
+
}));
|
|
296
|
+
return { completed: false, loc, submittedEditor };
|
|
297
|
+
}
|
|
298
|
+
async function fillComposer(command) {
|
|
299
|
+
const { query, result } = session.current;
|
|
300
|
+
let loc, submittedEditor;
|
|
301
|
+
if (query.view !== "composer" || result.editors?.length !== 1)
|
|
302
|
+
throw new Error("编辑器未唯一观察");
|
|
303
|
+
loc = await unique(locator("textbox", result.editors[0].label));
|
|
304
|
+
session.current = null;
|
|
305
|
+
await loc.fill(command.text);
|
|
306
|
+
const submits = result.buttons.filter((b) => /^(回复|Reply|发帖|发布|Post|更新|Update)$/i.test(b.label));
|
|
307
|
+
if (submits.length !== 1)
|
|
308
|
+
throw new Error("编辑后提交入口不唯一");
|
|
309
|
+
await locator("button", submits[0].label)
|
|
310
|
+
.and(tab.playwright.locator(':not([disabled]):not([aria-disabled="true"])'))
|
|
311
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
312
|
+
return { completed: true };
|
|
313
|
+
return { completed: false, loc, submittedEditor };
|
|
314
|
+
}
|
|
315
|
+
async function submitPost(command) {
|
|
316
|
+
const { query, result } = session.current;
|
|
317
|
+
let loc, submittedEditor;
|
|
318
|
+
if (query.view !== "composer")
|
|
319
|
+
throw new Error("未观察编辑器");
|
|
320
|
+
const pattern = command.operation === "submitReply"
|
|
321
|
+
? /^(回复|Reply)$/i
|
|
322
|
+
: /^(发帖|发布|Post)$/i;
|
|
323
|
+
const buttons = result.buttons?.filter((b) => pattern.test(b.label) && !b.disabled) ?? [];
|
|
324
|
+
if (buttons.length !== 1)
|
|
325
|
+
throw new Error("提交按钮未唯一观察");
|
|
326
|
+
loc = await unique(locator("button", buttons[0].label));
|
|
327
|
+
if (command.activation !== undefined &&
|
|
328
|
+
!["click", "keyboard"].includes(command.activation))
|
|
329
|
+
throw new Error("提交激活方式无效");
|
|
330
|
+
if (command.activation === "keyboard" && typeof loc.press !== "function")
|
|
331
|
+
throw new Error("缺语义按钮键盘提交能力");
|
|
332
|
+
if (command.operation === "submitReply") {
|
|
333
|
+
if (result.editors?.length !== 1 || !result.editorText?.trim())
|
|
334
|
+
throw new Error("回复提交缺唯一完整编辑器");
|
|
335
|
+
submittedEditor = locator("textbox", result.editors[0].label).filter({
|
|
336
|
+
hasText: new RegExp(
|
|
337
|
+
// Full text and actor are checked by platform.compose before this act.
|
|
338
|
+
// Use a short observed anchor for the retained-text wait: host locator
|
|
339
|
+
// bridges can reject a multi-kilobyte hasText regex on long posts.
|
|
340
|
+
result.editorText
|
|
341
|
+
.trim()
|
|
342
|
+
.split(/\r?\n/)[0]
|
|
343
|
+
.slice(0, 64)
|
|
344
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
345
|
+
});
|
|
346
|
+
await unique(submittedEditor);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
if (result.editors?.length !== 1 || !result.editorText?.trim())
|
|
350
|
+
throw new Error("发帖提交缺唯一完整编辑器");
|
|
351
|
+
// Publication with media may stay pending after the click. Do not let
|
|
352
|
+
// receipt navigation interrupt it while the submitted text is retained.
|
|
353
|
+
submittedEditor = locator("textbox", result.editors[0].label).filter({
|
|
354
|
+
hasText: new RegExp(
|
|
355
|
+
// Full text and actor are checked by platform.compose before this act.
|
|
356
|
+
// Use a short observed anchor for the retained-text wait: host locator
|
|
357
|
+
// bridges can reject a multi-kilobyte hasText regex on long posts.
|
|
358
|
+
result.editorText
|
|
359
|
+
.trim()
|
|
360
|
+
.split(/\r?\n/)[0]
|
|
361
|
+
.slice(0, 64)
|
|
362
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
363
|
+
});
|
|
364
|
+
await unique(submittedEditor);
|
|
365
|
+
}
|
|
366
|
+
return { completed: false, loc, submittedEditor };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
dismissMenu,
|
|
370
|
+
openControl,
|
|
371
|
+
openOwnPostMenu,
|
|
372
|
+
submitEditedPost,
|
|
373
|
+
expandPost,
|
|
374
|
+
nextPosts,
|
|
375
|
+
clearPreparedText,
|
|
376
|
+
closePreparedReply,
|
|
377
|
+
closePreparedMedia,
|
|
378
|
+
removePreparedMedia,
|
|
379
|
+
attachAssets,
|
|
380
|
+
follow,
|
|
381
|
+
openReply,
|
|
382
|
+
fillComposer,
|
|
383
|
+
submitPost,
|
|
384
|
+
submitReply: submitPost,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DOMDependencies } from "./browser-dom-dependencies.js";
|
|
2
|
+
export declare function createDOMNavigation(dependencies: Pick<DOMDependencies, "session" | "tab" | "observe">): {
|
|
3
|
+
searchSources: (command: any) => Promise<void>;
|
|
4
|
+
waitForFolder: (command: any) => Promise<void>;
|
|
5
|
+
waitForPosts: (command: any) => Promise<void>;
|
|
6
|
+
openObserved: (command: any) => Promise<void>;
|
|
7
|
+
refreshPerson: (command: any) => Promise<void>;
|
|
8
|
+
refreshPosts: (command: any) => Promise<void>;
|
|
9
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { xURL } from "./platform-rules.js";
|
|
2
|
+
export function createDOMNavigation(dependencies) {
|
|
3
|
+
const { session, tab, observe } = dependencies;
|
|
4
|
+
async function searchSources(command) {
|
|
5
|
+
if (typeof command.query !== "string" ||
|
|
6
|
+
!command.query.trim() ||
|
|
7
|
+
command.query.length > 300)
|
|
8
|
+
throw new Error("需一条有效搜索词");
|
|
9
|
+
session.current = null;
|
|
10
|
+
const destination = `https://x.com/search?q=${encodeURIComponent(command.query)}&src=typed_query&f=live`;
|
|
11
|
+
if (typeof tab.url !== "function" || (await tab.url()) !== destination)
|
|
12
|
+
await tab.goto(destination);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
async function waitForFolder(command) {
|
|
16
|
+
await tab.playwright
|
|
17
|
+
.getByText(command.folder, { exact: true })
|
|
18
|
+
.waitFor({ state: "visible", timeoutMs: 2000 });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
async function waitForPosts(command) {
|
|
22
|
+
await tab.playwright
|
|
23
|
+
.getByRole("article")
|
|
24
|
+
.first()
|
|
25
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
async function openObserved(command) {
|
|
29
|
+
xURL(command.url);
|
|
30
|
+
if (!command.evidenceRef)
|
|
31
|
+
throw new Error("导航缺已观察证据");
|
|
32
|
+
// A direct adapter caller must also preserve a page already at the target.
|
|
33
|
+
// Explicit recovery uses forceNavigation; durable verification uses refreshPerson.
|
|
34
|
+
if (!command.forceNavigation &&
|
|
35
|
+
typeof tab.url === "function" &&
|
|
36
|
+
(await tab.url()) === xURL(command.url).href)
|
|
37
|
+
return;
|
|
38
|
+
const originalPage = session.current?.result?.page;
|
|
39
|
+
session.replyBinding = null;
|
|
40
|
+
session.current = null;
|
|
41
|
+
// Keep the loaded X client when the exact observed permalink is already visible.
|
|
42
|
+
// An ambiguous/missing link retains the existing navigation path, never an arbitrary click.
|
|
43
|
+
const path = xURL(command.url).pathname + xURL(command.url).search;
|
|
44
|
+
let link;
|
|
45
|
+
if (!command.forceNavigation &&
|
|
46
|
+
typeof tab.playwright?.locator === "function") {
|
|
47
|
+
link = tab.playwright
|
|
48
|
+
.locator(`a[href=${JSON.stringify(path)}]`)
|
|
49
|
+
.filter({ visible: true });
|
|
50
|
+
if (command.view === "person" &&
|
|
51
|
+
/^[A-Za-z0-9_]{1,15}$/.test(command.handle ?? "") &&
|
|
52
|
+
(await link.count()) > 1) {
|
|
53
|
+
// Avatar and name often share a profile href. Use the exact observed
|
|
54
|
+
// handle token to select the name link, never the arbitrary first match.
|
|
55
|
+
const named = link.filter({
|
|
56
|
+
hasText: new RegExp(`@${command.handle}(?![A-Za-z0-9_])`, "i"),
|
|
57
|
+
});
|
|
58
|
+
if ((await named.count()) === 1)
|
|
59
|
+
link = named;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const clickedLink = link && (await link.count()) === 1;
|
|
63
|
+
if (clickedLink)
|
|
64
|
+
await link.click();
|
|
65
|
+
else
|
|
66
|
+
await tab.goto(command.url);
|
|
67
|
+
try {
|
|
68
|
+
await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// One inert client-link timeout may use the same observed URL directly.
|
|
72
|
+
// A changed route, warning, unknown UI or interrupted connection never falls back.
|
|
73
|
+
if (!clickedLink ||
|
|
74
|
+
!originalPage ||
|
|
75
|
+
!/Timed out waiting for URL/.test(error instanceof Error ? error.message : ""))
|
|
76
|
+
throw error;
|
|
77
|
+
const check = await observe({
|
|
78
|
+
view: command.view,
|
|
79
|
+
handle: command.handle,
|
|
80
|
+
limit: 1,
|
|
81
|
+
});
|
|
82
|
+
if (check.page !== originalPage ||
|
|
83
|
+
check.needsStateCheck ||
|
|
84
|
+
check.alerts?.length)
|
|
85
|
+
throw error;
|
|
86
|
+
await tab.goto(command.url);
|
|
87
|
+
await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
88
|
+
}
|
|
89
|
+
if (command.view === "person") {
|
|
90
|
+
// Profile readiness belongs to profile content; self profiles have no Follow button.
|
|
91
|
+
await tab.playwright
|
|
92
|
+
.locator('[data-testid="UserName"]')
|
|
93
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
94
|
+
}
|
|
95
|
+
else if (command.view === "composer") {
|
|
96
|
+
await tab.playwright
|
|
97
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
98
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
99
|
+
}
|
|
100
|
+
else if (command.view === "posts") {
|
|
101
|
+
await tab.playwright
|
|
102
|
+
.getByRole("article")
|
|
103
|
+
.first()
|
|
104
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
async function refreshPerson(command) {
|
|
109
|
+
const at = xURL(await tab.url());
|
|
110
|
+
session.replyBinding = null;
|
|
111
|
+
if (at.href !== xURL(command.url).href ||
|
|
112
|
+
at.pathname.toLowerCase() !== `/${command.handle}`.toLowerCase())
|
|
113
|
+
throw new Error("持久核验页面不匹配");
|
|
114
|
+
session.current = null;
|
|
115
|
+
// Let the actual UI request settle before reloading; optimistic Following is not durable proof.
|
|
116
|
+
let idleTimedOut = false;
|
|
117
|
+
try {
|
|
118
|
+
await tab.playwright.waitForLoadState({
|
|
119
|
+
state: "networkidle",
|
|
120
|
+
timeoutMs: 8000,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!/Timed out waiting for networkidle/i.test(error.message ?? ""))
|
|
125
|
+
throw error;
|
|
126
|
+
// Long-lived X requests can prevent idle; the following reload still
|
|
127
|
+
// must expose the target relationship before any fresh observation.
|
|
128
|
+
idleTimedOut = true;
|
|
129
|
+
}
|
|
130
|
+
await tab.reload();
|
|
131
|
+
await tab.playwright
|
|
132
|
+
.getByRole("button", {
|
|
133
|
+
name: new RegExp(`^(?:正在关注|取消关注|关注|回关) @${command.handle}$`, "i"),
|
|
134
|
+
})
|
|
135
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
136
|
+
if (idleTimedOut)
|
|
137
|
+
session.refreshNotice =
|
|
138
|
+
"networkidle超时,已沿真实reload与目标关系控件恢复核验";
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
async function refreshPosts(command) {
|
|
142
|
+
session.replyBinding = null;
|
|
143
|
+
if (!command.evidenceRef ||
|
|
144
|
+
xURL(await tab.url()).href !== xURL(command.url).href)
|
|
145
|
+
throw new Error("公开核验页面不匹配");
|
|
146
|
+
session.current = null;
|
|
147
|
+
await tab.reload();
|
|
148
|
+
// Native observation settles the real loading transition when the tab exposes it.
|
|
149
|
+
if (typeof tab.getAXState === "function")
|
|
150
|
+
await tab.getAXState({ emit: false });
|
|
151
|
+
await tab.playwright
|
|
152
|
+
.getByRole("article")
|
|
153
|
+
.first()
|
|
154
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
searchSources,
|
|
159
|
+
waitForFolder,
|
|
160
|
+
waitForPosts,
|
|
161
|
+
openObserved,
|
|
162
|
+
refreshPerson,
|
|
163
|
+
refreshPosts,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readXNetworkDiagnostics(tab: any, { afterSequence, startObserving }?: any): Promise<any>;
|