@nextclaw/x-harness 0.1.0 → 0.2.0
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/LICENSE +231 -6
- package/README.md +17 -3
- package/SOURCE.md +9 -0
- package/cli/main.js +232 -188
- package/dist/api.d.ts +56 -2
- package/dist/api.js +178 -75
- package/dist/boundary.js +1 -0
- package/dist/browser-common.d.ts +11 -0
- package/dist/browser-common.js +34 -18
- package/dist/browser-dom-capabilities.d.ts +9 -0
- package/dist/browser-dom-capabilities.js +131 -0
- package/dist/browser-dom-dependencies.d.ts +16 -0
- package/dist/browser-dom-dependencies.js +1 -0
- package/dist/browser-dom-editing.d.ts +166 -0
- package/dist/browser-dom-editing.js +418 -0
- package/dist/browser-dom-navigation.d.ts +18 -0
- package/dist/browser-dom-navigation.js +181 -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 +7 -0
- package/dist/browser-dom-submission.js +101 -0
- package/dist/browser-dom.d.ts +14 -2
- package/dist/browser-dom.js +120 -824
- package/dist/browser-native.d.ts +14 -1
- package/dist/browser-native.js +180 -143
- package/dist/browser.js +6 -2
- package/dist/creator-native.d.ts +3 -0
- package/dist/creator-native.js +113 -0
- package/dist/creator.js +1 -1
- package/dist/diagnostics.d.ts +11 -0
- package/dist/diagnostics.js +10 -0
- package/dist/growth-batch.d.ts +8 -0
- package/dist/growth-batch.js +190 -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-dependencies.js +1 -0
- package/dist/growth-execution.d.ts +17 -0
- package/dist/growth-execution.js +346 -0
- package/dist/growth-parallel.d.ts +8 -0
- package/dist/growth-parallel.js +332 -0
- package/dist/growth-plan.d.ts +9 -0
- package/dist/growth-plan.js +229 -0
- package/dist/growth-policy.d.ts +18 -0
- package/dist/growth-policy.js +208 -0
- package/dist/growth-publishing.d.ts +10 -0
- package/dist/growth-publishing.js +266 -0
- package/dist/growth-reading.d.ts +8 -0
- package/dist/growth-reading.js +157 -0
- package/dist/growth-recovery.d.ts +9 -0
- package/dist/growth-recovery.js +187 -0
- package/dist/growth-state.d.ts +16 -0
- package/dist/growth-state.js +220 -0
- package/dist/growth-tasks.d.ts +20 -0
- package/dist/growth-tasks.js +164 -0
- package/dist/growth.d.ts +26 -1
- package/dist/growth.js +211 -1908
- package/dist/platform-capabilities.d.ts +8 -0
- package/dist/platform-capabilities.js +29 -0
- package/dist/platform-collections.d.ts +14 -0
- package/dist/platform-collections.js +250 -0
- package/dist/platform-dependencies.d.ts +43 -0
- package/dist/platform-dependencies.js +1 -0
- package/dist/platform-diagnostics.d.ts +14 -0
- package/dist/platform-diagnostics.js +63 -0
- package/dist/platform-observation.d.ts +16 -0
- package/dist/platform-observation.js +96 -0
- package/dist/platform-rules.d.ts +17 -0
- package/dist/platform-rules.js +41 -0
- package/dist/platform-targets.d.ts +14 -0
- package/dist/platform-targets.js +154 -0
- package/dist/platform-writing.d.ts +16 -0
- package/dist/platform-writing.js +202 -0
- package/dist/platform.d.ts +23 -2
- package/dist/platform.js +125 -678
- package/dist/readers-actions.d.ts +2 -0
- package/dist/readers-actions.js +66 -0
- package/dist/readers-ax-host.d.ts +12 -0
- package/dist/readers-ax-host.js +134 -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-program.js +5 -0
- package/dist/readers-ax-tree.d.ts +19 -0
- package/dist/readers-ax-tree.js +111 -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-cli.js +79 -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/readers.js +9 -1045
- package/dist/results.js +1 -1
- package/dist/round-state.js +20 -3
- package/dist/store.d.ts +22 -4
- package/dist/store.js +173 -151
- package/dist/types.d.ts +10 -0
- package/dist/types.js +1 -0
- package/generated/observation.mjs +3 -2
- package/package.json +23 -9
- 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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { xURL } from "./platform-rules.js";
|
|
2
|
+
export class DOMNavigation {
|
|
3
|
+
#session;
|
|
4
|
+
#tab;
|
|
5
|
+
#observe;
|
|
6
|
+
searchSources;
|
|
7
|
+
waitForFolder;
|
|
8
|
+
waitForPosts;
|
|
9
|
+
openObserved;
|
|
10
|
+
refreshPerson;
|
|
11
|
+
refreshPosts;
|
|
12
|
+
constructor(dependencies) {
|
|
13
|
+
const { session, tab, observe } = dependencies;
|
|
14
|
+
this.#session = session;
|
|
15
|
+
this.#tab = tab;
|
|
16
|
+
this.#observe = observe;
|
|
17
|
+
this.searchSources = this.handleSearchSources.bind(this);
|
|
18
|
+
this.waitForFolder = this.handleWaitForFolder.bind(this);
|
|
19
|
+
this.waitForPosts = this.handleWaitForPosts.bind(this);
|
|
20
|
+
this.openObserved = this.handleOpenObserved.bind(this);
|
|
21
|
+
this.refreshPerson = this.handleRefreshPerson.bind(this);
|
|
22
|
+
this.refreshPosts = this.handleRefreshPosts.bind(this);
|
|
23
|
+
}
|
|
24
|
+
async handleSearchSources(command) {
|
|
25
|
+
if (typeof command.query !== "string" ||
|
|
26
|
+
!command.query.trim() ||
|
|
27
|
+
command.query.length > 300)
|
|
28
|
+
throw new Error("需一条有效搜索词");
|
|
29
|
+
this.#session.current = null;
|
|
30
|
+
const destination = `https://x.com/search?q=${encodeURIComponent(command.query)}&src=typed_query&f=live`;
|
|
31
|
+
if (typeof this.#tab.url !== "function" ||
|
|
32
|
+
(await this.#tab.url()) !== destination)
|
|
33
|
+
await this.#tab.goto(destination);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
async handleWaitForFolder(command) {
|
|
37
|
+
await this.#tab.playwright
|
|
38
|
+
.getByText(command.folder, { exact: true })
|
|
39
|
+
.waitFor({ state: "visible", timeoutMs: 2000 });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
async handleWaitForPosts(command) {
|
|
43
|
+
await this.#tab.playwright
|
|
44
|
+
.getByRole("article")
|
|
45
|
+
.first()
|
|
46
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
async handleOpenObserved(command) {
|
|
50
|
+
xURL(command.url);
|
|
51
|
+
if (!command.evidenceRef)
|
|
52
|
+
throw new Error("导航缺已观察证据");
|
|
53
|
+
// A direct adapter caller must also preserve a page already at the target.
|
|
54
|
+
// Explicit recovery uses forceNavigation; durable verification uses refreshPerson.
|
|
55
|
+
if (!command.forceNavigation &&
|
|
56
|
+
typeof this.#tab.url === "function" &&
|
|
57
|
+
(await this.#tab.url()) === xURL(command.url).href)
|
|
58
|
+
return;
|
|
59
|
+
const originalPage = this.#session.current?.result?.page;
|
|
60
|
+
this.#session.replyBinding = null;
|
|
61
|
+
this.#session.current = null;
|
|
62
|
+
// Keep the loaded X client when the exact observed permalink is already visible.
|
|
63
|
+
// An ambiguous/missing link retains the existing navigation path, never an arbitrary click.
|
|
64
|
+
const path = xURL(command.url).pathname + xURL(command.url).search;
|
|
65
|
+
let link;
|
|
66
|
+
if (!command.forceNavigation &&
|
|
67
|
+
typeof this.#tab.playwright?.locator === "function") {
|
|
68
|
+
link = this.#tab.playwright
|
|
69
|
+
.locator(`a[href=${JSON.stringify(path)}]`)
|
|
70
|
+
.filter({ visible: true });
|
|
71
|
+
if (command.view === "person" &&
|
|
72
|
+
/^[A-Za-z0-9_]{1,15}$/.test(command.handle ?? "") &&
|
|
73
|
+
(await link.count()) > 1) {
|
|
74
|
+
// Avatar and name often share a profile href. Use the exact observed
|
|
75
|
+
// handle token to select the name link, never the arbitrary first match.
|
|
76
|
+
const named = link.filter({
|
|
77
|
+
hasText: new RegExp(`@${command.handle}(?![A-Za-z0-9_])`, "i"),
|
|
78
|
+
});
|
|
79
|
+
if ((await named.count()) === 1)
|
|
80
|
+
link = named;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const clickedLink = link && (await link.count()) === 1;
|
|
84
|
+
if (clickedLink)
|
|
85
|
+
await link.click();
|
|
86
|
+
else
|
|
87
|
+
await this.#tab.goto(command.url);
|
|
88
|
+
try {
|
|
89
|
+
await this.#tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// One inert client-link timeout may use the same observed URL directly.
|
|
93
|
+
// A changed route, warning, unknown UI or interrupted connection never falls back.
|
|
94
|
+
if (!clickedLink ||
|
|
95
|
+
!originalPage ||
|
|
96
|
+
!/Timed out waiting for URL/.test(error instanceof Error ? error.message : ""))
|
|
97
|
+
throw error;
|
|
98
|
+
const check = await this.#observe({
|
|
99
|
+
view: command.view,
|
|
100
|
+
handle: command.handle,
|
|
101
|
+
limit: 1,
|
|
102
|
+
});
|
|
103
|
+
if (check.page !== originalPage ||
|
|
104
|
+
check.needsStateCheck ||
|
|
105
|
+
check.alerts?.length)
|
|
106
|
+
throw error;
|
|
107
|
+
await this.#tab.goto(command.url);
|
|
108
|
+
await this.#tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
109
|
+
}
|
|
110
|
+
if (command.view === "person") {
|
|
111
|
+
// Profile readiness belongs to profile content; self profiles have no Follow button.
|
|
112
|
+
await this.#tab.playwright
|
|
113
|
+
.locator('[data-testid="UserName"]')
|
|
114
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
115
|
+
}
|
|
116
|
+
else if (command.view === "composer") {
|
|
117
|
+
await this.#tab.playwright
|
|
118
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
119
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
120
|
+
}
|
|
121
|
+
else if (command.view === "posts") {
|
|
122
|
+
await this.#tab.playwright
|
|
123
|
+
.getByRole("article")
|
|
124
|
+
.first()
|
|
125
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
async handleRefreshPerson(command) {
|
|
130
|
+
const at = xURL(await this.#tab.url());
|
|
131
|
+
this.#session.replyBinding = null;
|
|
132
|
+
if (at.href !== xURL(command.url).href ||
|
|
133
|
+
at.pathname.toLowerCase() !== `/${command.handle}`.toLowerCase())
|
|
134
|
+
throw new Error("持久核验页面不匹配");
|
|
135
|
+
this.#session.current = null;
|
|
136
|
+
// Let the actual UI request settle before reloading; optimistic Following is not durable proof.
|
|
137
|
+
let idleTimedOut = false;
|
|
138
|
+
try {
|
|
139
|
+
await this.#tab.playwright.waitForLoadState({
|
|
140
|
+
state: "networkidle",
|
|
141
|
+
timeoutMs: 8000,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (!/Timed out waiting for networkidle/i.test(error.message ?? ""))
|
|
146
|
+
throw error;
|
|
147
|
+
// Long-lived X requests can prevent idle; the following reload still
|
|
148
|
+
// must expose the target relationship before any fresh observation.
|
|
149
|
+
idleTimedOut = true;
|
|
150
|
+
}
|
|
151
|
+
await this.#tab.reload();
|
|
152
|
+
await this.#tab.playwright
|
|
153
|
+
.getByRole("button", {
|
|
154
|
+
name: new RegExp(`^(?:正在关注|取消关注|关注|回关) @${command.handle}$`, "i"),
|
|
155
|
+
})
|
|
156
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
157
|
+
if (idleTimedOut)
|
|
158
|
+
this.#session.refreshNotice =
|
|
159
|
+
"networkidle超时,已沿真实reload与目标关系控件恢复核验";
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
async handleRefreshPosts(command) {
|
|
163
|
+
this.#session.replyBinding = null;
|
|
164
|
+
if (!command.evidenceRef ||
|
|
165
|
+
xURL(await this.#tab.url()).href !== xURL(command.url).href)
|
|
166
|
+
throw new Error("公开核验页面不匹配");
|
|
167
|
+
this.#session.current = null;
|
|
168
|
+
await this.#tab.reload();
|
|
169
|
+
// Native observation settles the real loading transition when the tab exposes it.
|
|
170
|
+
if (typeof this.#tab.getAXState === "function")
|
|
171
|
+
await this.#tab.getAXState({ emit: false });
|
|
172
|
+
await this.#tab.playwright
|
|
173
|
+
.getByRole("article")
|
|
174
|
+
.first()
|
|
175
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function createDOMNavigation(...args) {
|
|
180
|
+
return new DOMNavigation(...args);
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readXNetworkDiagnostics(tab: any, { afterSequence, startObserving }?: any): Promise<any>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Read-only transport diagnostics through the same real Chrome tab capability.
|
|
2
|
+
// Never return credential headers, cookies or bodies; only numeric rate metadata is allowed.
|
|
3
|
+
export async function readXNetworkDiagnostics(tab, { afterSequence = 0, startObserving = false } = {}) {
|
|
4
|
+
if (typeof tab?.capabilities?.get !== "function")
|
|
5
|
+
throw new Error("缺真实Chrome诊断能力");
|
|
6
|
+
const cdp = await tab.capabilities.get("cdp");
|
|
7
|
+
if (typeof cdp?.readEvents !== "function")
|
|
8
|
+
throw new Error("缺只读网络事件能力");
|
|
9
|
+
if (startObserving)
|
|
10
|
+
await cdp.send("Network.enable");
|
|
11
|
+
const result = await cdp.readEvents({
|
|
12
|
+
afterSequence,
|
|
13
|
+
limit: 1000,
|
|
14
|
+
methods: [
|
|
15
|
+
"Network.requestWillBeSent",
|
|
16
|
+
"Network.responseReceived",
|
|
17
|
+
"Network.loadingFailed",
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
const traffic = new Map();
|
|
21
|
+
const times = [];
|
|
22
|
+
for (const event of result.events) {
|
|
23
|
+
const p = event.params ?? {};
|
|
24
|
+
let u;
|
|
25
|
+
try {
|
|
26
|
+
u = new URL(p.response?.url ?? p.request?.url);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!((u.hostname === "x.com" && u.pathname.startsWith("/i/api/")) ||
|
|
32
|
+
u.hostname === "api.x.com"))
|
|
33
|
+
continue;
|
|
34
|
+
const location = u.origin + u.pathname;
|
|
35
|
+
const row = traffic.get(location) ?? {
|
|
36
|
+
location,
|
|
37
|
+
requests: 0,
|
|
38
|
+
responses: 0,
|
|
39
|
+
statuses: {},
|
|
40
|
+
};
|
|
41
|
+
if (event.method === "Network.requestWillBeSent")
|
|
42
|
+
row.requests++;
|
|
43
|
+
if (event.method === "Network.responseReceived") {
|
|
44
|
+
row.responses++;
|
|
45
|
+
const status = p.response?.status;
|
|
46
|
+
if (Number.isInteger(status))
|
|
47
|
+
row.statuses[status] = (row.statuses[status] ?? 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
if (Number.isFinite(p.timestamp))
|
|
50
|
+
times.push(p.timestamp);
|
|
51
|
+
traffic.set(location, row);
|
|
52
|
+
}
|
|
53
|
+
const rates = (headers = {}) => {
|
|
54
|
+
const allowed = {};
|
|
55
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
56
|
+
const name = key.toLowerCase();
|
|
57
|
+
if (![
|
|
58
|
+
"x-rate-limit-limit",
|
|
59
|
+
"x-rate-limit-remaining",
|
|
60
|
+
"x-rate-limit-reset",
|
|
61
|
+
"retry-after",
|
|
62
|
+
].includes(name))
|
|
63
|
+
continue;
|
|
64
|
+
if (!/^\d{1,12}$/.test(String(value).trim()))
|
|
65
|
+
continue;
|
|
66
|
+
allowed[name] = Number(value);
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
if (allowed["x-rate-limit-limit"] !== undefined)
|
|
70
|
+
out.limit = allowed["x-rate-limit-limit"];
|
|
71
|
+
if (allowed["x-rate-limit-remaining"] !== undefined)
|
|
72
|
+
out.remaining = allowed["x-rate-limit-remaining"];
|
|
73
|
+
if (allowed["x-rate-limit-reset"] !== undefined) {
|
|
74
|
+
const date = new Date(allowed["x-rate-limit-reset"] * 1000);
|
|
75
|
+
if (Number.isFinite(date.getTime()))
|
|
76
|
+
out.resetAt = date.toISOString();
|
|
77
|
+
}
|
|
78
|
+
if (allowed["retry-after"] !== undefined)
|
|
79
|
+
out.retryAfterSeconds = allowed["retry-after"];
|
|
80
|
+
return Object.keys(out).length ? out : undefined;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
cursor: result.cursor,
|
|
84
|
+
hasMore: result.hasMore,
|
|
85
|
+
truncated: result.truncated,
|
|
86
|
+
traffic: {
|
|
87
|
+
complete: !result.truncated && !result.hasMore,
|
|
88
|
+
requests: [...traffic.values()].reduce((n, r) => n + r.requests, 0),
|
|
89
|
+
responses: [...traffic.values()].reduce((n, r) => n + r.responses, 0),
|
|
90
|
+
durationSeconds: times.length > 1 ? Math.max(...times) - Math.min(...times) : null,
|
|
91
|
+
endpointCount: traffic.size,
|
|
92
|
+
endpoints: [...traffic.values()]
|
|
93
|
+
.sort((a, b) => b.requests - a.requests)
|
|
94
|
+
.slice(0, 10),
|
|
95
|
+
},
|
|
96
|
+
events: result.events
|
|
97
|
+
.map((e) => {
|
|
98
|
+
const p = e.params ?? {};
|
|
99
|
+
const response = p.response ?? {};
|
|
100
|
+
let location;
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(response.url ?? p.request?.url);
|
|
103
|
+
location = ["http:", "https:"].includes(u.protocol)
|
|
104
|
+
? u.origin + u.pathname
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
location = null;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
method: e.method,
|
|
112
|
+
requestId: p.requestId,
|
|
113
|
+
location,
|
|
114
|
+
requestMethod: p.request?.method,
|
|
115
|
+
status: response.status,
|
|
116
|
+
errorText: p.errorText,
|
|
117
|
+
canceled: p.canceled,
|
|
118
|
+
timestamp: Number.isFinite(p.timestamp) ? p.timestamp : undefined,
|
|
119
|
+
rateLimit: rates(response.headers),
|
|
120
|
+
};
|
|
121
|
+
})
|
|
122
|
+
.filter((e) => e.method === "Network.loadingFailed" ||
|
|
123
|
+
(e.location &&
|
|
124
|
+
(e.status >= 400 ||
|
|
125
|
+
e.requestMethod === "POST" ||
|
|
126
|
+
/\/(EditTweet|CreateTweet)$/.test(e.location)))),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DOMDependencies } from "./browser-dom-dependencies.js";
|
|
2
|
+
export declare class DOMSubmission {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(dependencies: Pick<DOMDependencies, "submitAX" | "session" | "tab" | "observe" | "unique" | "locator">);
|
|
5
|
+
finishDOMAction(command: any, prepared: any, query: any, result: any): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createDOMSubmission(...args: ConstructorParameters<typeof DOMSubmission>): (command: any, prepared: any, query: any, result: any) => Promise<void>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { xURL } from "./platform-rules.js";
|
|
2
|
+
import { valueOf } from "./browser-common.js";
|
|
3
|
+
export class DOMSubmission {
|
|
4
|
+
#submitAX;
|
|
5
|
+
#session;
|
|
6
|
+
#tab;
|
|
7
|
+
#observe;
|
|
8
|
+
#unique;
|
|
9
|
+
#locator;
|
|
10
|
+
constructor(dependencies) {
|
|
11
|
+
this.finishDOMAction = this.finishDOMAction.bind(this);
|
|
12
|
+
const { submitAX, session, tab, observe, unique, locator } = dependencies;
|
|
13
|
+
this.#submitAX = submitAX;
|
|
14
|
+
this.#session = session;
|
|
15
|
+
this.#tab = tab;
|
|
16
|
+
this.#observe = observe;
|
|
17
|
+
this.#unique = unique;
|
|
18
|
+
this.#locator = locator;
|
|
19
|
+
}
|
|
20
|
+
async finishDOMAction(command, prepared, query, result) {
|
|
21
|
+
const { loc, submittedEditor } = prepared;
|
|
22
|
+
const binding = command.operation === "openReply"
|
|
23
|
+
? {
|
|
24
|
+
page: result.page,
|
|
25
|
+
postId: command.postId,
|
|
26
|
+
body: result.posts[0].body,
|
|
27
|
+
author: result.posts[0].author,
|
|
28
|
+
}
|
|
29
|
+
: null;
|
|
30
|
+
let nativeSubmitIndex;
|
|
31
|
+
if (this.#submitAX &&
|
|
32
|
+
["submitPost", "submitReply"].includes(command.operation)) {
|
|
33
|
+
const native = await this.#submitAX.observe({ mode: "composer" });
|
|
34
|
+
const editors = native.evidence?.filter((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label)) ?? [];
|
|
35
|
+
const buttons = native.evidence?.filter((e) => new RegExp(command.operation === "submitReply"
|
|
36
|
+
? "^按钮 (回复|Reply)(?:,|$)"
|
|
37
|
+
: "^按钮 (发帖|发布|Post)(?:,|$)", "i").test(e.label)) ?? [];
|
|
38
|
+
if (native.needsStateCheck ||
|
|
39
|
+
native.alerts?.length ||
|
|
40
|
+
xURL(native.page).href !== xURL(result.page).href ||
|
|
41
|
+
editors.length !== 1 ||
|
|
42
|
+
valueOf(editors[0].label)?.trim() !== result.editorText.trim() ||
|
|
43
|
+
buttons.length !== 1)
|
|
44
|
+
throw new Error("原生提交缺同页、全文及唯一按钮证据");
|
|
45
|
+
nativeSubmitIndex = buttons[0].index;
|
|
46
|
+
}
|
|
47
|
+
if (["submitPost", "submitReply"].includes(command.operation))
|
|
48
|
+
await command.beforeSubmit?.();
|
|
49
|
+
this.#session.current = null;
|
|
50
|
+
if (nativeSubmitIndex !== undefined)
|
|
51
|
+
await this.#submitAX.act({
|
|
52
|
+
index: nativeSubmitIndex,
|
|
53
|
+
operation: "click",
|
|
54
|
+
read: { mode: "composer" },
|
|
55
|
+
});
|
|
56
|
+
else if (command.activation === "keyboard")
|
|
57
|
+
await loc.press("Enter");
|
|
58
|
+
else
|
|
59
|
+
await loc.click();
|
|
60
|
+
if (binding) {
|
|
61
|
+
this.#session.replyBinding = binding;
|
|
62
|
+
await this.#tab.playwright
|
|
63
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
64
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
65
|
+
if (xURL(await this.#tab.url()).pathname === "/compose/post")
|
|
66
|
+
await this.#tab.playwright
|
|
67
|
+
.getByText(`回复 @${binding.author}`, { exact: true })
|
|
68
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
69
|
+
const composer = await this.#observe({ view: "composer" });
|
|
70
|
+
const quote = composer.replyQuotes?.length === 1 ? composer.replyQuotes[0] : null;
|
|
71
|
+
if (!composer.replyToId &&
|
|
72
|
+
!composer.needsStateCheck &&
|
|
73
|
+
!composer.alerts?.length &&
|
|
74
|
+
composer.editorText?.trim() === "" &&
|
|
75
|
+
composer.replyContexts?.length === 1 &&
|
|
76
|
+
composer.replyContexts[0].includes(`回复 @${binding.author}`) &&
|
|
77
|
+
/显示更多|Show more/.test(composer.replyContexts[0]) &&
|
|
78
|
+
quote?.length >= 30 &&
|
|
79
|
+
binding.body?.startsWith(quote)) {
|
|
80
|
+
// Expand the observed quote; the unchanged full-context binding gate must still pass.
|
|
81
|
+
const dialog = await this.#unique(this.#locator("dialog").filter({ visible: true }));
|
|
82
|
+
const more = await this.#unique(dialog.getByRole("button", { name: /^(显示更多|Show more)$/i }));
|
|
83
|
+
this.#session.current = null;
|
|
84
|
+
await more.click();
|
|
85
|
+
await this.#observe({ view: "composer" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Inline replies keep the editor mounted. Wait for this submitted text to clear before
|
|
89
|
+
// receipt navigation/reload can interrupt the actual submission request.
|
|
90
|
+
if (submittedEditor)
|
|
91
|
+
await submittedEditor.waitFor({ state: "hidden", timeoutMs: 10000 });
|
|
92
|
+
if (["submitPost", "submitReply"].includes(command.operation))
|
|
93
|
+
await this.#tab.playwright.waitForLoadState({
|
|
94
|
+
state: "networkidle",
|
|
95
|
+
timeoutMs: 8000,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function createDOMSubmission(...args) {
|
|
100
|
+
return new DOMSubmission(...args).finishDOMAction;
|
|
101
|
+
}
|
package/dist/browser-dom.d.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
export
|
|
2
|
-
export declare
|
|
1
|
+
export { readXNetworkDiagnostics } from "./browser-dom-network.js";
|
|
2
|
+
export declare class DOMXBrowser {
|
|
3
|
+
#private;
|
|
4
|
+
capabilities: (operation: any) => any;
|
|
5
|
+
observe: (query: any) => Promise<any>;
|
|
6
|
+
constructor(tab: any, { readDOM, nativeSubmit }?: any);
|
|
7
|
+
private handleObserve;
|
|
8
|
+
private locator;
|
|
9
|
+
private unique;
|
|
10
|
+
collectCreator({ handle, ...options }: import("./types.js").CreatorOptions): Promise<import("./boundary.js").BoundaryRecord>;
|
|
11
|
+
screenshot(): any;
|
|
12
|
+
act(command: any): Promise<any>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createDOMXBrowser(...args: ConstructorParameters<typeof DOMXBrowser>): any;
|